Add customer communication system

This commit is contained in:
sysadmin
2025-08-27 18:02:39 +01:00
parent 1f7c0af497
commit eae5be3e7c
136 changed files with 14552 additions and 97 deletions

26
LittleShop/.dockerignore Normal file
View File

@@ -0,0 +1,26 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/bin
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
README.md
*.db
logs/

View File

@@ -0,0 +1,332 @@
using System;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using LittleShop.DTOs;
using LittleShop.Enums;
using LittleShop.Services;
namespace LittleShop.Areas.Admin.Controllers;
[Area("Admin")]
[Authorize(Policy = "AdminOnly")]
public class BotsController : Controller
{
private readonly IBotService _botService;
private readonly IBotMetricsService _metricsService;
private readonly ITelegramBotManagerService _telegramManager;
private readonly ILogger<BotsController> _logger;
public BotsController(
IBotService botService,
IBotMetricsService metricsService,
ITelegramBotManagerService telegramManager,
ILogger<BotsController> logger)
{
_botService = botService;
_metricsService = metricsService;
_telegramManager = telegramManager;
_logger = logger;
}
// GET: Admin/Bots
public async Task<IActionResult> Index()
{
var bots = await _botService.GetAllBotsAsync();
return View(bots);
}
// GET: Admin/Bots/Details/5
public async Task<IActionResult> Details(Guid id)
{
var bot = await _botService.GetBotByIdAsync(id);
if (bot == null)
return NotFound();
// Get metrics summary for the last 30 days
var metricsSummary = await _metricsService.GetMetricsSummaryAsync(id, DateTime.UtcNow.AddDays(-30), DateTime.UtcNow);
ViewData["MetricsSummary"] = metricsSummary;
// Get active sessions
var activeSessions = await _metricsService.GetBotSessionsAsync(id, activeOnly: true);
ViewData["ActiveSessions"] = activeSessions;
return View(bot);
}
// GET: Admin/Bots/Create
public IActionResult Create()
{
return View(new BotRegistrationDto());
}
// GET: Admin/Bots/Wizard
public IActionResult Wizard()
{
return View(new BotWizardDto());
}
// POST: Admin/Bots/Wizard
[HttpPost]
// [ValidateAntiForgeryToken] // Temporarily disabled for testing
public async Task<IActionResult> Wizard(BotWizardDto dto)
{
_logger.LogInformation("Wizard POST received - BotName: '{BotName}', BotUsername: '{BotUsername}'", dto.BotName, dto.BotUsername);
if (!ModelState.IsValid)
{
_logger.LogWarning("Validation failed");
foreach (var error in ModelState)
{
_logger.LogWarning("Field {Field}: {Errors}", error.Key, string.Join(", ", error.Value.Errors.Select(e => e.ErrorMessage)));
}
return View(dto);
}
// Generate BotFather commands
var commands = GenerateBotFatherCommands(dto);
ViewData["BotFatherCommands"] = commands;
ViewData["ShowCommands"] = true;
_logger.LogInformation("Generated BotFather commands successfully for bot '{BotName}'", dto.BotName);
return View(dto);
}
// POST: Admin/Bots/CompleteWizard
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CompleteWizard(BotWizardDto dto)
{
if (string.IsNullOrEmpty(dto.BotToken))
{
ModelState.AddModelError("BotToken", "Bot token is required");
ViewData["BotFatherCommands"] = GenerateBotFatherCommands(dto);
ViewData["ShowCommands"] = true;
return View("Wizard", dto);
}
// Validate token first
if (!await ValidateTelegramToken(dto.BotToken))
{
ModelState.AddModelError("BotToken", "Invalid bot token");
ViewData["BotFatherCommands"] = GenerateBotFatherCommands(dto);
ViewData["ShowCommands"] = true;
return View("Wizard", dto);
}
// Create the bot
var registrationDto = new BotRegistrationDto
{
Name = dto.BotName,
Description = dto.Description,
Type = BotType.Telegram,
Version = "1.0.0",
PersonalityName = dto.PersonalityName,
InitialSettings = new Dictionary<string, object>
{
["telegram"] = new { botToken = dto.BotToken },
["personality"] = new { name = dto.PersonalityName }
}
};
try
{
var result = await _botService.RegisterBotAsync(registrationDto);
// Add bot to Telegram manager
var telegramAdded = await _telegramManager.AddBotAsync(result.BotId, dto.BotToken);
if (telegramAdded)
{
TempData["Success"] = $"Bot '{result.Name}' created successfully and is now running on Telegram!";
}
else
{
TempData["Warning"] = $"Bot '{result.Name}' created but failed to connect to Telegram. Check token.";
}
return RedirectToAction(nameof(Details), new { id = result.BotId });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create bot");
ModelState.AddModelError("", $"Failed to create bot: {ex.Message}");
return View("Wizard", dto);
}
}
// POST: Admin/Bots/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(BotRegistrationDto dto)
{
_logger.LogInformation("Received bot registration: Name={Name}, Type={Type}, Version={Version}",
dto?.Name, dto?.Type, dto?.Version);
if (!ModelState.IsValid)
{
_logger.LogWarning("Model validation failed for bot registration");
foreach (var error in ModelState.Values.SelectMany(v => v.Errors))
{
_logger.LogWarning("Validation error: {Error}", error.ErrorMessage);
}
return View(dto);
}
try
{
var result = await _botService.RegisterBotAsync(dto);
_logger.LogInformation("Bot registered successfully: {BotId}, Key: {KeyPrefix}...",
result.BotId, result.BotKey.Substring(0, 8));
TempData["BotKey"] = result.BotKey;
TempData["Success"] = $"Bot '{result.Name}' created successfully. Save the API key securely!";
return RedirectToAction(nameof(Details), new { id = result.BotId });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create bot");
ModelState.AddModelError("", $"Failed to create bot: {ex.Message}");
return View(dto);
}
}
// GET: Admin/Bots/Edit/5
public async Task<IActionResult> Edit(Guid id)
{
var bot = await _botService.GetBotByIdAsync(id);
if (bot == null)
return NotFound();
ViewData["BotSettings"] = JsonSerializer.Serialize(bot.Settings, new JsonSerializerOptions { WriteIndented = true });
return View(bot);
}
// POST: Admin/Bots/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(Guid id, string settingsJson, BotStatus status)
{
try
{
// Parse and update settings
var settings = JsonSerializer.Deserialize<Dictionary<string, object>>(settingsJson) ?? new Dictionary<string, object>();
var updateDto = new UpdateBotSettingsDto { Settings = settings };
await _botService.UpdateBotSettingsAsync(id, updateDto);
await _botService.UpdateBotStatusAsync(id, status);
TempData["Success"] = "Bot updated successfully";
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update bot");
TempData["Error"] = "Failed to update bot";
return RedirectToAction(nameof(Edit), new { id });
}
}
// GET: Admin/Bots/Metrics/5
public async Task<IActionResult> Metrics(Guid id, DateTime? startDate, DateTime? endDate)
{
var bot = await _botService.GetBotByIdAsync(id);
if (bot == null)
return NotFound();
var start = startDate ?? DateTime.UtcNow.AddDays(-7);
var end = endDate ?? DateTime.UtcNow;
var metricsSummary = await _metricsService.GetMetricsSummaryAsync(id, start, end);
var sessionSummary = await _metricsService.GetSessionSummaryAsync(id, start, end);
ViewData["Bot"] = bot;
ViewData["SessionSummary"] = sessionSummary;
ViewData["StartDate"] = start;
ViewData["EndDate"] = end;
return View(metricsSummary);
}
// POST: Admin/Bots/Delete/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(Guid id)
{
var success = await _botService.DeleteBotAsync(id);
if (!success)
{
TempData["Error"] = "Failed to delete bot";
return RedirectToAction(nameof(Index));
}
TempData["Success"] = "Bot deleted successfully";
return RedirectToAction(nameof(Index));
}
// POST: Admin/Bots/Suspend/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Suspend(Guid id)
{
await _botService.UpdateBotStatusAsync(id, BotStatus.Suspended);
TempData["Success"] = "Bot suspended";
return RedirectToAction(nameof(Details), new { id });
}
// POST: Admin/Bots/Activate/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Activate(Guid id)
{
await _botService.UpdateBotStatusAsync(id, BotStatus.Active);
TempData["Success"] = "Bot activated";
return RedirectToAction(nameof(Details), new { id });
}
// GET: Admin/Bots/RegenerateKey/5
public async Task<IActionResult> RegenerateKey(Guid id)
{
// This would require updating the bot model to support key regeneration
TempData["Error"] = "Key regeneration not yet implemented";
return RedirectToAction(nameof(Details), new { id });
}
private string GenerateBotFatherCommands(BotWizardDto dto)
{
var commands = new List<string>
{
"1. Open Telegram and find @BotFather",
"2. Send: /newbot",
$"3. Send bot name: {dto.BotName}",
$"4. Send bot username: {dto.BotUsername}",
"5. Copy the token from BotFather's response",
"6. Paste the token in the field below"
};
if (!string.IsNullOrEmpty(dto.Description))
{
commands.Add($"7. Optional: Send /setdescription and then: {dto.Description}");
}
return string.Join("\n", commands);
}
private async Task<bool> ValidateTelegramToken(string token)
{
try
{
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync($"https://api.telegram.org/bot{token}/getMe");
return response.IsSuccessStatusCode;
}
catch
{
return false;
}
}
}

View File

@@ -0,0 +1,109 @@
@model LittleShop.DTOs.BotRegistrationDto
@{
ViewData["Title"] = "Register New Bot";
}
<h1>Register New Bot</h1>
<hr />
<div class="row">
<div class="col-md-6">
<form action="/Admin/Bots/Create" method="post">
@Html.AntiForgeryToken()
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
@if (ViewData.ModelState.IsValid == false)
{
<div class="alert alert-danger">
<ul>
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
{
<li>@error.ErrorMessage</li>
}
</ul>
</div>
}
<div class="mb-3">
<label for="Name" class="form-label">Bot Name</label>
<input name="Name" id="Name" class="form-control" placeholder="e.g., Customer Service Bot" required />
<span class="text-danger"></span>
</div>
<div class="mb-3">
<label for="Description" class="form-label">Description</label>
<textarea name="Description" id="Description" class="form-control" rows="3"
placeholder="Brief description of what this bot does"></textarea>
<span class="text-danger"></span>
</div>
<div class="mb-3">
<label for="Type" class="form-label">Bot Type</label>
<select name="Type" id="Type" class="form-select">
<option value="0">Telegram</option>
<option value="1">Discord</option>
<option value="2">WhatsApp</option>
<option value="3">Signal</option>
<option value="4">Matrix</option>
<option value="5">IRC</option>
<option value="99">Custom</option>
</select>
<span class="text-danger"></span>
</div>
<div class="mb-3">
<label for="Version" class="form-label">Bot Version</label>
<input name="Version" id="Version" class="form-control" placeholder="e.g., 1.0.0" />
<span class="text-danger"></span>
</div>
<div class="mb-3">
<label class="form-label">Initial Settings (Optional)</label>
<div class="alert alert-info">
<small>You can configure detailed settings after registration.</small>
</div>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary">Register Bot</button>
<a href="/Admin/Bots" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Registration Information</h5>
</div>
<div class="card-body">
<p>After registering your bot, you will receive:</p>
<ul>
<li><strong>Bot ID:</strong> Unique identifier for your bot</li>
<li><strong>API Key:</strong> Secret key for authentication (save this securely!)</li>
<li><strong>Configuration:</strong> Access to manage bot settings</li>
</ul>
<hr />
<h6>Bot Types:</h6>
<ul class="small">
<li><strong>Telegram:</strong> Telegram messenger bot</li>
<li><strong>Discord:</strong> Discord server bot</li>
<li><strong>WhatsApp:</strong> WhatsApp Business API</li>
<li><strong>Signal:</strong> Signal messenger bot</li>
<li><strong>Matrix:</strong> Matrix protocol bot</li>
<li><strong>IRC:</strong> Internet Relay Chat bot</li>
<li><strong>Custom:</strong> Custom implementation</li>
</ul>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.5/jquery.validate.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.12/jquery.validate.unobtrusive.min.js"></script>
}

View File

