feat: Remove sample-related code from Facebook and Zalo services while introducing new AI, chatbot, and conversation management features across Facebook, WhatsApp, and X services.
This commit is contained in:
@@ -1,14 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to change status of a Sample.
|
||||
/// VI: Command để thay đổi trạng thái của Sample.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="NewStatus">EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel)</param>
|
||||
public record ChangeSampleStatusCommand(
|
||||
Guid SampleId,
|
||||
string NewStatus
|
||||
) : IRequest<bool>;
|
||||
@@ -1,70 +0,0 @@
|
||||
using MediatR;
|
||||
using FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ChangeSampleStatusCommand.
|
||||
/// VI: Handler cho ChangeSampleStatusCommand.
|
||||
/// </summary>
|
||||
public class ChangeSampleStatusCommandHandler : IRequestHandler<ChangeSampleStatusCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<ChangeSampleStatusCommandHandler> _logger;
|
||||
|
||||
public ChangeSampleStatusCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<ChangeSampleStatusCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
ChangeSampleStatusCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Changing status of sample {SampleId} to {NewStatus} / Thay đổi trạng thái sample {SampleId} thành {NewStatus}",
|
||||
request.SampleId, request.NewStatus);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Change status based on action / VI: Thay đổi trạng thái dựa trên action
|
||||
switch (request.NewStatus.ToLowerInvariant())
|
||||
{
|
||||
case "activate":
|
||||
sample.Activate();
|
||||
break;
|
||||
case "complete":
|
||||
sample.Complete();
|
||||
break;
|
||||
case "cancel":
|
||||
sample.Cancel();
|
||||
break;
|
||||
default:
|
||||
_logger.LogWarning(
|
||||
"Invalid status action: {NewStatus} / Action trạng thái không hợp lệ: {NewStatus}",
|
||||
request.NewStatus);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} status changed to {NewStatus} / Trạng thái sample {SampleId} đã đổi thành {NewStatus}",
|
||||
request.SampleId, request.NewStatus);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new ChatbotFlow.
|
||||
/// VI: Command để tạo một ChatbotFlow mới.
|
||||
/// </summary>
|
||||
/// <param name="ShopId">EN: Shop ID / VI: ID shop</param>
|
||||
/// <param name="Name">EN: Flow name / VI: Tên flow</param>
|
||||
/// <param name="TriggerType">EN: Trigger type (GetStarted, Keyword, Postback, Fallback) / VI: Loại trigger</param>
|
||||
/// <param name="TriggerValue">EN: Trigger value (keyword, payload, etc.) / VI: Giá trị trigger</param>
|
||||
public record CreateChatbotFlowCommand(
|
||||
Guid ShopId,
|
||||
string Name,
|
||||
string TriggerType,
|
||||
string TriggerValue
|
||||
) : IRequest<CreateChatbotFlowCommandResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of CreateChatbotFlowCommand.
|
||||
/// VI: Kết quả của CreateChatbotFlowCommand.
|
||||
/// </summary>
|
||||
public record CreateChatbotFlowCommandResult(Guid Id, bool Success);
|
||||
@@ -0,0 +1,77 @@
|
||||
using MediatR;
|
||||
using FacebookService.Domain.AggregatesModel.ChatbotAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateChatbotFlowCommand.
|
||||
/// VI: Handler cho CreateChatbotFlowCommand.
|
||||
/// </summary>
|
||||
public class CreateChatbotFlowCommandHandler : IRequestHandler<CreateChatbotFlowCommand, CreateChatbotFlowCommandResult>
|
||||
{
|
||||
private readonly IChatbotFlowRepository _flowRepository;
|
||||
private readonly ILogger<CreateChatbotFlowCommandHandler> _logger;
|
||||
|
||||
public CreateChatbotFlowCommandHandler(
|
||||
IChatbotFlowRepository flowRepository,
|
||||
ILogger<CreateChatbotFlowCommandHandler> logger)
|
||||
{
|
||||
_flowRepository = flowRepository ?? throw new ArgumentNullException(nameof(flowRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CreateChatbotFlowCommandResult> Handle(
|
||||
CreateChatbotFlowCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Creating chatbot flow: {Name} for shop: {ShopId}",
|
||||
request.Name, request.ShopId);
|
||||
|
||||
// EN: Check if trigger value already exists / VI: Kiểm tra trigger value đã tồn tại chưa
|
||||
var exists = await _flowRepository.ExistsByTriggerValueAsync(
|
||||
request.ShopId, request.TriggerValue, cancellationToken: cancellationToken);
|
||||
|
||||
if (exists)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Trigger value already exists: {TriggerValue} for shop: {ShopId}",
|
||||
request.TriggerValue, request.ShopId);
|
||||
return new CreateChatbotFlowCommandResult(Guid.Empty, Success: false);
|
||||
}
|
||||
|
||||
// EN: Parse trigger type / VI: Parse loại trigger
|
||||
var triggerType = ParseTriggerType(request.TriggerType);
|
||||
if (triggerType is null)
|
||||
{
|
||||
_logger.LogWarning("Invalid trigger type: {TriggerType}", request.TriggerType);
|
||||
return new CreateChatbotFlowCommandResult(Guid.Empty, Success: false);
|
||||
}
|
||||
|
||||
// EN: Create flow / VI: Tạo flow
|
||||
var flow = new ChatbotFlow(
|
||||
request.ShopId,
|
||||
request.Name,
|
||||
triggerType,
|
||||
request.TriggerValue);
|
||||
|
||||
_flowRepository.Add(flow);
|
||||
await _flowRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("ChatbotFlow created with ID: {FlowId}", flow.Id);
|
||||
|
||||
return new CreateChatbotFlowCommandResult(flow.Id, Success: true);
|
||||
}
|
||||
|
||||
private static FlowTriggerType? ParseTriggerType(string triggerType)
|
||||
{
|
||||
return triggerType?.ToLowerInvariant() switch
|
||||
{
|
||||
"getstarted" => FlowTriggerType.GetStarted,
|
||||
"keyword" => FlowTriggerType.Keyword,
|
||||
"postback" => FlowTriggerType.Postback,
|
||||
"fallback" => FlowTriggerType.Fallback,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using MediatR;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new Customer from Facebook user ID.
|
||||
/// VI: Command để tạo một Customer mới từ Facebook user ID.
|
||||
/// </summary>
|
||||
/// <param name="FacebookUserId">EN: Facebook user ID / VI: ID người dùng Facebook</param>
|
||||
/// <param name="Name">EN: Optional display name / VI: Tên hiển thị tùy chọn</param>
|
||||
/// <param name="Email">EN: Optional email / VI: Email tùy chọn</param>
|
||||
/// <param name="Phone">EN: Optional phone / VI: Điện thoại tùy chọn</param>
|
||||
/// <param name="ProfilePicUrl">EN: Optional profile picture URL / VI: URL ảnh đại diện tùy chọn</param>
|
||||
/// <param name="Locale">EN: Optional locale / VI: Locale tùy chọn</param>
|
||||
/// <param name="Timezone">EN: Optional timezone / VI: Timezone tùy chọn</param>
|
||||
public record CreateCustomerCommand(
|
||||
string FacebookUserId,
|
||||
string? Name = null,
|
||||
string? Email = null,
|
||||
string? Phone = null,
|
||||
string? ProfilePicUrl = null,
|
||||
string? Locale = null,
|
||||
string? Timezone = null
|
||||
) : IRequest<CreateCustomerCommandResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of CreateCustomerCommand.
|
||||
/// VI: Kết quả của CreateCustomerCommand.
|
||||
/// </summary>
|
||||
/// <param name="Id">EN: Created customer ID / VI: ID customer đã tạo</param>
|
||||
/// <param name="IsNew">EN: True if customer was created, false if already existed / VI: True nếu customer được tạo mới</param>
|
||||
public record CreateCustomerCommandResult(Guid Id, bool IsNew);
|
||||
@@ -0,0 +1,79 @@
|
||||
using MediatR;
|
||||
using FacebookService.Domain.AggregatesModel.CustomerAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateCustomerCommand.
|
||||
/// VI: Handler cho CreateCustomerCommand.
|
||||
/// </summary>
|
||||
public class CreateCustomerCommandHandler : IRequestHandler<CreateCustomerCommand, CreateCustomerCommandResult>
|
||||
{
|
||||
private readonly ICustomerRepository _customerRepository;
|
||||
private readonly ILogger<CreateCustomerCommandHandler> _logger;
|
||||
|
||||
public CreateCustomerCommandHandler(
|
||||
ICustomerRepository customerRepository,
|
||||
ILogger<CreateCustomerCommandHandler> logger)
|
||||
{
|
||||
_customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CreateCustomerCommandResult> Handle(
|
||||
CreateCustomerCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Processing CreateCustomerCommand for FacebookUserId: {FacebookUserId}",
|
||||
request.FacebookUserId);
|
||||
|
||||
// EN: Check if customer already exists / VI: Kiểm tra customer đã tồn tại chưa
|
||||
var existingCustomer = await _customerRepository.GetByFacebookUserIdAsync(
|
||||
request.FacebookUserId, cancellationToken);
|
||||
|
||||
if (existingCustomer is not null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Customer already exists with ID: {CustomerId}, updating profile",
|
||||
existingCustomer.Id);
|
||||
|
||||
// EN: Update existing customer profile / VI: Cập nhật profile customer sẵn có
|
||||
existingCustomer.UpdateProfile(
|
||||
request.Name,
|
||||
request.Email,
|
||||
request.Phone,
|
||||
request.ProfilePicUrl,
|
||||
request.Locale,
|
||||
request.Timezone);
|
||||
|
||||
_customerRepository.Update(existingCustomer);
|
||||
await _customerRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
return new CreateCustomerCommandResult(existingCustomer.Id, IsNew: false);
|
||||
}
|
||||
|
||||
// EN: Create new customer / VI: Tạo customer mới
|
||||
var customer = new Customer(request.FacebookUserId, request.Name);
|
||||
|
||||
// EN: Update profile with optional fields / VI: Cập nhật profile với fields tùy chọn
|
||||
customer.UpdateProfile(
|
||||
request.Name,
|
||||
request.Email,
|
||||
request.Phone,
|
||||
request.ProfilePicUrl,
|
||||
request.Locale,
|
||||
request.Timezone);
|
||||
|
||||
_customerRepository.Add(customer);
|
||||
|
||||
// EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events)
|
||||
await _customerRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Customer created successfully with ID: {CustomerId}",
|
||||
customer.Id);
|
||||
|
||||
return new CreateCustomerCommandResult(customer.Id, IsNew: true);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new Sample.
|
||||
/// VI: Command để tạo một Sample mới.
|
||||
/// </summary>
|
||||
/// <param name="Name">EN: Sample name / VI: Tên sample</param>
|
||||
/// <param name="Description">EN: Optional description / VI: Mô tả tùy chọn</param>
|
||||
public record CreateSampleCommand(
|
||||
string Name,
|
||||
string? Description
|
||||
) : IRequest<CreateSampleCommandResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of CreateSampleCommand.
|
||||
/// VI: Kết quả của CreateSampleCommand.
|
||||
/// </summary>
|
||||
/// <param name="Id">EN: Created sample ID / VI: ID sample đã tạo</param>
|
||||
public record CreateSampleCommandResult(Guid Id);
|
||||
@@ -1,46 +0,0 @@
|
||||
using MediatR;
|
||||
using FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateSampleCommand.
|
||||
/// VI: Handler cho CreateSampleCommand.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<CreateSampleCommandHandler> _logger;
|
||||
|
||||
public CreateSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<CreateSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CreateSampleCommandResult> Handle(
|
||||
CreateSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Creating new sample with name: {Name} / Tạo sample mới với tên: {Name}",
|
||||
request.Name);
|
||||
|
||||
// EN: Create domain entity / VI: Tạo domain entity
|
||||
var sample = new Sample(request.Name, request.Description);
|
||||
|
||||
// EN: Add to repository / VI: Thêm vào repository
|
||||
_sampleRepository.Add(sample);
|
||||
|
||||
// EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events)
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample created successfully with ID: {SampleId} / Sample đã tạo thành công với ID: {SampleId}",
|
||||
sample.Id);
|
||||
|
||||
return new CreateSampleCommandResult(sample.Id);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to delete a Sample.
|
||||
/// VI: Command để xóa một Sample.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID to delete / VI: ID sample cần xóa</param>
|
||||
public record DeleteSampleCommand(Guid SampleId) : IRequest<bool>;
|
||||
@@ -1,54 +0,0 @@
|
||||
using MediatR;
|
||||
using FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for DeleteSampleCommand.
|
||||
/// VI: Handler cho DeleteSampleCommand.
|
||||
/// </summary>
|
||||
public class DeleteSampleCommandHandler : IRequestHandler<DeleteSampleCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<DeleteSampleCommandHandler> _logger;
|
||||
|
||||
public DeleteSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<DeleteSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
DeleteSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Deleting sample {SampleId} / Xóa sample {SampleId}",
|
||||
request.SampleId);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Delete sample / VI: Xóa sample
|
||||
_sampleRepository.Delete(sample);
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} deleted successfully / Sample {SampleId} đã xóa thành công",
|
||||
request.SampleId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using MediatR;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to process an incoming message from Facebook Messenger.
|
||||
/// VI: Command để xử lý tin nhắn đến từ Facebook Messenger.
|
||||
/// </summary>
|
||||
/// <param name="PageId">EN: Facebook Page ID / VI: ID trang Facebook</param>
|
||||
/// <param name="SenderId">EN: Facebook sender (user) ID / VI: ID người gửi Facebook</param>
|
||||
/// <param name="MessageText">EN: Message content / VI: Nội dung tin nhắn</param>
|
||||
/// <param name="FacebookMessageId">EN: Facebook message ID (mid) / VI: ID tin nhắn Facebook</param>
|
||||
/// <param name="Timestamp">EN: Message timestamp / VI: Thời gian tin nhắn</param>
|
||||
/// <param name="QuickReplyPayload">EN: Quick reply payload if any / VI: Payload quick reply nếu có</param>
|
||||
public record ProcessIncomingMessageCommand(
|
||||
string PageId,
|
||||
string SenderId,
|
||||
string MessageText,
|
||||
string? FacebookMessageId = null,
|
||||
long? Timestamp = null,
|
||||
string? QuickReplyPayload = null
|
||||
) : IRequest<ProcessIncomingMessageResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of ProcessIncomingMessageCommand.
|
||||
/// VI: Kết quả của ProcessIncomingMessageCommand.
|
||||
/// </summary>
|
||||
public record ProcessIncomingMessageResult(
|
||||
Guid ConversationId,
|
||||
Guid CustomerId,
|
||||
Guid MessageId,
|
||||
bool IsNewConversation,
|
||||
bool IsNewCustomer
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
using MediatR;
|
||||
using FacebookService.Domain.AggregatesModel.CustomerAggregate;
|
||||
using FacebookService.Domain.AggregatesModel.ConversationAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ProcessIncomingMessageCommand.
|
||||
/// VI: Handler cho ProcessIncomingMessageCommand.
|
||||
/// </summary>
|
||||
public class ProcessIncomingMessageHandler : IRequestHandler<ProcessIncomingMessageCommand, ProcessIncomingMessageResult>
|
||||
{
|
||||
private readonly ICustomerRepository _customerRepository;
|
||||
private readonly IConversationRepository _conversationRepository;
|
||||
private readonly ILogger<ProcessIncomingMessageHandler> _logger;
|
||||
|
||||
public ProcessIncomingMessageHandler(
|
||||
ICustomerRepository customerRepository,
|
||||
IConversationRepository conversationRepository,
|
||||
ILogger<ProcessIncomingMessageHandler> logger)
|
||||
{
|
||||
_customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository));
|
||||
_conversationRepository = conversationRepository ?? throw new ArgumentNullException(nameof(conversationRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ProcessIncomingMessageResult> Handle(
|
||||
ProcessIncomingMessageCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Processing incoming message from sender: {SenderId} on page: {PageId}",
|
||||
request.SenderId, request.PageId);
|
||||
|
||||
var isNewCustomer = false;
|
||||
var isNewConversation = false;
|
||||
|
||||
// EN: Step 1 - Get or create customer / VI: Bước 1 - Lấy hoặc tạo customer
|
||||
var customer = await _customerRepository.GetByFacebookUserIdAsync(request.SenderId, cancellationToken);
|
||||
|
||||
if (customer is null)
|
||||
{
|
||||
customer = new Customer(request.SenderId);
|
||||
_customerRepository.Add(customer);
|
||||
isNewCustomer = true;
|
||||
|
||||
_logger.LogInformation("Created new customer for sender: {SenderId}", request.SenderId);
|
||||
}
|
||||
else
|
||||
{
|
||||
customer.RecordInteraction();
|
||||
_customerRepository.Update(customer);
|
||||
}
|
||||
|
||||
// EN: Step 2 - Get or create conversation / VI: Bước 2 - Lấy hoặc tạo conversation
|
||||
var conversation = await _conversationRepository.FindActiveByCustomerAndPageAsync(
|
||||
customer.Id, request.PageId, cancellationToken);
|
||||
|
||||
if (conversation is null)
|
||||
{
|
||||
conversation = new Conversation(customer.Id, request.PageId);
|
||||
_conversationRepository.Add(conversation);
|
||||
isNewConversation = true;
|
||||
|
||||
_logger.LogInformation("Created new conversation for customer: {CustomerId}", customer.Id);
|
||||
}
|
||||
|
||||
// EN: Step 3 - Add incoming message / VI: Bước 3 - Thêm tin nhắn đến
|
||||
var message = new Message(
|
||||
senderId: request.SenderId,
|
||||
content: request.MessageText,
|
||||
messageType: MessageType.Text,
|
||||
direction: MessageDirection.Incoming,
|
||||
facebookMessageId: request.FacebookMessageId,
|
||||
metadata: request.QuickReplyPayload != null
|
||||
? new Dictionary<string, object> { ["quick_reply_payload"] = request.QuickReplyPayload }
|
||||
: null
|
||||
);
|
||||
|
||||
conversation.AddMessage(message);
|
||||
_conversationRepository.Update(conversation);
|
||||
|
||||
// EN: Step 4 - Save all changes / VI: Bước 4 - Lưu tất cả thay đổi
|
||||
await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Message processed successfully. ConversationId: {ConversationId}, MessageId: {MessageId}",
|
||||
conversation.Id, message.Id);
|
||||
|
||||
return new ProcessIncomingMessageResult(
|
||||
ConversationId: conversation.Id,
|
||||
CustomerId: customer.Id,
|
||||
MessageId: message.Id,
|
||||
IsNewConversation: isNewConversation,
|
||||
IsNewCustomer: isNewCustomer
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to update customer tags and custom fields.
|
||||
/// VI: Command để cập nhật tags và custom fields của customer.
|
||||
/// </summary>
|
||||
/// <param name="CustomerId">EN: Customer ID / VI: ID customer</param>
|
||||
/// <param name="Tags">EN: New tags (replaces existing) / VI: Tags mới (thay thế hiện có)</param>
|
||||
/// <param name="CustomFields">EN: Custom fields to set / VI: Custom fields để đặt</param>
|
||||
public record UpdateCustomerCommand(
|
||||
Guid CustomerId,
|
||||
IEnumerable<string>? Tags = null,
|
||||
Dictionary<string, string>? CustomFields = null
|
||||
) : IRequest<UpdateCustomerCommandResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of UpdateCustomerCommand.
|
||||
/// VI: Kết quả của UpdateCustomerCommand.
|
||||
/// </summary>
|
||||
/// <param name="Success">EN: Whether update was successful / VI: Cập nhật có thành công không</param>
|
||||
/// <param name="CustomerId">EN: Updated customer ID / VI: ID customer đã cập nhật</param>
|
||||
public record UpdateCustomerCommandResult(bool Success, Guid CustomerId);
|
||||
@@ -0,0 +1,72 @@
|
||||
using MediatR;
|
||||
using FacebookService.Domain.AggregatesModel.CustomerAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for UpdateCustomerCommand.
|
||||
/// VI: Handler cho UpdateCustomerCommand.
|
||||
/// </summary>
|
||||
public class UpdateCustomerCommandHandler : IRequestHandler<UpdateCustomerCommand, UpdateCustomerCommandResult>
|
||||
{
|
||||
private readonly ICustomerRepository _customerRepository;
|
||||
private readonly ILogger<UpdateCustomerCommandHandler> _logger;
|
||||
|
||||
public UpdateCustomerCommandHandler(
|
||||
ICustomerRepository customerRepository,
|
||||
ILogger<UpdateCustomerCommandHandler> logger)
|
||||
{
|
||||
_customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<UpdateCustomerCommandResult> Handle(
|
||||
UpdateCustomerCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Processing UpdateCustomerCommand for CustomerId: {CustomerId}",
|
||||
request.CustomerId);
|
||||
|
||||
var customer = await _customerRepository.GetByIdAsync(request.CustomerId, cancellationToken);
|
||||
|
||||
if (customer is null)
|
||||
{
|
||||
_logger.LogWarning("Customer not found: {CustomerId}", request.CustomerId);
|
||||
return new UpdateCustomerCommandResult(Success: false, request.CustomerId);
|
||||
}
|
||||
|
||||
// EN: Update tags if provided / VI: Cập nhật tags nếu được cung cấp
|
||||
if (request.Tags is not null)
|
||||
{
|
||||
// EN: Clear existing tags and add new ones / VI: Xóa tags hiện có và thêm mới
|
||||
var currentTags = customer.Tags.ToList();
|
||||
foreach (var tag in currentTags)
|
||||
{
|
||||
customer.RemoveTag(tag);
|
||||
}
|
||||
foreach (var tag in request.Tags)
|
||||
{
|
||||
customer.AddTag(tag);
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Update custom fields if provided / VI: Cập nhật custom fields nếu được cung cấp
|
||||
if (request.CustomFields is not null)
|
||||
{
|
||||
foreach (var (key, value) in request.CustomFields)
|
||||
{
|
||||
customer.SetCustomField(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
_customerRepository.Update(customer);
|
||||
await _customerRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Customer updated successfully: {CustomerId}",
|
||||
customer.Id);
|
||||
|
||||
return new UpdateCustomerCommandResult(Success: true, customer.Id);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to update an existing Sample.
|
||||
/// VI: Command để cập nhật một Sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID to update / VI: ID sample cần cập nhật</param>
|
||||
/// <param name="Name">EN: New name / VI: Tên mới</param>
|
||||
/// <param name="Description">EN: New description / VI: Mô tả mới</param>
|
||||
public record UpdateSampleCommand(
|
||||
Guid SampleId,
|
||||
string Name,
|
||||
string? Description
|
||||
) : IRequest<bool>;
|
||||
@@ -1,54 +0,0 @@
|
||||
using MediatR;
|
||||
using FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for UpdateSampleCommand.
|
||||
/// VI: Handler cho UpdateSampleCommand.
|
||||
/// </summary>
|
||||
public class UpdateSampleCommandHandler : IRequestHandler<UpdateSampleCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<UpdateSampleCommandHandler> _logger;
|
||||
|
||||
public UpdateSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<UpdateSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
UpdateSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Updating sample {SampleId} / Cập nhật sample {SampleId}",
|
||||
request.SampleId);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Update sample using domain method / VI: Cập nhật sample sử dụng domain method
|
||||
sample.Update(request.Name, request.Description);
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} updated successfully / Sample {SampleId} đã cập nhật thành công",
|
||||
request.SampleId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using MediatR;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create or update AI Chatbot configuration.
|
||||
/// VI: Command để tạo hoặc cập nhật cấu hình AI Chatbot.
|
||||
/// </summary>
|
||||
/// <param name="ShopId">EN: Shop ID / VI: ID shop</param>
|
||||
/// <param name="Provider">EN: AI provider (OpenAI, AzureOpenAI) / VI: Nhà cung cấp AI</param>
|
||||
/// <param name="Model">EN: Model name (e.g., gpt-4-turbo) / VI: Tên model</param>
|
||||
/// <param name="SystemPrompt">EN: System prompt for AI context / VI: System prompt cho ngữ cảnh AI</param>
|
||||
/// <param name="Temperature">EN: Temperature (0.0 - 2.0) / VI: Temperature</param>
|
||||
/// <param name="MaxTokens">EN: Max tokens for response / VI: Max tokens cho response</param>
|
||||
public record UpsertAIChatbotConfigCommand(
|
||||
Guid ShopId,
|
||||
string Provider,
|
||||
string Model,
|
||||
string SystemPrompt,
|
||||
float Temperature = 0.7f,
|
||||
int MaxTokens = 500
|
||||
) : IRequest<UpsertAIChatbotConfigResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of UpsertAIChatbotConfigCommand.
|
||||
/// VI: Kết quả của UpsertAIChatbotConfigCommand.
|
||||
/// </summary>
|
||||
public record UpsertAIChatbotConfigResult(Guid Id, bool IsNew, bool Success);
|
||||
@@ -0,0 +1,101 @@
|
||||
using MediatR;
|
||||
using FacebookService.Domain.AggregatesModel.ChatbotAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for UpsertAIChatbotConfigCommand.
|
||||
/// VI: Handler cho UpsertAIChatbotConfigCommand.
|
||||
/// </summary>
|
||||
public class UpsertAIChatbotConfigCommandHandler : IRequestHandler<UpsertAIChatbotConfigCommand, UpsertAIChatbotConfigResult>
|
||||
{
|
||||
private readonly IAIChatbotConfigRepository _configRepository;
|
||||
private readonly ILogger<UpsertAIChatbotConfigCommandHandler> _logger;
|
||||
|
||||
public UpsertAIChatbotConfigCommandHandler(
|
||||
IAIChatbotConfigRepository configRepository,
|
||||
ILogger<UpsertAIChatbotConfigCommandHandler> logger)
|
||||
{
|
||||
_configRepository = configRepository ?? throw new ArgumentNullException(nameof(configRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<UpsertAIChatbotConfigResult> Handle(
|
||||
UpsertAIChatbotConfigCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Upserting AI chatbot config for shop: {ShopId}",
|
||||
request.ShopId);
|
||||
|
||||
// EN: Parse provider / VI: Parse provider
|
||||
var provider = ParseProvider(request.Provider);
|
||||
if (provider is null)
|
||||
{
|
||||
_logger.LogWarning("Invalid AI provider: {Provider}", request.Provider);
|
||||
return new UpsertAIChatbotConfigResult(Guid.Empty, IsNew: false, Success: false);
|
||||
}
|
||||
|
||||
// EN: Check for existing config / VI: Kiểm tra config sẵn có
|
||||
var existingConfig = await _configRepository.GetByShopIdAsync(request.ShopId, cancellationToken);
|
||||
|
||||
if (existingConfig is not null)
|
||||
{
|
||||
// EN: Update existing / VI: Cập nhật sẵn có
|
||||
try
|
||||
{
|
||||
existingConfig.UpdateConfig(
|
||||
provider,
|
||||
request.Model,
|
||||
request.SystemPrompt,
|
||||
request.Temperature,
|
||||
request.MaxTokens);
|
||||
|
||||
_configRepository.Update(existingConfig);
|
||||
await _configRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("AI config updated for shop: {ShopId}", request.ShopId);
|
||||
return new UpsertAIChatbotConfigResult(existingConfig.Id, IsNew: false, Success: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update AI config for shop: {ShopId}", request.ShopId);
|
||||
return new UpsertAIChatbotConfigResult(existingConfig.Id, IsNew: false, Success: false);
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Create new config / VI: Tạo config mới
|
||||
try
|
||||
{
|
||||
var config = new AIChatbotConfig(
|
||||
request.ShopId,
|
||||
provider,
|
||||
request.Model,
|
||||
request.SystemPrompt,
|
||||
request.Temperature,
|
||||
request.MaxTokens);
|
||||
|
||||
_configRepository.Add(config);
|
||||
await _configRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("AI config created for shop: {ShopId} with ID: {ConfigId}",
|
||||
request.ShopId, config.Id);
|
||||
return new UpsertAIChatbotConfigResult(config.Id, IsNew: true, Success: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to create AI config for shop: {ShopId}", request.ShopId);
|
||||
return new UpsertAIChatbotConfigResult(Guid.Empty, IsNew: true, Success: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static AIProvider? ParseProvider(string provider)
|
||||
{
|
||||
return provider?.ToLowerInvariant() switch
|
||||
{
|
||||
"openai" => AIProvider.OpenAI,
|
||||
"azureopenai" or "azure" => AIProvider.AzureOpenAI,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace FacebookService.API.Application.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// EN: ChatbotFlow DTO for API responses.
|
||||
/// VI: ChatbotFlow DTO cho API responses.
|
||||
/// </summary>
|
||||
public record ChatbotFlowDto(
|
||||
Guid Id,
|
||||
Guid ShopId,
|
||||
string Name,
|
||||
string TriggerType,
|
||||
string TriggerValue,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt,
|
||||
IReadOnlyList<FlowNodeDto>? Nodes = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: ChatbotFlow summary DTO for list responses.
|
||||
/// VI: ChatbotFlow summary DTO cho list responses.
|
||||
/// </summary>
|
||||
public record ChatbotFlowSummaryDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string TriggerType,
|
||||
string TriggerValue,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: FlowNode DTO for API responses.
|
||||
/// VI: FlowNode DTO cho API responses.
|
||||
/// </summary>
|
||||
public record FlowNodeDto(
|
||||
Guid Id,
|
||||
string NodeType,
|
||||
string? Content,
|
||||
int OrderIndex,
|
||||
IReadOnlyCollection<Guid> NextNodeIds,
|
||||
IReadOnlyDictionary<string, object>? Config = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: AIChatbotConfig DTO for API responses.
|
||||
/// VI: AIChatbotConfig DTO cho API responses.
|
||||
/// </summary>
|
||||
public record AIChatbotConfigDto(
|
||||
Guid Id,
|
||||
Guid ShopId,
|
||||
string Provider,
|
||||
string Model,
|
||||
string SystemPrompt,
|
||||
float Temperature,
|
||||
int MaxTokens,
|
||||
bool IsEnabled,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace FacebookService.API.Application.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Conversation DTO for API responses.
|
||||
/// VI: Conversation DTO cho API responses.
|
||||
/// </summary>
|
||||
public record ConversationDto(
|
||||
Guid Id,
|
||||
Guid CustomerId,
|
||||
CustomerSummaryDto? Customer,
|
||||
string PageId,
|
||||
string Status,
|
||||
string Channel,
|
||||
string? AssignedAgentId,
|
||||
DateTime? LastMessageAt,
|
||||
DateTime CreatedAt,
|
||||
IReadOnlyList<MessageDto>? Messages = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Conversation summary DTO for list responses.
|
||||
/// VI: Conversation summary DTO cho list responses.
|
||||
/// </summary>
|
||||
public record ConversationSummaryDto(
|
||||
Guid Id,
|
||||
Guid CustomerId,
|
||||
string? CustomerName,
|
||||
string PageId,
|
||||
string Status,
|
||||
string? LastMessageText,
|
||||
DateTime? LastMessageAt,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Message DTO for API responses.
|
||||
/// VI: Message DTO cho API responses.
|
||||
/// </summary>
|
||||
public record MessageDto(
|
||||
Guid Id,
|
||||
string SenderId,
|
||||
string Content,
|
||||
string MessageType,
|
||||
string Direction,
|
||||
DateTime SentAt,
|
||||
string? FacebookMessageId = null,
|
||||
IReadOnlyDictionary<string, object>? Metadata = null
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace FacebookService.API.Application.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Customer DTO for API responses.
|
||||
/// VI: Customer DTO cho API responses.
|
||||
/// </summary>
|
||||
public record CustomerDto(
|
||||
Guid Id,
|
||||
string FacebookUserId,
|
||||
string? Name,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string? ProfilePicUrl,
|
||||
string? Locale,
|
||||
string? Timezone,
|
||||
IReadOnlyCollection<string> Tags,
|
||||
IReadOnlyDictionary<string, string> CustomFields,
|
||||
DateTime FirstSeenAt,
|
||||
DateTime LastInteractionAt
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Customer summary DTO for list responses.
|
||||
/// VI: Customer summary DTO cho list responses.
|
||||
/// </summary>
|
||||
public record CustomerSummaryDto(
|
||||
Guid Id,
|
||||
string FacebookUserId,
|
||||
string? Name,
|
||||
string? ProfilePicUrl,
|
||||
IReadOnlyCollection<string> Tags,
|
||||
DateTime LastInteractionAt
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
using MediatR;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
|
||||
namespace FacebookService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get AI Chatbot config for a shop.
|
||||
/// VI: Query để lấy cấu hình AI Chatbot cho shop.
|
||||
/// </summary>
|
||||
/// <param name="ShopId">EN: Shop ID / VI: ID shop</param>
|
||||
public record GetAIChatbotConfigQuery(Guid ShopId) : IRequest<AIChatbotConfigDto?>;
|
||||
@@ -0,0 +1,44 @@
|
||||
using MediatR;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
using FacebookService.Domain.AggregatesModel.ChatbotAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetAIChatbotConfigQuery.
|
||||
/// VI: Handler cho GetAIChatbotConfigQuery.
|
||||
/// </summary>
|
||||
public class GetAIChatbotConfigQueryHandler : IRequestHandler<GetAIChatbotConfigQuery, AIChatbotConfigDto?>
|
||||
{
|
||||
private readonly IAIChatbotConfigRepository _configRepository;
|
||||
|
||||
public GetAIChatbotConfigQueryHandler(IAIChatbotConfigRepository configRepository)
|
||||
{
|
||||
_configRepository = configRepository ?? throw new ArgumentNullException(nameof(configRepository));
|
||||
}
|
||||
|
||||
public async Task<AIChatbotConfigDto?> Handle(
|
||||
GetAIChatbotConfigQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var config = await _configRepository.GetByShopIdAsync(request.ShopId, cancellationToken);
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AIChatbotConfigDto(
|
||||
config.Id,
|
||||
config.ShopId,
|
||||
config.Provider.Name,
|
||||
config.Model,
|
||||
config.SystemPrompt,
|
||||
config.Temperature,
|
||||
config.MaxTokens,
|
||||
config.IsEnabled,
|
||||
config.CreatedAt,
|
||||
config.UpdatedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using MediatR;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
|
||||
namespace FacebookService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a ChatbotFlow by ID with nodes.
|
||||
/// VI: Query để lấy một ChatbotFlow theo ID với nodes.
|
||||
/// </summary>
|
||||
/// <param name="FlowId">EN: Flow ID / VI: ID flow</param>
|
||||
public record GetChatbotFlowByIdQuery(Guid FlowId) : IRequest<ChatbotFlowDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get chatbot flows for a shop.
|
||||
/// VI: Query để lấy danh sách chatbot flows của shop.
|
||||
/// </summary>
|
||||
/// <param name="ShopId">EN: Shop ID / VI: ID shop</param>
|
||||
/// <param name="ActiveOnly">EN: Only return active flows / VI: Chỉ lấy flows đang hoạt động</param>
|
||||
public record GetChatbotFlowsQuery(
|
||||
Guid ShopId,
|
||||
bool ActiveOnly = false
|
||||
) : IRequest<IReadOnlyList<ChatbotFlowSummaryDto>>;
|
||||
@@ -0,0 +1,97 @@
|
||||
using MediatR;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
using FacebookService.Domain.AggregatesModel.ChatbotAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetChatbotFlowByIdQuery.
|
||||
/// VI: Handler cho GetChatbotFlowByIdQuery.
|
||||
/// </summary>
|
||||
public class GetChatbotFlowByIdQueryHandler : IRequestHandler<GetChatbotFlowByIdQuery, ChatbotFlowDto?>
|
||||
{
|
||||
private readonly IChatbotFlowRepository _flowRepository;
|
||||
|
||||
public GetChatbotFlowByIdQueryHandler(IChatbotFlowRepository flowRepository)
|
||||
{
|
||||
_flowRepository = flowRepository ?? throw new ArgumentNullException(nameof(flowRepository));
|
||||
}
|
||||
|
||||
public async Task<ChatbotFlowDto?> Handle(
|
||||
GetChatbotFlowByIdQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var flow = await _flowRepository.GetByIdWithNodesAsync(request.FlowId, cancellationToken);
|
||||
|
||||
if (flow is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapToDto(flow);
|
||||
}
|
||||
|
||||
private static ChatbotFlowDto MapToDto(ChatbotFlow flow)
|
||||
{
|
||||
var nodes = flow.Nodes.OrderBy(n => n.OrderIndex).Select(MapNodeToDto).ToList();
|
||||
|
||||
return new ChatbotFlowDto(
|
||||
flow.Id,
|
||||
flow.ShopId,
|
||||
flow.Name,
|
||||
flow.TriggerType.Name,
|
||||
flow.TriggerValue,
|
||||
flow.IsActive,
|
||||
flow.CreatedAt,
|
||||
flow.UpdatedAt,
|
||||
nodes
|
||||
);
|
||||
}
|
||||
|
||||
private static FlowNodeDto MapNodeToDto(FlowNode node)
|
||||
{
|
||||
return new FlowNodeDto(
|
||||
node.Id,
|
||||
node.NodeType.Name,
|
||||
node.Content,
|
||||
node.OrderIndex,
|
||||
node.NextNodeIds,
|
||||
node.Config
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetChatbotFlowsQuery.
|
||||
/// VI: Handler cho GetChatbotFlowsQuery.
|
||||
/// </summary>
|
||||
public class GetChatbotFlowsQueryHandler : IRequestHandler<GetChatbotFlowsQuery, IReadOnlyList<ChatbotFlowSummaryDto>>
|
||||
{
|
||||
private readonly IChatbotFlowRepository _flowRepository;
|
||||
|
||||
public GetChatbotFlowsQueryHandler(IChatbotFlowRepository flowRepository)
|
||||
{
|
||||
_flowRepository = flowRepository ?? throw new ArgumentNullException(nameof(flowRepository));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ChatbotFlowSummaryDto>> Handle(
|
||||
GetChatbotFlowsQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var flows = await _flowRepository.GetByShopIdAsync(request.ShopId, cancellationToken);
|
||||
|
||||
if (request.ActiveOnly)
|
||||
{
|
||||
flows = flows.Where(f => f.IsActive).ToList();
|
||||
}
|
||||
|
||||
return flows.Select(f => new ChatbotFlowSummaryDto(
|
||||
f.Id,
|
||||
f.Name,
|
||||
f.TriggerType.Name,
|
||||
f.TriggerValue,
|
||||
f.IsActive,
|
||||
f.CreatedAt
|
||||
)).ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using MediatR;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
|
||||
namespace FacebookService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a Conversation by ID with messages.
|
||||
/// VI: Query để lấy một Conversation theo ID với tin nhắn.
|
||||
/// </summary>
|
||||
/// <param name="ConversationId">EN: Conversation ID / VI: ID conversation</param>
|
||||
/// <param name="IncludeMessages">EN: Whether to include messages / VI: Có bao gồm tin nhắn không</param>
|
||||
public record GetConversationByIdQuery(
|
||||
Guid ConversationId,
|
||||
bool IncludeMessages = true
|
||||
) : IRequest<ConversationDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get conversations with pagination.
|
||||
/// VI: Query để lấy danh sách conversations với phân trang.
|
||||
/// </summary>
|
||||
/// <param name="ShopId">EN: Shop ID / VI: ID shop</param>
|
||||
/// <param name="Status">EN: Optional status filter / VI: Lọc theo status tùy chọn</param>
|
||||
/// <param name="Skip">EN: Number to skip / VI: Số lượng bỏ qua</param>
|
||||
/// <param name="Take">EN: Number to take / VI: Số lượng lấy</param>
|
||||
public record GetConversationsQuery(
|
||||
Guid? ShopId = null,
|
||||
string? Status = null,
|
||||
int Skip = 0,
|
||||
int Take = 20
|
||||
) : IRequest<GetConversationsQueryResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of GetConversationsQuery.
|
||||
/// VI: Kết quả của GetConversationsQuery.
|
||||
/// </summary>
|
||||
public record GetConversationsQueryResult(
|
||||
IReadOnlyList<ConversationSummaryDto> Conversations,
|
||||
int TotalCount
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
using MediatR;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
using FacebookService.Domain.AggregatesModel.ConversationAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetConversationByIdQuery.
|
||||
/// VI: Handler cho GetConversationByIdQuery.
|
||||
/// </summary>
|
||||
public class GetConversationByIdQueryHandler : IRequestHandler<GetConversationByIdQuery, ConversationDto?>
|
||||
{
|
||||
private readonly IConversationRepository _conversationRepository;
|
||||
|
||||
public GetConversationByIdQueryHandler(IConversationRepository conversationRepository)
|
||||
{
|
||||
_conversationRepository = conversationRepository ?? throw new ArgumentNullException(nameof(conversationRepository));
|
||||
}
|
||||
|
||||
public async Task<ConversationDto?> Handle(
|
||||
GetConversationByIdQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Conversation? conversation;
|
||||
|
||||
if (request.IncludeMessages)
|
||||
{
|
||||
conversation = await _conversationRepository.GetByIdWithMessagesAsync(
|
||||
request.ConversationId, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
conversation = await _conversationRepository.GetByIdAsync(
|
||||
request.ConversationId, cancellationToken);
|
||||
}
|
||||
|
||||
if (conversation is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapToDto(conversation, request.IncludeMessages);
|
||||
}
|
||||
|
||||
private static ConversationDto MapToDto(Conversation conversation, bool includeMessages)
|
||||
{
|
||||
var messages = includeMessages
|
||||
? conversation.Messages.OrderBy(m => m.SentAt).Select(MapMessageToDto).ToList()
|
||||
: null;
|
||||
|
||||
return new ConversationDto(
|
||||
conversation.Id,
|
||||
conversation.CustomerId,
|
||||
Customer: null, // EN: Not loaded here / VI: Không load ở đây
|
||||
conversation.PageId,
|
||||
conversation.Status.Name,
|
||||
conversation.Channel,
|
||||
conversation.AssignedAgentId,
|
||||
conversation.LastMessageAt,
|
||||
conversation.CreatedAt,
|
||||
messages
|
||||
);
|
||||
}
|
||||
|
||||
private static MessageDto MapMessageToDto(Message message)
|
||||
{
|
||||
return new MessageDto(
|
||||
message.Id,
|
||||
message.SenderId,
|
||||
message.Content,
|
||||
message.MessageType.Name,
|
||||
message.Direction.Name,
|
||||
message.SentAt,
|
||||
message.FacebookMessageId,
|
||||
message.Metadata
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using MediatR;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
|
||||
namespace FacebookService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a Customer by ID.
|
||||
/// VI: Query để lấy một Customer theo ID.
|
||||
/// </summary>
|
||||
/// <param name="CustomerId">EN: Customer ID / VI: ID customer</param>
|
||||
public record GetCustomerByIdQuery(Guid CustomerId) : IRequest<CustomerDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a Customer by Facebook User ID.
|
||||
/// VI: Query để lấy một Customer theo Facebook User ID.
|
||||
/// </summary>
|
||||
/// <param name="FacebookUserId">EN: Facebook User ID / VI: ID người dùng Facebook</param>
|
||||
public record GetCustomerByFacebookIdQuery(string FacebookUserId) : IRequest<CustomerDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get customers with pagination and filtering.
|
||||
/// VI: Query để lấy danh sách customers với phân trang và lọc.
|
||||
/// </summary>
|
||||
/// <param name="ShopId">EN: Shop ID / VI: ID shop</param>
|
||||
/// <param name="Tags">EN: Optional tag filter / VI: Lọc theo tags tùy chọn</param>
|
||||
/// <param name="Search">EN: Optional search term / VI: Từ khóa tìm kiếm tùy chọn</param>
|
||||
/// <param name="Skip">EN: Number to skip / VI: Số lượng bỏ qua</param>
|
||||
/// <param name="Take">EN: Number to take / VI: Số lượng lấy</param>
|
||||
public record GetCustomersQuery(
|
||||
Guid? ShopId = null,
|
||||
IEnumerable<string>? Tags = null,
|
||||
string? Search = null,
|
||||
int Skip = 0,
|
||||
int Take = 20
|
||||
) : IRequest<GetCustomersQueryResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of GetCustomersQuery with pagination info.
|
||||
/// VI: Kết quả của GetCustomersQuery với thông tin phân trang.
|
||||
/// </summary>
|
||||
public record GetCustomersQueryResult(
|
||||
IReadOnlyList<CustomerSummaryDto> Customers,
|
||||
int TotalCount
|
||||
);
|
||||
@@ -0,0 +1,92 @@
|
||||
using MediatR;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
using FacebookService.Domain.AggregatesModel.CustomerAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetCustomerByIdQuery.
|
||||
/// VI: Handler cho GetCustomerByIdQuery.
|
||||
/// </summary>
|
||||
public class GetCustomerByIdQueryHandler : IRequestHandler<GetCustomerByIdQuery, CustomerDto?>
|
||||
{
|
||||
private readonly ICustomerRepository _customerRepository;
|
||||
|
||||
public GetCustomerByIdQueryHandler(ICustomerRepository customerRepository)
|
||||
{
|
||||
_customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository));
|
||||
}
|
||||
|
||||
public async Task<CustomerDto?> Handle(
|
||||
GetCustomerByIdQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var customer = await _customerRepository.GetByIdAsync(request.CustomerId, cancellationToken);
|
||||
|
||||
if (customer is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapToDto(customer);
|
||||
}
|
||||
|
||||
private static CustomerDto MapToDto(Customer customer)
|
||||
{
|
||||
return new CustomerDto(
|
||||
customer.Id,
|
||||
customer.FacebookUserId,
|
||||
customer.Name,
|
||||
customer.Email,
|
||||
customer.Phone,
|
||||
customer.ProfilePicUrl,
|
||||
customer.Locale,
|
||||
customer.Timezone,
|
||||
customer.Tags,
|
||||
customer.CustomFields,
|
||||
customer.FirstSeenAt,
|
||||
customer.LastInteractionAt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetCustomerByFacebookIdQuery.
|
||||
/// VI: Handler cho GetCustomerByFacebookIdQuery.
|
||||
/// </summary>
|
||||
public class GetCustomerByFacebookIdQueryHandler : IRequestHandler<GetCustomerByFacebookIdQuery, CustomerDto?>
|
||||
{
|
||||
private readonly ICustomerRepository _customerRepository;
|
||||
|
||||
public GetCustomerByFacebookIdQueryHandler(ICustomerRepository customerRepository)
|
||||
{
|
||||
_customerRepository = customerRepository ?? throw new ArgumentNullException(nameof(customerRepository));
|
||||
}
|
||||
|
||||
public async Task<CustomerDto?> Handle(
|
||||
GetCustomerByFacebookIdQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var customer = await _customerRepository.GetByFacebookUserIdAsync(request.FacebookUserId, cancellationToken);
|
||||
|
||||
if (customer is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new CustomerDto(
|
||||
customer.Id,
|
||||
customer.FacebookUserId,
|
||||
customer.Name,
|
||||
customer.Email,
|
||||
customer.Phone,
|
||||
customer.ProfilePicUrl,
|
||||
customer.Locale,
|
||||
customer.Timezone,
|
||||
customer.Tags,
|
||||
customer.CustomFields,
|
||||
customer.FirstSeenAt,
|
||||
customer.LastInteractionAt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace FacebookService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a Sample by ID.
|
||||
/// VI: Query để lấy một Sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
|
||||
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample view model for API responses.
|
||||
/// VI: Sample view model cho API responses.
|
||||
/// </summary>
|
||||
public record SampleViewModel(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
string Status,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt
|
||||
);
|
||||
@@ -1,39 +0,0 @@
|
||||
using MediatR;
|
||||
using FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSampleQuery.
|
||||
/// VI: Handler cho GetSampleQuery.
|
||||
/// </summary>
|
||||
public class GetSampleQueryHandler : IRequestHandler<GetSampleQuery, SampleViewModel?>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
|
||||
public GetSampleQueryHandler(ISampleRepository sampleRepository)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
}
|
||||
|
||||
public async Task<SampleViewModel?> Handle(
|
||||
GetSampleQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SampleViewModel(
|
||||
sample.Id,
|
||||
sample.Name,
|
||||
sample.Description,
|
||||
sample.Status.Name,
|
||||
sample.CreatedAt,
|
||||
sample.UpdatedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace FacebookService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get all Samples.
|
||||
/// VI: Query để lấy tất cả Samples.
|
||||
/// </summary>
|
||||
public record GetSamplesQuery : IRequest<IEnumerable<SampleViewModel>>;
|
||||
@@ -1,34 +0,0 @@
|
||||
using MediatR;
|
||||
using FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace FacebookService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSamplesQuery.
|
||||
/// VI: Handler cho GetSamplesQuery.
|
||||
/// </summary>
|
||||
public class GetSamplesQueryHandler : IRequestHandler<GetSamplesQuery, IEnumerable<SampleViewModel>>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
|
||||
public GetSamplesQueryHandler(ISampleRepository sampleRepository)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SampleViewModel>> Handle(
|
||||
GetSamplesQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var samples = await _sampleRepository.GetAllAsync();
|
||||
|
||||
return samples.Select(sample => new SampleViewModel(
|
||||
sample.Id,
|
||||
sample.Name,
|
||||
sample.Description,
|
||||
sample.Status.Name,
|
||||
sample.CreatedAt,
|
||||
sample.UpdatedAt
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using FluentValidation;
|
||||
using FacebookService.API.Application.Commands;
|
||||
|
||||
namespace FacebookService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for ProcessIncomingMessageCommand.
|
||||
/// VI: Validator cho ProcessIncomingMessageCommand.
|
||||
/// </summary>
|
||||
public class ProcessIncomingMessageCommandValidator : AbstractValidator<ProcessIncomingMessageCommand>
|
||||
{
|
||||
public ProcessIncomingMessageCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PageId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Page ID is required / Page ID là bắt buộc")
|
||||
.MaximumLength(50)
|
||||
.WithMessage("Page ID must be less than 50 characters / Page ID phải ít hơn 50 ký tự");
|
||||
|
||||
RuleFor(x => x.SenderId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Sender ID is required / Sender ID là bắt buộc")
|
||||
.MaximumLength(50)
|
||||
.WithMessage("Sender ID must be less than 50 characters / Sender ID phải ít hơn 50 ký tự");
|
||||
|
||||
RuleFor(x => x.MessageText)
|
||||
.NotEmpty()
|
||||
.WithMessage("Message text is required / Nội dung tin nhắn là bắt buộc");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateChatbotFlowCommand.
|
||||
/// VI: Validator cho CreateChatbotFlowCommand.
|
||||
/// </summary>
|
||||
public class CreateChatbotFlowCommandValidator : AbstractValidator<CreateChatbotFlowCommand>
|
||||
{
|
||||
private static readonly string[] ValidTriggerTypes = { "GetStarted", "Keyword", "Postback", "Fallback" };
|
||||
|
||||
public CreateChatbotFlowCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ShopId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Shop ID is required / Shop ID là bắt buộc");
|
||||
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("Flow name is required / Tên flow là bắt buộc")
|
||||
.MaximumLength(255)
|
||||
.WithMessage("Flow name must be less than 255 characters / Tên flow phải ít hơn 255 ký tự");
|
||||
|
||||
RuleFor(x => x.TriggerType)
|
||||
.NotEmpty()
|
||||
.WithMessage("Trigger type is required / Loại trigger là bắt buộc")
|
||||
.Must(x => ValidTriggerTypes.Contains(x, StringComparer.OrdinalIgnoreCase))
|
||||
.WithMessage($"Trigger type must be one of: {string.Join(", ", ValidTriggerTypes)}");
|
||||
|
||||
RuleFor(x => x.TriggerValue)
|
||||
.NotEmpty()
|
||||
.WithMessage("Trigger value is required / Giá trị trigger là bắt buộc")
|
||||
.MaximumLength(255)
|
||||
.WithMessage("Trigger value must be less than 255 characters / Giá trị trigger phải ít hơn 255 ký tự");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpsertAIChatbotConfigCommand.
|
||||
/// VI: Validator cho UpsertAIChatbotConfigCommand.
|
||||
/// </summary>
|
||||
public class UpsertAIChatbotConfigCommandValidator : AbstractValidator<UpsertAIChatbotConfigCommand>
|
||||
{
|
||||
private static readonly string[] ValidProviders = { "OpenAI", "AzureOpenAI", "Azure" };
|
||||
|
||||
public UpsertAIChatbotConfigCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ShopId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Shop ID is required / Shop ID là bắt buộc");
|
||||
|
||||
RuleFor(x => x.Provider)
|
||||
.NotEmpty()
|
||||
.WithMessage("Provider is required / Provider là bắt buộc")
|
||||
.Must(x => ValidProviders.Contains(x, StringComparer.OrdinalIgnoreCase))
|
||||
.WithMessage($"Provider must be one of: {string.Join(", ", ValidProviders)}");
|
||||
|
||||
RuleFor(x => x.Model)
|
||||
.NotEmpty()
|
||||
.WithMessage("Model is required / Model là bắt buộc")
|
||||
.MaximumLength(50)
|
||||
.WithMessage("Model must be less than 50 characters / Model phải ít hơn 50 ký tự");
|
||||
|
||||
RuleFor(x => x.SystemPrompt)
|
||||
.NotEmpty()
|
||||
.WithMessage("System prompt is required / System prompt là bắt buộc");
|
||||
|
||||
RuleFor(x => x.Temperature)
|
||||
.InclusiveBetween(0.0f, 2.0f)
|
||||
.WithMessage("Temperature must be between 0.0 and 2.0 / Temperature phải từ 0.0 đến 2.0");
|
||||
|
||||
RuleFor(x => x.MaxTokens)
|
||||
.InclusiveBetween(1, 4096)
|
||||
.WithMessage("Max tokens must be between 1 and 4096 / Max tokens phải từ 1 đến 4096");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using FluentValidation;
|
||||
using FacebookService.API.Application.Commands;
|
||||
|
||||
namespace FacebookService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateCustomerCommand.
|
||||
/// VI: Validator cho CreateCustomerCommand.
|
||||
/// </summary>
|
||||
public class CreateCustomerCommandValidator : AbstractValidator<CreateCustomerCommand>
|
||||
{
|
||||
public CreateCustomerCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.FacebookUserId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Facebook User ID is required / Facebook User ID là bắt buộc")
|
||||
.MaximumLength(50)
|
||||
.WithMessage("Facebook User ID must be less than 50 characters / Facebook User ID phải ít hơn 50 ký tự");
|
||||
|
||||
RuleFor(x => x.Name)
|
||||
.MaximumLength(255)
|
||||
.WithMessage("Name must be less than 255 characters / Tên phải ít hơn 255 ký tự")
|
||||
.When(x => x.Name != null);
|
||||
|
||||
RuleFor(x => x.Email)
|
||||
.EmailAddress()
|
||||
.WithMessage("Email format is invalid / Định dạng email không hợp lệ")
|
||||
.MaximumLength(255)
|
||||
.WithMessage("Email must be less than 255 characters / Email phải ít hơn 255 ký tự")
|
||||
.When(x => !string.IsNullOrEmpty(x.Email));
|
||||
|
||||
RuleFor(x => x.Phone)
|
||||
.MaximumLength(50)
|
||||
.WithMessage("Phone must be less than 50 characters / Số điện thoại phải ít hơn 50 ký tự")
|
||||
.When(x => x.Phone != null);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
using FluentValidation;
|
||||
using FacebookService.API.Application.Commands;
|
||||
|
||||
namespace FacebookService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateSampleCommand.
|
||||
/// VI: Validator cho CreateSampleCommand.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandValidator : AbstractValidator<CreateSampleCommand>
|
||||
{
|
||||
public CreateSampleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("Name is required / Tên là bắt buộc")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000)
|
||||
.WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
|
||||
.When(x => x.Description != null);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using FluentValidation;
|
||||
using FacebookService.API.Application.Commands;
|
||||
|
||||
namespace FacebookService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateSampleCommand.
|
||||
/// VI: Validator cho UpdateSampleCommand.
|
||||
/// </summary>
|
||||
public class UpdateSampleCommandValidator : AbstractValidator<UpdateSampleCommand>
|
||||
{
|
||||
public UpdateSampleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.SampleId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Sample ID is required / ID sample là bắt buộc");
|
||||
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("Name is required / Tên là bắt buộc")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000)
|
||||
.WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
|
||||
.When(x => x.Description != null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using FacebookService.API.Application.Commands;
|
||||
using FacebookService.API.Application.Queries;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
|
||||
namespace FacebookService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for Chatbot Flow and AI Config management.
|
||||
/// VI: Controller quản lý Chatbot Flow và AI Config.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/chatbots")]
|
||||
[Produces("application/json")]
|
||||
public class ChatbotsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<ChatbotsController> _logger;
|
||||
|
||||
public ChatbotsController(IMediator mediator, ILogger<ChatbotsController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
#region Chatbot Flows
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get chatbot flows for a shop.
|
||||
/// VI: Lấy danh sách chatbot flows của shop.
|
||||
/// </summary>
|
||||
[HttpGet("flows")]
|
||||
[ProducesResponseType(typeof(IEnumerable<ChatbotFlowSummaryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetFlows(
|
||||
[FromQuery] Guid shopId,
|
||||
[FromQuery] bool activeOnly = false)
|
||||
{
|
||||
var flows = await _mediator.Send(new GetChatbotFlowsQuery(shopId, activeOnly));
|
||||
return Ok(new { success = true, data = flows });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a chatbot flow by ID.
|
||||
/// VI: Lấy chatbot flow theo ID.
|
||||
/// </summary>
|
||||
[HttpGet("flows/{id:guid}")]
|
||||
[ProducesResponseType(typeof(ChatbotFlowDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetFlow(Guid id)
|
||||
{
|
||||
var flow = await _mediator.Send(new GetChatbotFlowByIdQuery(id));
|
||||
|
||||
if (flow is null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new { code = "FLOW_NOT_FOUND", message = $"Chatbot flow with ID {id} not found" }
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = flow });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new chatbot flow.
|
||||
/// VI: Tạo chatbot flow mới.
|
||||
/// </summary>
|
||||
[HttpPost("flows")]
|
||||
[ProducesResponseType(typeof(CreateChatbotFlowCommandResult), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> CreateFlow([FromBody] CreateFlowRequest request)
|
||||
{
|
||||
var command = new CreateChatbotFlowCommand(
|
||||
request.ShopId,
|
||||
request.Name,
|
||||
request.TriggerType,
|
||||
request.TriggerValue);
|
||||
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
success = false,
|
||||
error = new { code = "FLOW_CREATE_FAILED", message = "Failed to create chatbot flow" }
|
||||
});
|
||||
}
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetFlow),
|
||||
new { id = result.Id },
|
||||
new { success = true, data = result });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AI Config
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get AI chatbot config for a shop.
|
||||
/// VI: Lấy cấu hình AI chatbot của shop.
|
||||
/// </summary>
|
||||
[HttpGet("ai-config")]
|
||||
[ProducesResponseType(typeof(AIChatbotConfigDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetAIConfig([FromQuery] Guid shopId)
|
||||
{
|
||||
var config = await _mediator.Send(new GetAIChatbotConfigQuery(shopId));
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new { code = "AI_CONFIG_NOT_FOUND", message = "AI config not found for this shop" }
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = config });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create or update AI chatbot config.
|
||||
/// VI: Tạo hoặc cập nhật cấu hình AI chatbot.
|
||||
/// </summary>
|
||||
[HttpPut("ai-config")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> UpsertAIConfig([FromBody] UpsertAIConfigRequest request)
|
||||
{
|
||||
var command = new UpsertAIChatbotConfigCommand(
|
||||
request.ShopId,
|
||||
request.Provider,
|
||||
request.Model,
|
||||
request.SystemPrompt,
|
||||
request.Temperature,
|
||||
request.MaxTokens);
|
||||
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
success = false,
|
||||
error = new { code = "AI_CONFIG_FAILED", message = "Failed to save AI config" }
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new {
|
||||
success = true,
|
||||
data = result,
|
||||
message = result.IsNew ? "AI config created" : "AI config updated"
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Request Models
|
||||
|
||||
public record CreateFlowRequest(
|
||||
Guid ShopId,
|
||||
string Name,
|
||||
string TriggerType,
|
||||
string TriggerValue
|
||||
);
|
||||
|
||||
public record UpsertAIConfigRequest(
|
||||
Guid ShopId,
|
||||
string Provider,
|
||||
string Model,
|
||||
string SystemPrompt,
|
||||
float Temperature = 0.7f,
|
||||
int MaxTokens = 500
|
||||
);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,71 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using FacebookService.API.Application.Queries;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
|
||||
namespace FacebookService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for Conversation management.
|
||||
/// VI: Controller quản lý Conversation.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/conversations")]
|
||||
[Produces("application/json")]
|
||||
public class ConversationsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<ConversationsController> _logger;
|
||||
|
||||
public ConversationsController(IMediator mediator, ILogger<ConversationsController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a conversation by ID.
|
||||
/// VI: Lấy conversation theo ID.
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(ConversationDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetConversation(
|
||||
Guid id,
|
||||
[FromQuery] bool includeMessages = true)
|
||||
{
|
||||
var conversation = await _mediator.Send(
|
||||
new GetConversationByIdQuery(id, includeMessages));
|
||||
|
||||
if (conversation is null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new { code = "CONVERSATION_NOT_FOUND", message = $"Conversation with ID {id} not found" }
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = conversation });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get conversations for a shop with pagination.
|
||||
/// VI: Lấy danh sách conversations của shop với phân trang.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GetConversationsQueryResult), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetConversations(
|
||||
[FromQuery] Guid? shopId = null,
|
||||
[FromQuery] string? status = null,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 20)
|
||||
{
|
||||
var result = await _mediator.Send(
|
||||
new GetConversationsQuery(shopId, status, skip, take));
|
||||
|
||||
return Ok(new { success = true, data = result.Conversations, total = result.TotalCount });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using FacebookService.API.Application.Commands;
|
||||
using FacebookService.API.Application.Queries;
|
||||
using FacebookService.API.Application.Dtos;
|
||||
|
||||
namespace FacebookService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for Customer management.
|
||||
/// VI: Controller quản lý Customer.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/customers")]
|
||||
[Produces("application/json")]
|
||||
public class CustomersController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<CustomersController> _logger;
|
||||
|
||||
public CustomersController(IMediator mediator, ILogger<CustomersController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a customer by ID.
|
||||
/// VI: Lấy customer theo ID.
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetCustomer(Guid id)
|
||||
{
|
||||
var customer = await _mediator.Send(new GetCustomerByIdQuery(id));
|
||||
|
||||
if (customer is null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new { code = "CUSTOMER_NOT_FOUND", message = $"Customer with ID {id} not found" }
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = customer });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a customer by Facebook User ID.
|
||||
/// VI: Lấy customer theo Facebook User ID.
|
||||
/// </summary>
|
||||
[HttpGet("facebook/{facebookUserId}")]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetCustomerByFacebookId(string facebookUserId)
|
||||
{
|
||||
var customer = await _mediator.Send(new GetCustomerByFacebookIdQuery(facebookUserId));
|
||||
|
||||
if (customer is null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new { code = "CUSTOMER_NOT_FOUND", message = $"Customer with Facebook ID {facebookUserId} not found" }
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = customer });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create or update a customer.
|
||||
/// VI: Tạo hoặc cập nhật customer.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CreateCustomerCommandResult), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> CreateCustomer([FromBody] CreateCustomerRequest request)
|
||||
{
|
||||
var command = new CreateCustomerCommand(
|
||||
request.FacebookUserId,
|
||||
request.Name,
|
||||
request.Email,
|
||||
request.Phone,
|
||||
request.ProfilePicUrl,
|
||||
request.Locale,
|
||||
request.Timezone);
|
||||
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetCustomer),
|
||||
new { id = result.Id },
|
||||
new { success = true, data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update customer tags and custom fields.
|
||||
/// VI: Cập nhật tags và custom fields của customer.
|
||||
/// </summary>
|
||||
[HttpPatch("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateCustomer(Guid id, [FromBody] UpdateCustomerRequest request)
|
||||
{
|
||||
var command = new UpdateCustomerCommand(id, request.Tags, request.CustomFields);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new { code = "CUSTOMER_NOT_FOUND", message = $"Customer with ID {id} not found" }
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "Customer updated successfully" });
|
||||
}
|
||||
}
|
||||
|
||||
#region Request Models
|
||||
|
||||
public record CreateCustomerRequest(
|
||||
string FacebookUserId,
|
||||
string? Name = null,
|
||||
string? Email = null,
|
||||
string? Phone = null,
|
||||
string? ProfilePicUrl = null,
|
||||
string? Locale = null,
|
||||
string? Timezone = null
|
||||
);
|
||||
|
||||
public record UpdateCustomerRequest(
|
||||
IEnumerable<string>? Tags = null,
|
||||
Dictionary<string, string>? CustomFields = null
|
||||
);
|
||||
|
||||
#endregion
|
||||
@@ -1,200 +0,0 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using FacebookService.API.Application.Commands;
|
||||
using FacebookService.API.Application.Queries;
|
||||
|
||||
namespace FacebookService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for Sample CRUD operations using CQRS pattern.
|
||||
/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Produces("application/json")]
|
||||
public class SamplesController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<SamplesController> _logger;
|
||||
|
||||
public SamplesController(IMediator mediator, ILogger<SamplesController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all samples.
|
||||
/// VI: Lấy tất cả samples.
|
||||
/// </summary>
|
||||
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<SampleViewModel>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSamples()
|
||||
{
|
||||
var samples = await _mediator.Send(new GetSamplesQuery());
|
||||
return Ok(new { success = true, data = samples });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a sample by ID.
|
||||
/// VI: Lấy một sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <returns>EN: Sample details / VI: Chi tiết sample</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSample(Guid id)
|
||||
{
|
||||
var sample = await _mediator.Send(new GetSampleQuery(id));
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = sample });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new sample.
|
||||
/// VI: Tạo một sample mới.
|
||||
/// </summary>
|
||||
/// <param name="request">EN: Create request / VI: Request tạo</param>
|
||||
/// <returns>EN: Created sample ID / VI: ID sample đã tạo</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> CreateSample([FromBody] CreateSampleRequest request)
|
||||
{
|
||||
var command = new CreateSampleCommand(request.Name, request.Description);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetSample),
|
||||
new { id = result.Id },
|
||||
new { success = true, data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing sample.
|
||||
/// VI: Cập nhật một sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="request">EN: Update request / VI: Request cập nhật</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateSample(Guid id, [FromBody] UpdateSampleRequest request)
|
||||
{
|
||||
var command = new UpdateSampleCommand(id, request.Name, request.Description);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "Sample updated successfully / Sample đã cập nhật thành công" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a sample.
|
||||
/// VI: Xóa một sample.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteSample(Guid id)
|
||||
{
|
||||
var command = new DeleteSampleCommand(id);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Change sample status.
|
||||
/// VI: Thay đổi trạng thái sample.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="request">EN: Status change request / VI: Request thay đổi trạng thái</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ChangeSampleStatus(Guid id, [FromBody] ChangeStatusRequest request)
|
||||
{
|
||||
var command = new ChangeSampleStatusCommand(id, request.Status);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "STATUS_CHANGE_FAILED",
|
||||
message = "Failed to change sample status / Thay đổi trạng thái sample thất bại"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "Sample status changed successfully / Trạng thái sample đã thay đổi thành công" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for creating a sample.
|
||||
/// VI: Model request để tạo sample.
|
||||
/// </summary>
|
||||
public record CreateSampleRequest(string Name, string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for updating a sample.
|
||||
/// VI: Model request để cập nhật sample.
|
||||
/// </summary>
|
||||
public record UpdateSampleRequest(string Name, string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for changing sample status.
|
||||
/// VI: Model request để thay đổi trạng thái sample.
|
||||
/// </summary>
|
||||
public record ChangeStatusRequest(string Status);
|
||||
@@ -0,0 +1,229 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using FacebookService.API.Application.Commands;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace FacebookService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for Facebook Messenger webhooks.
|
||||
/// VI: Controller cho Facebook Messenger webhooks.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/webhooks/facebook")]
|
||||
[Produces("application/json")]
|
||||
public class WebhooksController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<WebhooksController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public WebhooksController(
|
||||
IMediator mediator,
|
||||
ILogger<WebhooksController> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Webhook verification endpoint for Facebook.
|
||||
/// VI: Endpoint xác thực webhook cho Facebook.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public IActionResult VerifyWebhook(
|
||||
[FromQuery(Name = "hub.mode")] string mode,
|
||||
[FromQuery(Name = "hub.verify_token")] string verifyToken,
|
||||
[FromQuery(Name = "hub.challenge")] string challenge)
|
||||
{
|
||||
_logger.LogInformation("Webhook verification request received");
|
||||
|
||||
var expectedToken = _configuration["Facebook:VerifyToken"]
|
||||
?? _configuration["FACEBOOK_VERIFY_TOKEN"];
|
||||
|
||||
if (mode == "subscribe" && verifyToken == expectedToken)
|
||||
{
|
||||
_logger.LogInformation("Webhook verified successfully");
|
||||
return Ok(challenge);
|
||||
}
|
||||
|
||||
_logger.LogWarning("Webhook verification failed: invalid token");
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Receive webhook events from Facebook Messenger.
|
||||
/// VI: Nhận webhook events từ Facebook Messenger.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ReceiveWebhook([FromBody] FacebookWebhookPayload payload)
|
||||
{
|
||||
_logger.LogInformation("Webhook event received: {Object}", payload?.Object);
|
||||
|
||||
// EN: Verify signature if configured / VI: Xác thực signature nếu được cấu hình
|
||||
var appSecret = _configuration["Facebook:AppSecret"] ?? _configuration["FACEBOOK_APP_SECRET"];
|
||||
if (!string.IsNullOrEmpty(appSecret))
|
||||
{
|
||||
var signature = Request.Headers["X-Hub-Signature-256"].FirstOrDefault();
|
||||
if (!await VerifySignatureAsync(appSecret, signature))
|
||||
{
|
||||
_logger.LogWarning("Invalid webhook signature");
|
||||
return BadRequest(new { error = "Invalid signature" });
|
||||
}
|
||||
}
|
||||
|
||||
if (payload?.Object != "page")
|
||||
{
|
||||
return Ok("EVENT_RECEIVED");
|
||||
}
|
||||
|
||||
// EN: Process each entry / VI: Xử lý từng entry
|
||||
foreach (var entry in payload.Entry ?? Enumerable.Empty<WebhookEntry>())
|
||||
{
|
||||
foreach (var messaging in entry.Messaging ?? Enumerable.Empty<MessagingEvent>())
|
||||
{
|
||||
if (messaging.Message != null)
|
||||
{
|
||||
await ProcessMessageAsync(entry.Id, messaging);
|
||||
}
|
||||
else if (messaging.Postback != null)
|
||||
{
|
||||
await ProcessPostbackAsync(entry.Id, messaging);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Return 200 quickly to avoid timeout / VI: Trả về 200 nhanh để tránh timeout
|
||||
return Ok("EVENT_RECEIVED");
|
||||
}
|
||||
|
||||
private async Task ProcessMessageAsync(string pageId, MessagingEvent messaging)
|
||||
{
|
||||
try
|
||||
{
|
||||
var command = new ProcessIncomingMessageCommand(
|
||||
PageId: pageId,
|
||||
SenderId: messaging.Sender?.Id ?? "",
|
||||
MessageText: messaging.Message?.Text ?? "",
|
||||
FacebookMessageId: messaging.Message?.Mid,
|
||||
Timestamp: messaging.Timestamp,
|
||||
QuickReplyPayload: messaging.Message?.QuickReply?.Payload
|
||||
);
|
||||
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Message processed: ConversationId={ConversationId}, IsNew={IsNew}",
|
||||
result.ConversationId, result.IsNewConversation);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing message from sender: {SenderId}",
|
||||
messaging.Sender?.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessPostbackAsync(string pageId, MessagingEvent messaging)
|
||||
{
|
||||
try
|
||||
{
|
||||
var command = new ProcessIncomingMessageCommand(
|
||||
PageId: pageId,
|
||||
SenderId: messaging.Sender?.Id ?? "",
|
||||
MessageText: messaging.Postback?.Title ?? "",
|
||||
QuickReplyPayload: messaging.Postback?.Payload
|
||||
);
|
||||
|
||||
await _mediator.Send(command);
|
||||
|
||||
_logger.LogInformation("Postback processed for sender: {SenderId}",
|
||||
messaging.Sender?.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing postback from sender: {SenderId}",
|
||||
messaging.Sender?.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> VerifySignatureAsync(string appSecret, string? signature)
|
||||
{
|
||||
if (string.IsNullOrEmpty(signature))
|
||||
return false;
|
||||
|
||||
// EN: Read raw body / VI: Đọc raw body
|
||||
Request.Body.Position = 0;
|
||||
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
Request.Body.Position = 0;
|
||||
|
||||
// EN: Calculate expected signature / VI: Tính signature mong đợi
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(appSecret));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
|
||||
var expectedSignature = "sha256=" + BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
|
||||
|
||||
return signature == expectedSignature;
|
||||
}
|
||||
}
|
||||
|
||||
#region Webhook Payload Models
|
||||
|
||||
/// <summary>
|
||||
/// EN: Facebook webhook payload model.
|
||||
/// VI: Model payload webhook Facebook.
|
||||
/// </summary>
|
||||
public record FacebookWebhookPayload
|
||||
{
|
||||
public string? Object { get; init; }
|
||||
public List<WebhookEntry>? Entry { get; init; }
|
||||
}
|
||||
|
||||
public record WebhookEntry
|
||||
{
|
||||
public string Id { get; init; } = "";
|
||||
public long Time { get; init; }
|
||||
public List<MessagingEvent>? Messaging { get; init; }
|
||||
}
|
||||
|
||||
public record MessagingEvent
|
||||
{
|
||||
public Sender? Sender { get; init; }
|
||||
public Recipient? Recipient { get; init; }
|
||||
public long Timestamp { get; init; }
|
||||
public MessagePayload? Message { get; init; }
|
||||
public PostbackPayload? Postback { get; init; }
|
||||
}
|
||||
|
||||
public record Sender { public string? Id { get; init; } }
|
||||
public record Recipient { public string? Id { get; init; } }
|
||||
|
||||
public record MessagePayload
|
||||
{
|
||||
public string? Mid { get; init; }
|
||||
public string? Text { get; init; }
|
||||
public QuickReplyPayload? QuickReply { get; init; }
|
||||
public List<AttachmentPayload>? Attachments { get; init; }
|
||||
}
|
||||
|
||||
public record QuickReplyPayload { public string? Payload { get; init; } }
|
||||
public record PostbackPayload
|
||||
{
|
||||
public string? Title { get; init; }
|
||||
public string? Payload { get; init; }
|
||||
}
|
||||
public record AttachmentPayload
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public AttachmentInnerPayload? Payload { get; init; }
|
||||
}
|
||||
public record AttachmentInnerPayload { public string? Url { get; init; } }
|
||||
|
||||
#endregion
|
||||
@@ -1,61 +0,0 @@
|
||||
using FacebookService.Domain.SeedWork;
|
||||
|
||||
namespace FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for Sample aggregate.
|
||||
/// VI: Interface repository cho Sample aggregate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Following repository pattern, this interface defines the contract
|
||||
/// for data access operations on Sample aggregate.
|
||||
/// VI: Theo pattern repository, interface này định nghĩa contract
|
||||
/// cho các thao tác truy cập dữ liệu trên Sample aggregate.
|
||||
/// </remarks>
|
||||
public interface ISampleRepository : IRepository<Sample>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get a sample by its ID.
|
||||
/// VI: Lấy một sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="sampleId">EN: The sample ID / VI: ID của sample</param>
|
||||
/// <returns>EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy</returns>
|
||||
Task<Sample?> GetAsync(Guid sampleId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all samples.
|
||||
/// VI: Lấy tất cả samples.
|
||||
/// </summary>
|
||||
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
|
||||
Task<IEnumerable<Sample>> GetAllAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a new sample.
|
||||
/// VI: Thêm một sample mới.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to add / VI: Sample cần thêm</param>
|
||||
/// <returns>EN: The added sample / VI: Sample đã thêm</returns>
|
||||
Sample Add(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing sample.
|
||||
/// VI: Cập nhật một sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to update / VI: Sample cần cập nhật</param>
|
||||
void Update(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a sample.
|
||||
/// VI: Xóa một sample.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to delete / VI: Sample cần xóa</param>
|
||||
void Delete(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get samples by status.
|
||||
/// VI: Lấy samples theo trạng thái.
|
||||
/// </summary>
|
||||
/// <param name="statusId">EN: The status ID / VI: ID trạng thái</param>
|
||||
/// <returns>EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước</returns>
|
||||
Task<IEnumerable<Sample>> GetByStatusAsync(int statusId);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
using FacebookService.Domain.Events;
|
||||
using FacebookService.Domain.Exceptions;
|
||||
using FacebookService.Domain.SeedWork;
|
||||
|
||||
namespace FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample aggregate root demonstrating DDD patterns.
|
||||
/// VI: Sample aggregate root minh họa các pattern DDD.
|
||||
/// </summary>
|
||||
public class Sample : Entity, IAggregateRoot
|
||||
{
|
||||
// EN: Private fields for encapsulation
|
||||
// VI: Fields private để đóng gói
|
||||
private string _name = null!;
|
||||
private string? _description;
|
||||
private SampleStatus _status = null!;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample name (required).
|
||||
/// VI: Tên sample (bắt buộc).
|
||||
/// </summary>
|
||||
public string Name => _name;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Optional description.
|
||||
/// VI: Mô tả tùy chọn.
|
||||
/// </summary>
|
||||
public string? Description => _description;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current status.
|
||||
/// VI: Trạng thái hiện tại.
|
||||
/// </summary>
|
||||
public SampleStatus Status => _status;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Status ID for EF Core mapping.
|
||||
/// VI: ID trạng thái cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int StatusId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời gian cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt => _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected Sample()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new Sample with required information.
|
||||
/// VI: Tạo một Sample mới với thông tin bắt buộc.
|
||||
/// </summary>
|
||||
/// <param name="name">EN: Sample name / VI: Tên sample</param>
|
||||
/// <param name="description">EN: Optional description / VI: Mô tả tùy chọn</param>
|
||||
public Sample(string name, string? description = null) : this()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Sample name cannot be empty");
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_name = name;
|
||||
_description = description;
|
||||
_status = SampleStatus.Draft;
|
||||
StatusId = SampleStatus.Draft.Id;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
|
||||
// EN: Add domain event for creation
|
||||
// VI: Thêm domain event cho việc tạo
|
||||
AddDomainEvent(new SampleCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update sample information.
|
||||
/// VI: Cập nhật thông tin sample.
|
||||
/// </summary>
|
||||
public void Update(string name, string? description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Sample name cannot be empty");
|
||||
|
||||
if (_status == SampleStatus.Cancelled)
|
||||
throw new SampleDomainException("Cannot update a cancelled sample");
|
||||
|
||||
_name = name;
|
||||
_description = description;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Activate the sample.
|
||||
/// VI: Kích hoạt sample.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
if (_status != SampleStatus.Draft)
|
||||
throw new SampleDomainException("Only draft samples can be activated");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Active;
|
||||
StatusId = SampleStatus.Active.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Complete the sample.
|
||||
/// VI: Hoàn thành sample.
|
||||
/// </summary>
|
||||
public void Complete()
|
||||
{
|
||||
if (_status != SampleStatus.Active)
|
||||
throw new SampleDomainException("Only active samples can be completed");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Completed;
|
||||
StatusId = SampleStatus.Completed.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancel the sample.
|
||||
/// VI: Hủy sample.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
if (_status == SampleStatus.Completed)
|
||||
throw new SampleDomainException("Cannot cancel a completed sample");
|
||||
|
||||
if (_status == SampleStatus.Cancelled)
|
||||
throw new SampleDomainException("Sample is already cancelled");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Cancelled;
|
||||
StatusId = SampleStatus.Cancelled.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using FacebookService.Domain.SeedWork;
|
||||
|
||||
namespace FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample status enumeration following type-safe enum pattern.
|
||||
/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu.
|
||||
/// </summary>
|
||||
public class SampleStatus : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Draft status - initial state
|
||||
/// VI: Trạng thái nháp - trạng thái ban đầu
|
||||
/// </summary>
|
||||
public static SampleStatus Draft = new(1, nameof(Draft));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Active status - ready for use
|
||||
/// VI: Trạng thái hoạt động - sẵn sàng sử dụng
|
||||
/// </summary>
|
||||
public static SampleStatus Active = new(2, nameof(Active));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Completed status - finished processing
|
||||
/// VI: Trạng thái hoàn thành - đã xử lý xong
|
||||
/// </summary>
|
||||
public static SampleStatus Completed = new(3, nameof(Completed));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancelled status - cancelled by user
|
||||
/// VI: Trạng thái đã hủy - bị hủy bởi người dùng
|
||||
/// </summary>
|
||||
public static SampleStatus Cancelled = new(4, nameof(Cancelled));
|
||||
|
||||
public SampleStatus(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all available statuses.
|
||||
/// VI: Lấy tất cả các trạng thái có sẵn.
|
||||
/// </summary>
|
||||
public static IEnumerable<SampleStatus> List() => GetAll<SampleStatus>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse status from name.
|
||||
/// VI: Parse trạng thái từ tên.
|
||||
/// </summary>
|
||||
public static SampleStatus FromName(string name)
|
||||
{
|
||||
var status = List().SingleOrDefault(s =>
|
||||
string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse status from ID.
|
||||
/// VI: Parse trạng thái từ ID.
|
||||
/// </summary>
|
||||
public static SampleStatus From(int id)
|
||||
{
|
||||
var status = List().SingleOrDefault(s => s.Id == id);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
using MediatR;
|
||||
using FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace FacebookService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a new Sample is created.
|
||||
/// VI: Domain event được phát ra khi một Sample mới được tạo.
|
||||
/// </summary>
|
||||
public class SampleCreatedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The newly created sample.
|
||||
/// VI: Sample mới được tạo.
|
||||
/// </summary>
|
||||
public Sample Sample { get; }
|
||||
|
||||
public SampleCreatedDomainEvent(Sample sample)
|
||||
{
|
||||
Sample = sample;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using MediatR;
|
||||
using FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace FacebookService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when Sample status changes.
|
||||
/// VI: Domain event được phát ra khi trạng thái Sample thay đổi.
|
||||
/// </summary>
|
||||
public class SampleStatusChangedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The sample ID.
|
||||
/// VI: ID của sample.
|
||||
/// </summary>
|
||||
public Guid SampleId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Previous status before the change.
|
||||
/// VI: Trạng thái trước khi thay đổi.
|
||||
/// </summary>
|
||||
public SampleStatus PreviousStatus { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: New status after the change.
|
||||
/// VI: Trạng thái mới sau khi thay đổi.
|
||||
/// </summary>
|
||||
public SampleStatus NewStatus { get; }
|
||||
|
||||
public SampleStatusChangedDomainEvent(
|
||||
Guid sampleId,
|
||||
SampleStatus previousStatus,
|
||||
SampleStatus newStatus)
|
||||
{
|
||||
SampleId = sampleId;
|
||||
PreviousStatus = previousStatus;
|
||||
NewStatus = newStatus;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace FacebookService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Exception for Sample aggregate domain errors.
|
||||
/// VI: Exception cho các lỗi domain của Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleDomainException : DomainException
|
||||
{
|
||||
public SampleDomainException()
|
||||
{
|
||||
}
|
||||
|
||||
public SampleDomainException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public SampleDomainException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ using FacebookService.Domain.AggregatesModel.ConversationAggregate;
|
||||
using FacebookService.Domain.AggregatesModel.ChatbotAggregate;
|
||||
using FacebookService.Infrastructure.Idempotency;
|
||||
using FacebookService.Infrastructure.Repositories;
|
||||
using FacebookService.Infrastructure.ExternalServices;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
|
||||
namespace FacebookService.Infrastructure;
|
||||
|
||||
@@ -57,6 +60,36 @@ public static class DependencyInjection
|
||||
// EN: Register idempotency services / VI: Đăng ký idempotency services
|
||||
services.AddScoped<IRequestManager, RequestManager>();
|
||||
|
||||
// EN: Register Facebook Messenger client / VI: Đăng ký Facebook Messenger client
|
||||
services.AddHttpClient<IFacebookMessengerClient, FacebookMessengerClient>()
|
||||
.AddPolicyHandler(FacebookMessengerClient.GetRetryPolicy());
|
||||
|
||||
// EN: Register AI clients / VI: Đăng ký AI clients
|
||||
var aiProvider = configuration["AI:Provider"]
|
||||
?? configuration["AI_PROVIDER"]
|
||||
?? "openai";
|
||||
|
||||
if (aiProvider.Equals("azure", StringComparison.OrdinalIgnoreCase) ||
|
||||
aiProvider.Equals("azureopenai", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
services.AddHttpClient<IAIClient, AzureOpenAIClient>()
|
||||
.AddPolicyHandler(GetAIRetryPolicy());
|
||||
}
|
||||
else
|
||||
{
|
||||
services.AddHttpClient<IAIClient, OpenAIClient>()
|
||||
.AddPolicyHandler(GetAIRetryPolicy());
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static IAsyncPolicy<HttpResponseMessage> GetAIRetryPolicy()
|
||||
{
|
||||
return HttpPolicyExtensions
|
||||
.HandleTransientHttpError()
|
||||
.OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
.WaitAndRetryAsync(3, retryAttempt =>
|
||||
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace FacebookService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Sample entity.
|
||||
/// VI: Cấu hình EF Core cho entity Sample.
|
||||
/// </summary>
|
||||
public class SampleEntityTypeConfiguration : IEntityTypeConfiguration<Sample>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Sample> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("samples");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
// EN: Ignore domain events (not persisted)
|
||||
// VI: Bỏ qua domain events (không lưu)
|
||||
builder.Ignore(s => s.DomainEvents);
|
||||
|
||||
// EN: Properties / VI: Các thuộc tính
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_description")
|
||||
.HasColumnName("description")
|
||||
.HasMaxLength(1000);
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// EN: Status relationship / VI: Quan hệ với Status
|
||||
builder.Property(s => s.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(s => s.Status)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.StatusId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// EN: Indexes / VI: Các index
|
||||
builder.HasIndex("_name");
|
||||
builder.HasIndex(s => s.StatusId);
|
||||
builder.HasIndex("_createdAt");
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace FacebookService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for SampleStatus enumeration.
|
||||
/// VI: Cấu hình EF Core cho enumeration SampleStatus.
|
||||
/// </summary>
|
||||
public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration<SampleStatus>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SampleStatus> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("sample_statuses");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Seed initial data / VI: Seed dữ liệu ban đầu
|
||||
builder.HasData(
|
||||
SampleStatus.Draft,
|
||||
SampleStatus.Active,
|
||||
SampleStatus.Completed,
|
||||
SampleStatus.Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Polly;
|
||||
using Polly.Extensions.Http;
|
||||
|
||||
namespace FacebookService.Infrastructure.ExternalServices;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Facebook Messenger API client implementation.
|
||||
/// VI: Triển khai client cho Facebook Messenger API.
|
||||
/// </summary>
|
||||
public class FacebookMessengerClient : IFacebookMessengerClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<FacebookMessengerClient> _logger;
|
||||
private readonly string _pageAccessToken;
|
||||
private readonly string _apiVersion;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public FacebookMessengerClient(
|
||||
HttpClient httpClient,
|
||||
IConfiguration configuration,
|
||||
ILogger<FacebookMessengerClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_pageAccessToken = configuration["Facebook:PageAccessToken"]
|
||||
?? configuration["FACEBOOK_PAGE_ACCESS_TOKEN"]
|
||||
?? throw new InvalidOperationException("Facebook Page Access Token not configured");
|
||||
|
||||
_apiVersion = configuration["Facebook:ApiVersion"]
|
||||
?? configuration["FACEBOOK_API_VERSION"]
|
||||
?? "v18.0";
|
||||
|
||||
_httpClient.BaseAddress = new Uri($"https://graph.facebook.com/{_apiVersion}/");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SendMessageResult> SendTextMessageAsync(
|
||||
string recipientId,
|
||||
string message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
recipient = new { id = recipientId },
|
||||
message = new { text = message }
|
||||
};
|
||||
|
||||
return await SendRequestAsync(payload, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SendMessageResult> SendQuickRepliesAsync(
|
||||
string recipientId,
|
||||
string message,
|
||||
IEnumerable<QuickReply> quickReplies,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
recipient = new { id = recipientId },
|
||||
message = new
|
||||
{
|
||||
text = message,
|
||||
quick_replies = quickReplies.Select(qr => new
|
||||
{
|
||||
content_type = qr.ContentType,
|
||||
title = qr.Title,
|
||||
payload = qr.Payload,
|
||||
image_url = qr.ImageUrl
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
return await SendRequestAsync(payload, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SendMessageResult> SendGenericTemplateAsync(
|
||||
string recipientId,
|
||||
IEnumerable<TemplateElement> elements,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
recipient = new { id = recipientId },
|
||||
message = new
|
||||
{
|
||||
attachment = new
|
||||
{
|
||||
type = "template",
|
||||
payload = new
|
||||
{
|
||||
template_type = "generic",
|
||||
elements = elements.Select(MapElement)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return await SendRequestAsync(payload, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<SendMessageResult> SendButtonTemplateAsync(
|
||||
string recipientId,
|
||||
string text,
|
||||
IEnumerable<TemplateButton> buttons,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
recipient = new { id = recipientId },
|
||||
message = new
|
||||
{
|
||||
attachment = new
|
||||
{
|
||||
type = "template",
|
||||
payload = new
|
||||
{
|
||||
template_type = "button",
|
||||
text = text,
|
||||
buttons = buttons.Select(MapButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return await SendRequestAsync(payload, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<FacebookUserProfile?> GetUserProfileAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{userId}?fields=first_name,last_name,profile_pic,locale,timezone&access_token={_pageAccessToken}";
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Failed to get user profile for: {UserId}", userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
|
||||
return new FacebookUserProfile(
|
||||
Id: userId,
|
||||
FirstName: data.TryGetProperty("first_name", out var fn) ? fn.GetString() : null,
|
||||
LastName: data.TryGetProperty("last_name", out var ln) ? ln.GetString() : null,
|
||||
ProfilePic: data.TryGetProperty("profile_pic", out var pp) ? pp.GetString() : null,
|
||||
Locale: data.TryGetProperty("locale", out var loc) ? loc.GetString() : null,
|
||||
Timezone: data.TryGetProperty("timezone", out var tz) ? (float?)tz.GetSingle() : null
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting user profile for: {UserId}", userId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> MarkAsSeenAsync(
|
||||
string recipientId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
recipient = new { id = recipientId },
|
||||
sender_action = "mark_seen"
|
||||
};
|
||||
|
||||
var result = await SendRequestAsync(payload, cancellationToken);
|
||||
return result.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> ShowTypingAsync(
|
||||
string recipientId,
|
||||
bool isTyping = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
recipient = new { id = recipientId },
|
||||
sender_action = isTyping ? "typing_on" : "typing_off"
|
||||
};
|
||||
|
||||
var result = await SendRequestAsync(payload, cancellationToken);
|
||||
return result.Success;
|
||||
}
|
||||
|
||||
private async Task<SendMessageResult> SendRequestAsync(
|
||||
object payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"me/messages?access_token={_pageAccessToken}";
|
||||
var response = await _httpClient.PostAsJsonAsync(url, payload, JsonOptions, cancellationToken);
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(content);
|
||||
return new SendMessageResult(
|
||||
Success: true,
|
||||
MessageId: data.TryGetProperty("message_id", out var mid) ? mid.GetString() : null,
|
||||
RecipientId: data.TryGetProperty("recipient_id", out var rid) ? rid.GetString() : null
|
||||
);
|
||||
}
|
||||
|
||||
_logger.LogWarning("Facebook API error: {Response}", content);
|
||||
return new SendMessageResult(Success: false, Error: content);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error sending message to Facebook");
|
||||
return new SendMessageResult(Success: false, Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static object MapElement(TemplateElement element)
|
||||
{
|
||||
return new
|
||||
{
|
||||
title = element.Title,
|
||||
subtitle = element.Subtitle,
|
||||
image_url = element.ImageUrl,
|
||||
default_action = element.DefaultAction != null ? MapButton(element.DefaultAction) : null,
|
||||
buttons = element.Buttons?.Select(MapButton)
|
||||
};
|
||||
}
|
||||
|
||||
private static object MapButton(TemplateButton button)
|
||||
{
|
||||
return button.Type switch
|
||||
{
|
||||
"web_url" => new { type = "web_url", title = button.Title, url = button.Url },
|
||||
"postback" => new { type = "postback", title = button.Title, payload = button.Payload },
|
||||
"phone_number" => new { type = "phone_number", title = button.Title, payload = button.Payload },
|
||||
_ => new { type = button.Type, title = button.Title }
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create retry policy for HTTP requests.
|
||||
/// VI: Tạo retry policy cho HTTP requests.
|
||||
/// </summary>
|
||||
public static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
|
||||
{
|
||||
return HttpPolicyExtensions
|
||||
.HandleTransientHttpError()
|
||||
.WaitAndRetryAsync(3, retryAttempt =>
|
||||
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace FacebookService.Infrastructure.ExternalServices;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Interface for AI/LLM service client.
|
||||
/// VI: Interface cho AI/LLM service client.
|
||||
/// </summary>
|
||||
public interface IAIClient
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Generate a chat completion response.
|
||||
/// VI: Tạo response chat completion.
|
||||
/// </summary>
|
||||
Task<AICompletionResult> GenerateChatCompletionAsync(
|
||||
string systemPrompt,
|
||||
IEnumerable<ChatMessage> conversationHistory,
|
||||
AICompletionOptions options,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if the AI service is available.
|
||||
/// VI: Kiểm tra AI service có sẵn sàng không.
|
||||
/// </summary>
|
||||
Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
#region Models
|
||||
|
||||
public record ChatMessage(
|
||||
string Role, // "user", "assistant", "system"
|
||||
string Content
|
||||
);
|
||||
|
||||
public record AICompletionOptions(
|
||||
string Model,
|
||||
float Temperature = 0.7f,
|
||||
int MaxTokens = 500,
|
||||
float TopP = 1.0f,
|
||||
float FrequencyPenalty = 0.0f,
|
||||
float PresencePenalty = 0.0f
|
||||
);
|
||||
|
||||
public record AICompletionResult(
|
||||
bool Success,
|
||||
string? Content = null,
|
||||
int? PromptTokens = null,
|
||||
int? CompletionTokens = null,
|
||||
string? Error = null,
|
||||
string? FinishReason = null
|
||||
);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,113 @@
|
||||
namespace FacebookService.Infrastructure.ExternalServices;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Interface for Facebook Messenger API client.
|
||||
/// VI: Interface cho Facebook Messenger API client.
|
||||
/// </summary>
|
||||
public interface IFacebookMessengerClient
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Send a text message to a user.
|
||||
/// VI: Gửi tin nhắn văn bản đến người dùng.
|
||||
/// </summary>
|
||||
Task<SendMessageResult> SendTextMessageAsync(
|
||||
string recipientId,
|
||||
string message,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send a message with quick replies.
|
||||
/// VI: Gửi tin nhắn với quick replies.
|
||||
/// </summary>
|
||||
Task<SendMessageResult> SendQuickRepliesAsync(
|
||||
string recipientId,
|
||||
string message,
|
||||
IEnumerable<QuickReply> quickReplies,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send a generic template (cards).
|
||||
/// VI: Gửi template generic (cards).
|
||||
/// </summary>
|
||||
Task<SendMessageResult> SendGenericTemplateAsync(
|
||||
string recipientId,
|
||||
IEnumerable<TemplateElement> elements,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send a button template.
|
||||
/// VI: Gửi template button.
|
||||
/// </summary>
|
||||
Task<SendMessageResult> SendButtonTemplateAsync(
|
||||
string recipientId,
|
||||
string text,
|
||||
IEnumerable<TemplateButton> buttons,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get user profile from Facebook.
|
||||
/// VI: Lấy thông tin người dùng từ Facebook.
|
||||
/// </summary>
|
||||
Task<FacebookUserProfile?> GetUserProfileAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark message as seen.
|
||||
/// VI: Đánh dấu tin nhắn đã xem.
|
||||
/// </summary>
|
||||
Task<bool> MarkAsSeenAsync(
|
||||
string recipientId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Show typing indicator.
|
||||
/// VI: Hiển thị indicator đang gõ.
|
||||
/// </summary>
|
||||
Task<bool> ShowTypingAsync(
|
||||
string recipientId,
|
||||
bool isTyping = true,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
#region Models
|
||||
|
||||
public record SendMessageResult(
|
||||
bool Success,
|
||||
string? MessageId = null,
|
||||
string? RecipientId = null,
|
||||
string? Error = null
|
||||
);
|
||||
|
||||
public record QuickReply(
|
||||
string ContentType, // "text", "user_phone_number", "user_email"
|
||||
string? Title = null,
|
||||
string? Payload = null,
|
||||
string? ImageUrl = null
|
||||
);
|
||||
|
||||
public record TemplateElement(
|
||||
string Title,
|
||||
string? Subtitle = null,
|
||||
string? ImageUrl = null,
|
||||
TemplateButton? DefaultAction = null,
|
||||
IEnumerable<TemplateButton>? Buttons = null
|
||||
);
|
||||
|
||||
public record TemplateButton(
|
||||
string Type, // "web_url", "postback", "phone_number"
|
||||
string Title,
|
||||
string? Url = null,
|
||||
string? Payload = null
|
||||
);
|
||||
|
||||
public record FacebookUserProfile(
|
||||
string Id,
|
||||
string? FirstName,
|
||||
string? LastName,
|
||||
string? ProfilePic,
|
||||
string? Locale,
|
||||
float? Timezone
|
||||
);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,223 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FacebookService.Infrastructure.ExternalServices;
|
||||
|
||||
/// <summary>
|
||||
/// EN: OpenAI API client implementation.
|
||||
/// VI: Triển khai client cho OpenAI API.
|
||||
/// </summary>
|
||||
public class OpenAIClient : IAIClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<OpenAIClient> _logger;
|
||||
private readonly string _apiKey;
|
||||
|
||||
public OpenAIClient(
|
||||
HttpClient httpClient,
|
||||
IConfiguration configuration,
|
||||
ILogger<OpenAIClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_apiKey = configuration["OpenAI:ApiKey"]
|
||||
?? configuration["OPENAI_API_KEY"]
|
||||
?? throw new InvalidOperationException("OpenAI API Key not configured");
|
||||
|
||||
_httpClient.BaseAddress = new Uri("https://api.openai.com/v1/");
|
||||
_httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_apiKey}");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AICompletionResult> GenerateChatCompletionAsync(
|
||||
string systemPrompt,
|
||||
IEnumerable<ChatMessage> conversationHistory,
|
||||
AICompletionOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var messages = new List<object>
|
||||
{
|
||||
new { role = "system", content = systemPrompt }
|
||||
};
|
||||
|
||||
messages.AddRange(conversationHistory.Select(m => new
|
||||
{
|
||||
role = m.Role,
|
||||
content = m.Content
|
||||
}));
|
||||
|
||||
var payload = new
|
||||
{
|
||||
model = options.Model,
|
||||
messages = messages,
|
||||
temperature = options.Temperature,
|
||||
max_tokens = options.MaxTokens,
|
||||
top_p = options.TopP,
|
||||
frequency_penalty = options.FrequencyPenalty,
|
||||
presence_penalty = options.PresencePenalty
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(
|
||||
"chat/completions", payload, cancellationToken);
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("OpenAI API error: {Response}", content);
|
||||
return new AICompletionResult(Success: false, Error: content);
|
||||
}
|
||||
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(content);
|
||||
var choice = data.GetProperty("choices")[0];
|
||||
var message = choice.GetProperty("message");
|
||||
var usage = data.GetProperty("usage");
|
||||
|
||||
return new AICompletionResult(
|
||||
Success: true,
|
||||
Content: message.GetProperty("content").GetString(),
|
||||
PromptTokens: usage.GetProperty("prompt_tokens").GetInt32(),
|
||||
CompletionTokens: usage.GetProperty("completion_tokens").GetInt32(),
|
||||
FinishReason: choice.GetProperty("finish_reason").GetString()
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error calling OpenAI API");
|
||||
return new AICompletionResult(Success: false, Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync("models", cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Azure OpenAI API client implementation.
|
||||
/// VI: Triển khai client cho Azure OpenAI API.
|
||||
/// </summary>
|
||||
public class AzureOpenAIClient : IAIClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<AzureOpenAIClient> _logger;
|
||||
private readonly string _deploymentName;
|
||||
private readonly string _apiVersion;
|
||||
|
||||
public AzureOpenAIClient(
|
||||
HttpClient httpClient,
|
||||
IConfiguration configuration,
|
||||
ILogger<AzureOpenAIClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var endpoint = configuration["AzureOpenAI:Endpoint"]
|
||||
?? configuration["AZURE_OPENAI_ENDPOINT"]
|
||||
?? throw new InvalidOperationException("Azure OpenAI Endpoint not configured");
|
||||
|
||||
var apiKey = configuration["AzureOpenAI:ApiKey"]
|
||||
?? configuration["AZURE_OPENAI_KEY"]
|
||||
?? throw new InvalidOperationException("Azure OpenAI API Key not configured");
|
||||
|
||||
_deploymentName = configuration["AzureOpenAI:DeploymentName"]
|
||||
?? configuration["AZURE_OPENAI_DEPLOYMENT_NAME"]
|
||||
?? "gpt-4";
|
||||
|
||||
_apiVersion = configuration["AzureOpenAI:ApiVersion"]
|
||||
?? "2024-02-01";
|
||||
|
||||
_httpClient.BaseAddress = new Uri(endpoint.TrimEnd('/') + "/");
|
||||
_httpClient.DefaultRequestHeaders.Add("api-key", apiKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<AICompletionResult> GenerateChatCompletionAsync(
|
||||
string systemPrompt,
|
||||
IEnumerable<ChatMessage> conversationHistory,
|
||||
AICompletionOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var messages = new List<object>
|
||||
{
|
||||
new { role = "system", content = systemPrompt }
|
||||
};
|
||||
|
||||
messages.AddRange(conversationHistory.Select(m => new
|
||||
{
|
||||
role = m.Role,
|
||||
content = m.Content
|
||||
}));
|
||||
|
||||
var payload = new
|
||||
{
|
||||
messages = messages,
|
||||
temperature = options.Temperature,
|
||||
max_tokens = options.MaxTokens,
|
||||
top_p = options.TopP,
|
||||
frequency_penalty = options.FrequencyPenalty,
|
||||
presence_penalty = options.PresencePenalty
|
||||
};
|
||||
|
||||
var url = $"openai/deployments/{_deploymentName}/chat/completions?api-version={_apiVersion}";
|
||||
var response = await _httpClient.PostAsJsonAsync(url, payload, cancellationToken);
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("Azure OpenAI API error: {Response}", content);
|
||||
return new AICompletionResult(Success: false, Error: content);
|
||||
}
|
||||
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(content);
|
||||
var choice = data.GetProperty("choices")[0];
|
||||
var message = choice.GetProperty("message");
|
||||
var usage = data.GetProperty("usage");
|
||||
|
||||
return new AICompletionResult(
|
||||
Success: true,
|
||||
Content: message.GetProperty("content").GetString(),
|
||||
PromptTokens: usage.GetProperty("prompt_tokens").GetInt32(),
|
||||
CompletionTokens: usage.GetProperty("completion_tokens").GetInt32(),
|
||||
FinishReason: choice.GetProperty("finish_reason").GetString()
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error calling Azure OpenAI API");
|
||||
return new AICompletionResult(Success: false, Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"openai/deployments/{_deploymentName}?api-version={_apiVersion}";
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using FacebookService.API.Application.Commands;
|
||||
using FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
using FacebookService.Domain.SeedWork;
|
||||
using Xunit;
|
||||
|
||||
namespace FacebookService.UnitTests.Application;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for CreateSampleCommandHandler.
|
||||
/// VI: Unit tests cho CreateSampleCommandHandler.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<ISampleRepository> _mockRepository;
|
||||
private readonly Mock<ILogger<CreateSampleCommandHandler>> _mockLogger;
|
||||
private readonly CreateSampleCommandHandler _handler;
|
||||
|
||||
public CreateSampleCommandHandlerTests()
|
||||
{
|
||||
_mockRepository = new Mock<ISampleRepository>();
|
||||
_mockLogger = new Mock<ILogger<CreateSampleCommandHandler>>();
|
||||
|
||||
var mockUnitOfWork = new Mock<IUnitOfWork>();
|
||||
mockUnitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
_mockRepository.SetupGet(r => r.UnitOfWork).Returns(mockUnitOfWork.Object);
|
||||
|
||||
_handler = new CreateSampleCommandHandler(_mockRepository.Object, _mockLogger.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidCommand_ShouldCreateSampleAndReturnId()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSampleCommand("Test Sample", "Test Description");
|
||||
|
||||
_mockRepository.Setup(r => r.Add(It.IsAny<Sample>()))
|
||||
.Returns((Sample s) => s);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().NotBeEmpty();
|
||||
_mockRepository.Verify(r => r.Add(It.IsAny<Sample>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidCommand_ShouldCallSaveEntities()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSampleCommand("Test Sample", null);
|
||||
|
||||
// Act
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_mockRepository.Verify(r => r.UnitOfWork.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
using FluentAssertions;
|
||||
using FacebookService.Domain.AggregatesModel.SampleAggregate;
|
||||
using FacebookService.Domain.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace FacebookService.UnitTests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for Sample aggregate.
|
||||
/// VI: Unit tests cho Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleAggregateTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateSample_WithValidName_ShouldCreateWithDraftStatus()
|
||||
{
|
||||
// Arrange
|
||||
var name = "Test Sample";
|
||||
var description = "Test Description";
|
||||
|
||||
// Act
|
||||
var sample = new Sample(name, description);
|
||||
|
||||
// Assert
|
||||
sample.Name.Should().Be(name);
|
||||
sample.Description.Should().Be(description);
|
||||
sample.Status.Should().Be(SampleStatus.Draft);
|
||||
sample.Id.Should().NotBeEmpty();
|
||||
sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSample_WithEmptyName_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var name = "";
|
||||
|
||||
// Act
|
||||
var act = () => new Sample(name);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Sample name cannot be empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_WhenDraft_ShouldChangeToActive()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.ClearDomainEvents();
|
||||
|
||||
// Act
|
||||
sample.Activate();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Active);
|
||||
sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_WhenNotDraft_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Activate();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Only draft samples can be activated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_WhenActive_ShouldChangeToCompleted()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
sample.ClearDomainEvents();
|
||||
|
||||
// Act
|
||||
sample.Complete();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
|
||||
// Act
|
||||
sample.Cancel();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_WhenCompleted_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
sample.Complete();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Cancel();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Cannot cancel a completed sample");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Original Name", "Original Description");
|
||||
var newName = "Updated Name";
|
||||
var newDescription = "Updated Description";
|
||||
|
||||
// Act
|
||||
sample.Update(newName, newDescription);
|
||||
|
||||
// Assert
|
||||
sample.Name.Should().Be(newName);
|
||||
sample.Description.Should().Be(newDescription);
|
||||
sample.UpdatedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WhenCancelled_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Cancel();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Update("New Name", null);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Cannot update a cancelled sample");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MediatR;
|
||||
|
||||
namespace WhatsAppService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to connect a WhatsApp Business Account.
|
||||
/// VI: Command để kết nối WhatsApp Business Account.
|
||||
/// </summary>
|
||||
public record ConnectWhatsAppAccountCommand(
|
||||
Guid ShopId,
|
||||
string PhoneNumberId,
|
||||
string AccessToken,
|
||||
string? WebhookUrl
|
||||
) : IRequest<ConnectWhatsAppAccountResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of connecting a WhatsApp account.
|
||||
/// VI: Kết quả kết nối WhatsApp account.
|
||||
/// </summary>
|
||||
public record ConnectWhatsAppAccountResult(
|
||||
bool Success,
|
||||
Guid? AccountId,
|
||||
string? Error);
|
||||
@@ -0,0 +1,68 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
|
||||
|
||||
namespace WhatsAppService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ConnectWhatsAppAccountCommand.
|
||||
/// VI: Handler cho ConnectWhatsAppAccountCommand.
|
||||
/// </summary>
|
||||
public class ConnectWhatsAppAccountCommandHandler : IRequestHandler<ConnectWhatsAppAccountCommand, ConnectWhatsAppAccountResult>
|
||||
{
|
||||
private readonly IWhatsAppAccountRepository _repository;
|
||||
private readonly ILogger<ConnectWhatsAppAccountCommandHandler> _logger;
|
||||
|
||||
public ConnectWhatsAppAccountCommandHandler(
|
||||
IWhatsAppAccountRepository repository,
|
||||
ILogger<ConnectWhatsAppAccountCommandHandler> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<ConnectWhatsAppAccountResult> Handle(
|
||||
ConnectWhatsAppAccountCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Check if account already exists for this shop
|
||||
var existing = await _repository.GetByShopIdAsync(request.ShopId, cancellationToken);
|
||||
if (existing != null)
|
||||
{
|
||||
return new ConnectWhatsAppAccountResult(false, null, "Shop already has a connected WhatsApp account");
|
||||
}
|
||||
|
||||
// Check if phone number is already in use
|
||||
var existingPhone = await _repository.GetByPhoneNumberIdAsync(request.PhoneNumberId, cancellationToken);
|
||||
if (existingPhone != null)
|
||||
{
|
||||
return new ConnectWhatsAppAccountResult(false, null, "Phone number is already connected to another shop");
|
||||
}
|
||||
|
||||
// TODO: Encrypt access token before storing
|
||||
var encryptedToken = request.AccessToken; // Placeholder - should use encryption service
|
||||
|
||||
var account = new WhatsAppAccount(
|
||||
request.ShopId,
|
||||
request.PhoneNumberId,
|
||||
encryptedToken,
|
||||
request.WebhookUrl);
|
||||
|
||||
await _repository.AddAsync(account, cancellationToken);
|
||||
await _repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"WhatsApp account connected for shop {ShopId}. AccountId: {AccountId}",
|
||||
request.ShopId, account.Id);
|
||||
|
||||
return new ConnectWhatsAppAccountResult(true, account.Id, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to connect WhatsApp account for shop {ShopId}", request.ShopId);
|
||||
return new ConnectWhatsAppAccountResult(false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using MediatR;
|
||||
|
||||
namespace WhatsAppService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to process an incoming WhatsApp message from webhook.
|
||||
/// VI: Command để xử lý tin nhắn WhatsApp đến từ webhook.
|
||||
/// </summary>
|
||||
public record ProcessIncomingMessageCommand(
|
||||
string PhoneNumberId,
|
||||
string SenderWaId,
|
||||
string MessageId,
|
||||
string MessageType,
|
||||
string? TextBody,
|
||||
string? MediaId,
|
||||
string? MediaMimeType,
|
||||
object? Interactive,
|
||||
DateTime Timestamp
|
||||
) : IRequest<ProcessIncomingMessageResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of processing incoming message.
|
||||
/// VI: Kết quả xử lý tin nhắn đến.
|
||||
/// </summary>
|
||||
public record ProcessIncomingMessageResult(
|
||||
bool Success,
|
||||
Guid? ConversationId,
|
||||
Guid? MessageId,
|
||||
bool IsNewConversation,
|
||||
string? Error);
|
||||
@@ -0,0 +1,98 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
|
||||
using WhatsAppService.Domain.AggregatesModel.CustomerAggregate;
|
||||
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
|
||||
|
||||
namespace WhatsAppService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ProcessIncomingMessageCommand.
|
||||
/// VI: Handler cho ProcessIncomingMessageCommand.
|
||||
/// </summary>
|
||||
public class ProcessIncomingMessageCommandHandler : IRequestHandler<ProcessIncomingMessageCommand, ProcessIncomingMessageResult>
|
||||
{
|
||||
private readonly IWhatsAppAccountRepository _accountRepository;
|
||||
private readonly IConversationRepository _conversationRepository;
|
||||
private readonly ICustomerRepository _customerRepository;
|
||||
private readonly ILogger<ProcessIncomingMessageCommandHandler> _logger;
|
||||
|
||||
public ProcessIncomingMessageCommandHandler(
|
||||
IWhatsAppAccountRepository accountRepository,
|
||||
IConversationRepository conversationRepository,
|
||||
ICustomerRepository customerRepository,
|
||||
ILogger<ProcessIncomingMessageCommandHandler> logger)
|
||||
{
|
||||
_accountRepository = accountRepository;
|
||||
_conversationRepository = conversationRepository;
|
||||
_customerRepository = customerRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ProcessIncomingMessageResult> Handle(
|
||||
ProcessIncomingMessageCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Find WhatsApp account by phone number ID
|
||||
var account = await _accountRepository.GetByPhoneNumberIdAsync(request.PhoneNumberId, cancellationToken);
|
||||
if (account == null)
|
||||
{
|
||||
_logger.LogWarning("No WhatsApp account found for phone number ID: {PhoneNumberId}", request.PhoneNumberId);
|
||||
return new ProcessIncomingMessageResult(false, null, null, false, "Account not found");
|
||||
}
|
||||
|
||||
var shopId = account.ShopId;
|
||||
var isNewConversation = false;
|
||||
|
||||
// Get or create customer
|
||||
var customer = await _customerRepository.GetByWaIdAsync(shopId, request.SenderWaId, cancellationToken);
|
||||
if (customer == null)
|
||||
{
|
||||
customer = new Customer(shopId, request.SenderWaId);
|
||||
await _customerRepository.AddAsync(customer, cancellationToken);
|
||||
}
|
||||
|
||||
// Get or create conversation
|
||||
var conversation = await _conversationRepository.GetActiveByCustomerAsync(shopId, request.SenderWaId, cancellationToken);
|
||||
if (conversation == null)
|
||||
{
|
||||
conversation = new Conversation(shopId, request.SenderWaId, account.Id);
|
||||
await _conversationRepository.AddAsync(conversation, cancellationToken);
|
||||
isNewConversation = true;
|
||||
}
|
||||
|
||||
// Create message content based on type
|
||||
var content = CreateMessageContent(request);
|
||||
|
||||
// Add message to conversation
|
||||
var message = conversation.AddMessage(content, Message.Directions.Inbound, request.MessageId);
|
||||
|
||||
await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Processed incoming message. ConversationId: {ConversationId}, MessageId: {MessageId}, IsNew: {IsNew}",
|
||||
conversation.Id, message.Id, isNewConversation);
|
||||
|
||||
return new ProcessIncomingMessageResult(true, conversation.Id, message.Id, isNewConversation, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process incoming message from {SenderWaId}", request.SenderWaId);
|
||||
return new ProcessIncomingMessageResult(false, null, null, false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageContent CreateMessageContent(ProcessIncomingMessageCommand request)
|
||||
{
|
||||
return request.MessageType.ToLowerInvariant() switch
|
||||
{
|
||||
"text" => MessageContent.CreateText(request.TextBody ?? ""),
|
||||
"image" or "video" or "audio" or "document" =>
|
||||
MessageContent.CreateMedia(request.MessageType, request.MediaId ?? "", null, request.MediaMimeType),
|
||||
"interactive" => MessageContent.CreateInteractive(request.Interactive ?? new { }),
|
||||
_ => MessageContent.CreateText(request.TextBody ?? $"[Unsupported: {request.MessageType}]")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using MediatR;
|
||||
|
||||
namespace WhatsAppService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to send a WhatsApp message.
|
||||
/// VI: Command để gửi tin nhắn WhatsApp.
|
||||
/// </summary>
|
||||
public record SendMessageCommand(
|
||||
Guid ConversationId,
|
||||
string MessageType,
|
||||
string? Text,
|
||||
string? MediaUrl,
|
||||
string? Caption,
|
||||
object? Interactive
|
||||
) : IRequest<SendMessageResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of sending a message.
|
||||
/// VI: Kết quả gửi tin nhắn.
|
||||
/// </summary>
|
||||
public record SendMessageResult(
|
||||
bool Success,
|
||||
Guid? MessageId,
|
||||
string? WhatsAppMessageId,
|
||||
string? Error);
|
||||
@@ -0,0 +1,120 @@
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
|
||||
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
|
||||
using WhatsAppService.Infrastructure.ExternalServices;
|
||||
|
||||
namespace WhatsAppService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for SendMessageCommand.
|
||||
/// VI: Handler cho SendMessageCommand.
|
||||
/// </summary>
|
||||
public class SendMessageCommandHandler : IRequestHandler<SendMessageCommand, SendMessageResult>
|
||||
{
|
||||
private readonly IConversationRepository _conversationRepository;
|
||||
private readonly IWhatsAppAccountRepository _accountRepository;
|
||||
private readonly IWhatsAppCloudApiClient _whatsAppClient;
|
||||
private readonly ILogger<SendMessageCommandHandler> _logger;
|
||||
|
||||
public SendMessageCommandHandler(
|
||||
IConversationRepository conversationRepository,
|
||||
IWhatsAppAccountRepository accountRepository,
|
||||
IWhatsAppCloudApiClient whatsAppClient,
|
||||
ILogger<SendMessageCommandHandler> logger)
|
||||
{
|
||||
_conversationRepository = conversationRepository;
|
||||
_accountRepository = accountRepository;
|
||||
_whatsAppClient = whatsAppClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SendMessageResult> Handle(
|
||||
SendMessageCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get conversation
|
||||
var conversation = await _conversationRepository.GetByIdAsync(request.ConversationId, cancellationToken);
|
||||
if (conversation == null)
|
||||
{
|
||||
return new SendMessageResult(false, null, null, "Conversation not found");
|
||||
}
|
||||
|
||||
// Check 24h window
|
||||
if (!conversation.IsWithinMessagingWindow())
|
||||
{
|
||||
return new SendMessageResult(false, null, null, "Conversation outside 24h messaging window. Use template message.");
|
||||
}
|
||||
|
||||
// Get WhatsApp account
|
||||
var account = await _accountRepository.GetByIdAsync(conversation.WhatsAppAccountId, cancellationToken);
|
||||
if (account == null || account.Status != WhatsAppAccountStatus.Active)
|
||||
{
|
||||
return new SendMessageResult(false, null, null, "WhatsApp account not available");
|
||||
}
|
||||
|
||||
// TODO: Decrypt access token
|
||||
var accessToken = account.AccessTokenEncrypted;
|
||||
|
||||
// Create message content
|
||||
var content = CreateMessageContent(request);
|
||||
|
||||
// Send via WhatsApp API
|
||||
var apiResult = await SendViaApiAsync(account.PhoneNumberId, accessToken, conversation.CustomerWaId, request, cancellationToken);
|
||||
|
||||
if (!apiResult.Success)
|
||||
{
|
||||
return new SendMessageResult(false, null, null, apiResult.ErrorMessage);
|
||||
}
|
||||
|
||||
// Add message to conversation
|
||||
var message = conversation.AddMessage(content, Message.Directions.Outbound, apiResult.MessageId);
|
||||
await _conversationRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sent message. ConversationId: {ConversationId}, MessageId: {MessageId}",
|
||||
conversation.Id, message.Id);
|
||||
|
||||
return new SendMessageResult(true, message.Id, apiResult.MessageId, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send message for conversation {ConversationId}", request.ConversationId);
|
||||
return new SendMessageResult(false, null, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static MessageContent CreateMessageContent(SendMessageCommand request)
|
||||
{
|
||||
return request.MessageType.ToLowerInvariant() switch
|
||||
{
|
||||
"text" => MessageContent.CreateText(request.Text ?? ""),
|
||||
"image" or "video" or "audio" or "document" =>
|
||||
MessageContent.CreateMedia(request.MessageType, request.MediaUrl ?? "", request.Caption),
|
||||
"interactive" => MessageContent.CreateInteractive(request.Interactive ?? new { }),
|
||||
_ => MessageContent.CreateText(request.Text ?? "")
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<Infrastructure.ExternalServices.SendMessageResult> SendViaApiAsync(
|
||||
string phoneNumberId,
|
||||
string accessToken,
|
||||
string recipientWaId,
|
||||
SendMessageCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return request.MessageType.ToLowerInvariant() switch
|
||||
{
|
||||
"text" => await _whatsAppClient.SendTextMessageAsync(
|
||||
phoneNumberId, accessToken, recipientWaId, request.Text ?? "", cancellationToken),
|
||||
"image" or "video" or "audio" or "document" => await _whatsAppClient.SendMediaMessageAsync(
|
||||
phoneNumberId, accessToken, recipientWaId, request.MessageType, request.MediaUrl ?? "", request.Caption, cancellationToken),
|
||||
"interactive" => await _whatsAppClient.SendInteractiveMessageAsync(
|
||||
phoneNumberId, accessToken, recipientWaId, request.Interactive ?? new { }, cancellationToken),
|
||||
_ => await _whatsAppClient.SendTextMessageAsync(
|
||||
phoneNumberId, accessToken, recipientWaId, request.Text ?? "", cancellationToken)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MediatR;
|
||||
|
||||
namespace WhatsAppService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get conversations by shop with pagination.
|
||||
/// VI: Query để lấy conversations theo shop với phân trang.
|
||||
/// </summary>
|
||||
public record GetConversationsQuery(
|
||||
Guid ShopId,
|
||||
int? StatusId,
|
||||
int Skip = 0,
|
||||
int Take = 20
|
||||
) : IRequest<GetConversationsResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of GetConversationsQuery.
|
||||
/// VI: Kết quả của GetConversationsQuery.
|
||||
/// </summary>
|
||||
public record GetConversationsResult(
|
||||
List<ConversationViewModel> Conversations,
|
||||
int TotalCount);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Conversation view model.
|
||||
/// VI: View model cho conversation.
|
||||
/// </summary>
|
||||
public record ConversationViewModel(
|
||||
Guid Id,
|
||||
string CustomerWaId,
|
||||
string Status,
|
||||
Guid? AssignedAgentId,
|
||||
DateTime? LastMessageAt,
|
||||
DateTime CreatedAt,
|
||||
DateTime? ExpiresAt,
|
||||
int MessageCount);
|
||||
@@ -0,0 +1,61 @@
|
||||
using MediatR;
|
||||
using WhatsAppService.Domain.AggregatesModel.AutomationFlowAggregate;
|
||||
using WhatsAppService.Domain.Events;
|
||||
|
||||
namespace WhatsAppService.API.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event handler for processing automation triggers on new messages.
|
||||
/// VI: Domain event handler để xử lý automation triggers khi có tin nhắn mới.
|
||||
/// </summary>
|
||||
public class AutomationTriggerHandler : INotificationHandler<MessageReceivedEvent>
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<AutomationTriggerHandler> _logger;
|
||||
|
||||
public AutomationTriggerHandler(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<AutomationTriggerHandler> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(MessageReceivedEvent notification, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var flowRepository = scope.ServiceProvider.GetRequiredService<IAutomationFlowRepository>();
|
||||
|
||||
// Get active keyword-triggered flows for this shop
|
||||
var flows = await flowRepository.GetByTriggerTypeAsync(
|
||||
notification.ShopId,
|
||||
TriggerType.Keyword,
|
||||
cancellationToken);
|
||||
|
||||
if (flows.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var messageText = notification.Content.Text?.ToLowerInvariant() ?? "";
|
||||
|
||||
foreach (var flow in flows.OrderByDescending(f => f.Priority))
|
||||
{
|
||||
// TODO: Parse trigger config and check for keyword matches
|
||||
// This is a placeholder - actual implementation would parse TriggerConfig JSON
|
||||
_logger.LogDebug(
|
||||
"Checking flow {FlowId} for keyword triggers on message from {CustomerWaId}",
|
||||
flow.Id, notification.CustomerWaId);
|
||||
|
||||
// If matched, execute flow steps
|
||||
// await ExecuteFlowAsync(flow, notification, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing automation triggers for message {MessageId}", notification.MessageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
|
||||
|
||||
namespace WhatsAppService.API.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Background job to check and expire conversations outside 24h window.
|
||||
/// VI: Background job để kiểm tra và hết hạn conversations ngoài cửa sổ 24h.
|
||||
/// </summary>
|
||||
public class ConversationExpiryJob : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<ConversationExpiryJob> _logger;
|
||||
private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
public ConversationExpiryJob(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<ConversationExpiryJob> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("ConversationExpiryJob started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessExpiredConversationsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in ConversationExpiryJob");
|
||||
}
|
||||
|
||||
await Task.Delay(_checkInterval, stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("ConversationExpiryJob stopped");
|
||||
}
|
||||
|
||||
private async Task ProcessExpiredConversationsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IConversationRepository>();
|
||||
|
||||
var expiredConversations = await repository.GetExpiredConversationsAsync(cancellationToken);
|
||||
|
||||
if (expiredConversations.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Found {Count} expired conversations", expiredConversations.Count);
|
||||
|
||||
foreach (var conversation in expiredConversations)
|
||||
{
|
||||
conversation.CheckExpiry();
|
||||
}
|
||||
|
||||
await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation("Processed {Count} expired conversations", expiredConversations.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WhatsAppService.API.Application.Commands;
|
||||
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
|
||||
|
||||
namespace WhatsAppService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for managing conversations.
|
||||
/// VI: Controller để quản lý conversations.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Produces("application/json")]
|
||||
public class ConversationsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IConversationRepository _repository;
|
||||
private readonly ILogger<ConversationsController> _logger;
|
||||
|
||||
public ConversationsController(
|
||||
IMediator mediator,
|
||||
IConversationRepository repository,
|
||||
ILogger<ConversationsController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get conversations by shop with pagination.
|
||||
/// VI: Lấy conversations theo shop với phân trang.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetConversations(
|
||||
[FromQuery] Guid shopId,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 20)
|
||||
{
|
||||
ConversationStatus? statusFilter = null;
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
{
|
||||
statusFilter = ConversationStatus.FromName(status);
|
||||
}
|
||||
|
||||
var conversations = await _repository.GetByShopIdAsync(shopId, skip, take, statusFilter);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = conversations.Select(c => new
|
||||
{
|
||||
id = c.Id,
|
||||
customerWaId = c.CustomerWaId,
|
||||
status = c.Status.Name,
|
||||
assignedAgentId = c.AssignedAgentId,
|
||||
lastMessageAt = c.LastMessageAt,
|
||||
createdAt = c.CreatedAt,
|
||||
expiresAt = c.ExpiresAt,
|
||||
messageCount = c.Messages.Count
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get conversation by ID with messages.
|
||||
/// VI: Lấy conversation theo ID kèm messages.
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var conversation = await _repository.GetByIdAsync(id);
|
||||
if (conversation == null)
|
||||
{
|
||||
return NotFound(new { success = false, error = "Conversation not found" });
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
id = conversation.Id,
|
||||
customerWaId = conversation.CustomerWaId,
|
||||
status = conversation.Status.Name,
|
||||
assignedAgentId = conversation.AssignedAgentId,
|
||||
lastMessageAt = conversation.LastMessageAt,
|
||||
createdAt = conversation.CreatedAt,
|
||||
expiresAt = conversation.ExpiresAt,
|
||||
messages = conversation.Messages.OrderBy(m => m.Timestamp).Select(m => new
|
||||
{
|
||||
id = m.Id,
|
||||
direction = m.Direction,
|
||||
contentType = m.Content.Type,
|
||||
text = m.Content.Text,
|
||||
status = m.Status,
|
||||
timestamp = m.Timestamp
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send a message in conversation.
|
||||
/// VI: Gửi tin nhắn trong conversation.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/messages")]
|
||||
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> SendMessage(Guid id, [FromBody] SendMessageRequest request)
|
||||
{
|
||||
var command = new SendMessageCommand(
|
||||
id,
|
||||
request.Type,
|
||||
request.Text,
|
||||
request.MediaUrl,
|
||||
request.Caption,
|
||||
request.Interactive);
|
||||
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return BadRequest(new { success = false, error = result.Error });
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
messageId = result.MessageId,
|
||||
whatsAppMessageId = result.WhatsAppMessageId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Close a conversation.
|
||||
/// VI: Đóng conversation.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/close")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Close(Guid id)
|
||||
{
|
||||
var conversation = await _repository.GetByIdAsync(id);
|
||||
if (conversation == null)
|
||||
{
|
||||
return NotFound(new { success = false, error = "Conversation not found" });
|
||||
}
|
||||
|
||||
conversation.Close();
|
||||
await _repository.UnitOfWork.SaveEntitiesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public record SendMessageRequest(
|
||||
string Type,
|
||||
string? Text,
|
||||
string? MediaUrl,
|
||||
string? Caption,
|
||||
object? Interactive);
|
||||
@@ -0,0 +1,173 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WhatsAppService.Domain.AggregatesModel.CustomerAggregate;
|
||||
|
||||
namespace WhatsAppService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for managing customers.
|
||||
/// VI: Controller để quản lý khách hàng.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Produces("application/json")]
|
||||
public class CustomersController : ControllerBase
|
||||
{
|
||||
private readonly ICustomerRepository _repository;
|
||||
private readonly ILogger<CustomersController> _logger;
|
||||
|
||||
public CustomersController(
|
||||
ICustomerRepository repository,
|
||||
ILogger<CustomersController> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get customers by shop with pagination.
|
||||
/// VI: Lấy khách hàng theo shop với phân trang.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetCustomers(
|
||||
[FromQuery] Guid shopId,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 20)
|
||||
{
|
||||
var customers = await _repository.GetByShopIdAsync(shopId, skip, take);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = customers.Select(c => new
|
||||
{
|
||||
id = c.Id,
|
||||
waId = c.WaId,
|
||||
name = c.Name,
|
||||
profilePictureUrl = c.ProfilePictureUrl,
|
||||
optInStatus = c.Consent.Status,
|
||||
tags = c.Tags,
|
||||
firstContactedAt = c.FirstContactedAt,
|
||||
createdAt = c.CreatedAt
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get customer by ID.
|
||||
/// VI: Lấy khách hàng theo ID.
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var customer = await _repository.GetByIdAsync(id);
|
||||
if (customer == null)
|
||||
{
|
||||
return NotFound(new { success = false, error = "Customer not found" });
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
id = customer.Id,
|
||||
waId = customer.WaId,
|
||||
name = customer.Name,
|
||||
profilePictureUrl = customer.ProfilePictureUrl,
|
||||
optInStatus = customer.Consent.Status,
|
||||
optInTimestamp = customer.Consent.Timestamp,
|
||||
optInSource = customer.Consent.Source,
|
||||
tags = customer.Tags,
|
||||
customFields = customer.CustomFields,
|
||||
firstContactedAt = customer.FirstContactedAt,
|
||||
createdAt = customer.CreatedAt
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update customer profile.
|
||||
/// VI: Cập nhật hồ sơ khách hàng.
|
||||
/// </summary>
|
||||
[HttpPatch("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateCustomerRequest request)
|
||||
{
|
||||
var customer = await _repository.GetByIdAsync(id);
|
||||
if (customer == null)
|
||||
{
|
||||
return NotFound(new { success = false, error = "Customer not found" });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.Name))
|
||||
{
|
||||
customer.UpdateName(request.Name);
|
||||
}
|
||||
|
||||
if (request.Tags != null)
|
||||
{
|
||||
// Clear and re-add tags
|
||||
foreach (var tag in customer.Tags.ToList())
|
||||
{
|
||||
customer.RemoveTag(tag);
|
||||
}
|
||||
foreach (var tag in request.Tags)
|
||||
{
|
||||
customer.AddTag(tag);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.CustomFields != null)
|
||||
{
|
||||
foreach (var field in request.CustomFields)
|
||||
{
|
||||
customer.SetCustomField(field.Key, field.Value);
|
||||
}
|
||||
}
|
||||
|
||||
await _repository.UnitOfWork.SaveEntitiesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update customer opt-in consent.
|
||||
/// VI: Cập nhật đồng ý opt-in của khách hàng.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/consent")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateConsent(Guid id, [FromBody] UpdateConsentRequest request)
|
||||
{
|
||||
var customer = await _repository.GetByIdAsync(id);
|
||||
if (customer == null)
|
||||
{
|
||||
return NotFound(new { success = false, error = "Customer not found" });
|
||||
}
|
||||
|
||||
if (request.OptIn)
|
||||
{
|
||||
customer.OptIn(request.Source ?? "api");
|
||||
}
|
||||
else
|
||||
{
|
||||
customer.OptOut(request.Source ?? "api");
|
||||
}
|
||||
|
||||
await _repository.UnitOfWork.SaveEntitiesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateCustomerRequest(
|
||||
string? Name,
|
||||
List<string>? Tags,
|
||||
Dictionary<string, string>? CustomFields);
|
||||
|
||||
public record UpdateConsentRequest(
|
||||
bool OptIn,
|
||||
string? Source);
|
||||
@@ -0,0 +1,188 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WhatsAppService.API.Application.Commands;
|
||||
|
||||
namespace WhatsAppService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for handling WhatsApp webhooks.
|
||||
/// VI: Controller để xử lý webhooks WhatsApp.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/webhooks")]
|
||||
public class WebhooksController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<WebhooksController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public WebhooksController(
|
||||
IMediator mediator,
|
||||
ILogger<WebhooksController> logger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Webhook verification endpoint (GET).
|
||||
/// VI: Endpoint xác minh webhook (GET).
|
||||
/// </summary>
|
||||
[HttpGet("whatsapp")]
|
||||
public IActionResult VerifyWebhook(
|
||||
[FromQuery(Name = "hub.mode")] string? mode,
|
||||
[FromQuery(Name = "hub.verify_token")] string? verifyToken,
|
||||
[FromQuery(Name = "hub.challenge")] string? challenge)
|
||||
{
|
||||
_logger.LogInformation("Webhook verification request. Mode: {Mode}", mode);
|
||||
|
||||
if (mode != "subscribe")
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
var expectedToken = _configuration["WhatsApp:WebhookVerifyToken"];
|
||||
if (verifyToken != expectedToken)
|
||||
{
|
||||
_logger.LogWarning("Invalid verify token received");
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
_logger.LogInformation("Webhook verified successfully");
|
||||
return Ok(challenge);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Webhook event handler (POST).
|
||||
/// VI: Handler sự kiện webhook (POST).
|
||||
/// </summary>
|
||||
[HttpPost("whatsapp")]
|
||||
public async Task<IActionResult> HandleWebhook(
|
||||
[FromHeader(Name = "X-Hub-Signature-256")] string? signature,
|
||||
[FromBody] JsonElement payload)
|
||||
{
|
||||
// EN: Verify signature
|
||||
// VI: Xác minh chữ ký
|
||||
var appSecret = _configuration["WhatsApp:AppSecret"];
|
||||
if (!string.IsNullOrEmpty(appSecret) && !string.IsNullOrEmpty(signature))
|
||||
{
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payload.GetRawText());
|
||||
var expectedSignature = ComputeHmacSha256(payloadBytes, appSecret);
|
||||
|
||||
if (!$"sha256={expectedSignature}".Equals(signature, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("Invalid webhook signature");
|
||||
return Unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
// EN: Return 200 immediately, process async
|
||||
// VI: Trả về 200 ngay lập tức, xử lý bất đồng bộ
|
||||
_ = Task.Run(async () => await ProcessWebhookAsync(payload));
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private async Task ProcessWebhookAsync(JsonElement payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
var objectType = payload.GetProperty("object").GetString();
|
||||
if (objectType != "whatsapp_business_account")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var entries = payload.GetProperty("entry");
|
||||
foreach (var entry in entries.EnumerateArray())
|
||||
{
|
||||
var changes = entry.GetProperty("changes");
|
||||
foreach (var change in changes.EnumerateArray())
|
||||
{
|
||||
var field = change.GetProperty("field").GetString();
|
||||
if (field != "messages")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = change.GetProperty("value");
|
||||
await ProcessMessagesAsync(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing webhook payload");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessMessagesAsync(JsonElement value)
|
||||
{
|
||||
if (!value.TryGetProperty("messages", out var messages))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var metadata = value.GetProperty("metadata");
|
||||
var phoneNumberId = metadata.GetProperty("phone_number_id").GetString() ?? "";
|
||||
|
||||
foreach (var message in messages.EnumerateArray())
|
||||
{
|
||||
var from = message.GetProperty("from").GetString() ?? "";
|
||||
var messageId = message.GetProperty("id").GetString() ?? "";
|
||||
var timestamp = message.GetProperty("timestamp").GetString() ?? "";
|
||||
var type = message.GetProperty("type").GetString() ?? "text";
|
||||
|
||||
string? textBody = null;
|
||||
string? mediaId = null;
|
||||
string? mimeType = null;
|
||||
object? interactive = null;
|
||||
|
||||
if (type == "text" && message.TryGetProperty("text", out var text))
|
||||
{
|
||||
textBody = text.GetProperty("body").GetString();
|
||||
}
|
||||
else if (type is "image" or "video" or "audio" or "document")
|
||||
{
|
||||
if (message.TryGetProperty(type, out var media))
|
||||
{
|
||||
mediaId = media.TryGetProperty("id", out var id) ? id.GetString() : null;
|
||||
mimeType = media.TryGetProperty("mime_type", out var mime) ? mime.GetString() : null;
|
||||
}
|
||||
}
|
||||
else if (type == "interactive" && message.TryGetProperty("interactive", out var interactiveEl))
|
||||
{
|
||||
interactive = JsonSerializer.Deserialize<object>(interactiveEl.GetRawText());
|
||||
}
|
||||
|
||||
var command = new ProcessIncomingMessageCommand(
|
||||
phoneNumberId,
|
||||
from,
|
||||
messageId,
|
||||
type,
|
||||
textBody,
|
||||
mediaId,
|
||||
mimeType,
|
||||
interactive,
|
||||
DateTimeOffset.FromUnixTimeSeconds(long.Parse(timestamp)).UtcDateTime
|
||||
);
|
||||
|
||||
var result = await _mediator.Send(command);
|
||||
_logger.LogInformation(
|
||||
"Message processed. Success: {Success}, ConversationId: {ConversationId}",
|
||||
result.Success, result.ConversationId);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeHmacSha256(byte[] data, string key)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(key));
|
||||
var hash = hmac.ComputeHash(data);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using WhatsAppService.API.Application.Commands;
|
||||
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
|
||||
|
||||
namespace WhatsAppService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for managing WhatsApp accounts.
|
||||
/// VI: Controller để quản lý WhatsApp accounts.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Produces("application/json")]
|
||||
public class WhatsAppAccountsController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IWhatsAppAccountRepository _repository;
|
||||
private readonly ILogger<WhatsAppAccountsController> _logger;
|
||||
|
||||
public WhatsAppAccountsController(
|
||||
IMediator mediator,
|
||||
IWhatsAppAccountRepository repository,
|
||||
ILogger<WhatsAppAccountsController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Connect a WhatsApp Business Account.
|
||||
/// VI: Kết nối WhatsApp Business Account.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Connect([FromBody] ConnectAccountRequest request)
|
||||
{
|
||||
var command = new ConnectWhatsAppAccountCommand(
|
||||
request.ShopId,
|
||||
request.PhoneNumberId,
|
||||
request.AccessToken,
|
||||
request.WebhookUrl);
|
||||
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return BadRequest(new { success = false, error = result.Error });
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = new { accountId = result.AccountId } });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get WhatsApp account by shop ID.
|
||||
/// VI: Lấy WhatsApp account theo shop ID.
|
||||
/// </summary>
|
||||
[HttpGet("shop/{shopId:guid}")]
|
||||
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetByShopId(Guid shopId)
|
||||
{
|
||||
var account = await _repository.GetByShopIdAsync(shopId);
|
||||
if (account == null)
|
||||
{
|
||||
return NotFound(new { success = false, error = "Account not found" });
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
data = new
|
||||
{
|
||||
id = account.Id,
|
||||
shopId = account.ShopId,
|
||||
phoneNumberId = account.PhoneNumberId,
|
||||
status = account.Status.Name,
|
||||
messageTier = account.MessageTier,
|
||||
webhookUrl = account.WebhookUrl,
|
||||
createdAt = account.CreatedAt
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Disconnect WhatsApp account.
|
||||
/// VI: Ngắt kết nối WhatsApp account.
|
||||
/// </summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Disconnect(Guid id)
|
||||
{
|
||||
var account = await _repository.GetByIdAsync(id);
|
||||
if (account == null)
|
||||
{
|
||||
return NotFound(new { success = false, error = "Account not found" });
|
||||
}
|
||||
|
||||
account.Disconnect();
|
||||
await _repository.UnitOfWork.SaveEntitiesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public record ConnectAccountRequest(
|
||||
Guid ShopId,
|
||||
string PhoneNumberId,
|
||||
string AccessToken,
|
||||
string? WebhookUrl);
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using WhatsAppService.Domain.AggregatesModel.AIAgentAggregate;
|
||||
|
||||
namespace WhatsAppService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for AIAgent entity.
|
||||
/// VI: Cấu hình EF Core cho entity AIAgent.
|
||||
/// </summary>
|
||||
public class AIAgentEntityTypeConfiguration : IEntityTypeConfiguration<AIAgent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AIAgent> builder)
|
||||
{
|
||||
builder.ToTable("ai_agents");
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Ignore(e => e.DomainEvents);
|
||||
|
||||
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
|
||||
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
|
||||
builder.Property<string>("_agentName").HasColumnName("agent_name").HasMaxLength(200).IsRequired();
|
||||
builder.Property<Guid?>("_knowledgeBaseId").HasColumnName("knowledge_base_id");
|
||||
builder.Property<bool>("_isActive").HasColumnName("is_active").HasDefaultValue(false);
|
||||
builder.Property<int?>("_maxPromptTokens").HasColumnName("max_prompt_tokens");
|
||||
builder.Property<int?>("_maxCompletionTokens").HasColumnName("max_completion_tokens");
|
||||
builder.Property<decimal?>("_dailyBudgetUsd").HasColumnName("daily_budget_usd").HasPrecision(10, 2);
|
||||
builder.Property<decimal?>("_monthlyBudgetUsd").HasColumnName("monthly_budget_usd").HasPrecision(10, 2);
|
||||
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
|
||||
builder.Property<DateTime?>("_updatedAt").HasColumnName("updated_at");
|
||||
|
||||
// EN: AgentPersonality as owned entity / VI: AgentPersonality như owned entity
|
||||
builder.OwnsOne<AgentPersonality>("_personality", personality =>
|
||||
{
|
||||
personality.Property(p => p.Tone).HasColumnName("personality_tone").HasMaxLength(50).HasDefaultValue("friendly");
|
||||
personality.Property(p => p.Language).HasColumnName("personality_language").HasMaxLength(20).HasDefaultValue("vietnamese");
|
||||
personality.Property(p => p.PromptTemplate).HasColumnName("prompt_template");
|
||||
|
||||
// EN: Constraints stored as JSONB / VI: Constraints lưu dưới dạng JSONB
|
||||
personality.Property<string>("ConstraintsJson")
|
||||
.HasColumnName("personality_constraints")
|
||||
.HasColumnType("jsonb")
|
||||
.HasDefaultValueSql("'[]'::jsonb");
|
||||
|
||||
personality.Ignore(p => p.Constraints);
|
||||
});
|
||||
|
||||
builder.HasIndex("_shopId").HasDatabaseName("idx_ai_agents_shop");
|
||||
builder.HasIndex("_shopId", "_isActive").HasDatabaseName("idx_ai_agents_shop_active");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using WhatsAppService.Domain.AggregatesModel.AutomationFlowAggregate;
|
||||
|
||||
namespace WhatsAppService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for AutomationFlow entity.
|
||||
/// VI: Cấu hình EF Core cho entity AutomationFlow.
|
||||
/// </summary>
|
||||
public class AutomationFlowEntityTypeConfiguration : IEntityTypeConfiguration<AutomationFlow>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<AutomationFlow> builder)
|
||||
{
|
||||
builder.ToTable("automation_flows");
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Ignore(e => e.DomainEvents);
|
||||
|
||||
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
|
||||
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
|
||||
builder.Property<string>("_flowName").HasColumnName("flow_name").HasMaxLength(200).IsRequired();
|
||||
builder.Property<string>("_triggerConfig").HasColumnName("trigger_config").HasColumnType("jsonb").IsRequired();
|
||||
builder.Property<bool>("_isActive").HasColumnName("is_active").HasDefaultValue(false);
|
||||
builder.Property<int>("_priority").HasColumnName("priority").HasDefaultValue(50);
|
||||
builder.Property<int>("_executionCount").HasColumnName("execution_count").HasDefaultValue(0);
|
||||
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
|
||||
builder.Property<DateTime?>("_updatedAt").HasColumnName("updated_at");
|
||||
|
||||
builder.Property(e => e.TriggerTypeId).HasColumnName("trigger_type_id").IsRequired();
|
||||
builder.HasOne(e => e.TriggerType).WithMany().HasForeignKey(e => e.TriggerTypeId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
var stepsNav = builder.Metadata.FindNavigation(nameof(AutomationFlow.Steps))!;
|
||||
stepsNav.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
builder.HasIndex("_shopId", "_isActive").HasDatabaseName("idx_flows_shop_active");
|
||||
builder.HasIndex(e => e.TriggerTypeId).HasDatabaseName("idx_flows_trigger");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for FlowStep entity.
|
||||
/// VI: Cấu hình EF Core cho entity FlowStep.
|
||||
/// </summary>
|
||||
public class FlowStepEntityTypeConfiguration : IEntityTypeConfiguration<FlowStep>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<FlowStep> builder)
|
||||
{
|
||||
builder.ToTable("flow_steps");
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
|
||||
builder.Property<Guid>("_flowId").HasColumnName("flow_id").IsRequired();
|
||||
builder.Property<int>("_order").HasColumnName("step_order").IsRequired();
|
||||
builder.Property<string>("_action").HasColumnName("action").HasMaxLength(50).IsRequired();
|
||||
builder.Property<string>("_actionConfig").HasColumnName("action_config").HasColumnType("jsonb").IsRequired();
|
||||
builder.Property<string?>("_conditions").HasColumnName("conditions").HasColumnType("jsonb");
|
||||
builder.Property<string?>("_nextStepMapping").HasColumnName("next_step_mapping").HasColumnType("jsonb");
|
||||
|
||||
builder.HasIndex("_flowId", "_order").IsUnique().HasDatabaseName("idx_flow_steps_order");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for TriggerType enumeration.
|
||||
/// VI: Cấu hình EF Core cho enumeration TriggerType.
|
||||
/// </summary>
|
||||
public class TriggerTypeEntityTypeConfiguration : IEntityTypeConfiguration<TriggerType>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TriggerType> builder)
|
||||
{
|
||||
builder.ToTable("trigger_types");
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever().IsRequired();
|
||||
builder.Property(s => s.Name).HasColumnName("name").HasMaxLength(50).IsRequired();
|
||||
|
||||
builder.HasData(
|
||||
TriggerType.Keyword,
|
||||
TriggerType.Event,
|
||||
TriggerType.Schedule
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
|
||||
|
||||
namespace WhatsAppService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Conversation entity.
|
||||
/// VI: Cấu hình EF Core cho entity Conversation.
|
||||
/// </summary>
|
||||
public class ConversationEntityTypeConfiguration : IEntityTypeConfiguration<Conversation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Conversation> builder)
|
||||
{
|
||||
builder.ToTable("conversations");
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Ignore(e => e.DomainEvents);
|
||||
|
||||
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
|
||||
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
|
||||
builder.Property<string>("_customerWaId").HasColumnName("customer_wa_id").HasMaxLength(20).IsRequired();
|
||||
builder.Property<Guid>("_whatsAppAccountId").HasColumnName("whatsapp_account_id").IsRequired();
|
||||
builder.Property<Guid?>("_assignedAgentId").HasColumnName("assigned_agent_id");
|
||||
builder.Property<DateTime?>("_lastMessageAt").HasColumnName("last_message_at");
|
||||
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
|
||||
builder.Property<DateTime?>("_expiresAt").HasColumnName("expires_at");
|
||||
|
||||
builder.Property<List<string>>("_tags")
|
||||
.HasColumnName("tags")
|
||||
.HasColumnType("jsonb")
|
||||
.HasDefaultValueSql("'[]'::jsonb");
|
||||
|
||||
builder.Property(e => e.StatusId).HasColumnName("status_id").IsRequired();
|
||||
builder.HasOne(e => e.Status).WithMany().HasForeignKey(e => e.StatusId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// EN: Messages as owned collection / VI: Messages như collection sở hữu
|
||||
var messagesNav = builder.Metadata.FindNavigation(nameof(Conversation.Messages))!;
|
||||
messagesNav.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
builder.HasIndex("_shopId", "StatusId").HasDatabaseName("idx_conversations_shop_status");
|
||||
builder.HasIndex("_customerWaId").HasDatabaseName("idx_conversations_customer");
|
||||
builder.HasIndex("_expiresAt").HasDatabaseName("idx_conversations_expires");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Message entity.
|
||||
/// VI: Cấu hình EF Core cho entity Message.
|
||||
/// </summary>
|
||||
public class MessageEntityTypeConfiguration : IEntityTypeConfiguration<Message>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Message> builder)
|
||||
{
|
||||
builder.ToTable("messages");
|
||||
builder.HasKey(e => e.Id);
|
||||
|
||||
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
|
||||
builder.Property<Guid>("_conversationId").HasColumnName("conversation_id").IsRequired();
|
||||
builder.Property<string?>("_whatsAppMessageId").HasColumnName("whatsapp_message_id").HasMaxLength(100);
|
||||
builder.Property<string>("_direction").HasColumnName("direction").HasMaxLength(10).IsRequired();
|
||||
builder.Property<string>("_status").HasColumnName("status").HasMaxLength(20).HasDefaultValue("sent");
|
||||
builder.Property<DateTime>("_timestamp").HasColumnName("timestamp").IsRequired();
|
||||
|
||||
// EN: MessageContent as owned entity / VI: MessageContent như owned entity
|
||||
builder.OwnsOne<MessageContent>("_content", content =>
|
||||
{
|
||||
content.Property(c => c.Type).HasColumnName("content_type").HasMaxLength(20).IsRequired();
|
||||
content.Property(c => c.Text).HasColumnName("content_text");
|
||||
content.Property(c => c.MediaUrl).HasColumnName("media_url").HasMaxLength(500);
|
||||
content.Property(c => c.Caption).HasColumnName("caption").HasMaxLength(1000);
|
||||
content.Property(c => c.MimeType).HasColumnName("mime_type").HasMaxLength(100);
|
||||
content.Property(c => c.InteractiveJson).HasColumnName("interactive_json").HasColumnType("jsonb");
|
||||
});
|
||||
|
||||
builder.HasIndex("_conversationId", "_timestamp").HasDatabaseName("idx_messages_conversation_timestamp");
|
||||
builder.HasIndex("_whatsAppMessageId").IsUnique().HasDatabaseName("idx_messages_wa_id");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for ConversationStatus enumeration.
|
||||
/// VI: Cấu hình EF Core cho enumeration ConversationStatus.
|
||||
/// </summary>
|
||||
public class ConversationStatusEntityTypeConfiguration : IEntityTypeConfiguration<ConversationStatus>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ConversationStatus> builder)
|
||||
{
|
||||
builder.ToTable("conversation_statuses");
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever().IsRequired();
|
||||
builder.Property(s => s.Name).HasColumnName("name").HasMaxLength(50).IsRequired();
|
||||
|
||||
builder.HasData(
|
||||
ConversationStatus.Active,
|
||||
ConversationStatus.Closed,
|
||||
ConversationStatus.Expired
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using WhatsAppService.Domain.AggregatesModel.CustomerAggregate;
|
||||
|
||||
namespace WhatsAppService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Customer entity.
|
||||
/// VI: Cấu hình EF Core cho entity Customer.
|
||||
/// </summary>
|
||||
public class CustomerEntityTypeConfiguration : IEntityTypeConfiguration<Customer>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Customer> builder)
|
||||
{
|
||||
builder.ToTable("customers");
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Ignore(e => e.DomainEvents);
|
||||
|
||||
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
|
||||
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
|
||||
builder.Property<string>("_waId").HasColumnName("wa_id").HasMaxLength(20).IsRequired();
|
||||
builder.Property<string?>("_name").HasColumnName("name").HasMaxLength(200);
|
||||
builder.Property<string?>("_profilePictureUrl").HasColumnName("profile_picture_url").HasMaxLength(500);
|
||||
builder.Property<DateTime>("_firstContactedAt").HasColumnName("first_contacted_at").IsRequired();
|
||||
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
|
||||
builder.Property<DateTime?>("_updatedAt").HasColumnName("updated_at");
|
||||
|
||||
// EN: Tags stored as JSONB / VI: Tags lưu dưới dạng JSONB
|
||||
builder.Property<List<string>>("_tags")
|
||||
.HasColumnName("tags")
|
||||
.HasColumnType("jsonb")
|
||||
.HasDefaultValueSql("'[]'::jsonb");
|
||||
|
||||
// EN: Custom fields stored as JSONB / VI: Custom fields lưu dưới dạng JSONB
|
||||
builder.Property<Dictionary<string, string>>("_customFields")
|
||||
.HasColumnName("custom_fields")
|
||||
.HasColumnType("jsonb")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
// EN: OptInConsent as owned entity / VI: OptInConsent như owned entity
|
||||
builder.OwnsOne<OptInConsent>("_consent", consent =>
|
||||
{
|
||||
consent.Property(c => c.Status).HasColumnName("opt_in_status").HasMaxLength(20).HasDefaultValue("pending");
|
||||
consent.Property(c => c.Timestamp).HasColumnName("opt_in_timestamp");
|
||||
consent.Property(c => c.Source).HasColumnName("opt_in_source").HasMaxLength(50);
|
||||
});
|
||||
|
||||
builder.HasIndex("_shopId", "_waId").IsUnique().HasDatabaseName("idx_customers_shop_waid");
|
||||
builder.HasIndex("_shopId").HasDatabaseName("idx_customers_shop");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
|
||||
|
||||
namespace WhatsAppService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for WhatsAppAccount entity.
|
||||
/// VI: Cấu hình EF Core cho entity WhatsAppAccount.
|
||||
/// </summary>
|
||||
public class WhatsAppAccountEntityTypeConfiguration : IEntityTypeConfiguration<WhatsAppAccount>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WhatsAppAccount> builder)
|
||||
{
|
||||
builder.ToTable("whatsapp_accounts");
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Ignore(e => e.DomainEvents);
|
||||
|
||||
builder.Property(e => e.Id).HasColumnName("id").IsRequired();
|
||||
builder.Property<Guid>("_shopId").HasColumnName("shop_id").IsRequired();
|
||||
builder.Property<string>("_phoneNumberId").HasColumnName("phone_number_id").HasMaxLength(50).IsRequired();
|
||||
builder.Property<string>("_accessTokenEncrypted").HasColumnName("access_token_encrypted").IsRequired();
|
||||
builder.Property<string?>("_webhookUrl").HasColumnName("webhook_url").HasMaxLength(500);
|
||||
builder.Property<string?>("_webhookVerifyToken").HasColumnName("webhook_verify_token").HasMaxLength(100);
|
||||
builder.Property<string>("_messageTier").HasColumnName("message_tier").HasMaxLength(20).HasDefaultValue("tier_1");
|
||||
builder.Property<DateTime>("_createdAt").HasColumnName("created_at").IsRequired();
|
||||
builder.Property<DateTime?>("_updatedAt").HasColumnName("updated_at");
|
||||
|
||||
builder.Property(e => e.StatusId).HasColumnName("status_id").IsRequired();
|
||||
builder.HasOne(e => e.Status).WithMany().HasForeignKey(e => e.StatusId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasIndex("_shopId").HasDatabaseName("idx_whatsapp_accounts_shop");
|
||||
builder.HasIndex("_phoneNumberId").IsUnique().HasDatabaseName("idx_whatsapp_accounts_phone");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for WhatsAppAccountStatus enumeration.
|
||||
/// VI: Cấu hình EF Core cho enumeration WhatsAppAccountStatus.
|
||||
/// </summary>
|
||||
public class WhatsAppAccountStatusEntityTypeConfiguration : IEntityTypeConfiguration<WhatsAppAccountStatus>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<WhatsAppAccountStatus> builder)
|
||||
{
|
||||
builder.ToTable("whatsapp_account_statuses");
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever().IsRequired();
|
||||
builder.Property(s => s.Name).HasColumnName("name").HasMaxLength(50).IsRequired();
|
||||
|
||||
builder.HasData(
|
||||
WhatsAppAccountStatus.Pending,
|
||||
WhatsAppAccountStatus.Active,
|
||||
WhatsAppAccountStatus.Suspended,
|
||||
WhatsAppAccountStatus.Disconnected
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
namespace WhatsAppService.Infrastructure.ExternalServices;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Interface for LLM (Large Language Model) service.
|
||||
/// VI: Interface cho dịch vụ LLM (Large Language Model).
|
||||
/// </summary>
|
||||
public interface ILlmService
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Generate a response based on conversation context.
|
||||
/// VI: Tạo phản hồi dựa trên ngữ cảnh hội thoại.
|
||||
/// </summary>
|
||||
Task<LlmResponse> GenerateResponseAsync(
|
||||
LlmRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Extract intent from user message.
|
||||
/// VI: Trích xuất ý định từ tin nhắn người dùng.
|
||||
/// </summary>
|
||||
Task<IntentResult> ExtractIntentAsync(
|
||||
string message,
|
||||
string[] possibleIntents,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: LLM request with conversation context.
|
||||
/// VI: Yêu cầu LLM với ngữ cảnh hội thoại.
|
||||
/// </summary>
|
||||
public record LlmRequest(
|
||||
string SystemPrompt,
|
||||
List<ConversationMessage> Messages,
|
||||
int? MaxTokens = null,
|
||||
double? Temperature = null);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Message in conversation context.
|
||||
/// VI: Tin nhắn trong ngữ cảnh hội thoại.
|
||||
/// </summary>
|
||||
public record ConversationMessage(
|
||||
string Role, // "user", "assistant", "system"
|
||||
string Content);
|
||||
|
||||
/// <summary>
|
||||
/// EN: LLM response with usage stats.
|
||||
/// VI: Phản hồi LLM với thống kê sử dụng.
|
||||
/// </summary>
|
||||
public record LlmResponse(
|
||||
bool Success,
|
||||
string? Content,
|
||||
int PromptTokens,
|
||||
int CompletionTokens,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Intent extraction result.
|
||||
/// VI: Kết quả trích xuất ý định.
|
||||
/// </summary>
|
||||
public record IntentResult(
|
||||
bool Success,
|
||||
string? Intent,
|
||||
double Confidence,
|
||||
string? Error);
|
||||
@@ -0,0 +1,85 @@
|
||||
namespace WhatsAppService.Infrastructure.ExternalServices;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Interface for WhatsApp Cloud API client.
|
||||
/// VI: Interface cho WhatsApp Cloud API client.
|
||||
/// </summary>
|
||||
public interface IWhatsAppCloudApiClient
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Send a text message.
|
||||
/// VI: Gửi tin nhắn văn bản.
|
||||
/// </summary>
|
||||
Task<SendMessageResult> SendTextMessageAsync(
|
||||
string phoneNumberId,
|
||||
string accessToken,
|
||||
string recipientWaId,
|
||||
string text,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send a template message (for outside 24h window).
|
||||
/// VI: Gửi tin nhắn template (cho ngoài cửa sổ 24h).
|
||||
/// </summary>
|
||||
Task<SendMessageResult> SendTemplateMessageAsync(
|
||||
string phoneNumberId,
|
||||
string accessToken,
|
||||
string recipientWaId,
|
||||
string templateName,
|
||||
string languageCode,
|
||||
object[]? components = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send a media message (image, video, document, audio).
|
||||
/// VI: Gửi tin nhắn media (ảnh, video, tài liệu, audio).
|
||||
/// </summary>
|
||||
Task<SendMessageResult> SendMediaMessageAsync(
|
||||
string phoneNumberId,
|
||||
string accessToken,
|
||||
string recipientWaId,
|
||||
string mediaType,
|
||||
string mediaUrl,
|
||||
string? caption = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send an interactive message (buttons, list).
|
||||
/// VI: Gửi tin nhắn interactive (buttons, list).
|
||||
/// </summary>
|
||||
Task<SendMessageResult> SendInteractiveMessageAsync(
|
||||
string phoneNumberId,
|
||||
string accessToken,
|
||||
string recipientWaId,
|
||||
object interactiveContent,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Mark message as read.
|
||||
/// VI: Đánh dấu tin nhắn đã đọc.
|
||||
/// </summary>
|
||||
Task<bool> MarkMessageAsReadAsync(
|
||||
string phoneNumberId,
|
||||
string accessToken,
|
||||
string messageId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get media URL by media ID.
|
||||
/// VI: Lấy URL media theo media ID.
|
||||
/// </summary>
|
||||
Task<string?> GetMediaUrlAsync(
|
||||
string accessToken,
|
||||
string mediaId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of sending a message.
|
||||
/// VI: Kết quả gửi tin nhắn.
|
||||
/// </summary>
|
||||
public record SendMessageResult(
|
||||
bool Success,
|
||||
string? MessageId,
|
||||
string? ErrorCode,
|
||||
string? ErrorMessage);
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.ClientModel;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenAI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
namespace WhatsAppService.Infrastructure.ExternalServices;
|
||||
|
||||
/// <summary>
|
||||
/// EN: OpenAI LLM service implementation.
|
||||
/// VI: Triển khai dịch vụ OpenAI LLM.
|
||||
/// </summary>
|
||||
public class OpenAILlmService : ILlmService
|
||||
{
|
||||
private readonly ChatClient _chatClient;
|
||||
private readonly ILogger<OpenAILlmService> _logger;
|
||||
private readonly OpenAISettings _settings;
|
||||
|
||||
public OpenAILlmService(
|
||||
IOptions<OpenAISettings> settings,
|
||||
ILogger<OpenAILlmService> logger)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
|
||||
var apiKey = _settings.ApiKey ?? throw new InvalidOperationException("OpenAI API key is not configured");
|
||||
var client = new OpenAIClient(apiKey);
|
||||
_chatClient = client.GetChatClient(_settings.Model ?? "gpt-4o-mini");
|
||||
}
|
||||
|
||||
public async Task<LlmResponse> GenerateResponseAsync(
|
||||
LlmRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// Add system prompt
|
||||
if (!string.IsNullOrEmpty(request.SystemPrompt))
|
||||
{
|
||||
messages.Add(ChatMessage.CreateSystemMessage(request.SystemPrompt));
|
||||
}
|
||||
|
||||
// Add conversation history
|
||||
foreach (var msg in request.Messages)
|
||||
{
|
||||
messages.Add(msg.Role.ToLowerInvariant() switch
|
||||
{
|
||||
"user" => ChatMessage.CreateUserMessage(msg.Content),
|
||||
"assistant" => ChatMessage.CreateAssistantMessage(msg.Content),
|
||||
"system" => ChatMessage.CreateSystemMessage(msg.Content),
|
||||
_ => ChatMessage.CreateUserMessage(msg.Content)
|
||||
});
|
||||
}
|
||||
|
||||
var options = new ChatCompletionOptions
|
||||
{
|
||||
MaxOutputTokenCount = request.MaxTokens ?? _settings.MaxTokens ?? 500,
|
||||
Temperature = (float?)(request.Temperature ?? _settings.Temperature ?? 0.7)
|
||||
};
|
||||
|
||||
_logger.LogDebug("Sending request to OpenAI with {MessageCount} messages", messages.Count);
|
||||
|
||||
var response = await _chatClient.CompleteChatAsync(messages, options, cancellationToken);
|
||||
var completion = response.Value;
|
||||
|
||||
var content = completion.Content.FirstOrDefault()?.Text;
|
||||
var usage = completion.Usage;
|
||||
|
||||
_logger.LogInformation(
|
||||
"OpenAI response received. Prompt tokens: {PromptTokens}, Completion tokens: {CompletionTokens}",
|
||||
usage.InputTokenCount, usage.OutputTokenCount);
|
||||
|
||||
return new LlmResponse(
|
||||
Success: true,
|
||||
Content: content,
|
||||
PromptTokens: usage.InputTokenCount,
|
||||
CompletionTokens: usage.OutputTokenCount,
|
||||
Error: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to generate response from OpenAI");
|
||||
return new LlmResponse(
|
||||
Success: false,
|
||||
Content: null,
|
||||
PromptTokens: 0,
|
||||
CompletionTokens: 0,
|
||||
Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IntentResult> ExtractIntentAsync(
|
||||
string message,
|
||||
string[] possibleIntents,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var intentsStr = string.Join(", ", possibleIntents);
|
||||
var systemPrompt = $"You are an intent classifier. Analyze the user message and classify it into one of these intents: {intentsStr}. Respond with JSON only in this format: {{\"intent\": \"intent_name\", \"confidence\": 0.0-1.0}}";
|
||||
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
ChatMessage.CreateSystemMessage(systemPrompt),
|
||||
ChatMessage.CreateUserMessage(message)
|
||||
};
|
||||
|
||||
var options = new ChatCompletionOptions
|
||||
{
|
||||
MaxOutputTokenCount = 100,
|
||||
Temperature = 0.1f
|
||||
};
|
||||
|
||||
var response = await _chatClient.CompleteChatAsync(messages, options, cancellationToken);
|
||||
var content = response.Value.Content.FirstOrDefault()?.Text ?? "{}";
|
||||
|
||||
var result = JsonSerializer.Deserialize<IntentExtraction>(content);
|
||||
|
||||
return new IntentResult(
|
||||
Success: true,
|
||||
Intent: result?.Intent,
|
||||
Confidence: result?.Confidence ?? 0,
|
||||
Error: null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to extract intent");
|
||||
return new IntentResult(
|
||||
Success: false,
|
||||
Intent: null,
|
||||
Confidence: 0,
|
||||
Error: ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private record IntentExtraction(string? Intent, double Confidence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: OpenAI configuration settings.
|
||||
/// VI: Cấu hình OpenAI.
|
||||
/// </summary>
|
||||
public class OpenAISettings
|
||||
{
|
||||
public string? ApiKey { get; set; }
|
||||
public string? Model { get; set; } = "gpt-4o-mini";
|
||||
public int? MaxTokens { get; set; } = 500;
|
||||
public double? Temperature { get; set; } = 0.7;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace WhatsAppService.Infrastructure.ExternalServices;
|
||||
|
||||
/// <summary>
|
||||
/// EN: WhatsApp Cloud API client implementation with resilience.
|
||||
/// VI: Triển khai WhatsApp Cloud API client với khả năng phục hồi.
|
||||
/// </summary>
|
||||
public class WhatsAppCloudApiClient : IWhatsAppCloudApiClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<WhatsAppCloudApiClient> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
|
||||
private const string BaseUrl = "https://graph.facebook.com";
|
||||
private const string ApiVersion = "v18.0";
|
||||
|
||||
public WhatsAppCloudApiClient(HttpClient httpClient, ILogger<WhatsAppCloudApiClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SendMessageResult> SendTextMessageAsync(
|
||||
string phoneNumberId,
|
||||
string accessToken,
|
||||
string recipientWaId,
|
||||
string text,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
messaging_product = "whatsapp",
|
||||
recipient_type = "individual",
|
||||
to = recipientWaId,
|
||||
type = "text",
|
||||
text = new { body = text }
|
||||
};
|
||||
|
||||
return await SendMessageAsync(phoneNumberId, accessToken, payload, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SendMessageResult> SendTemplateMessageAsync(
|
||||
string phoneNumberId,
|
||||
string accessToken,
|
||||
string recipientWaId,
|
||||
string templateName,
|
||||
string languageCode,
|
||||
object[]? components = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var template = new Dictionary<string, object>
|
||||
{
|
||||
["name"] = templateName,
|
||||
["language"] = new { code = languageCode }
|
||||
};
|
||||
|
||||
if (components != null)
|
||||
{
|
||||
template["components"] = components;
|
||||
}
|
||||
|
||||
var payload = new
|
||||
{
|
||||
messaging_product = "whatsapp",
|
||||
recipient_type = "individual",
|
||||
to = recipientWaId,
|
||||
type = "template",
|
||||
template
|
||||
};
|
||||
|
||||
return await SendMessageAsync(phoneNumberId, accessToken, payload, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SendMessageResult> SendMediaMessageAsync(
|
||||
string phoneNumberId,
|
||||
string accessToken,
|
||||
string recipientWaId,
|
||||
string mediaType,
|
||||
string mediaUrl,
|
||||
string? caption = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var mediaContent = new Dictionary<string, object> { ["link"] = mediaUrl };
|
||||
if (!string.IsNullOrEmpty(caption))
|
||||
{
|
||||
mediaContent["caption"] = caption;
|
||||
}
|
||||
|
||||
var payload = new Dictionary<string, object>
|
||||
{
|
||||
["messaging_product"] = "whatsapp",
|
||||
["recipient_type"] = "individual",
|
||||
["to"] = recipientWaId,
|
||||
["type"] = mediaType,
|
||||
[mediaType] = mediaContent
|
||||
};
|
||||
|
||||
return await SendMessageAsync(phoneNumberId, accessToken, payload, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<SendMessageResult> SendInteractiveMessageAsync(
|
||||
string phoneNumberId,
|
||||
string accessToken,
|
||||
string recipientWaId,
|
||||
object interactiveContent,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
messaging_product = "whatsapp",
|
||||
recipient_type = "individual",
|
||||
to = recipientWaId,
|
||||
type = "interactive",
|
||||
interactive = interactiveContent
|
||||
};
|
||||
|
||||
return await SendMessageAsync(phoneNumberId, accessToken, payload, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> MarkMessageAsReadAsync(
|
||||
string phoneNumberId,
|
||||
string accessToken,
|
||||
string messageId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{BaseUrl}/{ApiVersion}/{phoneNumberId}/messages";
|
||||
var payload = new
|
||||
{
|
||||
messaging_product = "whatsapp",
|
||||
status = "read",
|
||||
message_id = messageId
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
|
||||
request.Content = JsonContent.Create(payload, options: _jsonOptions);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to mark message {MessageId} as read", messageId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GetMediaUrlAsync(
|
||||
string accessToken,
|
||||
string mediaId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{BaseUrl}/{ApiVersion}/{mediaId}";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<MediaResponse>(cancellationToken: cancellationToken);
|
||||
return result?.Url;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get media URL for {MediaId}", mediaId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SendMessageResult> SendMessageAsync(
|
||||
string phoneNumberId,
|
||||
string accessToken,
|
||||
object payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = $"{BaseUrl}/{ApiVersion}/{phoneNumberId}/messages";
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken);
|
||||
request.Content = JsonContent.Create(payload, options: _jsonOptions);
|
||||
|
||||
_logger.LogDebug("Sending message to WhatsApp API: {Url}", url);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<WhatsAppSendResponse>(content, _jsonOptions);
|
||||
var messageId = result?.Messages?.FirstOrDefault()?.Id;
|
||||
|
||||
_logger.LogInformation("Message sent successfully. MessageId: {MessageId}", messageId);
|
||||
return new SendMessageResult(true, messageId, null, null);
|
||||
}
|
||||
|
||||
var error = JsonSerializer.Deserialize<WhatsAppErrorResponse>(content, _jsonOptions);
|
||||
_logger.LogWarning("WhatsApp API error: {Code} - {Message}", error?.Error?.Code, error?.Error?.Message);
|
||||
|
||||
return new SendMessageResult(false, null, error?.Error?.Code?.ToString(), error?.Error?.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send message via WhatsApp API");
|
||||
return new SendMessageResult(false, null, "EXCEPTION", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private record WhatsAppSendResponse(
|
||||
[property: JsonPropertyName("messages")] List<MessageInfo>? Messages);
|
||||
|
||||
private record MessageInfo(
|
||||
[property: JsonPropertyName("id")] string? Id);
|
||||
|
||||
private record WhatsAppErrorResponse(
|
||||
[property: JsonPropertyName("error")] ErrorInfo? Error);
|
||||
|
||||
private record ErrorInfo(
|
||||
[property: JsonPropertyName("code")] int? Code,
|
||||
[property: JsonPropertyName("message")] string? Message);
|
||||
|
||||
private record MediaResponse(
|
||||
[property: JsonPropertyName("url")] string? Url);
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using WhatsAppService.Domain.AggregatesModel.AIAgentAggregate;
|
||||
using WhatsAppService.Domain.AggregatesModel.AutomationFlowAggregate;
|
||||
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
|
||||
using WhatsAppService.Domain.AggregatesModel.CustomerAggregate;
|
||||
using WhatsAppService.Domain.AggregatesModel.SampleAggregate;
|
||||
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
|
||||
using WhatsAppService.Domain.SeedWork;
|
||||
using WhatsAppService.Infrastructure.EntityConfigurations;
|
||||
|
||||
@@ -16,12 +21,26 @@ public class WhatsAppServiceContext : DbContext, IUnitOfWork
|
||||
private readonly IMediator _mediator;
|
||||
private IDbContextTransaction? _currentTransaction;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Samples table.
|
||||
/// VI: Bảng Samples.
|
||||
/// </summary>
|
||||
// EN: Sample (template) / VI: Sample (mẫu)
|
||||
public DbSet<Sample> Samples => Set<Sample>();
|
||||
|
||||
// EN: WhatsApp Accounts / VI: Tài khoản WhatsApp
|
||||
public DbSet<WhatsAppAccount> WhatsAppAccounts => Set<WhatsAppAccount>();
|
||||
|
||||
// EN: Customers / VI: Khách hàng
|
||||
public DbSet<Customer> Customers => Set<Customer>();
|
||||
|
||||
// EN: Conversations & Messages / VI: Hội thoại & Tin nhắn
|
||||
public DbSet<Conversation> Conversations => Set<Conversation>();
|
||||
public DbSet<Message> Messages => Set<Message>();
|
||||
|
||||
// EN: Automation Flows / VI: Luồng tự động
|
||||
public DbSet<AutomationFlow> AutomationFlows => Set<AutomationFlow>();
|
||||
public DbSet<FlowStep> FlowSteps => Set<FlowStep>();
|
||||
|
||||
// EN: AI Agents / VI: AI Agents
|
||||
public DbSet<AIAgent> AIAgents => Set<AIAgent>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Read-only access to current transaction.
|
||||
/// VI: Truy cập chỉ đọc đến transaction hiện tại.
|
||||
@@ -50,8 +69,30 @@ public class WhatsAppServiceContext : DbContext, IUnitOfWork
|
||||
{
|
||||
// EN: Apply entity configurations
|
||||
// VI: Áp dụng các cấu hình entity
|
||||
|
||||
// Sample (template)
|
||||
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
|
||||
|
||||
// WhatsApp Account
|
||||
modelBuilder.ApplyConfiguration(new WhatsAppAccountEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new WhatsAppAccountStatusEntityTypeConfiguration());
|
||||
|
||||
// Customer
|
||||
modelBuilder.ApplyConfiguration(new CustomerEntityTypeConfiguration());
|
||||
|
||||
// Conversation & Message
|
||||
modelBuilder.ApplyConfiguration(new ConversationEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new MessageEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ConversationStatusEntityTypeConfiguration());
|
||||
|
||||
// Automation Flow
|
||||
modelBuilder.ApplyConfiguration(new AutomationFlowEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new FlowStepEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new TriggerTypeEntityTypeConfiguration());
|
||||
|
||||
// AI Agent
|
||||
modelBuilder.ApplyConfiguration(new AIAgentEntityTypeConfiguration());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WhatsAppService.Domain.AggregatesModel.AIAgentAggregate;
|
||||
using WhatsAppService.Domain.SeedWork;
|
||||
|
||||
namespace WhatsAppService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for AIAgent aggregate.
|
||||
/// VI: Triển khai repository cho AIAgent aggregate.
|
||||
/// </summary>
|
||||
public class AIAgentRepository : IAIAgentRepository
|
||||
{
|
||||
private readonly WhatsAppServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public AIAgentRepository(WhatsAppServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<AIAgent> AddAsync(AIAgent agent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = await _context.AIAgents.AddAsync(agent, cancellationToken);
|
||||
return entry.Entity;
|
||||
}
|
||||
|
||||
public void Update(AIAgent agent)
|
||||
{
|
||||
_context.Entry(agent).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<AIAgent?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.AIAgents.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AIAgent?> GetActiveByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.AIAgents
|
||||
.FirstOrDefaultAsync(a =>
|
||||
EF.Property<Guid>(a, "_shopId") == shopId &&
|
||||
EF.Property<bool>(a, "_isActive") == true,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AIAgent>> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.AIAgents
|
||||
.Where(a => EF.Property<Guid>(a, "_shopId") == shopId)
|
||||
.OrderByDescending(a => EF.Property<DateTime>(a, "_createdAt"))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WhatsAppService.Domain.AggregatesModel.AutomationFlowAggregate;
|
||||
using WhatsAppService.Domain.SeedWork;
|
||||
|
||||
namespace WhatsAppService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for AutomationFlow aggregate.
|
||||
/// VI: Triển khai repository cho AutomationFlow aggregate.
|
||||
/// </summary>
|
||||
public class AutomationFlowRepository : IAutomationFlowRepository
|
||||
{
|
||||
private readonly WhatsAppServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public AutomationFlowRepository(WhatsAppServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<AutomationFlow> AddAsync(AutomationFlow flow, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = await _context.AutomationFlows.AddAsync(flow, cancellationToken);
|
||||
return entry.Entity;
|
||||
}
|
||||
|
||||
public void Update(AutomationFlow flow)
|
||||
{
|
||||
_context.Entry(flow).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<AutomationFlow?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.AutomationFlows
|
||||
.Include(f => f.TriggerType)
|
||||
.Include(f => f.Steps.OrderBy(s => EF.Property<int>(s, "_order")))
|
||||
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AutomationFlow>> GetActiveByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.AutomationFlows
|
||||
.Include(f => f.TriggerType)
|
||||
.Include(f => f.Steps.OrderBy(s => EF.Property<int>(s, "_order")))
|
||||
.Where(f =>
|
||||
EF.Property<Guid>(f, "_shopId") == shopId &&
|
||||
EF.Property<bool>(f, "_isActive") == true)
|
||||
.OrderByDescending(f => EF.Property<int>(f, "_priority"))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AutomationFlow>> GetByTriggerTypeAsync(Guid shopId, TriggerType triggerType, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.AutomationFlows
|
||||
.Include(f => f.TriggerType)
|
||||
.Include(f => f.Steps.OrderBy(s => EF.Property<int>(s, "_order")))
|
||||
.Where(f =>
|
||||
EF.Property<Guid>(f, "_shopId") == shopId &&
|
||||
f.TriggerTypeId == triggerType.Id &&
|
||||
EF.Property<bool>(f, "_isActive") == true)
|
||||
.OrderByDescending(f => EF.Property<int>(f, "_priority"))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WhatsAppService.Domain.AggregatesModel.ConversationAggregate;
|
||||
using WhatsAppService.Domain.SeedWork;
|
||||
|
||||
namespace WhatsAppService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Conversation aggregate.
|
||||
/// VI: Triển khai repository cho Conversation aggregate.
|
||||
/// </summary>
|
||||
public class ConversationRepository : IConversationRepository
|
||||
{
|
||||
private readonly WhatsAppServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public ConversationRepository(WhatsAppServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<Conversation> AddAsync(Conversation conversation, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = await _context.Conversations.AddAsync(conversation, cancellationToken);
|
||||
return entry.Entity;
|
||||
}
|
||||
|
||||
public void Update(Conversation conversation)
|
||||
{
|
||||
_context.Entry(conversation).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<Conversation?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Conversations
|
||||
.Include(c => c.Status)
|
||||
.Include(c => c.Messages)
|
||||
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Conversation?> GetActiveByCustomerAsync(Guid shopId, string customerWaId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Conversations
|
||||
.Include(c => c.Status)
|
||||
.Include(c => c.Messages)
|
||||
.FirstOrDefaultAsync(c =>
|
||||
EF.Property<Guid>(c, "_shopId") == shopId &&
|
||||
EF.Property<string>(c, "_customerWaId") == customerWaId &&
|
||||
c.StatusId == ConversationStatus.Active.Id,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Conversation>> GetByShopIdAsync(Guid shopId, int skip, int take, ConversationStatus? status = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _context.Conversations
|
||||
.Include(c => c.Status)
|
||||
.Where(c => EF.Property<Guid>(c, "_shopId") == shopId);
|
||||
|
||||
if (status != null)
|
||||
{
|
||||
query = query.Where(c => c.StatusId == status.Id);
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderByDescending(c => EF.Property<DateTime?>(c, "_lastMessageAt"))
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Conversation>> GetExpiredConversationsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return await _context.Conversations
|
||||
.Include(c => c.Status)
|
||||
.Where(c =>
|
||||
c.StatusId == ConversationStatus.Active.Id &&
|
||||
EF.Property<DateTime?>(c, "_expiresAt") < now)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WhatsAppService.Domain.AggregatesModel.CustomerAggregate;
|
||||
using WhatsAppService.Domain.SeedWork;
|
||||
|
||||
namespace WhatsAppService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Customer aggregate.
|
||||
/// VI: Triển khai repository cho Customer aggregate.
|
||||
/// </summary>
|
||||
public class CustomerRepository : ICustomerRepository
|
||||
{
|
||||
private readonly WhatsAppServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public CustomerRepository(WhatsAppServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<Customer> AddAsync(Customer customer, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = await _context.Customers.AddAsync(customer, cancellationToken);
|
||||
return entry.Entity;
|
||||
}
|
||||
|
||||
public void Update(Customer customer)
|
||||
{
|
||||
_context.Entry(customer).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<Customer?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Customers.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<Customer?> GetByWaIdAsync(Guid shopId, string waId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Customers
|
||||
.FirstOrDefaultAsync(c =>
|
||||
EF.Property<Guid>(c, "_shopId") == shopId &&
|
||||
EF.Property<string>(c, "_waId") == waId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Customer>> GetByShopIdAsync(Guid shopId, int skip, int take, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.Customers
|
||||
.Where(c => EF.Property<Guid>(c, "_shopId") == shopId)
|
||||
.OrderByDescending(c => EF.Property<DateTime>(c, "_createdAt"))
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Customer>> GetByTagsAsync(Guid shopId, IEnumerable<string> tags, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tagList = tags.ToList();
|
||||
return await _context.Customers
|
||||
.Where(c => EF.Property<Guid>(c, "_shopId") == shopId)
|
||||
.ToListAsync(cancellationToken);
|
||||
// Note: Full JSONB tag filtering would require raw SQL for optimal performance
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using WhatsAppService.Domain.AggregatesModel.WhatsAppAccountAggregate;
|
||||
using WhatsAppService.Domain.SeedWork;
|
||||
|
||||
namespace WhatsAppService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for WhatsAppAccount aggregate.
|
||||
/// VI: Triển khai repository cho WhatsAppAccount aggregate.
|
||||
/// </summary>
|
||||
public class WhatsAppAccountRepository : IWhatsAppAccountRepository
|
||||
{
|
||||
private readonly WhatsAppServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public WhatsAppAccountRepository(WhatsAppServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public async Task<WhatsAppAccount> AddAsync(WhatsAppAccount account, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entry = await _context.WhatsAppAccounts.AddAsync(account, cancellationToken);
|
||||
return entry.Entity;
|
||||
}
|
||||
|
||||
public void Update(WhatsAppAccount account)
|
||||
{
|
||||
_context.Entry(account).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<WhatsAppAccount?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.WhatsAppAccounts
|
||||
.Include(a => a.Status)
|
||||
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<WhatsAppAccount?> GetByShopIdAsync(Guid shopId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.WhatsAppAccounts
|
||||
.Include(a => a.Status)
|
||||
.FirstOrDefaultAsync(a => EF.Property<Guid>(a, "_shopId") == shopId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<WhatsAppAccount?> GetByPhoneNumberIdAsync(string phoneNumberId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.WhatsAppAccounts
|
||||
.Include(a => a.Status)
|
||||
.FirstOrDefaultAsync(a => EF.Property<string>(a, "_phoneNumberId") == phoneNumberId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
<!-- EN: Resilience with Polly / VI: Resilience với Polly -->
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0" />
|
||||
<PackageReference Include="OpenAI" Version="2.1.0" />
|
||||
<PackageReference Include="Polly" Version="8.5.0" />
|
||||
|
||||
<!-- EN: Redis cache / VI: Redis cache -->
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MktXService.Domain.AggregatesModel.AIConversationSessionAggregate;
|
||||
|
||||
namespace MktXService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for AIConversationSession entity with JSONB for context/slots.
|
||||
/// VI: Cấu hình EF Core cho entity AIConversationSession với JSONB cho context/slots.
|
||||
/// </summary>
|
||||
public class AIConversationSessionEntityTypeConfiguration : IEntityTypeConfiguration<AIConversationSession>
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public void Configure(EntityTypeBuilder<AIConversationSession> builder)
|
||||
{
|
||||
builder.ToTable("ai_conversation_sessions");
|
||||
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Ignore(s => s.DomainEvents);
|
||||
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<Guid>("_conversationId")
|
||||
.HasColumnName("conversation_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_currentIntent")
|
||||
.HasColumnName("current_intent")
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property<bool>("_isActive")
|
||||
.HasColumnName("is_active")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_lastActivityAt")
|
||||
.HasColumnName("last_activity_at");
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
// ===========================================
|
||||
// EN: JSONB for conversation context (chat history)
|
||||
// VI: JSONB cho ngữ cảnh hội thoại (lịch sử chat)
|
||||
// ===========================================
|
||||
builder.Property<List<ContextItem>>("_context")
|
||||
.HasColumnName("context")
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||
v => JsonSerializer.Deserialize<List<ContextItem>>(v, JsonOptions)
|
||||
?? new List<ContextItem>(),
|
||||
new ValueComparer<List<ContextItem>>(
|
||||
(c1, c2) => c1!.SequenceEqual(c2!),
|
||||
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||
c => c.ToList()));
|
||||
|
||||
// EN: JSONB for slots (extracted entities from conversation)
|
||||
// VI: JSONB cho slots (entities được trích xuất từ hội thoại)
|
||||
builder.Property<Dictionary<string, object>>("_slots")
|
||||
.HasColumnName("slots")
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||
v => JsonSerializer.Deserialize<Dictionary<string, object>>(v, JsonOptions)
|
||||
?? new Dictionary<string, object>(),
|
||||
new ValueComparer<Dictionary<string, object>>(
|
||||
(c1, c2) => c1!.SequenceEqual(c2!),
|
||||
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||
c => new Dictionary<string, object>(c)));
|
||||
|
||||
// ===========================================
|
||||
// EN: Indexes
|
||||
// VI: Các index
|
||||
// ===========================================
|
||||
builder.HasIndex("_conversationId")
|
||||
.HasDatabaseName("ix_ai_sessions_conversation_id");
|
||||
|
||||
builder.HasIndex("_isActive")
|
||||
.HasDatabaseName("ix_ai_sessions_is_active");
|
||||
|
||||
// EN: Composite index for finding active session by conversation
|
||||
// VI: Index kết hợp để tìm session active theo conversation
|
||||
builder.HasIndex("_conversationId", "_isActive")
|
||||
.HasDatabaseName("ix_ai_sessions_conversation_active");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MktXService.Domain.AggregatesModel.AutomationFlowAggregate;
|
||||
|
||||
namespace MktXService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for AutomationFlow entity with JSONB for trigger/config.
|
||||
/// VI: Cấu hình EF Core cho entity AutomationFlow với JSONB cho trigger/config.
|
||||
/// </summary>
|
||||
public class AutomationFlowEntityTypeConfiguration : IEntityTypeConfiguration<AutomationFlow>
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public void Configure(EntityTypeBuilder<AutomationFlow> builder)
|
||||
{
|
||||
builder.ToTable("automation_flows");
|
||||
|
||||
builder.HasKey(f => f.Id);
|
||||
|
||||
builder.Ignore(f => f.DomainEvents);
|
||||
|
||||
// EN: Basic properties
|
||||
// VI: Các thuộc tính cơ bản
|
||||
builder.Property(f => f.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<Guid>("_merchantId")
|
||||
.HasColumnName("merchant_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(f => f.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// ===========================================
|
||||
// EN: JSONB Column for FlowTrigger (Value Object)
|
||||
// VI: Cột JSONB cho FlowTrigger (Value Object)
|
||||
// ===========================================
|
||||
builder.Property<FlowTrigger>("_trigger")
|
||||
.HasColumnName("trigger")
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||
v => JsonSerializer.Deserialize<FlowTrigger>(v, JsonOptions)!)
|
||||
.IsRequired();
|
||||
|
||||
// ===========================================
|
||||
// EN: Relationships
|
||||
// VI: Quan hệ
|
||||
// ===========================================
|
||||
builder.HasOne(f => f.Status)
|
||||
.WithMany()
|
||||
.HasForeignKey(f => f.StatusId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// EN: One-to-many with FlowNodes
|
||||
// VI: Quan hệ một-nhiều với FlowNodes
|
||||
builder.HasMany(f => f.Nodes)
|
||||
.WithOne()
|
||||
.HasForeignKey(n => n.FlowId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// EN: One-to-many with FlowConnections
|
||||
// VI: Quan hệ một-nhiều với FlowConnections
|
||||
builder.HasMany(f => f.Connections)
|
||||
.WithOne()
|
||||
.HasForeignKey(c => c.FlowId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// ===========================================
|
||||
// EN: Indexes
|
||||
// VI: Các index
|
||||
// ===========================================
|
||||
builder.HasIndex("_merchantId")
|
||||
.HasDatabaseName("ix_automation_flows_merchant_id");
|
||||
|
||||
builder.HasIndex(f => f.StatusId)
|
||||
.HasDatabaseName("ix_automation_flows_status_id");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for FlowNode entity.
|
||||
/// VI: Cấu hình EF Core cho entity FlowNode.
|
||||
/// </summary>
|
||||
public class FlowNodeEntityTypeConfiguration : IEntityTypeConfiguration<FlowNode>
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public void Configure(EntityTypeBuilder<FlowNode> builder)
|
||||
{
|
||||
builder.ToTable("flow_nodes");
|
||||
|
||||
builder.HasKey(n => n.Id);
|
||||
|
||||
builder.Property(n => n.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(n => n.FlowId)
|
||||
.HasColumnName("flow_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(n => n.Type)
|
||||
.HasColumnName("type")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(n => n.PositionX)
|
||||
.HasColumnName("position_x");
|
||||
|
||||
builder.Property(n => n.PositionY)
|
||||
.HasColumnName("position_y");
|
||||
|
||||
// EN: Config stored as JSONB
|
||||
// VI: Config lưu dưới dạng JSONB
|
||||
builder.Property(n => n.Config)
|
||||
.HasColumnName("config")
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => v == null ? null : JsonSerializer.Serialize(v, JsonOptions),
|
||||
v => v == null ? null : JsonSerializer.Deserialize<Dictionary<string, object>>(v, JsonOptions));
|
||||
|
||||
builder.HasIndex(n => n.FlowId)
|
||||
.HasDatabaseName("ix_flow_nodes_flow_id");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for FlowConnection entity.
|
||||
/// VI: Cấu hình EF Core cho entity FlowConnection.
|
||||
/// </summary>
|
||||
public class FlowConnectionEntityTypeConfiguration : IEntityTypeConfiguration<FlowConnection>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<FlowConnection> builder)
|
||||
{
|
||||
builder.ToTable("flow_connections");
|
||||
|
||||
builder.HasKey(c => c.Id);
|
||||
|
||||
builder.Property(c => c.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.FlowId)
|
||||
.HasColumnName("flow_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.FromNodeId)
|
||||
.HasColumnName("from_node_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.ToNodeId)
|
||||
.HasColumnName("to_node_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.Condition)
|
||||
.HasColumnName("condition")
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.HasIndex(c => c.FlowId)
|
||||
.HasDatabaseName("ix_flow_connections_flow_id");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for FlowStatus enumeration.
|
||||
/// VI: Cấu hình EF Core cho enumeration FlowStatus.
|
||||
/// </summary>
|
||||
public class FlowStatusEntityTypeConfiguration : IEntityTypeConfiguration<FlowStatus>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<FlowStatus> builder)
|
||||
{
|
||||
builder.ToTable("flow_statuses");
|
||||
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasData(
|
||||
FlowStatus.Draft,
|
||||
FlowStatus.Active,
|
||||
FlowStatus.Inactive
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MktXService.Domain.AggregatesModel.CampaignAggregate;
|
||||
|
||||
namespace MktXService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Campaign entity with JSONB support.
|
||||
/// VI: Cấu hình EF Core cho entity Campaign với hỗ trợ JSONB.
|
||||
/// </summary>
|
||||
public class CampaignEntityTypeConfiguration : IEntityTypeConfiguration<Campaign>
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public void Configure(EntityTypeBuilder<Campaign> builder)
|
||||
{
|
||||
// EN: Table name (snake_case for PostgreSQL)
|
||||
// VI: Tên bảng (snake_case cho PostgreSQL)
|
||||
builder.ToTable("campaigns");
|
||||
|
||||
// EN: Primary key
|
||||
// VI: Khóa chính
|
||||
builder.HasKey(c => c.Id);
|
||||
|
||||
// EN: Ignore domain events
|
||||
// VI: Bỏ qua domain events
|
||||
builder.Ignore(c => c.DomainEvents);
|
||||
|
||||
// EN: Basic properties
|
||||
// VI: Các thuộc tính cơ bản
|
||||
builder.Property(c => c.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<Guid>("_merchantId")
|
||||
.HasColumnName("merchant_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_type")
|
||||
.HasColumnName("type")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<Guid?>("_templateId")
|
||||
.HasColumnName("template_id");
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// ===========================================
|
||||
// EN: JSONB Columns for Complex Types
|
||||
// VI: Cột JSONB cho các kiểu phức tạp
|
||||
// ===========================================
|
||||
|
||||
// EN: SegmentIds stored as JSONB array
|
||||
// VI: SegmentIds lưu dưới dạng mảng JSONB
|
||||
builder.Property<List<Guid>>("_segmentIds")
|
||||
.HasColumnName("segment_ids")
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||
v => JsonSerializer.Deserialize<List<Guid>>(v, JsonOptions) ?? new List<Guid>(),
|
||||
new ValueComparer<List<Guid>>(
|
||||
(c1, c2) => c1!.SequenceEqual(c2!),
|
||||
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||
c => c.ToList()));
|
||||
|
||||
// EN: Schedule stored as JSONB (Value Object)
|
||||
// VI: Schedule lưu dưới dạng JSONB (Value Object)
|
||||
builder.Property<CampaignSchedule?>("_schedule")
|
||||
.HasColumnName("schedule")
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => v == null ? null : JsonSerializer.Serialize(v, JsonOptions),
|
||||
v => v == null ? null : JsonSerializer.Deserialize<CampaignSchedule>(v, JsonOptions));
|
||||
|
||||
// EN: Metrics stored as JSONB (Value Object)
|
||||
// VI: Metrics lưu dưới dạng JSONB (Value Object)
|
||||
builder.Property<CampaignMetrics>("_metrics")
|
||||
.HasColumnName("metrics")
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||
v => JsonSerializer.Deserialize<CampaignMetrics>(v, JsonOptions)
|
||||
?? new CampaignMetrics(0, 0, 0, 0, 0, 0))
|
||||
.IsRequired();
|
||||
|
||||
// ===========================================
|
||||
// EN: Relationships
|
||||
// VI: Quan hệ
|
||||
// ===========================================
|
||||
|
||||
// EN: Status relationship (Enumeration pattern)
|
||||
// VI: Quan hệ với Status (pattern Enumeration)
|
||||
builder.HasOne(c => c.Status)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.StatusId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// ===========================================
|
||||
// EN: Indexes for commonly queried columns
|
||||
// VI: Index cho các cột hay truy vấn
|
||||
// ===========================================
|
||||
builder.HasIndex("_merchantId")
|
||||
.HasDatabaseName("ix_campaigns_merchant_id");
|
||||
|
||||
builder.HasIndex(c => c.StatusId)
|
||||
.HasDatabaseName("ix_campaigns_status_id");
|
||||
|
||||
builder.HasIndex("_createdAt")
|
||||
.HasDatabaseName("ix_campaigns_created_at")
|
||||
.IsDescending();
|
||||
|
||||
// EN: Composite index for merchant + status queries
|
||||
// VI: Index kết hợp cho truy vấn merchant + status
|
||||
builder.HasIndex("_merchantId", "StatusId")
|
||||
.HasDatabaseName("ix_campaigns_merchant_status");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for CampaignStatus enumeration.
|
||||
/// VI: Cấu hình EF Core cho enumeration CampaignStatus.
|
||||
/// </summary>
|
||||
public class CampaignStatusEntityTypeConfiguration : IEntityTypeConfiguration<CampaignStatus>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<CampaignStatus> builder)
|
||||
{
|
||||
builder.ToTable("campaign_statuses");
|
||||
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Seed data for enumeration values
|
||||
// VI: Dữ liệu gốc cho các giá trị enumeration
|
||||
builder.HasData(
|
||||
CampaignStatus.Draft,
|
||||
CampaignStatus.Scheduled,
|
||||
CampaignStatus.Running,
|
||||
CampaignStatus.Paused,
|
||||
CampaignStatus.Completed,
|
||||
CampaignStatus.Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MktXService.Domain.AggregatesModel.ContactAggregate;
|
||||
|
||||
namespace MktXService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Contact entity.
|
||||
/// VI: Cấu hình EF Core cho entity Contact.
|
||||
/// </summary>
|
||||
public class ContactEntityTypeConfiguration : IEntityTypeConfiguration<Contact>
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public void Configure(EntityTypeBuilder<Contact> builder)
|
||||
{
|
||||
builder.ToTable("contacts");
|
||||
|
||||
builder.HasKey(c => c.Id);
|
||||
builder.Ignore(c => c.DomainEvents);
|
||||
|
||||
builder.Property(c => c.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<Guid>("_accountId")
|
||||
.HasColumnName("account_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_twitterUserId")
|
||||
.HasColumnName("twitter_user_id")
|
||||
.HasMaxLength(100)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_username")
|
||||
.HasColumnName("username")
|
||||
.HasMaxLength(100)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_displayName")
|
||||
.HasColumnName("display_name")
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property<string?>("_profileImageUrl")
|
||||
.HasColumnName("profile_image_url")
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property<string>("_source")
|
||||
.HasColumnName("source")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Custom attributes as JSONB
|
||||
// VI: Thuộc tính tùy chỉnh dạng JSONB
|
||||
builder.Property<Dictionary<string, object>>("_attributes")
|
||||
.HasColumnName("attributes")
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||
v => JsonSerializer.Deserialize<Dictionary<string, object>>(v, JsonOptions)
|
||||
?? new Dictionary<string, object>(),
|
||||
new ValueComparer<Dictionary<string, object>>(
|
||||
(c1, c2) => c1!.SequenceEqual(c2!),
|
||||
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||
c => new Dictionary<string, object>(c)));
|
||||
|
||||
builder.Property<DateTime>("_firstInteractionAt")
|
||||
.HasColumnName("first_interaction_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_lastInteractionAt")
|
||||
.HasColumnName("last_interaction_at");
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// EN: One-to-many with ContactTags
|
||||
// VI: Quan hệ một-nhiều với ContactTags
|
||||
builder.HasMany(c => c.Tags)
|
||||
.WithOne()
|
||||
.HasForeignKey(t => t.ContactId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// EN: Unique constraint on account_id + twitter_user_id
|
||||
// VI: Ràng buộc unique trên account_id + twitter_user_id
|
||||
builder.HasIndex("_accountId", "_twitterUserId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_contacts_account_twitter_user");
|
||||
|
||||
builder.HasIndex("_accountId")
|
||||
.HasDatabaseName("ix_contacts_account_id");
|
||||
|
||||
builder.HasIndex("_username")
|
||||
.HasDatabaseName("ix_contacts_username");
|
||||
|
||||
builder.HasIndex("_lastInteractionAt")
|
||||
.HasDatabaseName("ix_contacts_last_interaction");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for ContactTag entity.
|
||||
/// VI: Cấu hình EF Core cho entity ContactTag.
|
||||
/// </summary>
|
||||
public class ContactTagEntityTypeConfiguration : IEntityTypeConfiguration<ContactTag>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ContactTag> builder)
|
||||
{
|
||||
builder.ToTable("contact_tags");
|
||||
|
||||
builder.HasKey(t => t.Id);
|
||||
|
||||
builder.Property(t => t.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(t => t.ContactId)
|
||||
.HasColumnName("contact_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(100)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Unique per contact
|
||||
builder.HasIndex(t => t.ContactId, "_name")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_contact_tags_contact_name");
|
||||
|
||||
builder.HasIndex("_name")
|
||||
.HasDatabaseName("ix_contact_tags_name");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MktXService.Domain.AggregatesModel.ConversationAggregate;
|
||||
|
||||
namespace MktXService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Conversation entity.
|
||||
/// VI: Cấu hình EF Core cho entity Conversation.
|
||||
/// </summary>
|
||||
public class ConversationEntityTypeConfiguration : IEntityTypeConfiguration<Conversation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Conversation> builder)
|
||||
{
|
||||
builder.ToTable("conversations");
|
||||
|
||||
builder.HasKey(c => c.Id);
|
||||
builder.Ignore(c => c.DomainEvents);
|
||||
|
||||
builder.Property(c => c.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<Guid>("_contactId")
|
||||
.HasColumnName("contact_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<Guid>("_accountId")
|
||||
.HasColumnName("account_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_channel")
|
||||
.HasColumnName("channel")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<Guid?>("_assignedToUserId")
|
||||
.HasColumnName("assigned_to_user_id");
|
||||
|
||||
builder.Property<DateTime>("_startedAt")
|
||||
.HasColumnName("started_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_closedAt")
|
||||
.HasColumnName("closed_at");
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// EN: Relationships
|
||||
builder.HasOne(c => c.Status)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.StatusId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasMany(c => c.Messages)
|
||||
.WithOne()
|
||||
.HasForeignKey(m => m.ConversationId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// EN: Indexes
|
||||
builder.HasIndex("_contactId")
|
||||
.HasDatabaseName("ix_conversations_contact_id");
|
||||
|
||||
builder.HasIndex("_accountId")
|
||||
.HasDatabaseName("ix_conversations_account_id");
|
||||
|
||||
builder.HasIndex(c => c.StatusId)
|
||||
.HasDatabaseName("ix_conversations_status_id");
|
||||
|
||||
builder.HasIndex("_assignedToUserId")
|
||||
.HasDatabaseName("ix_conversations_assigned_user");
|
||||
|
||||
// EN: Composite for finding open conversations per contact
|
||||
builder.HasIndex("_contactId", "StatusId")
|
||||
.HasDatabaseName("ix_conversations_contact_status");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for ConversationStatus enumeration.
|
||||
/// VI: Cấu hình EF Core cho enumeration ConversationStatus.
|
||||
/// </summary>
|
||||
public class ConversationStatusEntityTypeConfiguration : IEntityTypeConfiguration<ConversationStatus>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ConversationStatus> builder)
|
||||
{
|
||||
builder.ToTable("conversation_statuses");
|
||||
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasData(
|
||||
ConversationStatus.Open,
|
||||
ConversationStatus.Closed,
|
||||
ConversationStatus.Pending
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Message entity.
|
||||
/// VI: Cấu hình EF Core cho entity Message.
|
||||
/// </summary>
|
||||
public class MessageEntityTypeConfiguration : IEntityTypeConfiguration<Message>
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public void Configure(EntityTypeBuilder<Message> builder)
|
||||
{
|
||||
builder.ToTable("messages");
|
||||
|
||||
builder.HasKey(m => m.Id);
|
||||
|
||||
builder.Property(m => m.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(m => m.ConversationId)
|
||||
.HasColumnName("conversation_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_twitterMessageId")
|
||||
.HasColumnName("twitter_message_id")
|
||||
.HasMaxLength(100);
|
||||
|
||||
builder.Property<string>("_direction")
|
||||
.HasColumnName("direction")
|
||||
.HasMaxLength(20)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_type")
|
||||
.HasColumnName("type")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_content")
|
||||
.HasColumnName("content")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Attachments as JSONB
|
||||
builder.Property<List<MessageAttachment>?>("_attachments")
|
||||
.HasColumnName("attachments")
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => v == null ? null : JsonSerializer.Serialize(v, JsonOptions),
|
||||
v => v == null ? null : JsonSerializer.Deserialize<List<MessageAttachment>>(v, JsonOptions));
|
||||
|
||||
builder.Property<bool>("_isFromBot")
|
||||
.HasColumnName("is_from_bot")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime>("_sentAt")
|
||||
.HasColumnName("sent_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Indexes
|
||||
builder.HasIndex(m => m.ConversationId)
|
||||
.HasDatabaseName("ix_messages_conversation_id");
|
||||
|
||||
builder.HasIndex("_twitterMessageId")
|
||||
.HasDatabaseName("ix_messages_twitter_id");
|
||||
|
||||
builder.HasIndex("_sentAt")
|
||||
.HasDatabaseName("ix_messages_sent_at");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MktXService.Domain.AggregatesModel.SegmentAggregate;
|
||||
|
||||
namespace MktXService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Segment entity.
|
||||
/// VI: Cấu hình EF Core cho entity Segment.
|
||||
/// </summary>
|
||||
public class SegmentEntityTypeConfiguration : IEntityTypeConfiguration<Segment>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Segment> builder)
|
||||
{
|
||||
builder.ToTable("segments");
|
||||
|
||||
builder.HasKey(s => s.Id);
|
||||
builder.Ignore(s => s.DomainEvents);
|
||||
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<Guid>("_merchantId")
|
||||
.HasColumnName("merchant_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<int>("_contactCount")
|
||||
.HasColumnName("contact_count")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// EN: One-to-many with SegmentConditions
|
||||
builder.HasMany(s => s.Conditions)
|
||||
.WithOne()
|
||||
.HasForeignKey(c => c.SegmentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// EN: Indexes
|
||||
builder.HasIndex("_merchantId")
|
||||
.HasDatabaseName("ix_segments_merchant_id");
|
||||
|
||||
builder.HasIndex("_name")
|
||||
.HasDatabaseName("ix_segments_name");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for SegmentCondition entity.
|
||||
/// VI: Cấu hình EF Core cho entity SegmentCondition.
|
||||
/// </summary>
|
||||
public class SegmentConditionEntityTypeConfiguration : IEntityTypeConfiguration<SegmentCondition>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SegmentCondition> builder)
|
||||
{
|
||||
builder.ToTable("segment_conditions");
|
||||
|
||||
builder.HasKey(c => c.Id);
|
||||
|
||||
builder.Property(c => c.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(c => c.SegmentId)
|
||||
.HasColumnName("segment_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_field")
|
||||
.HasColumnName("field")
|
||||
.HasMaxLength(100)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_operator")
|
||||
.HasColumnName("operator")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_value")
|
||||
.HasColumnName("value")
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.HasIndex(c => c.SegmentId)
|
||||
.HasDatabaseName("ix_segment_conditions_segment_id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MktXService.Domain.AggregatesModel.TemplateAggregate;
|
||||
|
||||
namespace MktXService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Template entity.
|
||||
/// VI: Cấu hình EF Core cho entity Template.
|
||||
/// </summary>
|
||||
public class TemplateEntityTypeConfiguration : IEntityTypeConfiguration<Template>
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public void Configure(EntityTypeBuilder<Template> builder)
|
||||
{
|
||||
builder.ToTable("templates");
|
||||
|
||||
builder.HasKey(t => t.Id);
|
||||
builder.Ignore(t => t.DomainEvents);
|
||||
|
||||
builder.Property(t => t.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<Guid>("_merchantId")
|
||||
.HasColumnName("merchant_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_type")
|
||||
.HasColumnName("type")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_content")
|
||||
.HasColumnName("content")
|
||||
.IsRequired();
|
||||
|
||||
// EN: Variables list as JSONB
|
||||
builder.Property<List<string>>("_variables")
|
||||
.HasColumnName("variables")
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||
v => JsonSerializer.Deserialize<List<string>>(v, JsonOptions) ?? new List<string>(),
|
||||
new ValueComparer<List<string>>(
|
||||
(c1, c2) => c1!.SequenceEqual(c2!),
|
||||
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||
c => c.ToList()));
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// EN: Indexes
|
||||
builder.HasIndex("_merchantId")
|
||||
.HasDatabaseName("ix_templates_merchant_id");
|
||||
|
||||
builder.HasIndex("_type")
|
||||
.HasDatabaseName("ix_templates_type");
|
||||
|
||||
builder.HasIndex("_name")
|
||||
.HasDatabaseName("ix_templates_name");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MktXService.Domain.AggregatesModel.TwitterAccountAggregate;
|
||||
|
||||
namespace MktXService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for TwitterAccount entity.
|
||||
/// VI: Cấu hình EF Core cho entity TwitterAccount.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// EN: SECURITY NOTE: OAuth tokens (_oauthToken, _oauthTokenSecret) are stored as-is in this configuration.
|
||||
/// In production, these should be encrypted using IDataProtector before storage.
|
||||
/// Consider implementing a ValueConverter that uses DataProtectionProvider for encryption/decryption.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// VI: LƯU Ý BẢO MẬT: OAuth tokens (_oauthToken, _oauthTokenSecret) được lưu nguyên trong cấu hình này.
|
||||
/// Trong production, nên mã hóa bằng IDataProtector trước khi lưu.
|
||||
/// Xem xét triển khai ValueConverter sử dụng DataProtectionProvider để mã hóa/giải mã.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class TwitterAccountEntityTypeConfiguration : IEntityTypeConfiguration<TwitterAccount>
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public void Configure(EntityTypeBuilder<TwitterAccount> builder)
|
||||
{
|
||||
builder.ToTable("twitter_accounts");
|
||||
|
||||
builder.HasKey(a => a.Id);
|
||||
|
||||
builder.Ignore(a => a.DomainEvents);
|
||||
|
||||
builder.Property(a => a.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<Guid>("_merchantId")
|
||||
.HasColumnName("merchant_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_twitterUserId")
|
||||
.HasColumnName("twitter_user_id")
|
||||
.HasMaxLength(100)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_username")
|
||||
.HasColumnName("username")
|
||||
.HasMaxLength(100)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_displayName")
|
||||
.HasColumnName("display_name")
|
||||
.HasMaxLength(200);
|
||||
|
||||
builder.Property<string?>("_profileImageUrl")
|
||||
.HasColumnName("profile_image_url")
|
||||
.HasMaxLength(500);
|
||||
|
||||
builder.Property(a => a.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
// EN: OAuth tokens - SHOULD BE ENCRYPTED IN PRODUCTION
|
||||
// VI: OAuth tokens - NÊN ĐƯỢC MÃ HÓA TRONG PRODUCTION
|
||||
builder.Property<string>("_oauthToken")
|
||||
.HasColumnName("oauth_token")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_oauthTokenSecret")
|
||||
.HasColumnName("oauth_token_secret")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_webhookId")
|
||||
.HasColumnName("webhook_id")
|
||||
.HasMaxLength(100);
|
||||
|
||||
// EN: Settings as JSONB
|
||||
// VI: Settings dạng JSONB
|
||||
builder.Property<Dictionary<string, object>>("_settings")
|
||||
.HasColumnName("settings")
|
||||
.HasColumnType("jsonb")
|
||||
.HasConversion(
|
||||
v => JsonSerializer.Serialize(v, JsonOptions),
|
||||
v => JsonSerializer.Deserialize<Dictionary<string, object>>(v, JsonOptions)
|
||||
?? new Dictionary<string, object>(),
|
||||
new ValueComparer<Dictionary<string, object>>(
|
||||
(c1, c2) => c1!.SequenceEqual(c2!),
|
||||
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||
c => new Dictionary<string, object>(c)));
|
||||
|
||||
builder.Property<DateTime>("_connectedAt")
|
||||
.HasColumnName("connected_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// EN: Relationships
|
||||
builder.HasOne(a => a.Status)
|
||||
.WithMany()
|
||||
.HasForeignKey(a => a.StatusId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// EN: Unique constraint on twitter_user_id
|
||||
// VI: Ràng buộc unique trên twitter_user_id
|
||||
builder.HasIndex("_twitterUserId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_twitter_accounts_twitter_user_id");
|
||||
|
||||
builder.HasIndex("_merchantId")
|
||||
.HasDatabaseName("ix_twitter_accounts_merchant_id");
|
||||
|
||||
builder.HasIndex(a => a.StatusId)
|
||||
.HasDatabaseName("ix_twitter_accounts_status_id");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for TwitterAccountStatus enumeration.
|
||||
/// VI: Cấu hình EF Core cho enumeration TwitterAccountStatus.
|
||||
/// </summary>
|
||||
public class TwitterAccountStatusEntityTypeConfiguration : IEntityTypeConfiguration<TwitterAccountStatus>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TwitterAccountStatus> builder)
|
||||
{
|
||||
builder.ToTable("twitter_account_statuses");
|
||||
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasData(
|
||||
TwitterAccountStatus.Pending,
|
||||
TwitterAccountStatus.Active,
|
||||
TwitterAccountStatus.Inactive,
|
||||
TwitterAccountStatus.Error,
|
||||
TwitterAccountStatus.Disconnected
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
namespace MktXService.Infrastructure.ExternalServices.OpenAI;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Interface for AI/LLM service client operations.
|
||||
/// VI: Interface cho các thao tác client dịch vụ AI/LLM.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Abstracts OpenAI API for chat completions and embeddings.
|
||||
/// VI: Trừu tượng hóa OpenAI API cho chat completions và embeddings.
|
||||
/// </remarks>
|
||||
public interface IAIServiceClient
|
||||
{
|
||||
#region Chat Completions
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generate a chat completion response.
|
||||
/// VI: Tạo phản hồi chat completion.
|
||||
/// </summary>
|
||||
Task<ChatCompletionResult> GenerateChatResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatCompletionOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generate a chat completion with function calling.
|
||||
/// VI: Tạo chat completion với function calling.
|
||||
/// </summary>
|
||||
Task<ChatCompletionResult> GenerateChatWithFunctionsAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
IEnumerable<FunctionDefinition> functions,
|
||||
ChatCompletionOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Stream chat completion response.
|
||||
/// VI: Stream phản hồi chat completion.
|
||||
/// </summary>
|
||||
IAsyncEnumerable<string> StreamChatResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatCompletionOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Intent Detection
|
||||
|
||||
/// <summary>
|
||||
/// EN: Detect intent from user message.
|
||||
/// VI: Phát hiện ý định từ tin nhắn người dùng.
|
||||
/// </summary>
|
||||
Task<IntentDetectionResult> DetectIntentAsync(
|
||||
string message,
|
||||
IEnumerable<string> possibleIntents,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Extract entities/slots from user message.
|
||||
/// VI: Trích xuất entities/slots từ tin nhắn người dùng.
|
||||
/// </summary>
|
||||
Task<Dictionary<string, object>> ExtractEntitiesAsync(
|
||||
string message,
|
||||
IEnumerable<EntityDefinition> entityDefinitions,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Embeddings
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generate embeddings for text.
|
||||
/// VI: Tạo embeddings cho văn bản.
|
||||
/// </summary>
|
||||
Task<float[]> GenerateEmbeddingAsync(
|
||||
string text,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generate embeddings for multiple texts.
|
||||
/// VI: Tạo embeddings cho nhiều văn bản.
|
||||
/// </summary>
|
||||
Task<IEnumerable<float[]>> GenerateEmbeddingsAsync(
|
||||
IEnumerable<string> texts,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Moderation
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if content violates usage policies.
|
||||
/// VI: Kiểm tra xem nội dung có vi phạm chính sách sử dụng không.
|
||||
/// </summary>
|
||||
Task<ModerationResult> ModerateContentAsync(
|
||||
string content,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
/// <summary>
|
||||
/// EN: Chat message for conversation context.
|
||||
/// VI: Tin nhắn chat cho ngữ cảnh hội thoại.
|
||||
/// </summary>
|
||||
public record ChatMessage(
|
||||
string Role, // "system", "user", "assistant", "function"
|
||||
string Content,
|
||||
string? Name = null,
|
||||
string? FunctionCall = null);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Options for chat completion.
|
||||
/// VI: Tùy chọn cho chat completion.
|
||||
/// </summary>
|
||||
public record ChatCompletionOptions(
|
||||
string Model = "gpt-4o-mini",
|
||||
double Temperature = 0.7,
|
||||
int MaxTokens = 1000,
|
||||
double TopP = 1.0,
|
||||
double FrequencyPenalty = 0,
|
||||
double PresencePenalty = 0,
|
||||
string? SystemPrompt = null);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of chat completion.
|
||||
/// VI: Kết quả của chat completion.
|
||||
/// </summary>
|
||||
public record ChatCompletionResult(
|
||||
bool Success,
|
||||
string? Content,
|
||||
string? FunctionName,
|
||||
string? FunctionArguments,
|
||||
string? Error,
|
||||
int TokensUsed,
|
||||
string? FinishReason);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Function definition for function calling.
|
||||
/// VI: Định nghĩa function cho function calling.
|
||||
/// </summary>
|
||||
public record FunctionDefinition(
|
||||
string Name,
|
||||
string Description,
|
||||
Dictionary<string, object> Parameters);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity definition for extraction.
|
||||
/// VI: Định nghĩa entity để trích xuất.
|
||||
/// </summary>
|
||||
public record EntityDefinition(
|
||||
string Name,
|
||||
string Type,
|
||||
string Description,
|
||||
bool Required = false);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of intent detection.
|
||||
/// VI: Kết quả phát hiện ý định.
|
||||
/// </summary>
|
||||
public record IntentDetectionResult(
|
||||
string Intent,
|
||||
double Confidence,
|
||||
Dictionary<string, object>? ExtractedEntities = null);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of content moderation.
|
||||
/// VI: Kết quả kiểm duyệt nội dung.
|
||||
/// </summary>
|
||||
public record ModerationResult(
|
||||
bool Flagged,
|
||||
Dictionary<string, bool> Categories,
|
||||
Dictionary<string, double> CategoryScores);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,399 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Polly;
|
||||
using Polly.CircuitBreaker;
|
||||
|
||||
namespace MktXService.Infrastructure.ExternalServices.OpenAI;
|
||||
|
||||
/// <summary>
|
||||
/// EN: OpenAI API client implementation with Polly resilience.
|
||||
/// VI: Triển khai OpenAI API client với Polly resilience.
|
||||
/// </summary>
|
||||
public class OpenAIServiceClient : IAIServiceClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly OpenAIOptions _options;
|
||||
private readonly ILogger<OpenAIServiceClient> _logger;
|
||||
private readonly AsyncCircuitBreakerPolicy<HttpResponseMessage> _circuitBreakerPolicy;
|
||||
|
||||
private const string BaseUrl = "https://api.openai.com/v1";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public OpenAIServiceClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<OpenAIOptions> options,
|
||||
ILogger<OpenAIServiceClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", _options.ApiKey);
|
||||
|
||||
_circuitBreakerPolicy = Policy<HttpResponseMessage>
|
||||
.Handle<HttpRequestException>()
|
||||
.OrResult(r => (int)r.StatusCode >= 500)
|
||||
.CircuitBreakerAsync(
|
||||
5,
|
||||
TimeSpan.FromMinutes(1),
|
||||
(outcome, breakDuration) =>
|
||||
{
|
||||
_logger.LogError("OpenAI circuit breaker opened for {Duration}s", breakDuration.TotalSeconds);
|
||||
},
|
||||
() => _logger.LogInformation("OpenAI circuit breaker reset"));
|
||||
}
|
||||
|
||||
#region Chat Completions
|
||||
|
||||
public async Task<ChatCompletionResult> GenerateChatResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatCompletionOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new ChatCompletionOptions();
|
||||
|
||||
var payload = new
|
||||
{
|
||||
model = options.Model,
|
||||
messages = messages.Select(m => new { role = m.Role, content = m.Content, name = m.Name }).ToList(),
|
||||
temperature = options.Temperature,
|
||||
max_tokens = options.MaxTokens,
|
||||
top_p = options.TopP,
|
||||
frequency_penalty = options.FrequencyPenalty,
|
||||
presence_penalty = options.PresencePenalty
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await SendRequestAsync($"{BaseUrl}/chat/completions", payload, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogError("OpenAI API error: {Error}", error);
|
||||
return new ChatCompletionResult(false, null, null, null, error, 0, null);
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(content);
|
||||
|
||||
var choice = result.GetProperty("choices")[0];
|
||||
var message = choice.GetProperty("message");
|
||||
var usage = result.GetProperty("usage");
|
||||
|
||||
return new ChatCompletionResult(
|
||||
true,
|
||||
message.TryGetProperty("content", out var c) ? c.GetString() : null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
usage.GetProperty("total_tokens").GetInt32(),
|
||||
choice.GetProperty("finish_reason").GetString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to generate chat response");
|
||||
return new ChatCompletionResult(false, null, null, null, ex.Message, 0, null);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ChatCompletionResult> GenerateChatWithFunctionsAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
IEnumerable<FunctionDefinition> functions,
|
||||
ChatCompletionOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new ChatCompletionOptions();
|
||||
|
||||
var payload = new
|
||||
{
|
||||
model = options.Model,
|
||||
messages = messages.Select(m => new { role = m.Role, content = m.Content }).ToList(),
|
||||
functions = functions.Select(f => new
|
||||
{
|
||||
name = f.Name,
|
||||
description = f.Description,
|
||||
parameters = f.Parameters
|
||||
}).ToList(),
|
||||
function_call = "auto",
|
||||
temperature = options.Temperature,
|
||||
max_tokens = options.MaxTokens
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await SendRequestAsync($"{BaseUrl}/chat/completions", payload, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new ChatCompletionResult(false, null, null, null, error, 0, null);
|
||||
}
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(content);
|
||||
|
||||
var choice = result.GetProperty("choices")[0];
|
||||
var message = choice.GetProperty("message");
|
||||
var usage = result.GetProperty("usage");
|
||||
|
||||
string? functionName = null;
|
||||
string? functionArgs = null;
|
||||
|
||||
if (message.TryGetProperty("function_call", out var fc))
|
||||
{
|
||||
functionName = fc.GetProperty("name").GetString();
|
||||
functionArgs = fc.GetProperty("arguments").GetString();
|
||||
}
|
||||
|
||||
return new ChatCompletionResult(
|
||||
true,
|
||||
message.TryGetProperty("content", out var c) ? c.GetString() : null,
|
||||
functionName,
|
||||
functionArgs,
|
||||
null,
|
||||
usage.GetProperty("total_tokens").GetInt32(),
|
||||
choice.GetProperty("finish_reason").GetString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to generate chat with functions");
|
||||
return new ChatCompletionResult(false, null, null, null, ex.Message, 0, null);
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> StreamChatResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatCompletionOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
options ??= new ChatCompletionOptions();
|
||||
|
||||
var payload = new
|
||||
{
|
||||
model = options.Model,
|
||||
messages = messages.Select(m => new { role = m.Role, content = m.Content }).ToList(),
|
||||
temperature = options.Temperature,
|
||||
max_tokens = options.MaxTokens,
|
||||
stream = true
|
||||
};
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/chat/completions")
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(cancellationToken)) != null && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (string.IsNullOrEmpty(line) || !line.StartsWith("data: "))
|
||||
continue;
|
||||
|
||||
var data = line[6..];
|
||||
if (data == "[DONE]")
|
||||
break;
|
||||
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(data);
|
||||
if (json.TryGetProperty("choices", out var choices) &&
|
||||
choices.GetArrayLength() > 0 &&
|
||||
choices[0].TryGetProperty("delta", out var delta) &&
|
||||
delta.TryGetProperty("content", out var content))
|
||||
{
|
||||
var text = content.GetString();
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
yield return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Intent Detection
|
||||
|
||||
public async Task<IntentDetectionResult> DetectIntentAsync(
|
||||
string message,
|
||||
IEnumerable<string> possibleIntents,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var systemPrompt = $@"You are an intent classifier. Classify the user message into one of these intents: {string.Join(", ", possibleIntents)}.
|
||||
Respond ONLY with a JSON object: {{""intent"": ""<intent>"", ""confidence"": <0.0-1.0>}}";
|
||||
|
||||
var messages = new[]
|
||||
{
|
||||
new ChatMessage("system", systemPrompt),
|
||||
new ChatMessage("user", message)
|
||||
};
|
||||
|
||||
var result = await GenerateChatResponseAsync(messages, new ChatCompletionOptions(Temperature: 0.1), cancellationToken);
|
||||
|
||||
if (!result.Success || string.IsNullOrEmpty(result.Content))
|
||||
return new IntentDetectionResult("unknown", 0);
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(result.Content);
|
||||
return new IntentDetectionResult(
|
||||
json.GetProperty("intent").GetString() ?? "unknown",
|
||||
json.GetProperty("confidence").GetDouble());
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new IntentDetectionResult("unknown", 0);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, object>> ExtractEntitiesAsync(
|
||||
string message,
|
||||
IEnumerable<EntityDefinition> entityDefinitions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entityDesc = string.Join("\n", entityDefinitions.Select(e => $"- {e.Name} ({e.Type}): {e.Description}"));
|
||||
var systemPrompt = $@"Extract the following entities from the user message:
|
||||
{entityDesc}
|
||||
|
||||
Respond ONLY with a JSON object containing the extracted values.";
|
||||
|
||||
var messages = new[]
|
||||
{
|
||||
new ChatMessage("system", systemPrompt),
|
||||
new ChatMessage("user", message)
|
||||
};
|
||||
|
||||
var result = await GenerateChatResponseAsync(messages, new ChatCompletionOptions(Temperature: 0.1), cancellationToken);
|
||||
|
||||
if (!result.Success || string.IsNullOrEmpty(result.Content))
|
||||
return new Dictionary<string, object>();
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object>>(result.Content)
|
||||
?? new Dictionary<string, object>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new Dictionary<string, object>();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Embeddings
|
||||
|
||||
public async Task<float[]> GenerateEmbeddingAsync(string text, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
model = _options.EmbeddingModel,
|
||||
input = text
|
||||
};
|
||||
|
||||
var response = await SendRequestAsync($"{BaseUrl}/embeddings", payload, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return Array.Empty<float>();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(content);
|
||||
var embedding = result.GetProperty("data")[0].GetProperty("embedding");
|
||||
|
||||
return embedding.EnumerateArray().Select(e => e.GetSingle()).ToArray();
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<float[]>> GenerateEmbeddingsAsync(
|
||||
IEnumerable<string> texts,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
model = _options.EmbeddingModel,
|
||||
input = texts.ToList()
|
||||
};
|
||||
|
||||
var response = await SendRequestAsync($"{BaseUrl}/embeddings", payload, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return Enumerable.Empty<float[]>();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(content);
|
||||
|
||||
return result.GetProperty("data")
|
||||
.EnumerateArray()
|
||||
.Select(d => d.GetProperty("embedding").EnumerateArray().Select(e => e.GetSingle()).ToArray())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Moderation
|
||||
|
||||
public async Task<ModerationResult> ModerateContentAsync(string content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new { input = content };
|
||||
|
||||
var response = await SendRequestAsync($"{BaseUrl}/moderations", payload, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new ModerationResult(false, new Dictionary<string, bool>(), new Dictionary<string, double>());
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(responseContent);
|
||||
var moderationResult = result.GetProperty("results")[0];
|
||||
|
||||
var categories = moderationResult.GetProperty("categories");
|
||||
var categoryScores = moderationResult.GetProperty("category_scores");
|
||||
|
||||
return new ModerationResult(
|
||||
moderationResult.GetProperty("flagged").GetBoolean(),
|
||||
categories.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetBoolean()),
|
||||
categoryScores.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetDouble()));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helpers
|
||||
|
||||
private async Task<HttpResponseMessage> SendRequestAsync(string url, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _circuitBreakerPolicy.ExecuteAsync(async () =>
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
return await _httpClient.SendAsync(request, cancellationToken);
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: OpenAI API configuration options.
|
||||
/// VI: Các tùy chọn cấu hình OpenAI API.
|
||||
/// </summary>
|
||||
public class OpenAIOptions
|
||||
{
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
public string DefaultModel { get; set; } = "gpt-4o-mini";
|
||||
public string EmbeddingModel { get; set; } = "text-embedding-3-small";
|
||||
public int MaxTokens { get; set; } = 1000;
|
||||
public double DefaultTemperature { get; set; } = 0.7;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
namespace MktXService.Infrastructure.ExternalServices.Twitter;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Interface for Twitter API v2 client operations.
|
||||
/// VI: Interface cho các thao tác client Twitter API v2.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Implements Direct Message and Account Activity API operations.
|
||||
/// VI: Triển khai các thao tác Direct Message và Account Activity API.
|
||||
/// </remarks>
|
||||
public interface ITwitterApiClient
|
||||
{
|
||||
#region Direct Messages
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send a direct message to a user.
|
||||
/// VI: Gửi tin nhắn trực tiếp đến người dùng.
|
||||
/// </summary>
|
||||
Task<TwitterMessageResult> SendDirectMessageAsync(
|
||||
string recipientId,
|
||||
string text,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Send a direct message with media attachment.
|
||||
/// VI: Gửi tin nhắn trực tiếp với tệp đính kèm.
|
||||
/// </summary>
|
||||
Task<TwitterMessageResult> SendDirectMessageWithMediaAsync(
|
||||
string recipientId,
|
||||
string text,
|
||||
string mediaId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get conversation history with a participant.
|
||||
/// VI: Lấy lịch sử hội thoại với một người tham gia.
|
||||
/// </summary>
|
||||
Task<IEnumerable<TwitterDirectMessage>> GetConversationAsync(
|
||||
string participantId,
|
||||
int maxResults = 50,
|
||||
string? paginationToken = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region User Operations
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get user information by user ID.
|
||||
/// VI: Lấy thông tin người dùng theo user ID.
|
||||
/// </summary>
|
||||
Task<TwitterUser?> GetUserByIdAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get user information by username.
|
||||
/// VI: Lấy thông tin người dùng theo username.
|
||||
/// </summary>
|
||||
Task<TwitterUser?> GetUserByUsernameAsync(
|
||||
string username,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Verify OAuth credentials and get authenticated user.
|
||||
/// VI: Xác thực OAuth credentials và lấy thông tin người dùng đã xác thực.
|
||||
/// </summary>
|
||||
Task<TwitterUser?> VerifyCredentialsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Webhooks (Account Activity API)
|
||||
|
||||
/// <summary>
|
||||
/// EN: Register a webhook URL for receiving events.
|
||||
/// VI: Đăng ký webhook URL để nhận events.
|
||||
/// </summary>
|
||||
Task<string> RegisterWebhookAsync(
|
||||
string webhookUrl,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a registered webhook.
|
||||
/// VI: Xóa webhook đã đăng ký.
|
||||
/// </summary>
|
||||
Task DeleteWebhookAsync(
|
||||
string webhookId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Subscribe to webhook events for the authenticated user.
|
||||
/// VI: Đăng ký nhận webhook events cho người dùng đã xác thực.
|
||||
/// </summary>
|
||||
Task SubscribeToWebhookAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validate CRC token for webhook verification.
|
||||
/// VI: Xác thực CRC token cho webhook verification.
|
||||
/// </summary>
|
||||
string GenerateCrcResponse(string crcToken);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Media
|
||||
|
||||
/// <summary>
|
||||
/// EN: Upload media for use in messages.
|
||||
/// VI: Tải lên media để sử dụng trong tin nhắn.
|
||||
/// </summary>
|
||||
Task<string> UploadMediaAsync(
|
||||
byte[] mediaData,
|
||||
string mediaType,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rate Limiting
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get remaining rate limit for an endpoint.
|
||||
/// VI: Lấy rate limit còn lại cho một endpoint.
|
||||
/// </summary>
|
||||
Task<RateLimitInfo> GetRateLimitInfoAsync(string endpoint);
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of sending a direct message.
|
||||
/// VI: Kết quả gửi tin nhắn trực tiếp.
|
||||
/// </summary>
|
||||
public record TwitterMessageResult(
|
||||
bool Success,
|
||||
string? MessageId,
|
||||
string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Twitter direct message data.
|
||||
/// VI: Dữ liệu tin nhắn trực tiếp Twitter.
|
||||
/// </summary>
|
||||
public record TwitterDirectMessage(
|
||||
string Id,
|
||||
string SenderId,
|
||||
string RecipientId,
|
||||
string Text,
|
||||
DateTime CreatedAt,
|
||||
List<TwitterMessageAttachment>? Attachments);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Message attachment data.
|
||||
/// VI: Dữ liệu tệp đính kèm tin nhắn.
|
||||
/// </summary>
|
||||
public record TwitterMessageAttachment(
|
||||
string Type,
|
||||
string Url,
|
||||
string? MediaId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Twitter user information.
|
||||
/// VI: Thông tin người dùng Twitter.
|
||||
/// </summary>
|
||||
public record TwitterUser(
|
||||
string Id,
|
||||
string Username,
|
||||
string Name,
|
||||
string? ProfileImageUrl,
|
||||
string? Description,
|
||||
bool Verified,
|
||||
int FollowersCount,
|
||||
int FollowingCount);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Rate limit information.
|
||||
/// VI: Thông tin giới hạn tốc độ.
|
||||
/// </summary>
|
||||
public record RateLimitInfo(
|
||||
int Limit,
|
||||
int Remaining,
|
||||
DateTime ResetAt);
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,415 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Polly;
|
||||
using Polly.CircuitBreaker;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace MktXService.Infrastructure.ExternalServices.Twitter;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Twitter API v2 client implementation with Polly resilience.
|
||||
/// VI: Triển khai Twitter API v2 client với Polly resilience.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Uses OAuth 1.0a authentication and implements rate limiting handling.
|
||||
/// VI: Sử dụng xác thực OAuth 1.0a và xử lý rate limiting.
|
||||
/// </remarks>
|
||||
public class TwitterApiClient : ITwitterApiClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly TwitterApiOptions _options;
|
||||
private readonly ILogger<TwitterApiClient> _logger;
|
||||
private readonly AsyncRetryPolicy<HttpResponseMessage> _retryPolicy;
|
||||
private readonly AsyncCircuitBreakerPolicy<HttpResponseMessage> _circuitBreakerPolicy;
|
||||
|
||||
private const string BaseUrl = "https://api.twitter.com/2";
|
||||
private const string UploadUrl = "https://upload.twitter.com/1.1/media/upload.json";
|
||||
|
||||
public TwitterApiClient(
|
||||
HttpClient httpClient,
|
||||
IOptions<TwitterApiOptions> options,
|
||||
ILogger<TwitterApiClient> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
// EN: Configure resilience policies
|
||||
// VI: Cấu hình các policy resilience
|
||||
_retryPolicy = Policy<HttpResponseMessage>
|
||||
.Handle<HttpRequestException>()
|
||||
.OrResult(r => r.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||
.WaitAndRetryAsync(
|
||||
3,
|
||||
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
|
||||
(outcome, timespan, retryCount, context) =>
|
||||
{
|
||||
_logger.LogWarning("Twitter API retry {RetryCount} after {Delay}s", retryCount, timespan.TotalSeconds);
|
||||
});
|
||||
|
||||
_circuitBreakerPolicy = Policy<HttpResponseMessage>
|
||||
.Handle<HttpRequestException>()
|
||||
.OrResult(r => (int)r.StatusCode >= 500)
|
||||
.CircuitBreakerAsync(
|
||||
5,
|
||||
TimeSpan.FromMinutes(1),
|
||||
(outcome, breakDuration) =>
|
||||
{
|
||||
_logger.LogError("Twitter API circuit breaker opened for {Duration}s", breakDuration.TotalSeconds);
|
||||
},
|
||||
() => _logger.LogInformation("Twitter API circuit breaker reset"));
|
||||
}
|
||||
|
||||
#region Direct Messages
|
||||
|
||||
public async Task<TwitterMessageResult> SendDirectMessageAsync(
|
||||
string recipientId,
|
||||
string text,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
text,
|
||||
participant_id = recipientId
|
||||
};
|
||||
|
||||
var response = await ExecuteWithPoliciesAsync(
|
||||
() => CreateAuthenticatedRequest(HttpMethod.Post, $"{BaseUrl}/dm_conversations/with/{recipientId}/messages", payload),
|
||||
cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(content);
|
||||
var messageId = result.GetProperty("data").GetProperty("dm_event_id").GetString();
|
||||
|
||||
return new TwitterMessageResult(true, messageId, null);
|
||||
}
|
||||
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
_logger.LogError("Twitter API error: {Error}", error);
|
||||
return new TwitterMessageResult(false, null, error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send direct message to {RecipientId}", recipientId);
|
||||
return new TwitterMessageResult(false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TwitterMessageResult> SendDirectMessageWithMediaAsync(
|
||||
string recipientId,
|
||||
string text,
|
||||
string mediaId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
text,
|
||||
attachments = new[] { new { media_id = mediaId } }
|
||||
};
|
||||
|
||||
var response = await ExecuteWithPoliciesAsync(
|
||||
() => CreateAuthenticatedRequest(HttpMethod.Post, $"{BaseUrl}/dm_conversations/with/{recipientId}/messages", payload),
|
||||
cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(content);
|
||||
var messageId = result.GetProperty("data").GetProperty("dm_event_id").GetString();
|
||||
|
||||
return new TwitterMessageResult(true, messageId, null);
|
||||
}
|
||||
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new TwitterMessageResult(false, null, error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send direct message with media to {RecipientId}", recipientId);
|
||||
return new TwitterMessageResult(false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TwitterDirectMessage>> GetConversationAsync(
|
||||
string participantId,
|
||||
int maxResults = 50,
|
||||
string? paginationToken = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{BaseUrl}/dm_conversations/with/{participantId}/dm_events?max_results={maxResults}";
|
||||
if (!string.IsNullOrEmpty(paginationToken))
|
||||
url += $"&pagination_token={paginationToken}";
|
||||
|
||||
var response = await ExecuteWithPoliciesAsync(
|
||||
() => CreateAuthenticatedRequest(HttpMethod.Get, url),
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return Enumerable.Empty<TwitterDirectMessage>();
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(content);
|
||||
|
||||
var messages = new List<TwitterDirectMessage>();
|
||||
if (result.TryGetProperty("data", out var data))
|
||||
{
|
||||
foreach (var item in data.EnumerateArray())
|
||||
{
|
||||
messages.Add(new TwitterDirectMessage(
|
||||
item.GetProperty("id").GetString()!,
|
||||
item.GetProperty("sender_id").GetString()!,
|
||||
participantId,
|
||||
item.TryGetProperty("text", out var text) ? text.GetString()! : "",
|
||||
DateTime.Parse(item.GetProperty("created_at").GetString()!),
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region User Operations
|
||||
|
||||
public async Task<TwitterUser?> GetUserByIdAsync(string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{BaseUrl}/users/{userId}?user.fields=profile_image_url,description,verified,public_metrics";
|
||||
return await GetUserAsync(url, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TwitterUser?> GetUserByUsernameAsync(string username, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{BaseUrl}/users/by/username/{username}?user.fields=profile_image_url,description,verified,public_metrics";
|
||||
return await GetUserAsync(url, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TwitterUser?> VerifyCredentialsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{BaseUrl}/users/me?user.fields=profile_image_url,description,verified,public_metrics";
|
||||
return await GetUserAsync(url, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<TwitterUser?> GetUserAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await ExecuteWithPoliciesAsync(
|
||||
() => CreateAuthenticatedRequest(HttpMethod.Get, url),
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(content);
|
||||
|
||||
if (!result.TryGetProperty("data", out var data))
|
||||
return null;
|
||||
|
||||
var metrics = data.TryGetProperty("public_metrics", out var pm) ? pm : default;
|
||||
|
||||
return new TwitterUser(
|
||||
data.GetProperty("id").GetString()!,
|
||||
data.GetProperty("username").GetString()!,
|
||||
data.GetProperty("name").GetString()!,
|
||||
data.TryGetProperty("profile_image_url", out var img) ? img.GetString() : null,
|
||||
data.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
data.TryGetProperty("verified", out var ver) && ver.GetBoolean(),
|
||||
metrics.ValueKind != JsonValueKind.Undefined && metrics.TryGetProperty("followers_count", out var fc) ? fc.GetInt32() : 0,
|
||||
metrics.ValueKind != JsonValueKind.Undefined && metrics.TryGetProperty("following_count", out var foc) ? foc.GetInt32() : 0
|
||||
);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Webhooks
|
||||
|
||||
public async Task<string> RegisterWebhookAsync(string webhookUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"https://api.twitter.com/1.1/account_activity/all/{_options.EnvironmentName}/webhooks.json?url={Uri.EscapeDataString(webhookUrl)}";
|
||||
|
||||
var response = await ExecuteWithPoliciesAsync(
|
||||
() => CreateAuthenticatedRequest(HttpMethod.Post, url),
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new TwitterApiException($"Failed to register webhook: {await response.Content.ReadAsStringAsync(cancellationToken)}");
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var result = JsonSerializer.Deserialize<JsonElement>(content);
|
||||
return result.GetProperty("id").GetString()!;
|
||||
}
|
||||
|
||||
public async Task DeleteWebhookAsync(string webhookId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"https://api.twitter.com/1.1/account_activity/all/{_options.EnvironmentName}/webhooks/{webhookId}.json";
|
||||
|
||||
var response = await ExecuteWithPoliciesAsync(
|
||||
() => CreateAuthenticatedRequest(HttpMethod.Delete, url),
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new TwitterApiException($"Failed to delete webhook: {await response.Content.ReadAsStringAsync(cancellationToken)}");
|
||||
}
|
||||
|
||||
public async Task SubscribeToWebhookAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"https://api.twitter.com/1.1/account_activity/all/{_options.EnvironmentName}/subscriptions.json";
|
||||
|
||||
var response = await ExecuteWithPoliciesAsync(
|
||||
() => CreateAuthenticatedRequest(HttpMethod.Post, url),
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new TwitterApiException($"Failed to subscribe to webhook: {await response.Content.ReadAsStringAsync(cancellationToken)}");
|
||||
}
|
||||
|
||||
public string GenerateCrcResponse(string crcToken)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_options.ConsumerSecret));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(crcToken));
|
||||
return $"sha256={Convert.ToBase64String(hash)}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Media
|
||||
|
||||
public async Task<string> UploadMediaAsync(byte[] mediaData, string mediaType, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var content = new MultipartFormDataContent();
|
||||
content.Add(new ByteArrayContent(mediaData), "media", "media");
|
||||
content.Add(new StringContent(mediaType), "media_category");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, UploadUrl) { Content = content };
|
||||
AddOAuthHeader(request);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new TwitterApiException($"Failed to upload media: {await response.Content.ReadAsStringAsync(cancellationToken)}");
|
||||
|
||||
var result = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var json = JsonSerializer.Deserialize<JsonElement>(result);
|
||||
return json.GetProperty("media_id_string").GetString()!;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rate Limiting
|
||||
|
||||
public Task<RateLimitInfo> GetRateLimitInfoAsync(string endpoint)
|
||||
{
|
||||
// EN: Rate limit info is typically extracted from response headers
|
||||
// VI: Thông tin rate limit thường được trích xuất từ response headers
|
||||
// For now, return default values
|
||||
return Task.FromResult(new RateLimitInfo(15, 15, DateTime.UtcNow.AddMinutes(15)));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helpers
|
||||
|
||||
private async Task<HttpResponseMessage> ExecuteWithPoliciesAsync(
|
||||
Func<HttpRequestMessage> requestFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await _retryPolicy.ExecuteAsync(async () =>
|
||||
{
|
||||
return await _circuitBreakerPolicy.ExecuteAsync(async () =>
|
||||
{
|
||||
var request = requestFactory();
|
||||
AddOAuthHeader(request);
|
||||
return await _httpClient.SendAsync(request, cancellationToken);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateAuthenticatedRequest(HttpMethod method, string url, object? payload = null)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
|
||||
if (payload != null)
|
||||
{
|
||||
request.Content = new StringContent(
|
||||
JsonSerializer.Serialize(payload),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private void AddOAuthHeader(HttpRequestMessage request)
|
||||
{
|
||||
// EN: OAuth 1.0a signature generation
|
||||
// VI: Tạo chữ ký OAuth 1.0a
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||||
var nonce = Guid.NewGuid().ToString("N");
|
||||
|
||||
var parameters = new SortedDictionary<string, string>
|
||||
{
|
||||
["oauth_consumer_key"] = _options.ConsumerKey,
|
||||
["oauth_nonce"] = nonce,
|
||||
["oauth_signature_method"] = "HMAC-SHA1",
|
||||
["oauth_timestamp"] = timestamp,
|
||||
["oauth_token"] = _options.AccessToken,
|
||||
["oauth_version"] = "1.0"
|
||||
};
|
||||
|
||||
// EN: Create signature base string
|
||||
var baseString = CreateSignatureBaseString(request.Method.Method, request.RequestUri!.ToString(), parameters);
|
||||
var signingKey = $"{Uri.EscapeDataString(_options.ConsumerSecret)}&{Uri.EscapeDataString(_options.AccessTokenSecret)}";
|
||||
|
||||
using var hmac = new HMACSHA1(Encoding.UTF8.GetBytes(signingKey));
|
||||
var signature = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(baseString)));
|
||||
|
||||
parameters["oauth_signature"] = signature;
|
||||
|
||||
var authHeader = "OAuth " + string.Join(", ", parameters.Select(p => $"{p.Key}=\"{Uri.EscapeDataString(p.Value)}\""));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("OAuth", authHeader[6..]);
|
||||
}
|
||||
|
||||
private static string CreateSignatureBaseString(string method, string url, SortedDictionary<string, string> parameters)
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
var baseUrl = $"{uri.Scheme}://{uri.Host}{uri.AbsolutePath}";
|
||||
var paramString = string.Join("&", parameters.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}"));
|
||||
return $"{method.ToUpper()}&{Uri.EscapeDataString(baseUrl)}&{Uri.EscapeDataString(paramString)}";
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Twitter API configuration options.
|
||||
/// VI: Các tùy chọn cấu hình Twitter API.
|
||||
/// </summary>
|
||||
public class TwitterApiOptions
|
||||
{
|
||||
public string ConsumerKey { get; set; } = string.Empty;
|
||||
public string ConsumerSecret { get; set; } = string.Empty;
|
||||
public string AccessToken { get; set; } = string.Empty;
|
||||
public string AccessTokenSecret { get; set; } = string.Empty;
|
||||
public string EnvironmentName { get; set; } = "production";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Exception for Twitter API errors.
|
||||
/// VI: Exception cho lỗi Twitter API.
|
||||
/// </summary>
|
||||
public class TwitterApiException : Exception
|
||||
{
|
||||
public TwitterApiException(string message) : base(message) { }
|
||||
public TwitterApiException(string message, Exception inner) : base(message, inner) { }
|
||||
}
|
||||
@@ -1,26 +1,81 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using MktXService.Domain.AggregatesModel.AIConversationSessionAggregate;
|
||||
using MktXService.Domain.AggregatesModel.AutomationFlowAggregate;
|
||||
using MktXService.Domain.AggregatesModel.CampaignAggregate;
|
||||
using MktXService.Domain.AggregatesModel.ContactAggregate;
|
||||
using MktXService.Domain.AggregatesModel.ConversationAggregate;
|
||||
using MktXService.Domain.AggregatesModel.SampleAggregate;
|
||||
using MktXService.Domain.AggregatesModel.SegmentAggregate;
|
||||
using MktXService.Domain.AggregatesModel.TemplateAggregate;
|
||||
using MktXService.Domain.AggregatesModel.TwitterAccountAggregate;
|
||||
using MktXService.Domain.SeedWork;
|
||||
using MktXService.Infrastructure.EntityConfigurations;
|
||||
|
||||
namespace MktXService.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core DbContext for MktXService.
|
||||
/// VI: EF Core DbContext cho MktXService.
|
||||
/// EN: EF Core DbContext for MktXService with PostgreSQL and JSONB support.
|
||||
/// VI: EF Core DbContext cho MktXService với PostgreSQL và hỗ trợ JSONB.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Uses snake_case naming convention for PostgreSQL compatibility.
|
||||
/// VI: Sử dụng quy ước đặt tên snake_case cho tương thích PostgreSQL.
|
||||
/// </remarks>
|
||||
public class MktXServiceContext : DbContext, IUnitOfWork
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private IDbContextTransaction? _currentTransaction;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Samples table.
|
||||
/// VI: Bảng Samples.
|
||||
/// </summary>
|
||||
#region DbSets
|
||||
|
||||
// EN: Sample aggregate (template reference)
|
||||
// VI: Sample aggregate (tham chiếu template)
|
||||
public DbSet<Sample> Samples => Set<Sample>();
|
||||
public DbSet<SampleStatus> SampleStatuses => Set<SampleStatus>();
|
||||
|
||||
// EN: Twitter Account aggregate
|
||||
// VI: Aggregate tài khoản Twitter
|
||||
public DbSet<TwitterAccount> TwitterAccounts => Set<TwitterAccount>();
|
||||
public DbSet<TwitterAccountStatus> TwitterAccountStatuses => Set<TwitterAccountStatus>();
|
||||
|
||||
// EN: Contact aggregate
|
||||
// VI: Aggregate liên hệ
|
||||
public DbSet<Contact> Contacts => Set<Contact>();
|
||||
public DbSet<ContactTag> ContactTags => Set<ContactTag>();
|
||||
|
||||
// EN: Conversation aggregate
|
||||
// VI: Aggregate hội thoại
|
||||
public DbSet<Conversation> Conversations => Set<Conversation>();
|
||||
public DbSet<ConversationStatus> ConversationStatuses => Set<ConversationStatus>();
|
||||
public DbSet<Message> Messages => Set<Message>();
|
||||
|
||||
// EN: Template aggregate
|
||||
// VI: Aggregate mẫu tin
|
||||
public DbSet<Template> Templates => Set<Template>();
|
||||
|
||||
// EN: Campaign aggregate
|
||||
// VI: Aggregate chiến dịch
|
||||
public DbSet<Campaign> Campaigns => Set<Campaign>();
|
||||
public DbSet<CampaignStatus> CampaignStatuses => Set<CampaignStatus>();
|
||||
|
||||
// EN: Segment aggregate
|
||||
// VI: Aggregate phân khúc
|
||||
public DbSet<Segment> Segments => Set<Segment>();
|
||||
public DbSet<SegmentCondition> SegmentConditions => Set<SegmentCondition>();
|
||||
|
||||
// EN: Automation Flow aggregate
|
||||
// VI: Aggregate luồng tự động
|
||||
public DbSet<AutomationFlow> AutomationFlows => Set<AutomationFlow>();
|
||||
public DbSet<FlowStatus> FlowStatuses => Set<FlowStatus>();
|
||||
public DbSet<FlowNode> FlowNodes => Set<FlowNode>();
|
||||
public DbSet<FlowConnection> FlowConnections => Set<FlowConnection>();
|
||||
|
||||
// EN: AI Conversation Session aggregate
|
||||
// VI: Aggregate phiên hội thoại AI
|
||||
public DbSet<AIConversationSession> AIConversationSessions => Set<AIConversationSession>();
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// EN: Read-only access to current transaction.
|
||||
@@ -48,10 +103,14 @@ public class MktXServiceContext : DbContext, IUnitOfWork
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// EN: Apply entity configurations
|
||||
// VI: Áp dụng các cấu hình entity
|
||||
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
|
||||
// EN: Apply all entity configurations from this assembly
|
||||
// VI: Áp dụng tất cả entity configurations từ assembly này
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MktXServiceContext).Assembly);
|
||||
|
||||
// EN: Configure snake_case naming convention for PostgreSQL
|
||||
// VI: Cấu hình quy ước đặt tên snake_case cho PostgreSQL
|
||||
// Note: Using Npgsql's built-in SnakeCaseNamingConvention when available
|
||||
// For now, we handle it in individual configurations
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MktXService.Domain.AggregatesModel.AIConversationSessionAggregate;
|
||||
using MktXService.Domain.SeedWork;
|
||||
|
||||
namespace MktXService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for AIConversationSession aggregate.
|
||||
/// VI: Triển khai repository cho AIConversationSession aggregate.
|
||||
/// </summary>
|
||||
public class AIConversationSessionRepository : IAIConversationSessionRepository
|
||||
{
|
||||
private readonly MktXServiceContext _context;
|
||||
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public AIConversationSessionRepository(MktXServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
public AIConversationSession Add(AIConversationSession session)
|
||||
{
|
||||
return _context.AIConversationSessions.Add(session).Entity;
|
||||
}
|
||||
|
||||
public void Update(AIConversationSession session)
|
||||
{
|
||||
_context.Entry(session).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<AIConversationSession?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await _context.AIConversationSessions.FirstOrDefaultAsync(s => s.Id == id);
|
||||
}
|
||||
|
||||
public async Task<AIConversationSession?> GetByConversationIdAsync(Guid conversationId)
|
||||
{
|
||||
return await _context.AIConversationSessions
|
||||
.FirstOrDefaultAsync(s => EF.Property<Guid>(s, "_conversationId") == conversationId);
|
||||
}
|
||||
|
||||
public async Task<AIConversationSession?> GetActiveByConversationIdAsync(Guid conversationId)
|
||||
{
|
||||
return await _context.AIConversationSessions
|
||||
.FirstOrDefaultAsync(s =>
|
||||
EF.Property<Guid>(s, "_conversationId") == conversationId &&
|
||||
EF.Property<bool>(s, "_isActive") == true);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user