@@ -0,0 +1,295 @@
@model LittleShop.DTOs.BotDto
@{
ViewData["Title"] = $"Bot Details - {Model.Name}";
var metricsSummary = ViewData["MetricsSummary"] as LittleShop.DTOs.BotMetricsSummaryDto;
var activeSessions = ViewData["ActiveSessions"] as IEnumerable<LittleShop.DTOs.BotSessionDto>;
}
<h1>Bot Details</h1>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (TempData["BotKey"] != null)
{
<div class="alert alert-warning">
<strong>Important!</strong> Save this API key securely. It will not be shown again:
<div class="mt-2">
<code class="bg-dark text-white p-2 d-block">@TempData["BotKey"]</code>
</div>
</div>
}
<div class="row">
<div class="col-md-8">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Bot Information</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-3">Name</dt>
<dd class="col-sm-9">@Model.Name</dd>
<dt class="col-sm-3">Description</dt>
<dd class="col-sm-9">@(string.IsNullOrEmpty(Model.Description) ? "N/A" : Model.Description)</dd>
<dt class="col-sm-3">Type</dt>
<dd class="col-sm-9"><span class="badge bg-info">@Model.Type</span></dd>
<dt class="col-sm-3">Platform Info</dt>
<dd class="col-sm-9">
@if (!string.IsNullOrEmpty(Model.PlatformUsername))
{
<div>
<strong>@@@Model.PlatformUsername</strong>
@if (!string.IsNullOrEmpty(Model.PlatformDisplayName))
{
<br />
<span class="text-muted">@Model.PlatformDisplayName</span>
}
@if (!string.IsNullOrEmpty(Model.PlatformId))
{
<br />
<small><code>ID: @Model.PlatformId</code></small>
}
</div>
}
else
{
<span class="text-muted">Not configured - platform info will be auto-detected on first connection</span>
}
</dd>
<dt class="col-sm-3">Status</dt>
<dd class="col-sm-9">
@switch (Model.Status)
{
case LittleShop.Enums.BotStatus.Active:
<span class="badge bg-success">Active</span>
break;
case LittleShop.Enums.BotStatus.Inactive:
<span class="badge bg-secondary">Inactive</span>
break;
case LittleShop.Enums.BotStatus.Suspended:
<span class="badge bg-warning">Suspended</span>
break;
default:
<span class="badge bg-dark">@Model.Status</span>
break;
}
</dd>
<dt class="col-sm-3">Bot ID</dt>
<dd class="col-sm-9"><code>@Model.Id</code></dd>
<dt class="col-sm-3">Version</dt>
<dd class="col-sm-9">@(string.IsNullOrEmpty(Model.Version) ? "N/A" : Model.Version)</dd>
<dt class="col-sm-3">IP Address</dt>
<dd class="col-sm-9">@(string.IsNullOrEmpty(Model.IpAddress) ? "N/A" : Model.IpAddress)</dd>
<dt class="col-sm-3">Created</dt>
<dd class="col-sm-9">@Model.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")</dd>
<dt class="col-sm-3">Last Seen</dt>
<dd class="col-sm-9">
@if (Model.LastSeenAt.HasValue)
{
@Model.LastSeenAt.Value.ToString("yyyy-MM-dd HH:mm:ss")
@if ((DateTime.UtcNow - Model.LastSeenAt.Value).TotalMinutes < 5)
{
<span class="badge bg-success ms-2">Online</span>
}
}
else
{
<span class="text-muted">Never</span>
}
</dd>
<dt class="col-sm-3">Config Synced</dt>
<dd class="col-sm-9">
@if (Model.LastConfigSyncAt.HasValue)
{
@Model.LastConfigSyncAt.Value.ToString("yyyy-MM-dd HH:mm:ss")
}
else
{
<span class="text-muted">Never</span>
}
</dd>
</dl>
</div>
</div>
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">30-Day Metrics Summary</h5>
</div>
<div class="card-body">
@if (metricsSummary != null)
{
<div class="row">
<div class="col-md-3">
<div class="text-center">
<h3>@metricsSummary.TotalSessions</h3>
<p class="text-muted">Total Sessions</p>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h3>@metricsSummary.TotalOrders</h3>
<p class="text-muted">Total Orders</p>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h3>$@metricsSummary.TotalRevenue.ToString("F2")</h3>
<p class="text-muted">Total Revenue</p>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h3>@metricsSummary.TotalErrors</h3>
<p class="text-muted">Total Errors</p>
</div>
</div>
</div>
@if (metricsSummary.UptimePercentage > 0)
{
<div class="progress mt-3">
<div class="progress-bar bg-success" role="progressbar" style="width: @metricsSummary.UptimePercentage%"
aria-valuenow="@metricsSummary.UptimePercentage" aria-valuemin="0" aria-valuemax="100">
Uptime: @metricsSummary.UptimePercentage.ToString("F1")%
</div>
</div>
}
}
else
{
<p class="text-muted">No metrics available</p>
}
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Active Sessions</h5>
</div>
<div class="card-body">
@if (activeSessions != null && activeSessions.Any())
{
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Session ID</th>
<th>Platform</th>
<th>Started</th>
<th>Messages</th>
<th>Orders</th>
<th>Spent</th>
</tr>
</thead>
<tbody>
@foreach (var session in activeSessions.Take(10))
{
<tr>
<td><small>@session.Id.ToString().Substring(0, 8)...</small></td>
<td>@session.Platform</td>
<td>@session.StartedAt.ToString("HH:mm:ss")</td>
<td>@session.MessageCount</td>
<td>@session.OrderCount</td>
<td>$@session.TotalSpent.ToString("F2")</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<p class="text-muted">No active sessions</p>
}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Actions</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="/Admin/Bots/Edit/@Model.Id" class="btn btn-primary">
<i class="bi bi-pencil"></i> Edit Settings
</a>
<a href="/Admin/Bots/Metrics/@Model.Id" class="btn btn-success">
<i class="bi bi-graph-up"></i> View Detailed Metrics
</a>
@if (Model.Status == LittleShop.Enums.BotStatus.Active)
{
<form action="/Admin/Bots/Suspend/@Model.Id" method="post">
<button type="submit" class="btn btn-warning w-100"
onclick="return confirm('Are you sure you want to suspend this bot?')">
<i class="bi bi-pause-circle"></i> Suspend Bot
</button>
</form>
}
else if (Model.Status == LittleShop.Enums.BotStatus.Suspended)
{
<form action="/Admin/Bots/Activate/@Model.Id" method="post">
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-play-circle"></i> Activate Bot
</button>
</form>
}
<hr />
<form action="/Admin/Bots/Delete/@Model.Id" method="post">
<button type="submit" class="btn btn-danger w-100"
onclick="return confirm('Are you sure you want to delete this bot? This action cannot be undone.')">
<i class="bi bi-trash"></i> Delete Bot
</button>
</form>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Quick Stats</h5>
</div>
<div class="card-body">
<ul class="list-unstyled mb-0">
<li>
<strong>Total Sessions:</strong> @Model.TotalSessions
</li>
<li>
<strong>Active Sessions:</strong> @Model.ActiveSessions
</li>
<li>
<strong>Total Orders:</strong> @Model.TotalOrders
</li>
<li>
<strong>Total Revenue:</strong> $@Model.TotalRevenue.ToString("F2")
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="mt-3">
<a href="/Admin/Bots" class="btn btn-secondary">Back to List</a>
</div>

View File

@@ -0,0 +1,135 @@
@model LittleShop.DTOs.BotDto
@{
ViewData["Title"] = $"Edit Bot - {Model.Name}";
var settingsJson = ViewData["BotSettings"] as string ?? "{}";
}
<h1>Edit Bot Settings</h1>
<hr />
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<form asp-action="Edit" method="post">
<div class="row">
<div class="col-md-8">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Bot Information</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-3">Name</dt>
<dd class="col-sm-9">@Model.Name</dd>
<dt class="col-sm-3">Type</dt>
<dd class="col-sm-9">@Model.Type</dd>
<dt class="col-sm-3">Bot ID</dt>
<dd class="col-sm-9"><code>@Model.Id</code></dd>
</dl>
<div class="mb-3">
<label for="status" class="form-label">Status</label>
<select name="status" id="status" class="form-select">
<option value="1" selected="@(Model.Status == LittleShop.Enums.BotStatus.Active)">Active</option>
<option value="2" selected="@(Model.Status == LittleShop.Enums.BotStatus.Inactive)">Inactive</option>
<option value="3" selected="@(Model.Status == LittleShop.Enums.BotStatus.Suspended)">Suspended</option>
<option value="4" selected="@(Model.Status == LittleShop.Enums.BotStatus.Maintenance)">Maintenance</option>
</select>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Bot Configuration (JSON)</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label for="settingsJson" class="form-label">Settings</label>
<textarea name="settingsJson" id="settingsJson" class="form-control font-monospace" rows="20">@settingsJson</textarea>
<small class="text-muted">Edit the JSON configuration for this bot. Be careful to maintain valid JSON format.</small>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Configuration Template</h5>
</div>
<div class="card-body">
<p class="small">Example configuration structure:</p>
<pre class="bg-light p-2 small"><code>{
"Telegram": {
"BotToken": "YOUR_BOT_TOKEN",
"WebhookUrl": "",
"AdminChatId": ""
},
"Privacy": {
"Mode": "strict",
"RequirePGP": false,
"EnableTor": false
},
"Features": {
"EnableQRCodes": true,
"EnableVoiceSearch": false
},
"Cryptocurrencies": [
"BTC", "XMR", "USDT"
]
}</code></pre>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Actions</h5>
</div>
<div class="card-body">
<button type="submit" class="btn btn-primary w-100 mb-2">
<i class="bi bi-save"></i> Save Changes
</button>
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-secondary w-100">
Cancel
</a>
</div>
</div>
</div>
</div>
</form>
@section Scripts {
<script>
// Pretty print JSON on load
document.addEventListener('DOMContentLoaded', function() {
var textarea = document.getElementById('settingsJson');
try {
var json = JSON.parse(textarea.value);
textarea.value = JSON.stringify(json, null, 2);
} catch (e) {
console.error('Invalid JSON:', e);
}
});
// Validate JSON before submit
document.querySelector('form').addEventListener('submit', function(e) {
var textarea = document.getElementById('settingsJson');
try {
JSON.parse(textarea.value);
} catch (error) {
e.preventDefault();
alert('Invalid JSON format: ' + error.message);
}
});
</script>
}

View File

@@ -0,0 +1,150 @@
@model IEnumerable<LittleShop.DTOs.BotDto>
@{
ViewData["Title"] = "Bot Management";
}
<h1>Bot Management</h1>
<p>
<a href="/Admin/Bots/Wizard" class="btn btn-primary">
<i class="fas fa-magic"></i> Create Telegram Bot (Wizard)
</a>
<a href="/Admin/Bots/Create" class="btn btn-outline-secondary">
<i class="fas fa-plus"></i> Manual Registration
</a>
</p>
@if (TempData["Success"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (TempData["Error"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Platform Info</th>
<th>Status</th>
<th>Active Sessions</th>
<th>Total Revenue</th>
<th>Last Seen</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var bot in Model)
{
<tr>
<td>
<strong>@bot.Name</strong>
@if (!string.IsNullOrEmpty(bot.PersonalityName))
{
<span class="badge bg-secondary ms-2">@bot.PersonalityName</span>
}
@if (!string.IsNullOrEmpty(bot.Description))
{
<br />
<small class="text-muted">@bot.Description</small>
}
</td>
<td>
<span class="badge bg-info">@bot.Type</span>
</td>
<td>
@if (!string.IsNullOrEmpty(bot.PlatformUsername))
{
<div>
<strong>@@@bot.PlatformUsername</strong>
@if (!string.IsNullOrEmpty(bot.PlatformDisplayName))
{
<br />
<small class="text-muted">@bot.PlatformDisplayName</small>
}
</div>
}
else
{
<span class="text-muted">Not configured</span>
}
</td>
<td>
@switch (bot.Status)
{
case LittleShop.Enums.BotStatus.Active:
<span class="badge bg-success">Active</span>
break;
case LittleShop.Enums.BotStatus.Inactive:
<span class="badge bg-secondary">Inactive</span>
break;
case LittleShop.Enums.BotStatus.Suspended:
<span class="badge bg-warning">Suspended</span>
break;
case LittleShop.Enums.BotStatus.Maintenance:
<span class="badge bg-info">Maintenance</span>
break;
default:
<span class="badge bg-dark">@bot.Status</span>
break;
}
</td>
<td>
<span class="badge bg-primary">@bot.ActiveSessions</span>
</td>
<td>$@bot.TotalRevenue.ToString("F2")</td>
<td>
@if (bot.LastSeenAt.HasValue)
{
<span title="@bot.LastSeenAt.Value.ToString("yyyy-MM-dd HH:mm:ss")">
@((DateTime.UtcNow - bot.LastSeenAt.Value).TotalMinutes < 5 ? "Online" : bot.LastSeenAt.Value.ToString("yyyy-MM-dd HH:mm"))
</span>
@if ((DateTime.UtcNow - bot.LastSeenAt.Value).TotalMinutes < 5)
{
<span class="text-success">●</span>
}
}
else
{
<span class="text-muted">Never</span>
}
</td>
<td>@bot.CreatedAt.ToString("yyyy-MM-dd")</td>
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="/Admin/Bots/Details/@bot.Id" class="btn btn-outline-info" title="View Details">
<i class="bi bi-eye"></i>
</a>
<a href="/Admin/Bots/Edit/@bot.Id" class="btn btn-outline-primary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a href="/Admin/Bots/Metrics/@bot.Id" class="btn btn-outline-success" title="View Metrics">
<i class="bi bi-graph-up"></i>
</a>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@if (!Model.Any())
{
<div class="alert alert-info">
No bots have been registered yet. <a asp-action="Create">Register your first bot</a>.
</div>
}

View File

@@ -0,0 +1,284 @@
@model LittleShop.DTOs.BotMetricsSummaryDto
@{
ViewData["Title"] = $"Bot Metrics - {Model.BotName}";
var bot = ViewData["Bot"] as LittleShop.DTOs.BotDto;
var sessionSummary = ViewData["SessionSummary"] as LittleShop.DTOs.BotSessionSummaryDto;
var startDate = (DateTime)ViewData["StartDate"]!;
var endDate = (DateTime)ViewData["EndDate"]!;
}
<h1>Bot Metrics</h1>
<h3>@Model.BotName</h3>
<div class="row mb-3">
<div class="col-md-12">
<form method="get" class="row g-3">
<div class="col-auto">
<label for="startDate" class="col-form-label">Start Date:</label>
</div>
<div class="col-auto">
<input type="date" id="startDate" name="startDate" class="form-control" value="@startDate.ToString("yyyy-MM-dd")" />
</div>
<div class="col-auto">
<label for="endDate" class="col-form-label">End Date:</label>
</div>
<div class="col-auto">
<input type="date" id="endDate" name="endDate" class="form-control" value="@endDate.ToString("yyyy-MM-dd")" />
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary">Update</button>
</div>
</form>
</div>
</div>
<div class="row">
<div class="col-md-3">
<div class="card text-center mb-3">
<div class="card-body">
<h2 class="text-primary">@Model.TotalSessions</h2>
<p class="text-muted">Total Sessions</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center mb-3">
<div class="card-body">
<h2 class="text-success">@Model.TotalOrders</h2>
<p class="text-muted">Total Orders</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center mb-3">
<div class="card-body">
<h2 class="text-info">$@Model.TotalRevenue.ToString("F2")</h2>
<p class="text-muted">Total Revenue</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center mb-3">
<div class="card-body">
<h2 class="text-warning">@Model.TotalMessages</h2>
<p class="text-muted">Total Messages</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Performance Metrics</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-6">Average Response Time:</dt>
<dd class="col-sm-6">@Model.AverageResponseTime.ToString("F2") ms</dd>
<dt class="col-sm-6">Uptime Percentage:</dt>
<dd class="col-sm-6">
@if (Model.UptimePercentage > 0)
{
<span class="badge bg-success">@Model.UptimePercentage.ToString("F1")%</span>
}
else
{
<span class="text-muted">N/A</span>
}
</dd>
<dt class="col-sm-6">Total Errors:</dt>
<dd class="col-sm-6">
@if (Model.TotalErrors > 0)
{
<span class="text-danger">@Model.TotalErrors</span>
}
else
{
<span class="text-success">0</span>
}
</dd>
<dt class="col-sm-6">Unique Sessions:</dt>
<dd class="col-sm-6">@Model.UniqueSessions</dd>
</dl>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Session Statistics</h5>
</div>
<div class="card-body">
@if (sessionSummary != null)
{
<dl class="row">
<dt class="col-sm-6">Active Sessions:</dt>
<dd class="col-sm-6">@sessionSummary.ActiveSessions</dd>
<dt class="col-sm-6">Completed Sessions:</dt>
<dd class="col-sm-6">@sessionSummary.CompletedSessions</dd>
<dt class="col-sm-6">Avg Session Duration:</dt>
<dd class="col-sm-6">@sessionSummary.AverageSessionDuration.ToString("F1") min</dd>
<dt class="col-sm-6">Avg Orders/Session:</dt>
<dd class="col-sm-6">@sessionSummary.AverageOrdersPerSession.ToString("F2")</dd>
<dt class="col-sm-6">Avg Spend/Session:</dt>
<dd class="col-sm-6">$@sessionSummary.AverageSpendPerSession.ToString("F2")</dd>
</dl>
}
else
{
<p class="text-muted">No session data available</p>
}
</div>
</div>
</div>
</div>
@if (Model.MetricsByType.Any())
{
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Metrics by Type</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Metric Type</th>
<th>Total Value</th>
</tr>
</thead>
<tbody>
@foreach (var metric in Model.MetricsByType.OrderByDescending(m => m.Value))
{
<tr>
<td>@metric.Key</td>
<td>@metric.Value.ToString("F0")</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
@if (sessionSummary != null)
{
<div class="row">
@if (sessionSummary.SessionsByPlatform.Any())
{
<div class="col-md-4">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Sessions by Platform</h5>
</div>
<div class="card-body">
<ul class="list-unstyled">
@foreach (var platform in sessionSummary.SessionsByPlatform)
{
<li>@platform.Key: <strong>@platform.Value</strong></li>
}
</ul>
</div>
</div>
</div>
}
@if (sessionSummary.SessionsByCountry.Any())
{
<div class="col-md-4">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Sessions by Country</h5>
</div>
<div class="card-body">
<ul class="list-unstyled">
@foreach (var country in sessionSummary.SessionsByCountry.Take(5))
{
<li>@country.Key: <strong>@country.Value</strong></li>
}
</ul>
</div>
</div>
</div>
}
@if (sessionSummary.SessionsByLanguage.Any())
{
<div class="col-md-4">
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Sessions by Language</h5>
</div>
<div class="card-body">
<ul class="list-unstyled">
@foreach (var language in sessionSummary.SessionsByLanguage)
{
<li>@language.Key: <strong>@language.Value</strong></li>
}
</ul>
</div>
</div>
</div>
}
</div>
}
@if (Model.TimeSeries.Any())
{
<div class="card">
<div class="card-header">
<h5 class="mb-0">Activity Timeline</h5>
</div>
<div class="card-body">
<canvas id="metricsChart"></canvas>
</div>
</div>
}
<div class="mt-3">
<a asp-action="Details" asp-route-id="@Model.BotId" class="btn btn-secondary">Back to Details</a>
<a asp-action="Index" class="btn btn-secondary">Back to List</a>
</div>
@section Scripts {
@if (Model.TimeSeries.Any())
{
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const ctx = document.getElementById('metricsChart').getContext('2d');
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: [@Html.Raw(string.Join(",", Model.TimeSeries.Select(t => $"'{t.Label}'")))],
datasets: [{
label: 'Activity',
data: [@string.Join(",", Model.TimeSeries.Select(t => t.Value))],
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.1
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
</script>
}
}

View File

@@ -0,0 +1,230 @@
@model LittleShop.DTOs.BotWizardDto
@{
ViewData["Title"] = "Telegram Bot Creation Wizard";
var showCommands = ViewData["ShowCommands"] as bool? ?? false;
var commands = ViewData["BotFatherCommands"] as string ?? "";
}
<h1>Telegram Bot Creation Wizard</h1>
<div class="row">
<div class="col-md-8">
@if (!showCommands)
{
<!-- Step 1: Basic Info -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Step 1: Bot Information</h5>
</div>
<div class="card-body">
<form asp-area="Admin" asp-controller="Bots" asp-action="Wizard" method="post">
@Html.AntiForgeryToken()
<div asp-validation-summary="All" class="text-danger"></div>
<div class="mb-3">
<label asp-for="BotName" class="form-label">Bot Display Name</label>
<input asp-for="BotName" class="form-control"
placeholder="e.g., LittleShop Electronics Bot" required />
<span asp-validation-for="BotName" class="text-danger"></span>
<small class="text-muted">This is the name users will see</small>
</div>
<div class="mb-3">
<label asp-for="BotUsername" class="form-label">Bot Username</label>
<div class="input-group">
<span class="input-group-text">@@</span>
<input asp-for="BotUsername" class="form-control"
placeholder="littleshop_bot" required />
</div>
<span asp-validation-for="BotUsername" class="text-danger"></span>
<small class="text-muted">Must end with 'bot' and be unique on Telegram</small>
</div>
<div class="mb-3">
<label for="PersonalityName" class="form-label">Personality</label>
<select asp-for="PersonalityName" class="form-select">
<option value="">Auto-assign (recommended)</option>
<option value="Alan" @(Model.PersonalityName == "Alan" ? "selected" : "")>Alan (Professional)</option>
<option value="Dave" @(Model.PersonalityName == "Dave" ? "selected" : "")>Dave (Casual)</option>
<option value="Sarah" @(Model.PersonalityName == "Sarah" ? "selected" : "")>Sarah (Helpful)</option>
<option value="Mike" @(Model.PersonalityName == "Mike" ? "selected" : "")>Mike (Direct)</option>
<option value="Emma" @(Model.PersonalityName == "Emma" ? "selected" : "")>Emma (Friendly)</option>
<option value="Tom" @(Model.PersonalityName == "Tom" ? "selected" : "")>Tom (Efficient)</option>
</select>
<small class="text-muted">Bot conversation style (can be changed later)</small>
</div>
<div class="mb-3">
<label for="Description" class="form-label">Description (Optional)</label>
<textarea asp-for="Description" class="form-control" rows="2"
placeholder="Brief description of what this bot does"></textarea>
</div>
<input type="submit" value="Generate BotFather Commands" class="btn btn-primary" />
<a href="/Admin/Bots" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
}
else
{
<!-- Step 2: BotFather Commands & Token -->
<div class="card mb-3">
<div class="card-header bg-info text-white">
<h5 class="mb-0">Step 2: Create Bot with BotFather</h5>
</div>
<div class="card-body">
<div class="alert alert-info">
<strong>Follow these steps in Telegram:</strong>
</div>
<div class="bg-light p-3 rounded">
<pre class="mb-0">@commands</pre>
</div>
<div class="mt-3">
<button class="btn btn-sm btn-outline-secondary" onclick="copyCommands()">
<i class="fas fa-copy"></i> Copy Instructions
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0">Step 3: Complete Bot Setup</h5>
</div>
<div class="card-body">
<form action="/Admin/Bots/CompleteWizard" method="post">
@Html.AntiForgeryToken()
<!-- Hidden fields to preserve data -->
<input type="hidden" name="BotName" value="@Model.BotName" />
<input type="hidden" name="BotUsername" value="@Model.BotUsername" />
<input type="hidden" name="Description" value="@Model.Description" />
<input type="hidden" name="PersonalityName" value="@Model.PersonalityName" />
<div class="mb-3">
<label for="BotToken" class="form-label">Bot Token from BotFather</label>
<input name="BotToken" id="BotToken" class="form-control font-monospace"
value="@Model.BotToken" placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz" required />
<span asp-validation-for="BotToken" class="text-danger"></span>
<small class="text-muted">Paste the token you received from @@BotFather</small>
</div>
<div class="d-grid gap-2 d-md-flex">
<button type="submit" class="btn btn-success me-md-2">
<i class="fas fa-check"></i> Create Bot & Validate Token
</button>
<button type="button" class="btn btn-secondary" onclick="history.back()">
<i class="fas fa-arrow-left"></i> Back to Edit Info
</button>
</div>
</form>
</div>
</div>
}
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Wizard Progress</h5>
</div>
<div class="card-body">
<ul class="list-unstyled">
<li class="@(!showCommands ? "text-primary fw-bold" : "text-success")">
<i class="fas fa-@(!showCommands ? "edit" : "check")"></i>
1. Bot Information
</li>
<li class="@(showCommands && string.IsNullOrEmpty(Model.BotToken) ? "text-primary fw-bold" : showCommands ? "text-success" : "text-muted")">
<i class="fas fa-@(showCommands && string.IsNullOrEmpty(Model.BotToken) ? "robot" : showCommands ? "check" : "circle")"></i>
2. Create with BotFather
</li>
<li class="@(!string.IsNullOrEmpty(Model.BotToken) ? "text-primary fw-bold" : "text-muted")">
<i class="fas fa-@(!string.IsNullOrEmpty(Model.BotToken) ? "key" : "circle")"></i>
3. Complete Setup
</li>
</ul>
</div>
</div>
@if (showCommands)
{
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">Quick Tips</h5>
</div>
<div class="card-body">
<ul class="small">
<li>BotFather responds instantly</li>
<li>Username must end with 'bot'</li>
<li>Keep your token secure</li>
<li>Token starts with numbers followed by colon</li>
</ul>
</div>
</div>
}
else
{
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">Personality Preview</h5>
</div>
<div class="card-body">
<p class="small">
@if (!string.IsNullOrEmpty(Model.PersonalityName))
{
<strong>@Model.PersonalityName</strong><text> personality selected</text>
}
else
{
<text>Auto-assigned personality based on bot name</text>
}
</p>
<p class="small text-muted">
Personalities affect how your bot communicates with customers.
This can be customized later in bot settings.
</p>
</div>
</div>
}
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
// Test if JavaScript is working
console.log('Wizard page scripts loaded');
function copyCommands() {
const commands = `@Html.Raw(commands)`;
navigator.clipboard.writeText(commands).then(() => {
alert('Commands copied to clipboard!');
});
}
// Auto-generate username from bot name
$(document).ready(function() {
console.log('Document ready, setting up auto-generation');
$('#BotName').on('input', function() {
try {
const name = $(this).val().toLowerCase()
.replace(/[^a-z0-9]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
if (name && !name.endsWith('_bot')) {
$('#BotUsername').val(name + '_bot');
}
} catch (err) {
console.error('Error in auto-generation:', err);
}
});
});
</script>
}

View File

@@ -10,6 +10,12 @@
<p class="text-muted">Order ID: @Model.Id</p>
</div>
<div class="col-auto">
@if (Model.Customer != null)
{
<button class="btn btn-success" onclick="showMessageModal('@Model.Id', '@Model.Customer.DisplayName')">
<i class="fas fa-comment"></i> Message Customer
</button>
}
<a href="@Url.Action("Edit", new { id = Model.Id })" class="btn btn-primary">
<i class="fas fa-edit"></i> Edit Order
</a>
@@ -28,7 +34,28 @@
<div class="card-body">
<div class="row">
<div class="col-md-6">
<p><strong>Identity Reference:</strong> @Model.IdentityReference</p>
@if (Model.Customer != null)
{
<p><strong>Customer:</strong> @Model.Customer.DisplayName
@if (!string.IsNullOrEmpty(Model.Customer.TelegramUsername))
{
<span class="text-muted">(@@@Model.Customer.TelegramUsername)</span>
}
</p>
<p><strong>Customer Type:</strong> <span class="badge bg-info">@Model.Customer.CustomerType</span></p>
@if (Model.Customer.RiskScore > 0)
{
<p><strong>Risk Score:</strong>
<span class="badge @(Model.Customer.RiskScore > 50 ? "bg-danger" : Model.Customer.RiskScore > 25 ? "bg-warning" : "bg-success")">
@Model.Customer.RiskScore/100
</span>
</p>
}
}
else if (!string.IsNullOrEmpty(Model.IdentityReference))
{
<p><strong>Identity Reference:</strong> @Model.IdentityReference</p>
}
<p><strong>Status:</strong>
@{
var badgeClass = Model.Status switch
@@ -192,4 +219,126 @@
</div>
}
</div>
</div>
</div>
@if (Model.Customer != null)
{
<!-- Customer Messaging Modal -->
<div class="modal fade" id="messageModal" tabindex="-1" aria-labelledby="messageModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="messageModalLabel">
<i class="fas fa-comment"></i> Message Customer: <span id="customerName"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="messageForm">
<input type="hidden" id="orderId" name="orderId" />
<input type="hidden" id="customerId" name="customerId" value="@Model.Customer.Id" />
<div class="mb-3">
<label for="messageType" class="form-label">Message Type</label>
<select class="form-select" id="messageType" name="messageType" required>
<option value="0">Order Update</option>
<option value="1">Payment Reminder</option>
<option value="2">Shipping Information</option>
<option value="3">Customer Service</option>
</select>
</div>
<div class="mb-3">
<label for="messageSubject" class="form-label">Subject</label>
<input type="text" class="form-control" id="messageSubject" name="subject" required
placeholder="Brief subject line..." maxlength="100">
</div>
<div class="mb-3">
<label for="messageContent" class="form-label">Message</label>
<textarea class="form-control" id="messageContent" name="content" rows="4" required
placeholder="Type your message to the customer..." maxlength="1000"></textarea>
<div class="form-text">Message will be delivered via Telegram</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isUrgent" name="isUrgent">
<label class="form-check-label" for="isUrgent">
Mark as urgent (higher priority delivery)
</label>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="sendMessage()">
<i class="fas fa-paper-plane"></i> Send Message
</button>
</div>
</div>
</div>
</div>
}
<script>
function showMessageModal(orderId, customerName) {
document.getElementById('orderId').value = orderId;
document.getElementById('customerName').textContent = customerName;
// Clear previous form data
document.getElementById('messageForm').reset();
document.getElementById('orderId').value = orderId; // Reset cleared this
// Show modal
var modal = new bootstrap.Modal(document.getElementById('messageModal'));
modal.show();
}
function sendMessage() {
const form = document.getElementById('messageForm');
const formData = new FormData(form);
const messageData = {
customerId: formData.get('customerId'),
orderId: formData.get('orderId'),
type: parseInt(formData.get('messageType')),
subject: formData.get('subject'),
content: formData.get('content'),
isUrgent: formData.get('isUrgent') === 'on',
priority: formData.get('isUrgent') === 'on' ? 1 : 5
};
// Send message via API
fetch('/api/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + sessionStorage.getItem('authToken')
},
body: JSON.stringify(messageData)
})
.then(response => {
if (response.ok) {
// Close modal and show success
var modal = bootstrap.Modal.getInstance(document.getElementById('messageModal'));
modal.hide();
// Show success message
alert('Message sent successfully! The customer will receive it via Telegram.');
// Optionally refresh the page to show updated communication history
window.location.reload();
} else {
response.text().then(error => {
alert('Failed to send message: ' + error);
});
}
})
.catch(error => {
console.error('Error sending message:', error);
alert('Error sending message. Please try again.');
});
}
</script>

View File

@@ -37,7 +37,27 @@
{
<tr>
<td><code>@order.Id.ToString().Substring(0, 8)...</code></td>
<td>@order.ShippingName</td>
<td>
@if (order.Customer != null)
{
<div>
<strong>@order.Customer.DisplayName</strong>
@if (!string.IsNullOrEmpty(order.Customer.TelegramUsername))
{
<br><small class="text-muted">@@@order.Customer.TelegramUsername</small>
}
<br><small class="badge bg-info">@order.Customer.CustomerType</small>
</div>
}
else
{
<span class="text-muted">@order.ShippingName</span>
@if (!string.IsNullOrEmpty(order.IdentityReference))
{
<br><small class="text-muted">(@order.IdentityReference)</small>
}
}
</td>
<td>@order.ShippingCity, @order.ShippingCountry</td>
<td>
@{
@@ -60,6 +80,12 @@
<a href="@Url.Action("Details", new { id = order.Id })" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i> View
</a>
@if (order.Customer != null)
{
<a href="@Url.Action("Details", new { id = order.Id })" class="btn btn-sm btn-success ms-1" title="Message Customer">
<i class="fas fa-comment"></i>
</a>
}
</td>
</tr>
}

View File

@@ -6,6 +6,7 @@
<title>@ViewData["Title"] - LittleShop Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
</head>
<body>
<header>
@@ -49,6 +50,11 @@
<i class="fas fa-users"></i> Users
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="@Url.Action("Index", "Bots", new { area = "Admin" })">
<i class="fas fa-robot"></i> Bots
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
@@ -76,6 +82,7 @@
</main>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>

View File

@@ -0,0 +1,4 @@
@* Client-side validation scripts *@
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.5/jquery.validate.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.12/jquery.validate.unobtrusive.min.js"></script>

View File

@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using LittleShop.DTOs;
using LittleShop.Services;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
private readonly IAuthService _authService;
public AuthController(IAuthService authService)
{
_authService = authService;
}
[HttpPost("login")]
public async Task<ActionResult<AuthResponseDto>> Login([FromBody] LoginDto loginDto)
{
var result = await _authService.LoginAsync(loginDto);
if (result != null)
{
return Ok(result);
}
return Unauthorized(new { message = "Invalid credentials" });
}
}

View File

@@ -0,0 +1,265 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using LittleShop.DTOs;
using LittleShop.Services;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
public class BotsController : ControllerBase
{
private readonly IBotService _botService;
private readonly IBotMetricsService _metricsService;
private readonly ILogger<BotsController> _logger;
public BotsController(
IBotService botService,
IBotMetricsService metricsService,
ILogger<BotsController> logger)
{
_botService = botService;
_metricsService = metricsService;
_logger = logger;
}
// Bot Registration and Authentication
[HttpPost("register")]
[AllowAnonymous]
public async Task<ActionResult<BotRegistrationResponseDto>> RegisterBot([FromBody] BotRegistrationDto dto)
{
try
{
var result = await _botService.RegisterBotAsync(dto);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to register bot");
return BadRequest("Failed to register bot");
}
}
[HttpPost("authenticate")]
[AllowAnonymous]
public async Task<ActionResult<BotDto>> AuthenticateBot([FromBody] BotAuthenticateDto dto)
{
var bot = await _botService.AuthenticateBotAsync(dto.BotKey);
if (bot == null)
return Unauthorized("Invalid bot key");
return Ok(bot);
}
// Bot Settings
[HttpGet("settings")]
public async Task<ActionResult<Dictionary<string, object>>> GetBotSettings()
{
var botKey = Request.Headers["X-Bot-Key"].ToString();
if (string.IsNullOrEmpty(botKey))
return Unauthorized("Bot key required");
var bot = await _botService.GetBotByKeyAsync(botKey);
if (bot == null)
return Unauthorized("Invalid bot key");
var settings = await _botService.GetBotSettingsAsync(bot.Id);
return Ok(settings);
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateBotSettings([FromBody] UpdateBotSettingsDto dto)
{
var botKey = Request.Headers["X-Bot-Key"].ToString();
if (string.IsNullOrEmpty(botKey))
return Unauthorized("Bot key required");
var bot = await _botService.GetBotByKeyAsync(botKey);
if (bot == null)
return Unauthorized("Invalid bot key");
var success = await _botService.UpdateBotSettingsAsync(bot.Id, dto);
if (!success)
return NotFound("Bot not found");
return NoContent();
}
// Heartbeat
[HttpPost("heartbeat")]
public async Task<IActionResult> RecordHeartbeat([FromBody] BotHeartbeatDto dto)
{
var botKey = Request.Headers["X-Bot-Key"].ToString();
if (string.IsNullOrEmpty(botKey))
return Unauthorized("Bot key required");
var bot = await _botService.GetBotByKeyAsync(botKey);
if (bot == null)
return Unauthorized("Invalid bot key");
await _botService.RecordHeartbeatAsync(bot.Id, dto);
return Ok();
}
[HttpPut("platform-info")]
public async Task<IActionResult> UpdatePlatformInfo([FromBody] UpdatePlatformInfoDto dto)
{
var botKey = Request.Headers["X-Bot-Key"].ToString();
if (string.IsNullOrEmpty(botKey))
return Unauthorized("Bot key required");
var bot = await _botService.GetBotByKeyAsync(botKey);
if (bot == null)
return Unauthorized("Invalid bot key");
var success = await _botService.UpdatePlatformInfoAsync(bot.Id, dto);
if (!success)
return NotFound("Bot not found");
return NoContent();
}
// Metrics
[HttpPost("metrics")]
public async Task<ActionResult<BotMetricDto>> RecordMetric([FromBody] CreateBotMetricDto dto)
{
var botKey = Request.Headers["X-Bot-Key"].ToString();
if (string.IsNullOrEmpty(botKey))
return Unauthorized("Bot key required");
var bot = await _botService.GetBotByKeyAsync(botKey);
if (bot == null)
return Unauthorized("Invalid bot key");
var metric = await _metricsService.RecordMetricAsync(bot.Id, dto);
return Ok(metric);
}
[HttpPost("metrics/batch")]
public async Task<IActionResult> RecordMetricsBatch([FromBody] BotMetricsBatchDto dto)
{
var botKey = Request.Headers["X-Bot-Key"].ToString();
if (string.IsNullOrEmpty(botKey))
return Unauthorized("Bot key required");
var bot = await _botService.GetBotByKeyAsync(botKey);
if (bot == null)
return Unauthorized("Invalid bot key");
var success = await _metricsService.RecordMetricsBatchAsync(bot.Id, dto);
if (!success)
return BadRequest("Failed to record metrics");
return Ok();
}
// Sessions
[HttpPost("sessions/start")]
public async Task<ActionResult<BotSessionDto>> StartSession([FromBody] CreateBotSessionDto dto)
{
var botKey = Request.Headers["X-Bot-Key"].ToString();
if (string.IsNullOrEmpty(botKey))
return Unauthorized("Bot key required");
var bot = await _botService.GetBotByKeyAsync(botKey);
if (bot == null)
return Unauthorized("Invalid bot key");
var session = await _metricsService.StartSessionAsync(bot.Id, dto);
return Ok(session);
}
[HttpPut("sessions/{sessionId}")]
public async Task<IActionResult> UpdateSession(Guid sessionId, [FromBody] UpdateBotSessionDto dto)
{
var botKey = Request.Headers["X-Bot-Key"].ToString();
if (string.IsNullOrEmpty(botKey))
return Unauthorized("Bot key required");
var bot = await _botService.GetBotByKeyAsync(botKey);
if (bot == null)
return Unauthorized("Invalid bot key");
var success = await _metricsService.UpdateSessionAsync(sessionId, dto);
if (!success)
return NotFound("Session not found");
return NoContent();
}
[HttpPost("sessions/{sessionId}/end")]
public async Task<IActionResult> EndSession(Guid sessionId)
{
var botKey = Request.Headers["X-Bot-Key"].ToString();
if (string.IsNullOrEmpty(botKey))
return Unauthorized("Bot key required");
var bot = await _botService.GetBotByKeyAsync(botKey);
if (bot == null)
return Unauthorized("Invalid bot key");
var success = await _metricsService.EndSessionAsync(sessionId);
if (!success)
return NotFound("Session not found");
return NoContent();
}
// Admin endpoints (require Bearer authentication)
[HttpGet]
[Authorize(AuthenticationSchemes = "Bearer", Roles = "Admin")]
public async Task<ActionResult> GetAllBots()
{
var bots = await _botService.GetAllBotsAsync();
return Ok(bots);
}
[HttpGet("{id}")]
[Authorize(AuthenticationSchemes = "Bearer", Roles = "Admin")]
public async Task<ActionResult<BotDto>> GetBot(Guid id)
{
var bot = await _botService.GetBotByIdAsync(id);
if (bot == null)
return NotFound();
return Ok(bot);
}
[HttpGet("{id}/metrics")]
[Authorize(AuthenticationSchemes = "Bearer", Roles = "Admin")]
public async Task<ActionResult> GetBotMetrics(Guid id, [FromQuery] DateTime? startDate, [FromQuery] DateTime? endDate)
{
var metrics = await _metricsService.GetBotMetricsAsync(id, startDate, endDate);
return Ok(metrics);
}
[HttpGet("{id}/metrics/summary")]
[Authorize(AuthenticationSchemes = "Bearer", Roles = "Admin")]
public async Task<ActionResult<BotMetricsSummaryDto>> GetMetricsSummary(Guid id, [FromQuery] DateTime? startDate, [FromQuery] DateTime? endDate)
{
var summary = await _metricsService.GetMetricsSummaryAsync(id, startDate, endDate);
return Ok(summary);
}
[HttpGet("{id}/sessions")]
[Authorize(AuthenticationSchemes = "Bearer", Roles = "Admin")]
public async Task<ActionResult> GetBotSessions(Guid id, [FromQuery] bool activeOnly = false)
{
var sessions = await _metricsService.GetBotSessionsAsync(id, activeOnly);
return Ok(sessions);
}
[HttpDelete("{id}")]
[Authorize(AuthenticationSchemes = "Bearer", Roles = "Admin")]
public async Task<IActionResult> DeleteBot(Guid id)
{
var success = await _botService.DeleteBotAsync(id);
if (!success)
return NotFound();
return NoContent();
}
}

View File

@@ -7,7 +7,6 @@ namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Bearer")]
public class CatalogController : ControllerBase
{
private readonly ICategoryService _categoryService;
@@ -39,13 +38,29 @@ public class CatalogController : ControllerBase
}
[HttpGet("products")]
public async Task<ActionResult<IEnumerable<ProductDto>>> GetProducts([FromQuery] Guid? categoryId = null)
public async Task<ActionResult<PagedResult<ProductDto>>> GetProducts(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 20,
[FromQuery] Guid? categoryId = null)
{
var products = categoryId.HasValue
var allProducts = categoryId.HasValue
? await _productService.GetProductsByCategoryAsync(categoryId.Value)
: await _productService.GetAllProductsAsync();
return Ok(products);
var productList = allProducts.ToList();
var totalCount = productList.Count;
var skip = (pageNumber - 1) * pageSize;
var pagedProducts = productList.Skip(skip).Take(pageSize).ToList();
var result = new PagedResult<ProductDto>
{
Items = pagedProducts,
TotalCount = totalCount,
PageNumber = pageNumber,
PageSize = pageSize
};
return Ok(result);
}
[HttpGet("products/{id}")]

View File

@@ -0,0 +1,139 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.DTOs;
using LittleShop.Services;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(AuthenticationSchemes = "Bearer")]
public class CustomersController : ControllerBase
{
private readonly ICustomerService _customerService;
private readonly ILogger<CustomersController> _logger;
public CustomersController(ICustomerService customerService, ILogger<CustomersController> logger)
{
_customerService = customerService;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<CustomerDto>>> GetCustomers([FromQuery] string? search = null)
{
if (!string.IsNullOrEmpty(search))
{
var searchResults = await _customerService.SearchCustomersAsync(search);
return Ok(searchResults);
}
var customers = await _customerService.GetAllCustomersAsync();
return Ok(customers);
}
[HttpGet("{id}")]
public async Task<ActionResult<CustomerDto>> GetCustomer(Guid id)
{
var customer = await _customerService.GetCustomerByIdAsync(id);
if (customer == null)
{
return NotFound("Customer not found");
}
return Ok(customer);
}
[HttpGet("by-telegram/{telegramUserId}")]
public async Task<ActionResult<CustomerDto>> GetCustomerByTelegramId(long telegramUserId)
{
var customer = await _customerService.GetCustomerByTelegramUserIdAsync(telegramUserId);
if (customer == null)
{
return NotFound("Customer not found");
}
return Ok(customer);
}
[HttpPost]
public async Task<ActionResult<CustomerDto>> CreateCustomer([FromBody] CreateCustomerDto createCustomerDto)
{
try
{
var customer = await _customerService.CreateCustomerAsync(createCustomerDto);
return CreatedAtAction(nameof(GetCustomer), new { id = customer.Id }, customer);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("get-or-create")]
[AllowAnonymous] // Allow TeleBot to create customers
public async Task<ActionResult<CustomerDto>> GetOrCreateCustomer([FromBody] CreateCustomerDto createCustomerDto)
{
var customer = await _customerService.GetOrCreateCustomerAsync(
createCustomerDto.TelegramUserId,
createCustomerDto.TelegramDisplayName,
createCustomerDto.TelegramUsername,
createCustomerDto.TelegramFirstName,
createCustomerDto.TelegramLastName);
if (customer == null)
{
return BadRequest("Failed to create customer");
}
return Ok(customer);
}
[HttpPut("{id}")]
public async Task<ActionResult<CustomerDto>> UpdateCustomer(Guid id, [FromBody] UpdateCustomerDto updateCustomerDto)
{
var customer = await _customerService.UpdateCustomerAsync(id, updateCustomerDto);
if (customer == null)
{
return NotFound("Customer not found");
}
return Ok(customer);
}
[HttpPost("{id}/block")]
public async Task<ActionResult> BlockCustomer(Guid id, [FromBody] string reason)
{
var success = await _customerService.BlockCustomerAsync(id, reason);
if (!success)
{
return NotFound("Customer not found");
}
return Ok(new { message = "Customer blocked successfully" });
}
[HttpPost("{id}/unblock")]
public async Task<ActionResult> UnblockCustomer(Guid id)
{
var success = await _customerService.UnblockCustomerAsync(id);
if (!success)
{
return NotFound("Customer not found");
}
return Ok(new { message = "Customer unblocked successfully" });
}
[HttpDelete("{id}")]
public async Task<ActionResult> DeleteCustomer(Guid id)
{
var success = await _customerService.DeleteCustomerAsync(id);
if (!success)
{
return NotFound("Customer not found");
}
return Ok(new { message = "Customer marked for deletion" });
}
}

View File

@@ -0,0 +1,134 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using LittleShop.DTOs;
using LittleShop.Services;
using System.Security.Claims;
namespace LittleShop.Controllers;
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = "AdminOnly")]
public class MessagesController : ControllerBase
{
private readonly ICustomerMessageService _messageService;
private readonly ILogger<MessagesController> _logger;
public MessagesController(ICustomerMessageService messageService, ILogger<MessagesController> logger)
{
_messageService = messageService;
_logger = logger;
}
[HttpPost]
public async Task<ActionResult<CustomerMessageDto>> SendMessage([FromBody] CreateCustomerMessageDto createMessageDto)
{
try
{
// Always set AdminUserId to null to avoid FK constraint issues for now
createMessageDto.AdminUserId = null;
// Validate that CustomerId exists
var customerExists = await _messageService.ValidateCustomerExistsAsync(createMessageDto.CustomerId);
if (!customerExists)
{
return BadRequest($"Customer {createMessageDto.CustomerId} does not exist");
}
// If OrderId is provided, validate it belongs to the customer
if (createMessageDto.OrderId.HasValue)
{
var orderBelongsToCustomer = await _messageService.ValidateOrderBelongsToCustomerAsync(
createMessageDto.OrderId.Value,
createMessageDto.CustomerId);
if (!orderBelongsToCustomer)
{
return BadRequest("Order does not belong to the specified customer");
}
}
var message = await _messageService.CreateMessageAsync(createMessageDto);
if (message == null)
{
return BadRequest("Failed to create message");
}
return Ok(message); // Use Ok instead of CreatedAtAction to avoid routing issues
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating customer message");
return BadRequest($"Error creating message: {ex.Message}");
}
}
[HttpGet("{id}")]
public async Task<ActionResult<CustomerMessageDto>> GetMessage(Guid id)
{
var message = await _messageService.GetMessageByIdAsync(id);
if (message == null)
{
return NotFound("Message not found");
}
return Ok(message);
}
[HttpGet("customer/{customerId}")]
public async Task<ActionResult<IEnumerable<CustomerMessageDto>>> GetCustomerMessages(Guid customerId)
{
var messages = await _messageService.GetCustomerMessagesAsync(customerId);
return Ok(messages);
}
[HttpGet("order/{orderId}")]
public async Task<ActionResult<IEnumerable<CustomerMessageDto>>> GetOrderMessages(Guid orderId)
{
var messages = await _messageService.GetOrderMessagesAsync(orderId);
return Ok(messages);
}
[HttpGet("pending")]
public async Task<ActionResult<IEnumerable<CustomerMessageDto>>> GetPendingMessages([FromQuery] string platform = "Telegram")
{
var messages = await _messageService.GetPendingMessagesAsync(platform);
return Ok(messages);
}
[HttpPost("{id}/mark-sent")]
public async Task<ActionResult> MarkMessageAsSent(Guid id, [FromQuery] string? platformMessageId = null)
{
var success = await _messageService.MarkMessageAsSentAsync(id, platformMessageId);
if (!success)
{
return NotFound("Message not found");
}
return Ok();
}
[HttpPost("{id}/mark-delivered")]
public async Task<ActionResult> MarkMessageAsDelivered(Guid id)
{
var success = await _messageService.MarkMessageAsDeliveredAsync(id);
if (!success)
{
return NotFound("Message not found");
}
return Ok();
}
[HttpPost("{id}/mark-failed")]
public async Task<ActionResult> MarkMessageAsFailed(Guid id, [FromBody] string reason)
{
var success = await _messageService.MarkMessageAsFailedAsync(id, reason);
if (!success)
{
return NotFound("Message not found");
}
return Ok();
}
}

View File

@@ -57,6 +57,7 @@ public class OrdersController : ControllerBase
// Public endpoints for client identity
[HttpGet("by-identity/{identityReference}")]
[AllowAnonymous]
public async Task<ActionResult<IEnumerable<OrderDto>>> GetOrdersByIdentity(string identityReference)
{
var orders = await _orderService.GetOrdersByIdentityAsync(identityReference);
@@ -64,6 +65,7 @@ public class OrdersController : ControllerBase
}
[HttpGet("by-identity/{identityReference}/{id}")]
[AllowAnonymous]
public async Task<ActionResult<OrderDto>> GetOrderByIdentity(string identityReference, Guid id)
{
var order = await _orderService.GetOrderByIdAsync(id);
@@ -76,6 +78,7 @@ public class OrdersController : ControllerBase
}
[HttpPost]
[AllowAnonymous]
public async Task<ActionResult<OrderDto>> CreateOrder([FromBody] CreateOrderDto createOrderDto)
{
try
@@ -91,6 +94,7 @@ public class OrdersController : ControllerBase
}
[HttpPost("{id}/payments")]
[AllowAnonymous]
public async Task<ActionResult<CryptoPaymentDto>> CreatePayment(Guid id, [FromBody] CreatePaymentDto createPaymentDto)
{
var order = await _orderService.GetOrderByIdAsync(id);

113
LittleShop/DTOs/BotDto.cs Normal file
View File

@@ -0,0 +1,113 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using LittleShop.Enums;
namespace LittleShop.DTOs;
public class BotDto
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public BotType Type { get; set; }
public BotStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? LastSeenAt { get; set; }
public DateTime? LastConfigSyncAt { get; set; }
public bool IsActive { get; set; }
public string Version { get; set; } = string.Empty;
public string IpAddress { get; set; } = string.Empty;
public string PlatformUsername { get; set; } = string.Empty;
public string PlatformDisplayName { get; set; } = string.Empty;
public string PlatformId { get; set; } = string.Empty;
public string PersonalityName { get; set; } = string.Empty;
public Dictionary<string, object> Settings { get; set; } = new();
// Metrics summary
public int TotalSessions { get; set; }
public int ActiveSessions { get; set; }
public decimal TotalRevenue { get; set; }
public int TotalOrders { get; set; }
}
public class BotRegistrationDto
{
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[StringLength(500)]
public string Description { get; set; } = string.Empty;
public BotType Type { get; set; }
[StringLength(50)]
public string Version { get; set; } = string.Empty;
[StringLength(50)]
public string PersonalityName { get; set; } = string.Empty;
public Dictionary<string, object> InitialSettings { get; set; } = new();
}
public class BotRegistrationResponseDto
{
public Guid BotId { get; set; }
public string BotKey { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public Dictionary<string, object> Settings { get; set; } = new();
}
public class BotAuthenticateDto
{
public string BotKey { get; set; } = string.Empty;
}
public class BotSettingsDto
{
public Dictionary<string, object> Telegram { get; set; } = new();
public Dictionary<string, object> Privacy { get; set; } = new();
public Dictionary<string, object> Features { get; set; } = new();
public Dictionary<string, object> Redis { get; set; } = new();
public List<string> Cryptocurrencies { get; set; } = new();
}
public class UpdateBotSettingsDto
{
public Dictionary<string, object> Settings { get; set; } = new();
}
public class BotHeartbeatDto
{
public string Version { get; set; } = string.Empty;
public string IpAddress { get; set; } = string.Empty;
public int ActiveSessions { get; set; }
public Dictionary<string, object> Status { get; set; } = new();
}
public class UpdatePlatformInfoDto
{
public string PlatformUsername { get; set; } = string.Empty;
public string PlatformDisplayName { get; set; } = string.Empty;
public string PlatformId { get; set; } = string.Empty;
}
public class BotWizardDto
{
[Required(ErrorMessage = "Bot name is required")]
[StringLength(50)]
public string BotName { get; set; } = string.Empty;
[Required(ErrorMessage = "Bot username is required")]
[StringLength(100)]
public string BotUsername { get; set; } = string.Empty;
[StringLength(500)]
public string Description { get; set; } = string.Empty;
[StringLength(50)]
public string PersonalityName { get; set; } = string.Empty;
public string BotToken { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using LittleShop.Enums;
namespace LittleShop.DTOs;
public class BotMetricDto
{
public Guid Id { get; set; }
public Guid BotId { get; set; }
public MetricType MetricType { get; set; }
public decimal Value { get; set; }
public string Category { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime RecordedAt { get; set; }
public Dictionary<string, object> Metadata { get; set; } = new();
}
public class CreateBotMetricDto
{
public MetricType MetricType { get; set; }
public decimal Value { get; set; }
public string Category { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public Dictionary<string, object> Metadata { get; set; } = new();
}
public class BotMetricsBatchDto
{
public List<CreateBotMetricDto> Metrics { get; set; } = new();
}
public class BotMetricsSummaryDto
{
public Guid BotId { get; set; }
public string BotName { get; set; } = string.Empty;
public DateTime PeriodStart { get; set; }
public DateTime PeriodEnd { get; set; }
// Key metrics
public int TotalSessions { get; set; }
public int UniqueSessions { get; set; }
public int TotalOrders { get; set; }
public decimal TotalRevenue { get; set; }
public int TotalMessages { get; set; }
public int TotalErrors { get; set; }
public decimal AverageResponseTime { get; set; }
public decimal UptimePercentage { get; set; }
// Breakdown by type
public Dictionary<string, decimal> MetricsByType { get; set; } = new();
// Time series data (for charts)
public List<TimeSeriesDataPoint> TimeSeries { get; set; } = new();
}
public class TimeSeriesDataPoint
{
public DateTime Timestamp { get; set; }
public string Label { get; set; } = string.Empty;
public decimal Value { get; set; }
}

View File

@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
namespace LittleShop.DTOs;
public class BotSessionDto
{
public Guid Id { get; set; }
public Guid BotId { get; set; }
public string SessionIdentifier { get; set; } = string.Empty;
public string Platform { get; set; } = string.Empty;
public DateTime StartedAt { get; set; }
public DateTime LastActivityAt { get; set; }
public DateTime? EndedAt { get; set; }
public int OrderCount { get; set; }
public int MessageCount { get; set; }
public decimal TotalSpent { get; set; }
public string Language { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
public bool IsAnonymous { get; set; }
public Dictionary<string, object> Metadata { get; set; } = new();
}
public class CreateBotSessionDto
{
public string SessionIdentifier { get; set; } = string.Empty;
public string Platform { get; set; } = string.Empty;
public string Language { get; set; } = "en";
public string Country { get; set; } = string.Empty;
public bool IsAnonymous { get; set; } = true;
public Dictionary<string, object> Metadata { get; set; } = new();
}
public class UpdateBotSessionDto
{
public int? OrderCount { get; set; }
public int? MessageCount { get; set; }
public decimal? TotalSpent { get; set; }
public bool? EndSession { get; set; }
public Dictionary<string, object>? Metadata { get; set; }
}
public class BotSessionSummaryDto
{
public int TotalSessions { get; set; }
public int ActiveSessions { get; set; }
public int CompletedSessions { get; set; }
public decimal AverageSessionDuration { get; set; } // in minutes
public decimal AverageOrdersPerSession { get; set; }
public decimal AverageSpendPerSession { get; set; }
public Dictionary<string, int> SessionsByPlatform { get; set; } = new();
public Dictionary<string, int> SessionsByCountry { get; set; } = new();
public Dictionary<string, int> SessionsByLanguage { get; set; } = new();
}

View File

@@ -0,0 +1,90 @@
using System.ComponentModel.DataAnnotations;
namespace LittleShop.DTOs;
public class CustomerDto
{
public Guid Id { get; set; }
public long TelegramUserId { get; set; }
public string TelegramUsername { get; set; } = string.Empty;
public string TelegramDisplayName { get; set; } = string.Empty;
public string TelegramFirstName { get; set; } = string.Empty;
public string TelegramLastName { get; set; } = string.Empty;
public string? Email { get; set; }
public string? PhoneNumber { get; set; }
public bool AllowMarketing { get; set; }
public bool AllowOrderUpdates { get; set; }
public string Language { get; set; } = "en";
public string Timezone { get; set; } = "UTC";
public int TotalOrders { get; set; }
public decimal TotalSpent { get; set; }
public decimal AverageOrderValue { get; set; }
public DateTime FirstOrderDate { get; set; }
public DateTime LastOrderDate { get; set; }
public string? CustomerNotes { get; set; }
public bool IsBlocked { get; set; }
public string? BlockReason { get; set; }
public int RiskScore { get; set; }
public int SuccessfulOrders { get; set; }
public int CancelledOrders { get; set; }
public int DisputedOrders { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public DateTime LastActiveAt { get; set; }
public DateTime? DataRetentionDate { get; set; }
public bool IsActive { get; set; }
// Calculated Properties
public string DisplayName { get; set; } = string.Empty;
public string CustomerType { get; set; } = string.Empty;
}
public class CustomerSummaryDto
{
public Guid Id { get; set; }
public string DisplayName { get; set; } = string.Empty;
public string TelegramUsername { get; set; } = string.Empty;
public int TotalOrders { get; set; }
public decimal TotalSpent { get; set; }
public string CustomerType { get; set; } = string.Empty;
public int RiskScore { get; set; }
public DateTime LastActiveAt { get; set; }
public bool IsBlocked { get; set; }
}
public class CreateCustomerDto
{
[Required]
public long TelegramUserId { get; set; }
public string TelegramUsername { get; set; } = string.Empty;
[Required]
public string TelegramDisplayName { get; set; } = string.Empty;
public string TelegramFirstName { get; set; } = string.Empty;
public string TelegramLastName { get; set; } = string.Empty;
public string? Email { get; set; }
public string? PhoneNumber { get; set; }
public bool AllowMarketing { get; set; } = false;
public bool AllowOrderUpdates { get; set; } = true;
public string Language { get; set; } = "en";
public string Timezone { get; set; } = "UTC";
}
public class UpdateCustomerDto
{
public string TelegramUsername { get; set; } = string.Empty;
public string TelegramDisplayName { get; set; } = string.Empty;
public string TelegramFirstName { get; set; } = string.Empty;
public string TelegramLastName { get; set; } = string.Empty;
public string? Email { get; set; }
public string? PhoneNumber { get; set; }
public bool AllowMarketing { get; set; }
public bool AllowOrderUpdates { get; set; }
public string Language { get; set; } = "en";
public string Timezone { get; set; } = "UTC";
public string? CustomerNotes { get; set; }
public bool IsBlocked { get; set; }
public string? BlockReason { get; set; }
}

View File

@@ -0,0 +1,118 @@
using System.ComponentModel.DataAnnotations;
using LittleShop.Models;
namespace LittleShop.DTOs;
public class CustomerMessageDto
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public Guid? OrderId { get; set; }
public Guid? AdminUserId { get; set; }
public MessageDirection Direction { get; set; }
public MessageType Type { get; set; }
public string Subject { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public MessageStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? SentAt { get; set; }
public DateTime? DeliveredAt { get; set; }
public DateTime? ReadAt { get; set; }
public DateTime? FailedAt { get; set; }
public string? FailureReason { get; set; }
public int RetryCount { get; set; }
public DateTime? NextRetryAt { get; set; }
public Guid? ParentMessageId { get; set; }
public Guid? ThreadId { get; set; }
public string Platform { get; set; } = string.Empty;
public string? PlatformMessageId { get; set; }
public int Priority { get; set; }
public DateTime? ScheduledFor { get; set; }
public DateTime? ExpiresAt { get; set; }
public bool RequiresResponse { get; set; }
public bool IsUrgent { get; set; }
public bool IsMarketing { get; set; }
public bool IsAutoGenerated { get; set; }
public string? AutoGenerationTrigger { get; set; }
// Navigation properties
public CustomerSummaryDto? Customer { get; set; }
public string? AdminUsername { get; set; }
public string? OrderReference { get; set; }
// For message delivery
public long TelegramUserId { get; set; }
// Helper properties
public string DisplayTitle { get; set; } = string.Empty;
public string StatusDisplay { get; set; } = string.Empty;
public string DirectionDisplay { get; set; } = string.Empty;
public string TypeDisplay { get; set; } = string.Empty;
}
public class CreateCustomerMessageDto
{
[Required]
public Guid CustomerId { get; set; }
public Guid? OrderId { get; set; }
public Guid? AdminUserId { get; set; } // Set by controller from claims
[Required]
public MessageType Type { get; set; }
[Required]
[StringLength(100)]
public string Subject { get; set; } = string.Empty;
[Required]
[StringLength(4000)]
public string Content { get; set; } = string.Empty;
public int Priority { get; set; } = 5;
public DateTime? ScheduledFor { get; set; }
public DateTime? ExpiresAt { get; set; }
public bool RequiresResponse { get; set; } = false;
public bool IsUrgent { get; set; } = false;
public bool IsMarketing { get; set; } = false;
}
public class CustomerMessageSummaryDto
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public string CustomerName { get; set; } = string.Empty;
public Guid? OrderId { get; set; }
public string? OrderReference { get; set; }
public MessageDirection Direction { get; set; }
public MessageType Type { get; set; }
public string Subject { get; set; } = string.Empty;
public MessageStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? SentAt { get; set; }
public bool IsUrgent { get; set; }
public bool RequiresResponse { get; set; }
public string DisplayTitle { get; set; } = string.Empty;
}
public class MessageThreadDto
{
public Guid ThreadId { get; set; }
public string Subject { get; set; } = string.Empty;
public Guid CustomerId { get; set; }
public string CustomerName { get; set; } = string.Empty;
public Guid? OrderId { get; set; }
public string? OrderReference { get; set; }
public DateTime StartedAt { get; set; }
public DateTime LastMessageAt { get; set; }
public int MessageCount { get; set; }
public bool HasUnreadMessages { get; set; }
public bool RequiresResponse { get; set; }
public List<CustomerMessageDto> Messages { get; set; } = new();
}

View File

@@ -6,8 +6,12 @@ namespace LittleShop.DTOs;
public class OrderDto
{
public Guid Id { get; set; }
public string IdentityReference { get; set; } = string.Empty;
public Guid? CustomerId { get; set; }
public string? IdentityReference { get; set; }
public OrderStatus Status { get; set; }
// Customer Information (embedded for convenience)
public CustomerSummaryDto? Customer { get; set; }
public decimal TotalAmount { get; set; }
public string Currency { get; set; } = "GBP";
public string ShippingName { get; set; } = string.Empty;
@@ -37,8 +41,13 @@ public class OrderItemDto
public class CreateOrderDto
{
[Required]
public string IdentityReference { get; set; } = string.Empty;
// Either Customer ID (for registered customers) OR Identity Reference (for anonymous)
public Guid? CustomerId { get; set; }
public string? IdentityReference { get; set; }
// Customer Information (collected at checkout for anonymous orders)
public CreateCustomerDto? CustomerInfo { get; set; }
[Required]
public string ShippingName { get; set; } = string.Empty;

View File

@@ -0,0 +1,12 @@
namespace LittleShop.DTOs;
public class PagedResult<T>
{
public List<T> Items { get; set; } = new();
public int TotalCount { get; set; }
public int PageNumber { get; set; }
public int PageSize { get; set; }
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
public bool HasPrevious => PageNumber > 1;
public bool HasNext => PageNumber < TotalPages;
}

View File

@@ -17,6 +17,11 @@ public class LittleShopContext : DbContext
public DbSet<OrderItem> OrderItems { get; set; }
public DbSet<CryptoPayment> CryptoPayments { get; set; }
public DbSet<ShippingRate> ShippingRates { get; set; }
public DbSet<Bot> Bots { get; set; }
public DbSet<BotMetric> BotMetrics { get; set; }
public DbSet<BotSession> BotSessions { get; set; }
public DbSet<Customer> Customers { get; set; }
public DbSet<CustomerMessage> CustomerMessages { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -54,6 +59,12 @@ public class LittleShopContext : DbContext
// Order entity
modelBuilder.Entity<Order>(entity =>
{
entity.HasOne(o => o.Customer)
.WithMany(c => c.Orders)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict)
.IsRequired(false); // Make customer optional for transition
entity.HasMany(o => o.Items)
.WithOne(oi => oi.Order)
.HasForeignKey(oi => oi.OrderId)
@@ -64,7 +75,9 @@ public class LittleShopContext : DbContext
.HasForeignKey(cp => cp.OrderId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(e => e.CustomerId);
entity.HasIndex(e => e.IdentityReference);
entity.HasIndex(e => e.CreatedAt);
});
// OrderItem entity
@@ -79,5 +92,92 @@ public class LittleShopContext : DbContext
entity.HasIndex(e => e.BTCPayInvoiceId);
entity.HasIndex(e => e.WalletAddress);
});
// Bot entity
modelBuilder.Entity<Bot>(entity =>
{
entity.HasIndex(e => e.BotKey).IsUnique();
entity.HasIndex(e => e.Name);
entity.HasIndex(e => e.Status);
entity.HasMany(b => b.Metrics)
.WithOne(m => m.Bot)
.HasForeignKey(m => m.BotId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(b => b.Sessions)
.WithOne(s => s.Bot)
.HasForeignKey(s => s.BotId)
.OnDelete(DeleteBehavior.Cascade);
});
// BotMetric entity
modelBuilder.Entity<BotMetric>(entity =>
{
entity.HasIndex(e => new { e.BotId, e.RecordedAt });
entity.HasIndex(e => e.MetricType);
});
// BotSession entity
modelBuilder.Entity<BotSession>(entity =>
{
entity.HasIndex(e => new { e.BotId, e.SessionIdentifier });
entity.HasIndex(e => e.StartedAt);
entity.HasIndex(e => e.LastActivityAt);
});
// Customer entity
modelBuilder.Entity<Customer>(entity =>
{
entity.HasIndex(e => e.TelegramUserId).IsUnique();
entity.HasIndex(e => e.TelegramUsername);
entity.HasIndex(e => e.Email);
entity.HasIndex(e => e.CreatedAt);
entity.HasIndex(e => e.LastActiveAt);
entity.HasIndex(e => e.DataRetentionDate);
entity.HasMany(c => c.Orders)
.WithOne(o => o.Customer)
.HasForeignKey(o => o.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasMany(c => c.Messages)
.WithOne(m => m.Customer)
.HasForeignKey(m => m.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
});
// CustomerMessage entity
modelBuilder.Entity<CustomerMessage>(entity =>
{
entity.HasOne(m => m.Customer)
.WithMany(c => c.Messages)
.HasForeignKey(m => m.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(m => m.Order)
.WithMany()
.HasForeignKey(m => m.OrderId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(m => m.AdminUser)
.WithMany()
.HasForeignKey(m => m.AdminUserId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(m => m.ParentMessage)
.WithMany(m => m.Replies)
.HasForeignKey(m => m.ParentMessageId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasIndex(e => new { e.CustomerId, e.CreatedAt });
entity.HasIndex(e => e.OrderId);
entity.HasIndex(e => e.Status);
entity.HasIndex(e => e.Direction);
entity.HasIndex(e => e.Type);
entity.HasIndex(e => e.ScheduledFor);
entity.HasIndex(e => e.ThreadId);
entity.HasIndex(e => e.ParentMessageId);
});
}
}

38
LittleShop/Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Copy project file and restore dependencies
COPY LittleShop.csproj .
RUN dotnet restore
# Copy all source files and build
COPY . .
RUN dotnet publish -c Release -o /app/publish
# Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
# Install curl for health checks
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copy published app
COPY --from=build /app/publish .
# Create directories for uploads and database
RUN mkdir -p /app/data /app/wwwroot/uploads/products /app/logs
# Set environment variables
ENV ASPNETCORE_URLS=http://+:5000
ENV ASPNETCORE_ENVIRONMENT=Production
# Expose port
EXPOSE 5000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:5000/api/test || exit 1
# Run the application
ENTRYPOINT ["dotnet", "LittleShop.dll"]

View File

@@ -0,0 +1,11 @@
namespace LittleShop.Enums;
public enum BotStatus
{
Pending = 0,
Active = 1,
Inactive = 2,
Suspended = 3,
Maintenance = 4,
Deleted = 5
}

View File

@@ -0,0 +1,12 @@
namespace LittleShop.Enums;
public enum BotType
{
Telegram = 0,
Discord = 1,
WhatsApp = 2,
Signal = 3,
Matrix = 4,
IRC = 5,
Custom = 99
}

View File

@@ -0,0 +1,21 @@
namespace LittleShop.Enums;
public enum MetricType
{
UserContact = 0,
NewSession = 1,
Order = 2,
Payment = 3,
Message = 4,
Command = 5,
Error = 6,
ApiCall = 7,
CacheHit = 8,
CacheMiss = 9,
ResponseTime = 10,
Uptime = 11,
Revenue = 12,
CartAbandoned = 13,
ProductView = 14,
CategoryBrowse = 15
}

View File

@@ -43,5 +43,35 @@ public class MappingProfile : Profile
.ForMember(dest => dest.ProductName, opt => opt.MapFrom(src => src.Product.Name));
CreateMap<CryptoPayment, CryptoPaymentDto>();
// Customer mappings
CreateMap<Customer, CustomerDto>()
.ForMember(dest => dest.DisplayName, opt => opt.Ignore())
.ForMember(dest => dest.CustomerType, opt => opt.Ignore());
CreateMap<CreateCustomerDto, Customer>();
CreateMap<UpdateCustomerDto, Customer>()
.ForAllMembers(opt => opt.Condition((src, dest, srcMember) => srcMember != null));
CreateMap<Customer, CustomerSummaryDto>()
.ForMember(dest => dest.DisplayName, opt => opt.MapFrom(src => src.DisplayName))
.ForMember(dest => dest.CustomerType, opt => opt.MapFrom(src => src.CustomerType));
// CustomerMessage mappings
CreateMap<CustomerMessage, CustomerMessageDto>()
.ForMember(dest => dest.Customer, opt => opt.MapFrom(src => src.Customer))
.ForMember(dest => dest.AdminUsername, opt => opt.MapFrom(src => src.AdminUser != null ? src.AdminUser.Username : null))
.ForMember(dest => dest.OrderReference, opt => opt.MapFrom(src => src.Order != null ? src.Order.Id.ToString().Substring(0, 8) : null))
.ForMember(dest => dest.TelegramUserId, opt => opt.MapFrom(src => src.Customer != null ? src.Customer.TelegramUserId : 0))
.ForMember(dest => dest.DisplayTitle, opt => opt.MapFrom(src => src.GetDisplayTitle()))
.ForMember(dest => dest.StatusDisplay, opt => opt.MapFrom(src => src.Status.ToString()))
.ForMember(dest => dest.DirectionDisplay, opt => opt.MapFrom(src => src.Direction == MessageDirection.AdminToCustomer ? "Outbound" : "Inbound"))
.ForMember(dest => dest.TypeDisplay, opt => opt.MapFrom(src => src.Type.ToString()));
CreateMap<CreateCustomerMessageDto, CustomerMessage>()
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => Guid.NewGuid()))
.ForMember(dest => dest.Direction, opt => opt.MapFrom(src => MessageDirection.AdminToCustomer))
.ForMember(dest => dest.CreatedAt, opt => opt.MapFrom(src => DateTime.UtcNow))
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => MessageStatus.Pending))
.ForMember(dest => dest.Platform, opt => opt.MapFrom(src => "Telegram"));
}
}

58
LittleShop/Models/Bot.cs Normal file
View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using LittleShop.Enums;
namespace LittleShop.Models;
public class Bot
{
public Guid Id { get; set; }
[Required]
[StringLength(256)]
public string BotKey { get; set; } = string.Empty;
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[StringLength(500)]
public string Description { get; set; } = string.Empty;
public BotType Type { get; set; }
public BotStatus Status { get; set; }
public string Settings { get; set; } = "{}"; // JSON storage
public DateTime CreatedAt { get; set; }
public DateTime? LastSeenAt { get; set; }
public DateTime? LastConfigSyncAt { get; set; }
public bool IsActive { get; set; }
[StringLength(50)]
public string Version { get; set; } = string.Empty;
[StringLength(50)]
public string IpAddress { get; set; } = string.Empty;
[StringLength(100)]
public string PlatformUsername { get; set; } = string.Empty;
[StringLength(200)]
public string PlatformDisplayName { get; set; } = string.Empty;
[StringLength(100)]
public string PlatformId { get; set; } = string.Empty;
[StringLength(50)]
public string PersonalityName { get; set; } = string.Empty;
// Navigation properties
public virtual ICollection<BotMetric> Metrics { get; set; } = new List<BotMetric>();
public virtual ICollection<BotSession> Sessions { get; set; } = new List<BotSession>();
}

View File

@@ -0,0 +1,30 @@
using System;
using System.ComponentModel.DataAnnotations;
using LittleShop.Enums;
namespace LittleShop.Models;
public class BotMetric
{
public Guid Id { get; set; }
[Required]
public Guid BotId { get; set; }
public MetricType MetricType { get; set; }
public decimal Value { get; set; }
public string Metadata { get; set; } = "{}"; // JSON storage for additional data
public DateTime RecordedAt { get; set; }
[StringLength(100)]
public string Category { get; set; } = string.Empty;
[StringLength(500)]
public string Description { get; set; } = string.Empty;
// Navigation property
public virtual Bot Bot { get; set; } = null!;
}

View File

@@ -0,0 +1,44 @@
using System;
using System.ComponentModel.DataAnnotations;
namespace LittleShop.Models;
public class BotSession
{
public Guid Id { get; set; }
[Required]
public Guid BotId { get; set; }
[Required]
[StringLength(256)]
public string SessionIdentifier { get; set; } = string.Empty; // Hashed user ID
[StringLength(100)]
public string Platform { get; set; } = string.Empty; // Telegram, Discord, etc.
public DateTime StartedAt { get; set; }
public DateTime LastActivityAt { get; set; }
public DateTime? EndedAt { get; set; }
public int OrderCount { get; set; }
public int MessageCount { get; set; }
public decimal TotalSpent { get; set; }
[StringLength(50)]
public string Language { get; set; } = "en";
[StringLength(100)]
public string Country { get; set; } = string.Empty;
public bool IsAnonymous { get; set; }
public string Metadata { get; set; } = "{}"; // JSON for additional session data
// Navigation property
public virtual Bot Bot { get; set; } = null!;
}

View File

@@ -0,0 +1,148 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace LittleShop.Models;
public class Customer
{
[Key]
public Guid Id { get; set; }
// Telegram Information
[Required]
public long TelegramUserId { get; set; }
[StringLength(100)]
public string TelegramUsername { get; set; } = string.Empty;
[Required]
[StringLength(200)]
public string TelegramDisplayName { get; set; } = string.Empty;
[StringLength(50)]
public string TelegramFirstName { get; set; } = string.Empty;
[StringLength(50)]
public string TelegramLastName { get; set; } = string.Empty;
// Optional Contact Information (if customer provides)
[StringLength(100)]
public string? Email { get; set; }
[StringLength(20)]
public string? PhoneNumber { get; set; }
// Customer Preferences
public bool AllowMarketing { get; set; } = false;
public bool AllowOrderUpdates { get; set; } = true;
[StringLength(10)]
public string Language { get; set; } = "en";
[StringLength(10)]
public string Timezone { get; set; } = "UTC";
// Customer Metrics
public int TotalOrders { get; set; } = 0;
[Column(TypeName = "decimal(18,2)")]
public decimal TotalSpent { get; set; } = 0;
[Column(TypeName = "decimal(18,2)")]
public decimal AverageOrderValue { get; set; } = 0;
public DateTime FirstOrderDate { get; set; }
public DateTime LastOrderDate { get; set; }
// Customer Service Notes
[StringLength(2000)]
public string? CustomerNotes { get; set; }
public bool IsBlocked { get; set; } = false;
[StringLength(500)]
public string? BlockReason { get; set; }
// Risk Assessment
public int RiskScore { get; set; } = 0; // 0-100, 0 = trusted, 100 = high risk
public int SuccessfulOrders { get; set; } = 0;
public int CancelledOrders { get; set; } = 0;
public int DisputedOrders { get; set; } = 0;
// Data Management
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastActiveAt { get; set; } = DateTime.UtcNow;
public DateTime? DataRetentionDate { get; set; } // When to delete this customer's data
public bool IsActive { get; set; } = true;
// Navigation properties
public virtual ICollection<Order> Orders { get; set; } = new List<Order>();
public virtual ICollection<CustomerMessage> Messages { get; set; } = new List<CustomerMessage>();
// Calculated Properties
public string DisplayName =>
!string.IsNullOrEmpty(TelegramDisplayName) ? TelegramDisplayName :
!string.IsNullOrEmpty(TelegramUsername) ? $"@{TelegramUsername}" :
$"{TelegramFirstName} {TelegramLastName}".Trim();
public string CustomerType =>
TotalOrders == 0 ? "New" :
TotalOrders == 1 ? "First-time" :
TotalOrders < 5 ? "Regular" :
TotalOrders < 20 ? "Loyal" : "VIP";
public void UpdateMetrics()
{
if (Orders?.Any() == true)
{
TotalOrders = Orders.Count();
TotalSpent = Orders.Sum(o => o.TotalAmount);
AverageOrderValue = TotalOrders > 0 ? TotalSpent / TotalOrders : 0;
FirstOrderDate = Orders.Min(o => o.CreatedAt);
LastOrderDate = Orders.Max(o => o.CreatedAt);
SuccessfulOrders = Orders.Count(o => o.Status == Enums.OrderStatus.Delivered);
CancelledOrders = Orders.Count(o => o.Status == Enums.OrderStatus.Cancelled);
}
UpdatedAt = DateTime.UtcNow;
// Update risk score based on order history
CalculateRiskScore();
}
private void CalculateRiskScore()
{
int score = 0;
// New customers have moderate risk
if (TotalOrders == 0) score += 30;
// High cancellation rate increases risk
if (TotalOrders > 0)
{
var cancellationRate = (double)CancelledOrders / TotalOrders;
if (cancellationRate > 0.5) score += 40;
else if (cancellationRate > 0.3) score += 20;
}
// Disputes increase risk significantly
score += DisputedOrders * 25;
// Long-term customers with good history reduce risk
if (TotalOrders > 10 && SuccessfulOrders > 8) score -= 20;
if (TotalSpent > 500) score -= 10;
// Clamp between 0-100
RiskScore = Math.Max(0, Math.Min(100, score));
}
}

View File

@@ -0,0 +1,185 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace LittleShop.Models;
public enum MessageDirection
{
AdminToCustomer = 0,
CustomerToAdmin = 1
}
public enum MessageStatus
{
Pending = 0, // Message created, waiting to be sent
Sent = 1, // Message delivered to customer
Delivered = 2, // Message acknowledged by bot/platform
Read = 3, // Customer has read the message
Failed = 4 // Delivery failed
}
public enum MessageType
{
OrderUpdate = 0, // Status update about an order
PaymentReminder = 1, // Payment reminder
ShippingInfo = 2, // Tracking/shipping information
CustomerService = 3, // General customer service
Marketing = 4, // Marketing/promotional (requires consent)
SystemAlert = 5 // System-generated alerts
}
public class CustomerMessage
{
[Key]
public Guid Id { get; set; }
// Relationships
[Required]
public Guid CustomerId { get; set; }
public Guid? OrderId { get; set; } // Optional - message may not be about specific order
public Guid? AdminUserId { get; set; } // Which admin sent the message (null for system messages or unidentified admins)
// Message Details
[Required]
public MessageDirection Direction { get; set; }
[Required]
public MessageType Type { get; set; }
[Required]
[StringLength(100)]
public string Subject { get; set; } = string.Empty;
[Required]
[StringLength(4000)] // Telegram message limit is ~4096 characters
public string Content { get; set; } = string.Empty;
public MessageStatus Status { get; set; } = MessageStatus.Pending;
// Delivery Information
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? SentAt { get; set; }
public DateTime? DeliveredAt { get; set; }
public DateTime? ReadAt { get; set; }
public DateTime? FailedAt { get; set; }
[StringLength(500)]
public string? FailureReason { get; set; }
public int RetryCount { get; set; } = 0;
public DateTime? NextRetryAt { get; set; }
// Message Threading (for conversations)
public Guid? ParentMessageId { get; set; } // Reply to another message
public Guid? ThreadId { get; set; } // Conversation thread identifier
// Platform-specific Information
[StringLength(100)]
public string Platform { get; set; } = "Telegram"; // Telegram, Email, SMS, etc.
[StringLength(200)]
public string? PlatformMessageId { get; set; } // Telegram message ID, email message ID, etc.
// Priority and Scheduling
public int Priority { get; set; } = 5; // 1-10, 1 = highest priority
public DateTime? ScheduledFor { get; set; } // For scheduled messages
public DateTime? ExpiresAt { get; set; } // Message expires if not delivered
// Customer Preferences
public bool RequiresResponse { get; set; } = false;
public bool IsUrgent { get; set; } = false;
public bool IsMarketing { get; set; } = false;
// Auto-generated flags
public bool IsAutoGenerated { get; set; } = false;
[StringLength(100)]
public string? AutoGenerationTrigger { get; set; } // e.g., "OrderStatusChanged", "PaymentOverdue"
// Data retention
public bool IsArchived { get; set; } = false;
public DateTime? ArchivedAt { get; set; }
// Navigation properties
public virtual Customer Customer { get; set; } = null!;
public virtual Order? Order { get; set; }
public virtual User? AdminUser { get; set; }
public virtual CustomerMessage? ParentMessage { get; set; }
public virtual ICollection<CustomerMessage> Replies { get; set; } = new List<CustomerMessage>();
// Helper methods
public bool CanRetry()
{
return Status == MessageStatus.Failed &&
RetryCount < 3 &&
(NextRetryAt == null || DateTime.UtcNow >= NextRetryAt) &&
(ExpiresAt == null || DateTime.UtcNow < ExpiresAt);
}
public void MarkAsSent()
{
Status = MessageStatus.Sent;
SentAt = DateTime.UtcNow;
}
public void MarkAsDelivered(string? platformMessageId = null)
{
Status = MessageStatus.Delivered;
DeliveredAt = DateTime.UtcNow;
if (!string.IsNullOrEmpty(platformMessageId))
{
PlatformMessageId = platformMessageId;
}
}
public void MarkAsRead()
{
Status = MessageStatus.Read;
ReadAt = DateTime.UtcNow;
}
public void MarkAsFailed(string reason)
{
Status = MessageStatus.Failed;
FailedAt = DateTime.UtcNow;
FailureReason = reason;
RetryCount++;
// Exponential backoff for retries
if (RetryCount <= 3)
{
var delayMinutes = Math.Pow(2, RetryCount) * 5; // 10, 20, 40 minutes
NextRetryAt = DateTime.UtcNow.AddMinutes(delayMinutes);
}
}
public string GetDisplayTitle()
{
var prefix = Direction == MessageDirection.AdminToCustomer ? "→" : "←";
var typeStr = Type switch
{
MessageType.OrderUpdate => "Order Update",
MessageType.PaymentReminder => "Payment",
MessageType.ShippingInfo => "Shipping",
MessageType.CustomerService => "Support",
MessageType.Marketing => "Marketing",
MessageType.SystemAlert => "System",
_ => "Message"
};
return $"{prefix} {typeStr}: {Subject}";
}
}

View File

@@ -9,9 +9,12 @@ public class Order
[Key]
public Guid Id { get; set; }
[Required]
// Customer Information (nullable for transition period)
public Guid? CustomerId { get; set; }
// Legacy identity reference (still used for anonymous orders)
[StringLength(100)]
public string IdentityReference { get; set; } = string.Empty;
public string? IdentityReference { get; set; }
public OrderStatus Status { get; set; } = OrderStatus.PendingPayment;
@@ -57,6 +60,7 @@ public class Order
public DateTime? ShippedAt { get; set; }
// Navigation properties
public virtual Customer? Customer { get; set; }
public virtual ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
public virtual ICollection<CryptoPayment> Payments { get; set; } = new List<CryptoPayment>();
}

View File

@@ -66,6 +66,12 @@ builder.Services.AddScoped<ICryptoPaymentService, CryptoPaymentService>();
builder.Services.AddScoped<IBTCPayServerService, BTCPayServerService>();
builder.Services.AddScoped<IShippingRateService, ShippingRateService>();
builder.Services.AddScoped<IDataSeederService, DataSeederService>();
builder.Services.AddScoped<IBotService, BotService>();
builder.Services.AddScoped<IBotMetricsService, BotMetricsService>();
builder.Services.AddScoped<ICustomerService, CustomerService>();
builder.Services.AddScoped<ICustomerMessageService, CustomerMessageService>();
builder.Services.AddSingleton<ITelegramBotManagerService, TelegramBotManagerService>();
builder.Services.AddHostedService<TelegramBotManagerService>();
// AutoMapper
builder.Services.AddAutoMapper(typeof(Program));

View File

@@ -0,0 +1,382 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using LittleShop.Data;
using LittleShop.DTOs;
using LittleShop.Models;
namespace LittleShop.Services;
public class BotMetricsService : IBotMetricsService
{
private readonly LittleShopContext _context;
private readonly ILogger<BotMetricsService> _logger;
public BotMetricsService(LittleShopContext context, ILogger<BotMetricsService> logger)
{
_context = context;
_logger = logger;
}
// Metrics Methods
public async Task<BotMetricDto> RecordMetricAsync(Guid botId, CreateBotMetricDto dto)
{
var metric = new BotMetric
{
Id = Guid.NewGuid(),
BotId = botId,
MetricType = dto.MetricType,
Value = dto.Value,
Category = dto.Category,
Description = dto.Description,
Metadata = JsonSerializer.Serialize(dto.Metadata),
RecordedAt = DateTime.UtcNow
};
_context.BotMetrics.Add(metric);
await _context.SaveChangesAsync();
return MapMetricToDto(metric);
}
public async Task<bool> RecordMetricsBatchAsync(Guid botId, BotMetricsBatchDto dto)
{
try
{
var metrics = dto.Metrics.Select(m => new BotMetric
{
Id = Guid.NewGuid(),
BotId = botId,
MetricType = m.MetricType,
Value = m.Value,
Category = m.Category,
Description = m.Description,
Metadata = JsonSerializer.Serialize(m.Metadata),
RecordedAt = DateTime.UtcNow
}).ToList();
_context.BotMetrics.AddRange(metrics);
await _context.SaveChangesAsync();
_logger.LogInformation("Recorded {Count} metrics for bot {BotId}", metrics.Count, botId);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to record metrics batch for bot {BotId}", botId);
return false;
}
}
public async Task<IEnumerable<BotMetricDto>> GetBotMetricsAsync(Guid botId, DateTime? startDate = null, DateTime? endDate = null)
{
var query = _context.BotMetrics.Where(m => m.BotId == botId);
if (startDate.HasValue)
query = query.Where(m => m.RecordedAt >= startDate.Value);
if (endDate.HasValue)
query = query.Where(m => m.RecordedAt <= endDate.Value);
var metrics = await query
.OrderByDescending(m => m.RecordedAt)
.Take(1000) // Limit to prevent large results
.ToListAsync();
return metrics.Select(MapMetricToDto);
}
public async Task<BotMetricsSummaryDto> GetMetricsSummaryAsync(Guid botId, DateTime? startDate = null, DateTime? endDate = null)
{
var bot = await _context.Bots.FindAsync(botId);
if (bot == null)
return new BotMetricsSummaryDto { BotId = botId };
var start = startDate ?? DateTime.UtcNow.AddDays(-30);
var end = endDate ?? DateTime.UtcNow;
var metrics = await _context.BotMetrics
.Where(m => m.BotId == botId && m.RecordedAt >= start && m.RecordedAt <= end)
.ToListAsync();
var sessions = await _context.BotSessions
.Where(s => s.BotId == botId && s.StartedAt >= start && s.StartedAt <= end)
.ToListAsync();
var summary = new BotMetricsSummaryDto
{
BotId = botId,
BotName = bot.Name,
PeriodStart = start,
PeriodEnd = end,
TotalSessions = sessions.Count,
UniqueSessions = sessions.Select(s => s.SessionIdentifier).Distinct().Count(),
TotalOrders = sessions.Sum(s => s.OrderCount),
TotalRevenue = sessions.Sum(s => s.TotalSpent),
TotalMessages = sessions.Sum(s => s.MessageCount),
TotalErrors = (int)metrics.Where(m => m.MetricType == Enums.MetricType.Error).Sum(m => m.Value)
};
// Calculate average response time
var responseTimeMetrics = metrics.Where(m => m.MetricType == Enums.MetricType.ResponseTime).ToList();
if (responseTimeMetrics.Any())
summary.AverageResponseTime = responseTimeMetrics.Average(m => m.Value);
// Calculate uptime percentage
var uptimeMetrics = metrics.Where(m => m.MetricType == Enums.MetricType.Uptime).ToList();
if (uptimeMetrics.Any())
{
var totalPossibleUptime = (end - start).TotalMinutes;
var actualUptime = uptimeMetrics.Sum(m => m.Value);
summary.UptimePercentage = (actualUptime / (decimal)totalPossibleUptime) * 100;
}
// Group metrics by type
summary.MetricsByType = metrics
.GroupBy(m => m.MetricType.ToString())
.ToDictionary(g => g.Key, g => g.Sum(m => m.Value));
// Generate time series data (daily aggregation)
summary.TimeSeries = GenerateTimeSeries(metrics, start, end);
return summary;
}
// Session Methods
public async Task<BotSessionDto> StartSessionAsync(Guid botId, CreateBotSessionDto dto)
{
var session = new BotSession
{
Id = Guid.NewGuid(),
BotId = botId,
SessionIdentifier = dto.SessionIdentifier,
Platform = dto.Platform,
StartedAt = DateTime.UtcNow,
LastActivityAt = DateTime.UtcNow,
Language = dto.Language,
Country = dto.Country,
IsAnonymous = dto.IsAnonymous,
Metadata = JsonSerializer.Serialize(dto.Metadata),
OrderCount = 0,
MessageCount = 0,
TotalSpent = 0
};
_context.BotSessions.Add(session);
await _context.SaveChangesAsync();
_logger.LogInformation("Started session {SessionId} for bot {BotId}", session.Id, botId);
return MapSessionToDto(session);
}
public async Task<bool> UpdateSessionAsync(Guid sessionId, UpdateBotSessionDto dto)
{
var session = await _context.BotSessions.FindAsync(sessionId);
if (session == null)
return false;
session.LastActivityAt = DateTime.UtcNow;
if (dto.OrderCount.HasValue)
session.OrderCount = dto.OrderCount.Value;
if (dto.MessageCount.HasValue)
session.MessageCount = dto.MessageCount.Value;
if (dto.TotalSpent.HasValue)
session.TotalSpent = dto.TotalSpent.Value;
if (dto.EndSession.HasValue && dto.EndSession.Value)
session.EndedAt = DateTime.UtcNow;
if (dto.Metadata != null)
session.Metadata = JsonSerializer.Serialize(dto.Metadata);
await _context.SaveChangesAsync();
return true;
}
public async Task<BotSessionDto?> GetSessionAsync(Guid sessionId)
{
var session = await _context.BotSessions.FindAsync(sessionId);
return session != null ? MapSessionToDto(session) : null;
}
public async Task<IEnumerable<BotSessionDto>> GetBotSessionsAsync(Guid botId, bool activeOnly = false)
{
var query = _context.BotSessions.Where(s => s.BotId == botId);
if (activeOnly)
query = query.Where(s => !s.EndedAt.HasValue);
var sessions = await query
.OrderByDescending(s => s.StartedAt)
.Take(100)
.ToListAsync();
return sessions.Select(MapSessionToDto);
}
public async Task<BotSessionSummaryDto> GetSessionSummaryAsync(Guid botId, DateTime? startDate = null, DateTime? endDate = null)
{
var query = _context.BotSessions.Where(s => s.BotId == botId);
if (startDate.HasValue)
query = query.Where(s => s.StartedAt >= startDate.Value);
if (endDate.HasValue)
query = query.Where(s => s.StartedAt <= endDate.Value);
var sessions = await query.ToListAsync();
var summary = new BotSessionSummaryDto
{
TotalSessions = sessions.Count,
ActiveSessions = sessions.Count(s => !s.EndedAt.HasValue),
CompletedSessions = sessions.Count(s => s.EndedAt.HasValue)
};
if (sessions.Any())
{
var completedSessions = sessions.Where(s => s.EndedAt.HasValue).ToList();
if (completedSessions.Any())
{
summary.AverageSessionDuration = (decimal)completedSessions
.Average(s => (s.EndedAt!.Value - s.StartedAt).TotalMinutes);
}
summary.AverageOrdersPerSession = (decimal)sessions.Average(s => s.OrderCount);
summary.AverageSpendPerSession = sessions.Average(s => s.TotalSpent);
summary.SessionsByPlatform = sessions
.GroupBy(s => s.Platform)
.ToDictionary(g => g.Key, g => g.Count());
summary.SessionsByCountry = sessions
.Where(s => !string.IsNullOrEmpty(s.Country))
.GroupBy(s => s.Country)
.ToDictionary(g => g.Key, g => g.Count());
summary.SessionsByLanguage = sessions
.GroupBy(s => s.Language)
.ToDictionary(g => g.Key, g => g.Count());
}
return summary;
}
public async Task<bool> EndSessionAsync(Guid sessionId)
{
var session = await _context.BotSessions.FindAsync(sessionId);
if (session == null || session.EndedAt.HasValue)
return false;
session.EndedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Ended session {SessionId}", sessionId);
return true;
}
public async Task<int> CleanupInactiveSessionsAsync(int inactiveMinutes = 30)
{
var cutoffTime = DateTime.UtcNow.AddMinutes(-inactiveMinutes);
var inactiveSessions = await _context.BotSessions
.Where(s => !s.EndedAt.HasValue && s.LastActivityAt < cutoffTime)
.ToListAsync();
foreach (var session in inactiveSessions)
{
session.EndedAt = DateTime.UtcNow;
}
await _context.SaveChangesAsync();
_logger.LogInformation("Cleaned up {Count} inactive sessions", inactiveSessions.Count);
return inactiveSessions.Count;
}
// Helper Methods
private BotMetricDto MapMetricToDto(BotMetric metric)
{
var metadata = new Dictionary<string, object>();
try
{
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(metric.Metadata)
?? new Dictionary<string, object>();
}
catch { }
return new BotMetricDto
{
Id = metric.Id,
BotId = metric.BotId,
MetricType = metric.MetricType,
Value = metric.Value,
Category = metric.Category,
Description = metric.Description,
RecordedAt = metric.RecordedAt,
Metadata = metadata
};
}
private BotSessionDto MapSessionToDto(BotSession session)
{
var metadata = new Dictionary<string, object>();
try
{
metadata = JsonSerializer.Deserialize<Dictionary<string, object>>(session.Metadata)
?? new Dictionary<string, object>();
}
catch { }
return new BotSessionDto
{
Id = session.Id,
BotId = session.BotId,
SessionIdentifier = session.SessionIdentifier,
Platform = session.Platform,
StartedAt = session.StartedAt,
LastActivityAt = session.LastActivityAt,
EndedAt = session.EndedAt,
OrderCount = session.OrderCount,
MessageCount = session.MessageCount,
TotalSpent = session.TotalSpent,
Language = session.Language,
Country = session.Country,
IsAnonymous = session.IsAnonymous,
Metadata = metadata
};
}
private List<TimeSeriesDataPoint> GenerateTimeSeries(List<BotMetric> metrics, DateTime start, DateTime end)
{
var dataPoints = new List<TimeSeriesDataPoint>();
var currentDate = start.Date;
while (currentDate <= end.Date)
{
var dayMetrics = metrics.Where(m => m.RecordedAt.Date == currentDate).ToList();
if (dayMetrics.Any())
{
dataPoints.Add(new TimeSeriesDataPoint
{
Timestamp = currentDate,
Label = currentDate.ToString("yyyy-MM-dd"),
Value = dayMetrics.Sum(m => m.Value)
});
}
currentDate = currentDate.AddDays(1);
}
return dataPoints;
}
}

View File

@@ -0,0 +1,330 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using LittleShop.Data;
using LittleShop.DTOs;
using LittleShop.Enums;
using LittleShop.Models;
namespace LittleShop.Services;
public class BotService : IBotService
{
private readonly LittleShopContext _context;
private readonly ILogger<BotService> _logger;
public BotService(LittleShopContext context, ILogger<BotService> logger)
{
_context = context;
_logger = logger;
}
public async Task<BotRegistrationResponseDto> RegisterBotAsync(BotRegistrationDto dto)
{
_logger.LogInformation("Registering new bot: {BotName}", dto.Name);
var botKey = await GenerateBotKeyAsync();
var bot = new Bot
{
Id = Guid.NewGuid(),
Name = dto.Name,
Description = dto.Description,
Type = dto.Type,
BotKey = botKey,
Status = BotStatus.Active,
Settings = JsonSerializer.Serialize(dto.InitialSettings),
Version = dto.Version,
PersonalityName = string.IsNullOrEmpty(dto.PersonalityName) ? AssignDefaultPersonality(dto.Name) : dto.PersonalityName,
CreatedAt = DateTime.UtcNow,
IsActive = true
};
_context.Bots.Add(bot);
await _context.SaveChangesAsync();
_logger.LogInformation("Bot registered successfully: {BotId}", bot.Id);
return new BotRegistrationResponseDto
{
BotId = bot.Id,
BotKey = botKey,
Name = bot.Name,
Settings = dto.InitialSettings
};
}
public async Task<BotDto?> AuthenticateBotAsync(string botKey)
{
var bot = await _context.Bots
.Include(b => b.Sessions)
.Include(b => b.Metrics)
.FirstOrDefaultAsync(b => b.BotKey == botKey && b.Status == BotStatus.Active);
if (bot == null)
{
_logger.LogWarning("Authentication failed for bot key: {BotKey}", botKey.Substring(0, 8) + "...");
return null;
}
// Update last seen
bot.LastSeenAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
return MapToDto(bot);
}
public async Task<BotDto?> GetBotByIdAsync(Guid id)
{
var bot = await _context.Bots
.Include(b => b.Sessions)
.Include(b => b.Metrics)
.FirstOrDefaultAsync(b => b.Id == id);
return bot != null ? MapToDto(bot) : null;
}
public async Task<BotDto?> GetBotByKeyAsync(string botKey)
{
var bot = await _context.Bots
.Include(b => b.Sessions)
.Include(b => b.Metrics)
.FirstOrDefaultAsync(b => b.BotKey == botKey);
return bot != null ? MapToDto(bot) : null;
}
public async Task<IEnumerable<BotDto>> GetAllBotsAsync()
{
try
{
var bots = await _context.Bots
.Include(b => b.Sessions)
.Include(b => b.Metrics)
.OrderByDescending(b => b.CreatedAt)
.ToListAsync();
return bots.Select(MapToDto);
}
catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.Message.Contains("no such table"))
{
// Tables don't exist yet - return empty list
return new List<BotDto>();
}
}
public async Task<IEnumerable<BotDto>> GetActiveBots()
{
try
{
var bots = await _context.Bots
.Include(b => b.Sessions)
.Include(b => b.Metrics)
.Where(b => b.Status == BotStatus.Active && b.IsActive)
.OrderByDescending(b => b.LastSeenAt)
.ToListAsync();
return bots.Select(MapToDto);
}
catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.Message.Contains("no such table"))
{
// Tables don't exist yet - return empty list
return new List<BotDto>();
}
}
public async Task<bool> UpdateBotSettingsAsync(Guid botId, UpdateBotSettingsDto dto)
{
var bot = await _context.Bots.FindAsync(botId);
if (bot == null)
return false;
bot.Settings = JsonSerializer.Serialize(dto.Settings);
bot.LastConfigSyncAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Updated settings for bot {BotId}", botId);
return true;
}
public async Task<bool> UpdateBotStatusAsync(Guid botId, BotStatus status)
{
var bot = await _context.Bots.FindAsync(botId);
if (bot == null)
return false;
var oldStatus = bot.Status;
bot.Status = status;
bot.IsActive = status == BotStatus.Active;
await _context.SaveChangesAsync();
_logger.LogInformation("Bot {BotId} status changed from {OldStatus} to {NewStatus}",
botId, oldStatus, status);
return true;
}
public async Task<bool> RecordHeartbeatAsync(Guid botId, BotHeartbeatDto dto)
{
var bot = await _context.Bots.FindAsync(botId);
if (bot == null)
return false;
bot.LastSeenAt = DateTime.UtcNow;
bot.Version = dto.Version;
bot.IpAddress = dto.IpAddress;
// Record uptime metric
var uptimeMetric = new BotMetric
{
Id = Guid.NewGuid(),
BotId = botId,
MetricType = MetricType.Uptime,
Value = 1,
Metadata = JsonSerializer.Serialize(dto.Status),
RecordedAt = DateTime.UtcNow,
Category = "System",
Description = "Heartbeat"
};
_context.BotMetrics.Add(uptimeMetric);
await _context.SaveChangesAsync();
return true;
}
public async Task<bool> DeleteBotAsync(Guid botId)
{
var bot = await _context.Bots.FindAsync(botId);
if (bot == null)
return false;
bot.Status = BotStatus.Deleted;
bot.IsActive = false;
await _context.SaveChangesAsync();
_logger.LogInformation("Bot {BotId} marked as deleted", botId);
return true;
}
public async Task<Dictionary<string, object>> GetBotSettingsAsync(Guid botId)
{
var bot = await _context.Bots.FindAsync(botId);
if (bot == null)
return new Dictionary<string, object>();
try
{
return JsonSerializer.Deserialize<Dictionary<string, object>>(bot.Settings)
?? new Dictionary<string, object>();
}
catch
{
return new Dictionary<string, object>();
}
}
public async Task<bool> ValidateBotKeyAsync(string botKey)
{
return await _context.Bots.AnyAsync(b => b.BotKey == botKey && b.Status == BotStatus.Active);
}
public Task<string> GenerateBotKeyAsync()
{
const string prefix = "bot_";
const int keyLength = 32;
using var rng = RandomNumberGenerator.Create();
var bytes = new byte[keyLength];
rng.GetBytes(bytes);
var key = prefix + Convert.ToBase64String(bytes)
.Replace("+", "")
.Replace("/", "")
.Replace("=", "")
.Substring(0, keyLength);
return Task.FromResult(key);
}
public async Task<bool> UpdatePlatformInfoAsync(Guid botId, UpdatePlatformInfoDto dto)
{
var bot = await _context.Bots.FindAsync(botId);
if (bot == null)
return false;
bot.PlatformUsername = dto.PlatformUsername;
bot.PlatformDisplayName = dto.PlatformDisplayName;
bot.PlatformId = dto.PlatformId;
await _context.SaveChangesAsync();
_logger.LogInformation("Updated platform info for bot {BotId}: @{Username} ({DisplayName})",
botId, dto.PlatformUsername, dto.PlatformDisplayName);
return true;
}
private BotDto MapToDto(Bot bot)
{
var settings = new Dictionary<string, object>();
try
{
settings = JsonSerializer.Deserialize<Dictionary<string, object>>(bot.Settings)
?? new Dictionary<string, object>();
}
catch { }
var activeSessions = bot.Sessions.Count(s => !s.EndedAt.HasValue);
var totalRevenue = bot.Sessions.Sum(s => s.TotalSpent);
var totalOrders = bot.Sessions.Sum(s => s.OrderCount);
return new BotDto
{
Id = bot.Id,
Name = bot.Name,
Description = bot.Description,
Type = bot.Type,
Status = bot.Status,
CreatedAt = bot.CreatedAt,
LastSeenAt = bot.LastSeenAt,
LastConfigSyncAt = bot.LastConfigSyncAt,
IsActive = bot.IsActive,
Version = bot.Version,
IpAddress = bot.IpAddress,
PlatformUsername = bot.PlatformUsername,
PlatformDisplayName = bot.PlatformDisplayName,
PlatformId = bot.PlatformId,
PersonalityName = bot.PersonalityName,
Settings = settings,
TotalSessions = bot.Sessions.Count,
ActiveSessions = activeSessions,
TotalRevenue = totalRevenue,
TotalOrders = totalOrders
};
}
private string AssignDefaultPersonality(string botName)
{
var personalities = new[] { "Alan", "Dave", "Sarah", "Mike", "Emma", "Tom" };
// Smart assignment based on name
var lowerName = botName.ToLower();
if (lowerName.Contains("alan")) return "Alan";
if (lowerName.Contains("dave")) return "Dave";
if (lowerName.Contains("sarah")) return "Sarah";
if (lowerName.Contains("mike")) return "Mike";
if (lowerName.Contains("emma")) return "Emma";
if (lowerName.Contains("tom")) return "Tom";
// Random assignment if no match
var random = new Random();
return personalities[random.Next(personalities.Length)];
}
}

View File

@@ -163,14 +163,18 @@ public class CryptoPaymentService : ICryptoPaymentService
private static string GenerateWalletAddress(CryptoCurrency currency)
{
// Placeholder wallet addresses - in production these would come from BTCPay Server
var guid = Guid.NewGuid().ToString("N"); // 32 characters
return currency switch
{
CryptoCurrency.BTC => "bc1q" + Guid.NewGuid().ToString("N")[..26],
CryptoCurrency.XMR => "4" + Guid.NewGuid().ToString("N")[..94],
CryptoCurrency.USDT => "0x" + Guid.NewGuid().ToString("N")[..38],
CryptoCurrency.LTC => "ltc1q" + Guid.NewGuid().ToString("N")[..26],
CryptoCurrency.ETH => "0x" + Guid.NewGuid().ToString("N")[..38],
_ => "placeholder_" + Guid.NewGuid().ToString("N")[..20]
CryptoCurrency.BTC => "bc1q" + guid[..26],
CryptoCurrency.XMR => "4" + guid + guid[..32], // XMR needs ~95 chars, use double GUID
CryptoCurrency.USDT => "0x" + guid[..32], // ERC-20 address
CryptoCurrency.LTC => "ltc1q" + guid[..26],
CryptoCurrency.ETH => "0x" + guid[..32],
CryptoCurrency.ZEC => "zs1" + guid + guid[..29], // Shielded address
CryptoCurrency.DASH => "X" + guid[..30],
CryptoCurrency.DOGE => "D" + guid[..30],
_ => "placeholder_" + guid[..20]
};
}
}

View File

@@ -0,0 +1,233 @@
using AutoMapper;
using Microsoft.EntityFrameworkCore;
using LittleShop.Data;
using LittleShop.DTOs;
using LittleShop.Models;
namespace LittleShop.Services;
public class CustomerMessageService : ICustomerMessageService
{
private readonly LittleShopContext _context;
private readonly IMapper _mapper;
private readonly ILogger<CustomerMessageService> _logger;
public CustomerMessageService(LittleShopContext context, IMapper mapper, ILogger<CustomerMessageService> logger)
{
_context = context;
_mapper = mapper;
_logger = logger;
}
public async Task<CustomerMessageDto?> CreateMessageAsync(CreateCustomerMessageDto createMessageDto)
{
try
{
var message = _mapper.Map<CustomerMessage>(createMessageDto);
message.Id = Guid.NewGuid();
message.Direction = MessageDirection.AdminToCustomer;
message.CreatedAt = DateTime.UtcNow;
message.Status = MessageStatus.Pending;
message.Platform = "Telegram";
// Generate thread ID if this is a new conversation
if (message.ParentMessageId == null)
{
message.ThreadId = Guid.NewGuid();
}
else
{
// Get parent message's thread ID
var parentMessage = await _context.CustomerMessages
.FirstOrDefaultAsync(m => m.Id == message.ParentMessageId);
message.ThreadId = parentMessage?.ThreadId ?? Guid.NewGuid();
}
_context.CustomerMessages.Add(message);
await _context.SaveChangesAsync();
_logger.LogInformation("Created message {MessageId} for customer {CustomerId}",
message.Id, message.CustomerId);
// Return the created message with includes
var createdMessage = await GetMessageByIdAsync(message.Id);
return createdMessage;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating message for customer {CustomerId}", createMessageDto.CustomerId);
return null;
}
}
public async Task<CustomerMessageDto?> GetMessageByIdAsync(Guid id)
{
var message = await _context.CustomerMessages
.Include(m => m.Customer)
.Include(m => m.Order)
.Include(m => m.AdminUser)
.Include(m => m.ParentMessage)
.Include(m => m.Replies)
.FirstOrDefaultAsync(m => m.Id == id);
if (message == null) return null;
return _mapper.Map<CustomerMessageDto>(message);
}
public async Task<IEnumerable<CustomerMessageDto>> GetCustomerMessagesAsync(Guid customerId)
{
var messages = await _context.CustomerMessages
.Include(m => m.Customer)
.Include(m => m.Order)
.Include(m => m.AdminUser)
.Where(m => m.CustomerId == customerId && !m.IsArchived)
.OrderByDescending(m => m.CreatedAt)
.ToListAsync();
return messages.Select(m => _mapper.Map<CustomerMessageDto>(m));
}
public async Task<IEnumerable<CustomerMessageDto>> GetOrderMessagesAsync(Guid orderId)
{
var messages = await _context.CustomerMessages
.Include(m => m.Customer)
.Include(m => m.Order)
.Include(m => m.AdminUser)
.Where(m => m.OrderId == orderId && !m.IsArchived)
.OrderByDescending(m => m.CreatedAt)
.ToListAsync();
return messages.Select(m => _mapper.Map<CustomerMessageDto>(m));
}
public async Task<IEnumerable<CustomerMessageDto>> GetPendingMessagesAsync(string platform = "Telegram")
{
var messages = await _context.CustomerMessages
.Include(m => m.Customer)
.Include(m => m.Order)
.Include(m => m.AdminUser)
.Where(m => m.Status == MessageStatus.Pending &&
m.Platform == platform &&
m.Direction == MessageDirection.AdminToCustomer &&
(m.ScheduledFor == null || m.ScheduledFor <= DateTime.UtcNow) &&
(m.ExpiresAt == null || m.ExpiresAt > DateTime.UtcNow))
.OrderBy(m => m.Priority)
.ThenBy(m => m.CreatedAt)
.Take(50) // Limit for performance
.ToListAsync();
return messages.Select(m => _mapper.Map<CustomerMessageDto>(m));
}
public async Task<bool> MarkMessageAsSentAsync(Guid messageId, string? platformMessageId = null)
{
var message = await _context.CustomerMessages.FindAsync(messageId);
if (message == null) return false;
message.MarkAsSent();
if (!string.IsNullOrEmpty(platformMessageId))
{
message.PlatformMessageId = platformMessageId;
}
await _context.SaveChangesAsync();
_logger.LogInformation("Marked message {MessageId} as sent", messageId);
return true;
}
public async Task<bool> MarkMessageAsDeliveredAsync(Guid messageId)
{
var message = await _context.CustomerMessages.FindAsync(messageId);
if (message == null) return false;
message.MarkAsDelivered();
await _context.SaveChangesAsync();
_logger.LogInformation("Marked message {MessageId} as delivered", messageId);
return true;
}
public async Task<bool> MarkMessageAsFailedAsync(Guid messageId, string reason)
{
var message = await _context.CustomerMessages.FindAsync(messageId);
if (message == null) return false;
message.MarkAsFailed(reason);
await _context.SaveChangesAsync();
_logger.LogWarning("Marked message {MessageId} as failed: {Reason}", messageId, reason);
return true;
}
public async Task<MessageThreadDto?> GetMessageThreadAsync(Guid threadId)
{
var messages = await _context.CustomerMessages
.Include(m => m.Customer)
.Include(m => m.Order)
.Include(m => m.AdminUser)
.Where(m => m.ThreadId == threadId)
.OrderBy(m => m.CreatedAt)
.ToListAsync();
if (!messages.Any()) return null;
var firstMessage = messages.First();
var thread = new MessageThreadDto
{
ThreadId = threadId,
Subject = firstMessage.Subject,
CustomerId = firstMessage.CustomerId,
CustomerName = firstMessage.Customer?.DisplayName ?? "Unknown",
OrderId = firstMessage.OrderId,
OrderReference = firstMessage.Order?.Id.ToString().Substring(0, 8),
StartedAt = firstMessage.CreatedAt,
LastMessageAt = messages.Max(m => m.CreatedAt),
MessageCount = messages.Count,
HasUnreadMessages = messages.Any(m => m.Direction == MessageDirection.CustomerToAdmin && m.Status != MessageStatus.Read),
RequiresResponse = messages.Any(m => m.RequiresResponse && m.Status != MessageStatus.Read),
Messages = messages.Select(m => _mapper.Map<CustomerMessageDto>(m)).ToList()
};
return thread;
}
public async Task<IEnumerable<MessageThreadDto>> GetActiveThreadsAsync()
{
var threads = await _context.CustomerMessages
.Include(m => m.Customer)
.Include(m => m.Order)
.Where(m => !m.IsArchived)
.GroupBy(m => m.ThreadId)
.Select(g => new MessageThreadDto
{
ThreadId = g.Key ?? Guid.Empty,
Subject = g.First().Subject,
CustomerId = g.First().CustomerId,
CustomerName = g.First().Customer != null ? g.First().Customer.DisplayName : "Unknown",
OrderId = g.First().OrderId,
OrderReference = g.First().Order != null ? g.First().Order.Id.ToString().Substring(0, 8) : null,
StartedAt = g.Min(m => m.CreatedAt),
LastMessageAt = g.Max(m => m.CreatedAt),
MessageCount = g.Count(),
HasUnreadMessages = g.Any(m => m.Direction == MessageDirection.CustomerToAdmin && m.Status != MessageStatus.Read),
RequiresResponse = g.Any(m => m.RequiresResponse && m.Status != MessageStatus.Read)
})
.OrderByDescending(t => t.LastMessageAt)
.Take(100) // Limit for performance
.ToListAsync();
return threads;
}
public async Task<bool> ValidateCustomerExistsAsync(Guid customerId)
{
return await _context.Customers.AnyAsync(c => c.Id == customerId);
}
public async Task<bool> ValidateOrderBelongsToCustomerAsync(Guid orderId, Guid customerId)
{
return await _context.Orders.AnyAsync(o => o.Id == orderId && o.CustomerId == customerId);
}
}

View File

@@ -0,0 +1,296 @@
using AutoMapper;
using Microsoft.EntityFrameworkCore;
using LittleShop.Data;
using LittleShop.DTOs;
using LittleShop.Models;
namespace LittleShop.Services;
public class CustomerService : ICustomerService
{
private readonly LittleShopContext _context;
private readonly IMapper _mapper;
private readonly ILogger<CustomerService> _logger;
public CustomerService(LittleShopContext context, IMapper mapper, ILogger<CustomerService> logger)
{
_context = context;
_mapper = mapper;
_logger = logger;
}
public async Task<CustomerDto?> GetCustomerByIdAsync(Guid id)
{
var customer = await _context.Customers
.Include(c => c.Orders)
.FirstOrDefaultAsync(c => c.Id == id);
if (customer == null) return null;
var dto = _mapper.Map<CustomerDto>(customer);
dto.DisplayName = customer.DisplayName;
dto.CustomerType = customer.CustomerType;
return dto;
}
public async Task<CustomerDto?> GetCustomerByTelegramUserIdAsync(long telegramUserId)
{
var customer = await _context.Customers
.Include(c => c.Orders)
.FirstOrDefaultAsync(c => c.TelegramUserId == telegramUserId);
if (customer == null) return null;
var dto = _mapper.Map<CustomerDto>(customer);
dto.DisplayName = customer.DisplayName;
dto.CustomerType = customer.CustomerType;
return dto;
}
public async Task<CustomerDto> CreateCustomerAsync(CreateCustomerDto createCustomerDto)
{
// Check if customer already exists
var existingCustomer = await _context.Customers
.FirstOrDefaultAsync(c => c.TelegramUserId == createCustomerDto.TelegramUserId);
if (existingCustomer != null)
{
throw new InvalidOperationException($"Customer with Telegram ID {createCustomerDto.TelegramUserId} already exists");
}
var customer = _mapper.Map<Customer>(createCustomerDto);
customer.Id = Guid.NewGuid();
customer.CreatedAt = DateTime.UtcNow;
customer.UpdatedAt = DateTime.UtcNow;
customer.LastActiveAt = DateTime.UtcNow;
customer.IsActive = true;
// Set data retention date (default: 2 years after creation)
customer.DataRetentionDate = DateTime.UtcNow.AddYears(2);
_context.Customers.Add(customer);
await _context.SaveChangesAsync();
_logger.LogInformation("Created new customer {CustomerId} for Telegram user {TelegramUserId}",
customer.Id, customer.TelegramUserId);
var dto = _mapper.Map<CustomerDto>(customer);
dto.DisplayName = customer.DisplayName;
dto.CustomerType = customer.CustomerType;
return dto;
}
public async Task<CustomerDto?> UpdateCustomerAsync(Guid id, UpdateCustomerDto updateCustomerDto)
{
var customer = await _context.Customers.FindAsync(id);
if (customer == null) return null;
_mapper.Map(updateCustomerDto, customer);
customer.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Updated customer {CustomerId}", id);
var dto = _mapper.Map<CustomerDto>(customer);
dto.DisplayName = customer.DisplayName;
dto.CustomerType = customer.CustomerType;
return dto;
}
public async Task<bool> DeleteCustomerAsync(Guid id)
{
var customer = await _context.Customers.FindAsync(id);
if (customer == null) return false;
// Instead of hard delete, mark as inactive for data retention compliance
customer.IsActive = false;
customer.DataRetentionDate = DateTime.UtcNow.AddDays(30); // Delete in 30 days
customer.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Marked customer {CustomerId} for deletion", id);
return true;
}
public async Task<IEnumerable<CustomerDto>> GetAllCustomersAsync()
{
var customers = await _context.Customers
.Where(c => c.IsActive)
.Include(c => c.Orders)
.OrderByDescending(c => c.LastActiveAt)
.ToListAsync();
return customers.Select(c =>
{
var dto = _mapper.Map<CustomerDto>(c);
dto.DisplayName = c.DisplayName;
dto.CustomerType = c.CustomerType;
return dto;
});
}
public async Task<IEnumerable<CustomerDto>> SearchCustomersAsync(string searchTerm)
{
var query = _context.Customers
.Where(c => c.IsActive)
.Include(c => c.Orders)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
searchTerm = searchTerm.ToLower();
query = query.Where(c =>
c.TelegramUsername.ToLower().Contains(searchTerm) ||
c.TelegramDisplayName.ToLower().Contains(searchTerm) ||
c.TelegramFirstName.ToLower().Contains(searchTerm) ||
c.TelegramLastName.ToLower().Contains(searchTerm) ||
(c.Email != null && c.Email.ToLower().Contains(searchTerm)));
}
var customers = await query
.OrderByDescending(c => c.LastActiveAt)
.Take(50) // Limit search results
.ToListAsync();
return customers.Select(c =>
{
var dto = _mapper.Map<CustomerDto>(c);
dto.DisplayName = c.DisplayName;
dto.CustomerType = c.CustomerType;
return dto;
});
}
public async Task<CustomerDto?> GetOrCreateCustomerAsync(long telegramUserId, string displayName, string username = "", string firstName = "", string lastName = "")
{
// Try to find existing customer
var customer = await _context.Customers
.Include(c => c.Orders)
.FirstOrDefaultAsync(c => c.TelegramUserId == telegramUserId);
if (customer != null)
{
// Update customer information if provided
bool updated = false;
if (!string.IsNullOrEmpty(displayName) && customer.TelegramDisplayName != displayName)
{
customer.TelegramDisplayName = displayName;
updated = true;
}
if (!string.IsNullOrEmpty(username) && customer.TelegramUsername != username)
{
customer.TelegramUsername = username;
updated = true;
}
if (!string.IsNullOrEmpty(firstName) && customer.TelegramFirstName != firstName)
{
customer.TelegramFirstName = firstName;
updated = true;
}
if (!string.IsNullOrEmpty(lastName) && customer.TelegramLastName != lastName)
{
customer.TelegramLastName = lastName;
updated = true;
}
customer.LastActiveAt = DateTime.UtcNow;
if (updated)
{
customer.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Updated existing customer {CustomerId} information", customer.Id);
}
else
{
await _context.SaveChangesAsync(); // Just update LastActiveAt
}
var existingDto = _mapper.Map<CustomerDto>(customer);
existingDto.DisplayName = customer.DisplayName;
existingDto.CustomerType = customer.CustomerType;
return existingDto;
}
// Create new customer
customer = new Customer
{
Id = Guid.NewGuid(),
TelegramUserId = telegramUserId,
TelegramUsername = username,
TelegramDisplayName = displayName,
TelegramFirstName = firstName,
TelegramLastName = lastName,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
LastActiveAt = DateTime.UtcNow,
DataRetentionDate = DateTime.UtcNow.AddYears(2),
IsActive = true,
AllowOrderUpdates = true,
AllowMarketing = false,
Language = "en",
Timezone = "UTC"
};
_context.Customers.Add(customer);
await _context.SaveChangesAsync();
_logger.LogInformation("Created new customer {CustomerId} for Telegram user {TelegramUserId} ({DisplayName})",
customer.Id, telegramUserId, displayName);
var dto = _mapper.Map<CustomerDto>(customer);
dto.DisplayName = customer.DisplayName;
dto.CustomerType = customer.CustomerType;
return dto;
}
public async Task UpdateCustomerMetricsAsync(Guid customerId)
{
var customer = await _context.Customers
.Include(c => c.Orders)
.FirstOrDefaultAsync(c => c.Id == customerId);
if (customer == null) return;
customer.UpdateMetrics();
await _context.SaveChangesAsync();
_logger.LogInformation("Updated metrics for customer {CustomerId}", customerId);
}
public async Task<bool> BlockCustomerAsync(Guid customerId, string reason)
{
var customer = await _context.Customers.FindAsync(customerId);
if (customer == null) return false;
customer.IsBlocked = true;
customer.BlockReason = reason;
customer.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogWarning("Blocked customer {CustomerId} - Reason: {Reason}", customerId, reason);
return true;
}
public async Task<bool> UnblockCustomerAsync(Guid customerId)
{
var customer = await _context.Customers.FindAsync(customerId);
if (customer == null) return false;
customer.IsBlocked = false;
customer.BlockReason = null;
customer.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.LogInformation("Unblocked customer {CustomerId}", customerId);
return true;
}
}

View File

@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using LittleShop.DTOs;
namespace LittleShop.Services;
public interface IBotMetricsService
{
// Metrics
Task<BotMetricDto> RecordMetricAsync(Guid botId, CreateBotMetricDto dto);
Task<bool> RecordMetricsBatchAsync(Guid botId, BotMetricsBatchDto dto);
Task<IEnumerable<BotMetricDto>> GetBotMetricsAsync(Guid botId, DateTime? startDate = null, DateTime? endDate = null);
Task<BotMetricsSummaryDto> GetMetricsSummaryAsync(Guid botId, DateTime? startDate = null, DateTime? endDate = null);
// Sessions
Task<BotSessionDto> StartSessionAsync(Guid botId, CreateBotSessionDto dto);
Task<bool> UpdateSessionAsync(Guid sessionId, UpdateBotSessionDto dto);
Task<BotSessionDto?> GetSessionAsync(Guid sessionId);
Task<IEnumerable<BotSessionDto>> GetBotSessionsAsync(Guid botId, bool activeOnly = false);
Task<BotSessionSummaryDto> GetSessionSummaryAsync(Guid botId, DateTime? startDate = null, DateTime? endDate = null);
Task<bool> EndSessionAsync(Guid sessionId);
Task<int> CleanupInactiveSessionsAsync(int inactiveMinutes = 30);
}

View File

@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using LittleShop.DTOs;
using LittleShop.Enums;
namespace LittleShop.Services;
public interface IBotService
{
Task<BotRegistrationResponseDto> RegisterBotAsync(BotRegistrationDto dto);
Task<BotDto?> AuthenticateBotAsync(string botKey);
Task<BotDto?> GetBotByIdAsync(Guid id);
Task<BotDto?> GetBotByKeyAsync(string botKey);
Task<IEnumerable<BotDto>> GetAllBotsAsync();
Task<IEnumerable<BotDto>> GetActiveBots();
Task<bool> UpdateBotSettingsAsync(Guid botId, UpdateBotSettingsDto dto);
Task<bool> UpdateBotStatusAsync(Guid botId, BotStatus status);
Task<bool> RecordHeartbeatAsync(Guid botId, BotHeartbeatDto dto);
Task<bool> DeleteBotAsync(Guid botId);
Task<Dictionary<string, object>> GetBotSettingsAsync(Guid botId);
Task<bool> ValidateBotKeyAsync(string botKey);
Task<string> GenerateBotKeyAsync();
Task<bool> UpdatePlatformInfoAsync(Guid botId, UpdatePlatformInfoDto dto);
}

View File

@@ -0,0 +1,20 @@
using LittleShop.DTOs;
using LittleShop.Models;
namespace LittleShop.Services;
public interface ICustomerMessageService
{
Task<CustomerMessageDto?> CreateMessageAsync(CreateCustomerMessageDto createMessageDto);
Task<CustomerMessageDto?> GetMessageByIdAsync(Guid id);
Task<IEnumerable<CustomerMessageDto>> GetCustomerMessagesAsync(Guid customerId);
Task<IEnumerable<CustomerMessageDto>> GetOrderMessagesAsync(Guid orderId);
Task<IEnumerable<CustomerMessageDto>> GetPendingMessagesAsync(string platform = "Telegram");
Task<bool> MarkMessageAsSentAsync(Guid messageId, string? platformMessageId = null);
Task<bool> MarkMessageAsDeliveredAsync(Guid messageId);
Task<bool> MarkMessageAsFailedAsync(Guid messageId, string reason);
Task<MessageThreadDto?> GetMessageThreadAsync(Guid threadId);
Task<IEnumerable<MessageThreadDto>> GetActiveThreadsAsync();
Task<bool> ValidateCustomerExistsAsync(Guid customerId);
Task<bool> ValidateOrderBelongsToCustomerAsync(Guid orderId, Guid customerId);
}

View File

@@ -0,0 +1,19 @@
using LittleShop.DTOs;
using LittleShop.Models;
namespace LittleShop.Services;
public interface ICustomerService
{
Task<CustomerDto?> GetCustomerByIdAsync(Guid id);
Task<CustomerDto?> GetCustomerByTelegramUserIdAsync(long telegramUserId);
Task<CustomerDto> CreateCustomerAsync(CreateCustomerDto createCustomerDto);
Task<CustomerDto?> UpdateCustomerAsync(Guid id, UpdateCustomerDto updateCustomerDto);
Task<bool> DeleteCustomerAsync(Guid id);
Task<IEnumerable<CustomerDto>> GetAllCustomersAsync();
Task<IEnumerable<CustomerDto>> SearchCustomersAsync(string searchTerm);
Task<CustomerDto?> GetOrCreateCustomerAsync(long telegramUserId, string displayName, string username = "", string firstName = "", string lastName = "");
Task UpdateCustomerMetricsAsync(Guid customerId);
Task<bool> BlockCustomerAsync(Guid customerId, string reason);
Task<bool> UnblockCustomerAsync(Guid customerId);
}

View File

@@ -0,0 +1,15 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace LittleShop.Services;
public interface ITelegramBotManagerService
{
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
Task<bool> AddBotAsync(Guid botId, string botToken);
Task<bool> RemoveBotAsync(Guid botId);
Task<bool> UpdateBotSettingsAsync(Guid botId);
Task<int> GetActiveBotCount();
}

View File

@@ -10,16 +10,19 @@ public class OrderService : IOrderService
{
private readonly LittleShopContext _context;
private readonly ILogger<OrderService> _logger;
private readonly ICustomerService _customerService;
public OrderService(LittleShopContext context, ILogger<OrderService> logger)
public OrderService(LittleShopContext context, ILogger<OrderService> logger, ICustomerService customerService)
{
_context = context;
_logger = logger;
_customerService = customerService;
}
public async Task<IEnumerable<OrderDto>> GetAllOrdersAsync()
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Payments)
@@ -32,6 +35,7 @@ public class OrderService : IOrderService
public async Task<IEnumerable<OrderDto>> GetOrdersByIdentityAsync(string identityReference)
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Payments)
@@ -45,6 +49,7 @@ public class OrderService : IOrderService
public async Task<OrderDto?> GetOrderByIdAsync(Guid id)
{
var order = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.Include(o => o.Payments)
@@ -59,10 +64,38 @@ public class OrderService : IOrderService
try
{
// Handle customer creation/linking during checkout
Guid? customerId = null;
string? identityReference = null;
if (createOrderDto.CustomerInfo != null)
{
// Create customer during checkout process
var customer = await _customerService.GetOrCreateCustomerAsync(
createOrderDto.CustomerInfo.TelegramUserId,
createOrderDto.CustomerInfo.TelegramDisplayName,
createOrderDto.CustomerInfo.TelegramUsername,
createOrderDto.CustomerInfo.TelegramFirstName,
createOrderDto.CustomerInfo.TelegramLastName);
customerId = customer?.Id;
}
else if (createOrderDto.CustomerId.HasValue)
{
// Order for existing customer
customerId = createOrderDto.CustomerId;
}
else
{
// Anonymous order (legacy support)
identityReference = createOrderDto.IdentityReference;
}
var order = new Order
{
Id = Guid.NewGuid(),
IdentityReference = createOrderDto.IdentityReference,
CustomerId = customerId,
IdentityReference = identityReference,
Status = OrderStatus.PendingPayment,
TotalAmount = 0,
Currency = "GBP",
@@ -105,8 +138,16 @@ public class OrderService : IOrderService
await _context.SaveChangesAsync();
await transaction.CommitAsync();
_logger.LogInformation("Created order {OrderId} for identity {Identity} with total {Total}",
order.Id, createOrderDto.IdentityReference, totalAmount);
if (customerId.HasValue)
{
_logger.LogInformation("Created order {OrderId} for customer {CustomerId} with total {Total}",
order.Id, customerId.Value, totalAmount);
}
else
{
_logger.LogInformation("Created order {OrderId} for identity {Identity} with total {Total}",
order.Id, identityReference, totalAmount);
}
// Reload order with includes
var createdOrder = await GetOrderByIdAsync(order.Id);
@@ -175,8 +216,26 @@ public class OrderService : IOrderService
return new OrderDto
{
Id = order.Id,
CustomerId = order.CustomerId,
IdentityReference = order.IdentityReference,
Status = order.Status,
Customer = order.Customer != null ? new CustomerSummaryDto
{
Id = order.Customer.Id,
DisplayName = !string.IsNullOrEmpty(order.Customer.TelegramDisplayName) ? order.Customer.TelegramDisplayName :
!string.IsNullOrEmpty(order.Customer.TelegramUsername) ? $"@{order.Customer.TelegramUsername}" :
$"{order.Customer.TelegramFirstName} {order.Customer.TelegramLastName}".Trim(),
TelegramUsername = order.Customer.TelegramUsername,
TotalOrders = order.Customer.TotalOrders,
TotalSpent = order.Customer.TotalSpent,
CustomerType = order.Customer.TotalOrders == 0 ? "New" :
order.Customer.TotalOrders == 1 ? "First-time" :
order.Customer.TotalOrders < 5 ? "Regular" :
order.Customer.TotalOrders < 20 ? "Loyal" : "VIP",
RiskScore = order.Customer.RiskScore,
LastActiveAt = order.Customer.LastActiveAt,
IsBlocked = order.Customer.IsBlocked
} : null,
TotalAmount = order.TotalAmount,
Currency = order.Currency,
ShippingName = order.ShippingName,

View File

@@ -0,0 +1,207 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using LittleShop.Data;
namespace LittleShop.Services;
public class TelegramBotManagerService : BackgroundService, ITelegramBotManagerService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TelegramBotManagerService> _logger;
private readonly ConcurrentDictionary<Guid, BotInstance> _activeBots = new();
public TelegramBotManagerService(IServiceProvider serviceProvider, ILogger<TelegramBotManagerService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("🤖 Telegram Bot Manager Service starting...");
try
{
// Load all active bots from database
await LoadActiveBotsAsync();
// Keep service running
while (!stoppingToken.IsCancellationRequested)
{
// Periodic health checks and cleanup
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
await PerformHealthChecksAsync();
}
}
catch (OperationCanceledException)
{
_logger.LogInformation("Telegram Bot Manager Service is stopping.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in Telegram Bot Manager Service");
}
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Telegram Bot Manager Service started");
await base.StartAsync(cancellationToken);
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping all Telegram bots...");
foreach (var bot in _activeBots.Values)
{
try
{
await bot.StopAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error stopping bot {BotId}", bot.BotId);
}
}
_activeBots.Clear();
await base.StopAsync(cancellationToken);
}
public async Task<bool> AddBotAsync(Guid botId, string botToken)
{
try
{
_logger.LogInformation("Adding bot {BotId} to Telegram manager", botId);
// Validate token first
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync($"https://api.telegram.org/bot{botToken}/getMe");
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Invalid bot token for bot {BotId}", botId);
return false;
}
// Create bot instance (placeholder for now - will implement Telegram.Bot later)
var botInstance = new BotInstance
{
BotId = botId,
BotToken = botToken,
IsRunning = false
};
_activeBots.TryAdd(botId, botInstance);
_logger.LogInformation("✅ Bot {BotId} added successfully", botId);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to add bot {BotId}", botId);
return false;
}
}
public async Task<bool> RemoveBotAsync(Guid botId)
{
if (_activeBots.TryRemove(botId, out var botInstance))
{
await botInstance.StopAsync();
_logger.LogInformation("Bot {BotId} removed from Telegram manager", botId);
return true;
}
return false;
}
public async Task<bool> UpdateBotSettingsAsync(Guid botId)
{
if (_activeBots.TryGetValue(botId, out var botInstance))
{
// Reload settings from database
_logger.LogInformation("Updating settings for bot {BotId}", botId);
return true;
}
return false;
}
public Task<int> GetActiveBotCount()
{
return Task.FromResult(_activeBots.Count(x => x.Value.IsRunning));
}
private async Task LoadActiveBotsAsync()
{
try
{
using var scope = _serviceProvider.CreateScope();
var botService = scope.ServiceProvider.GetRequiredService<IBotService>();
var activeBots = await botService.GetActiveBots();
_logger.LogInformation("Loading {Count} active bots", activeBots.Count());
foreach (var bot in activeBots)
{
// Look for telegram token in settings
if (bot.Settings.TryGetValue("telegram", out var telegramSettings) &&
telegramSettings is System.Text.Json.JsonElement telegramElement &&
telegramElement.TryGetProperty("botToken", out var tokenElement))
{
var token = tokenElement.GetString();
if (!string.IsNullOrEmpty(token) && token != "YOUR_BOT_TOKEN_HERE")
{
await AddBotAsync(bot.Id, token);
}
}
}
}
catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.Message.Contains("no such table"))
{
_logger.LogWarning("Bot tables don't exist yet. Skipping bot loading until database is fully initialized.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading active bots");
}
}
private async Task PerformHealthChecksAsync()
{
foreach (var kvp in _activeBots)
{
try
{
// Placeholder for health check logic
// In real implementation, would ping Telegram API
_logger.LogDebug("Health check for bot {BotId}", kvp.Key);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Health check failed for bot {BotId}", kvp.Key);
}
}
}
}
public class BotInstance
{
public Guid BotId { get; set; }
public string BotToken { get; set; } = string.Empty;
public bool IsRunning { get; set; }
public DateTime StartedAt { get; set; }
public Task StopAsync()
{
IsRunning = false;
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,30 @@
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=/app/data/littleshop.db"
},
"Jwt": {
"Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
"Issuer": "LittleShop",
"Audience": "LittleShop"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*",
"BTCPayServer": {
"Url": "",
"ApiKey": "",
"StoreId": ""
},
"Kestrel": {
"EndPoints": {
"Http": {
"Url": "http://0.0.0.0:5000"
}
}
}
}

View File

@@ -2,3 +2,4 @@
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 .AspNetCore.Cookies CfDJ8DxJ63S0c9FElmGFRyriOyxQ2VI2WE5MN0TByz7kNSHwr3VBa5sqlpt_YLV50eF1Yb04QBtu5uPrfXC_UzF1eF81n6XNYBeETJ9wfUrJwVFWThtFhEfOEzcYTtrr7ZB-yIMaV6A6QeOcIDnuiwquaWzkyno94PNydw2-lLD4jlBamWa32DiYNwI17zYglyaSEgS1ITdN4BQpfGSUAH2Mma6aw4MWZKK3xIj6Q8ps-x42Q-XWXgiKQhHvoSg09GpfFKoHBRIMWfxF5-6CkgVOGo7gGeXFhIEKrS6UcvyxfeQ2J79pR02IUfWgvGAStD5V2CBqoRphOnZMRj_Sgwhkon1JV-BRAkzmoG8UhGJe7l-xNnK8soPjER70h1ajZ-FNS-Zu7n5yupuCV50aRpvf1aroKryotLv9cDWgMVTlRzClrGqBwp2oTK6a1o9pkfHfQg

Binary file not shown.

View File

@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 .AspNetCore.Cookies CfDJ8DxJ63S0c9FElmGFRyriOywdY8scZrqW6HWcGVwU58xjlVJhpSDHLKxnS4BOGknzLfeD2kGcQpb80RJdM1i0aIg5RbvqYtjrjP7WPNau7I1yowNglh2p7yF8VjlgGJFaWNesPi9Kji_tXr8_WmOwJZtjgoAWgEgJ0cqbMj4Aep3boHP8Hb3WoJ9JEiB0s46ugKd2xFP08hwkMGDiAGgDsGebDlVDF-MZpTpaPn6Ebgk-OiOLKEq7FtsSx0Bm29LhT6V-9KRoE86P0_i0UZvOm2X9E71aACNQzsBpFg00yO0qaoOMrN4pn7-XxUBL1iJNqfn1bLHgD7su6nmJLCibCz3MF17PpJ_QFw6P3y3rSYOB63QjBaz8Euf0E9syBlbUZhbZyPYehmoYFYLTJcimq36TRyYYIQQcu34HJl8TueTRwubon4ONAkgvdoOctW-VXg

View File

@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 .AspNetCore.Cookies CfDJ8DxJ63S0c9FElmGFRyriOyx1P83b6s01w91tvjVvvqS0MMT88nURas_5MEm7nd25COxbSbvcfZgDEsbsZZVYuvYoRGy0-pb9jezRvH8x6dIbX4Cj_IMadP_UxbKVVAPQetoxm4NztkBvfUJGwlvIU5BU3CA9fZcktohA-EWG3uP9e0Zn_SndMmAKMY_r2LdRWr7F5pLRN5AWcWW3P6-e1dNiNwWANcd3jhb4JidyQzKopv8PSUGshZ95W93rhBz9S4ATYYY4bx0cPHErPJY5bFgKSG4FR4f_z0lsdJDXo9VANmreWaHEMH7edvTNzIarQBJmEf7BlNdlMPA64uz-I53sSSxcWQnmkCjWjrM2v8tOCNRRBYUgCy7SPTIly7_kBTsfNvWJbK1w6QHhrYOssKrg1otJunolituIUjtByKNPP3CICPnvRdkizy6sY54z2Q

View File

@@ -0,0 +1,6 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 0 .AspNetCore.Antiforgery.sPg1GqFvmmY CfDJ8DxJ63S0c9FElmGFRyriOyytVfaO36xemDWzgmgfcgLbYpXML8HPftA6ZSgZ1YJYEj_hiXOMdWjtWIsiccwTjMyl5Eb_8TQaqxTH3dZDDhpVXGqVN-EInaSXFRqwtzLeeZts4loGXgjbbQNk17vd_x8
#HttpOnly_localhost FALSE / FALSE 0 .AspNetCore.Cookies CfDJ8DxJ63S0c9FElmGFRyriOywW_kaSzrXpYdygsfTY10CIFJrE7uu1rz4ucx999nQRJqNpzMdxLsVcvMI4WPArl-KN6KfehVxlAy4ipFPKHyFliJFgycGTRUCmijuHXIarfVur9ZqVpQOSoXsaARBxmXeCnJpYw3Y1X--Ftf7qt6qT2bDxrUFXN5HbJUi-BSDPMroe5YBi4qB5YTbmp7PtgMeSY-Fx33-I2yjC_QaHGqRdVcqegLHfhgNr-6GoTZcB7QeA8YER-nNXsV2mmbwuz9zHZy3c_e1VpBlhORAH8t8-hrp3efuhzAECgI13Ko-8j0Kc-Vv99KCnWoKw5LLPCou7ki33K0rcKs2Y5gRPkc062CPWCCBC0JWlL06xO65xWteYXU7lADUktpx213WuXKuHN6vniQkJvEQuVJj4SR2hO2aAE5J8D4SnV52mwkfqxQ

224
LittleShop/test-wizard.html Normal file
View File

@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telegram Bot Creation Wizard - LittleShop Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/Admin">
<i class="fas fa-store"></i> LittleShop Admin
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link" href="/Admin">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/Admin/Categories">
<i class="fas fa-tags"></i> Categories
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/Admin/Products">
<i class="fas fa-box"></i> Products
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/Admin/Orders">
<i class="fas fa-shopping-cart"></i> Orders
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/Admin/ShippingRates">
<i class="fas fa-truck"></i> Shipping
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/Admin/Users">
<i class="fas fa-users"></i> Users
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/Admin/Bots">
<i class="fas fa-robot"></i> Bots
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-user"></i> admin
</a>
<ul class="dropdown-menu">
<li>
<form method="post" action="/Admin/Account/Logout">
<button type="submit" class="dropdown-item">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</form>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="container-fluid">
<main role="main" class="pb-3">
<h1>Telegram Bot Creation Wizard</h1>
<div class="row">
<div class="col-md-8">
<!-- Step 1: Basic Info -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Step 1: Bot Information</h5>
</div>
<div class="card-body">
<form asp-area="Admin" asp-controller="Bots" asp-action="Wizard" method="post">
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8DxJ63S0c9FElmGFRyriOywAO8gOGGx2ME7YDgMh8HxARyoeEPbEYF_OtnMlFj1eMaUu2Qybw5OWx6N2a00408PZKQoGm7gFE6q72AVRSDLq791hMcHFCiALdhlW3VDCRE29HxPzkOi0m-j68V6TKV-q_KQ4_zk7LgfUmLCFgAfsm2kotjMBhfqs45wL9x8nhg" />
<div asp-validation-summary="All" class="text-danger"></div>
<div class="mb-3">
<label asp-for="BotName" class="form-label">Bot Display Name</label>
<input asp-for="BotName" class="form-control"
placeholder="e.g., LittleShop Electronics Bot" required />
<span asp-validation-for="BotName" class="text-danger"></span>
<small class="text-muted">This is the name users will see</small>
</div>
<div class="mb-3">
<label asp-for="BotUsername" class="form-label">Bot Username</label>
<div class="input-group">
<span class="input-group-text">@</span>
<input asp-for="BotUsername" class="form-control"
placeholder="littleshop_bot" required />
</div>
<span asp-validation-for="BotUsername" class="text-danger"></span>
<small class="text-muted">Must end with 'bot' and be unique on Telegram</small>
</div>
<div class="mb-3">
<label for="PersonalityName" class="form-label">Personality</label>
<select asp-for="PersonalityName" class="form-select">
<option value="">Auto-assign (recommended)</option>
<option value="Alan" >Alan (Professional)</option>
<option value="Dave" >Dave (Casual)</option>
<option value="Sarah" >Sarah (Helpful)</option>
<option value="Mike" >Mike (Direct)</option>
<option value="Emma" >Emma (Friendly)</option>
<option value="Tom" >Tom (Efficient)</option>
</select>
<small class="text-muted">Bot conversation style (can be changed later)</small>
</div>
<div class="mb-3">
<label for="Description" class="form-label">Description (Optional)</label>
<textarea asp-for="Description" class="form-control" rows="2"
placeholder="Brief description of what this bot does"></textarea>
</div>
<input type="submit" value="Generate BotFather Commands" class="btn btn-primary" />
<a href="/Admin/Bots" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Wizard Progress</h5>
</div>
<div class="card-body">
<ul class="list-unstyled">
<li class="text-primary fw-bold">
<i class="fas fa-edit"></i>
1. Bot Information
</li>
<li class="text-muted">
<i class="fas fa-circle"></i>
2. Create with BotFather
</li>
<li class="text-muted">
<i class="fas fa-circle"></i>
3. Complete Setup
</li>
</ul>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">Personality Preview</h5>
</div>
<div class="card-body">
<p class="small">
Auto-assigned personality based on bot name </p>
<p class="small text-muted">
Personalities affect how your bot communicates with customers.
This can be customized later in bot settings.
</p>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<partial name="_ValidationScriptsPartial" />
<script>
// Test if JavaScript is working
console.log('Wizard page scripts loaded');
function copyCommands() {
const commands = ``;
navigator.clipboard.writeText(commands).then(() => {
alert('Commands copied to clipboard!');
});
}
// Auto-generate username from bot name
$(document).ready(function() {
console.log('Document ready, setting up auto-generation');
$('#BotName').on('input', function() {
try {
const name = $(this).val().toLowerCase()
.replace(/[^a-z0-9]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
if (name && !name.endsWith('_bot')) {
$('#BotUsername').val(name + '_bot');
}
} catch (err) {
console.error('Error in auto-generation:', err);
}
});
});
</script>
</body>
</html>

View File

224
LittleShop/wizard.html Normal file
View File

@@ -0,0 +1,224 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telegram Bot Creation Wizard - LittleShop Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
</head>
<body>
<header>
<nav class="navbar navbar-expand-sm navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="/Admin">
<i class="fas fa-store"></i> LittleShop Admin
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target=".navbar-collapse">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-collapse collapse d-sm-inline-flex justify-content-between">
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link" href="/Admin">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/Admin/Categories">
<i class="fas fa-tags"></i> Categories
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/Admin/Products">
<i class="fas fa-box"></i> Products
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/Admin/Orders">
<i class="fas fa-shopping-cart"></i> Orders
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/Admin/ShippingRates">
<i class="fas fa-truck"></i> Shipping
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/Admin/Users">
<i class="fas fa-users"></i> Users
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/Admin/Bots">
<i class="fas fa-robot"></i> Bots
</a>
</li>
</ul>
<ul class="navbar-nav">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
<i class="fas fa-user"></i> admin
</a>
<ul class="dropdown-menu">
<li>
<form method="post" action="/Admin/Account/Logout">
<button type="submit" class="dropdown-item">
<i class="fas fa-sign-out-alt"></i> Logout
</button>
</form>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</header>
<div class="container-fluid">
<main role="main" class="pb-3">
<h1>Telegram Bot Creation Wizard</h1>
<div class="row">
<div class="col-md-8">
<!-- Step 1: Basic Info -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Step 1: Bot Information</h5>
</div>
<div class="card-body">
<form asp-area="Admin" asp-controller="Bots" asp-action="Wizard" method="post">
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8DxJ63S0c9FElmGFRyriOyxtHuNw47houOiPBlthzQyPiKf9VWVnAx_pcF7wBhQLdU41vfn-d2-Km3hi22lPk3qY0n7ZnGz39DmlP8yczH-EJwXnnIDvrBp-gigGjKPJJwitTBbPiSg1GcarY6bbSfbU368kFk64Jnsd5VMc0tTukNDO9xXriuBWE6iEsQPb5A" />
<div asp-validation-summary="All" class="text-danger"></div>
<div class="mb-3">
<label asp-for="BotName" class="form-label">Bot Display Name</label>
<input asp-for="BotName" class="form-control"
placeholder="e.g., LittleShop Electronics Bot" required />
<span asp-validation-for="BotName" class="text-danger"></span>
<small class="text-muted">This is the name users will see</small>
</div>
<div class="mb-3">
<label asp-for="BotUsername" class="form-label">Bot Username</label>
<div class="input-group">
<span class="input-group-text">@</span>
<input asp-for="BotUsername" class="form-control"
placeholder="littleshop_bot" required />
</div>
<span asp-validation-for="BotUsername" class="text-danger"></span>
<small class="text-muted">Must end with 'bot' and be unique on Telegram</small>
</div>
<div class="mb-3">
<label for="PersonalityName" class="form-label">Personality</label>
<select asp-for="PersonalityName" class="form-select">
<option value="">Auto-assign (recommended)</option>
<option value="Alan" >Alan (Professional)</option>
<option value="Dave" >Dave (Casual)</option>
<option value="Sarah" >Sarah (Helpful)</option>
<option value="Mike" >Mike (Direct)</option>
<option value="Emma" >Emma (Friendly)</option>
<option value="Tom" >Tom (Efficient)</option>
</select>
<small class="text-muted">Bot conversation style (can be changed later)</small>
</div>
<div class="mb-3">
<label for="Description" class="form-label">Description (Optional)</label>
<textarea asp-for="Description" class="form-control" rows="2"
placeholder="Brief description of what this bot does"></textarea>
</div>
<input type="submit" value="Generate BotFather Commands" class="btn btn-primary" />
<a href="/Admin/Bots" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Wizard Progress</h5>
</div>
<div class="card-body">
<ul class="list-unstyled">
<li class="text-primary fw-bold">
<i class="fas fa-edit"></i>
1. Bot Information
</li>
<li class="text-muted">
<i class="fas fa-circle"></i>
2. Create with BotFather
</li>
<li class="text-muted">
<i class="fas fa-circle"></i>
3. Complete Setup
</li>
</ul>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">Personality Preview</h5>
</div>
<div class="card-body">
<p class="small">
Auto-assigned personality based on bot name </p>
<p class="small text-muted">
Personalities affect how your bot communicates with customers.
This can be customized later in bot settings.
</p>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<partial name="_ValidationScriptsPartial" />
<script>
// Test if JavaScript is working
console.log('Wizard page scripts loaded');
function copyCommands() {
const commands = ``;
navigator.clipboard.writeText(commands).then(() => {
alert('Commands copied to clipboard!');
});
}
// Auto-generate username from bot name
$(document).ready(function() {
console.log('Document ready, setting up auto-generation');
$('#BotName').on('input', function() {
try {
const name = $(this).val().toLowerCase()
.replace(/[^a-z0-9]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
if (name && !name.endsWith('_bot')) {
$('#BotUsername').val(name + '_bot');
}
} catch (err) {
console.error('Error in auto-generation:', err);
}
});
});
</script>
</body>
</html>