Merge pull request #84 from Moonlight-Panel/RewriteSupportChatBackend

Rewritten support chat backend. Added discord notifications
This commit is contained in:
Marcel Baumgartner
2023-04-21 00:38:07 +02:00
committed by GitHub
15 changed files with 1939 additions and 236 deletions

View File

@@ -45,6 +45,7 @@ public class DataContext : DbContext
public DbSet<CloudPanel> CloudPanels { get; set; }
public DbSet<MySqlDatabase> Databases { get; set; }
public DbSet<WebSpace> WebSpaces { get; set; }
public DbSet<SupportChatMessage> SupportChatMessages { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{

View File

@@ -0,0 +1,21 @@
using Moonlight.App.Models.Misc;
namespace Moonlight.App.Database.Entities;
public class SupportChatMessage
{
public int Id { get; set; }
public string Content { get; set; } = "";
public string Attachment { get; set; } = "";
public User? Sender { get; set; }
public User Recipient { get; set; }
public bool IsQuestion { get; set; } = false;
public QuestionType QuestionType { get; set; }
public string Answer { get; set; } = "";
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.App.Database.Migrations
{
/// <inheritdoc />
public partial class AddedNewSupportChatModels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Websites");
migrationBuilder.DropTable(
name: "PleskServers");
migrationBuilder.CreateTable(
name: "SupportChatMessages",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Content = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Attachment = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SenderId = table.Column<int>(type: "int", nullable: true),
RecipientId = table.Column<int>(type: "int", nullable: false),
IsQuestion = table.Column<bool>(type: "tinyint(1)", nullable: false),
QuestionType = table.Column<int>(type: "int", nullable: false),
Answer = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SupportChatMessages", x => x.Id);
table.ForeignKey(
name: "FK_SupportChatMessages_Users_RecipientId",
column: x => x.RecipientId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SupportChatMessages_Users_SenderId",
column: x => x.SenderId,
principalTable: "Users",
principalColumn: "Id");
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_SupportChatMessages_RecipientId",
table: "SupportChatMessages",
column: "RecipientId");
migrationBuilder.CreateIndex(
name: "IX_SupportChatMessages_SenderId",
table: "SupportChatMessages",
column: "SenderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SupportChatMessages");
migrationBuilder.CreateTable(
name: "PleskServers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
ApiKey = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ApiUrl = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Name = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_PleskServers", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Websites",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
OwnerId = table.Column<int>(type: "int", nullable: false),
PleskServerId = table.Column<int>(type: "int", nullable: false),
BaseDomain = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
FtpLogin = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
FtpPassword = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
PleskId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Websites", x => x.Id);
table.ForeignKey(
name: "FK_Websites_PleskServers_PleskServerId",
column: x => x.PleskServerId,
principalTable: "PleskServers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Websites_Users_OwnerId",
column: x => x.OwnerId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Websites_OwnerId",
table: "Websites",
column: "OwnerId");
migrationBuilder.CreateIndex(
name: "IX_Websites_PleskServerId",
table: "Websites",
column: "PleskServerId");
}
}
}

View File

@@ -453,29 +453,6 @@ namespace Moonlight.App.Database.Migrations
b.ToTable("NotificationClients");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.PleskServer", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("ApiKey")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("ApiUrl")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("PleskServers");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.Revoke", b =>
{
b.Property<int>("Id")
@@ -673,6 +650,51 @@ namespace Moonlight.App.Database.Migrations
b.ToTable("Subscriptions");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.SupportChatMessage", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Answer")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Attachment")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<bool>("IsQuestion")
.HasColumnType("tinyint(1)");
b.Property<int>("QuestionType")
.HasColumnType("int");
b.Property<int>("RecipientId")
.HasColumnType("int");
b.Property<int?>("SenderId")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("RecipientId");
b.HasIndex("SenderId");
b.ToTable("SupportChatMessages");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.SupportMessage", b =>
{
b.Property<int>("Id")
@@ -836,42 +858,6 @@ namespace Moonlight.App.Database.Migrations
b.ToTable("WebSpaces");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.Website", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("BaseDomain")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("FtpLogin")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("FtpPassword")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("OwnerId")
.HasColumnType("int");
b.Property<int>("PleskId")
.HasColumnType("int");
b.Property<int>("PleskServerId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("OwnerId");
b.HasIndex("PleskServerId");
b.ToTable("Websites");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.DdosAttack", b =>
{
b.HasOne("Moonlight.App.Database.Entities.Node", "Node")
@@ -1007,6 +993,23 @@ namespace Moonlight.App.Database.Migrations
.HasForeignKey("ServerId");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.SupportChatMessage", b =>
{
b.HasOne("Moonlight.App.Database.Entities.User", "Recipient")
.WithMany()
.HasForeignKey("RecipientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Moonlight.App.Database.Entities.User", "Sender")
.WithMany()
.HasForeignKey("SenderId");
b.Navigation("Recipient");
b.Navigation("Sender");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.SupportMessage", b =>
{
b.HasOne("Moonlight.App.Database.Entities.User", "Recipient")
@@ -1050,25 +1053,6 @@ namespace Moonlight.App.Database.Migrations
b.Navigation("Owner");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.Website", b =>
{
b.HasOne("Moonlight.App.Database.Entities.User", "Owner")
.WithMany()
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Moonlight.App.Database.Entities.PleskServer", "PleskServer")
.WithMany()
.HasForeignKey("PleskServerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Owner");
b.Navigation("PleskServer");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.Image", b =>
{
b.Navigation("DockerImages");

View File

@@ -80,7 +80,15 @@ public class EventSystem
var del = (Delegate)subscriber.Action;
((Task)del.DynamicInvoke(data)!).Wait();
try
{
((Task)del.DynamicInvoke(data)!).Wait();
}
catch (Exception e)
{
Logger.Warn($"Error emitting '{subscriber.Id} on {subscriber.Handle}'");
Logger.Warn(e);
}
stopWatch.Stop();

View File

@@ -0,0 +1,101 @@
using Discord;
using Discord.Webhook;
using Logging.Net;
using Moonlight.App.Database.Entities;
using Moonlight.App.Events;
namespace Moonlight.App.Services;
public class DiscordNotificationService
{
private readonly EventSystem Event;
private readonly ResourceService ResourceService;
private readonly DiscordWebhookClient Client;
private readonly string AppUrl;
public DiscordNotificationService(
EventSystem eventSystem,
ConfigService configService,
ResourceService resourceService)
{
Event = eventSystem;
ResourceService = resourceService;
var config = configService.GetSection("Moonlight").GetSection("DiscordNotifications");
if (config.GetValue<bool>("Enable"))
{
Logger.Info("Discord notifications enabled");
Client = new(config.GetValue<string>("WebHook"));
AppUrl = configService.GetSection("Moonlight").GetValue<string>("AppUrl");
Event.On<User>("supportChat.new", this, OnNewSupportChat);
Event.On<SupportChatMessage>("supportChat.message", this, OnSupportChatMessage);
Event.On<User>("supportChat.close", this, OnSupportChatClose);
}
else
{
Logger.Info("Discord notifications disabled");
}
}
private async Task OnSupportChatClose(User user)
{
await SendNotification("", builder =>
{
builder.Title = "A new support chat has been marked as closed";
builder.Color = Color.Red;
builder.AddField("Email", user.Email);
builder.AddField("Firstname", user.FirstName);
builder.AddField("Lastname", user.LastName);
builder.Url = $"{AppUrl}/admin/support/view/{user.Id}";
});
}
private async Task OnSupportChatMessage(SupportChatMessage message)
{
if(message.Sender == null)
return;
await SendNotification("", builder =>
{
builder.Title = "New message in support chat";
builder.Color = Color.Blue;
builder.AddField("Message", message.Content);
builder.Author = new EmbedAuthorBuilder()
.WithName($"{message.Sender.FirstName} {message.Sender.LastName}")
.WithIconUrl(ResourceService.Avatar(message.Sender));
builder.Url = $"{AppUrl}/admin/support/view/{message.Recipient.Id}";
});
}
private async Task OnNewSupportChat(User user)
{
await SendNotification("", builder =>
{
builder.Title = "A new support chat has been marked as active";
builder.Color = Color.Green;
builder.AddField("Email", user.Email);
builder.AddField("Firstname", user.FirstName);
builder.AddField("Lastname", user.LastName);
builder.Url = $"{AppUrl}/admin/support/view/{user.Id}";
});
}
private async Task SendNotification(string content, Action<EmbedBuilder>? embed = null)
{
Logger.Debug(Client);
var e = new EmbedBuilder();
embed?.Invoke(e);
await Client.SendMessageAsync(
content,
false,
new []{e.Build()},
"Moonlight Notification",
ResourceService.Image("logo.svg")
);
}
}

View File

@@ -0,0 +1,127 @@
using Moonlight.App.Database.Entities;
using Moonlight.App.Events;
using Moonlight.App.Services.Sessions;
namespace Moonlight.App.Services.SupportChat;
public class SupportChatAdminService
{
private readonly EventSystem Event;
private readonly IdentityService IdentityService;
private readonly SupportChatServerService ServerService;
public Func<SupportChatMessage, Task>? OnMessage { get; set; }
public Func<string[], Task>? OnTypingChanged { get; set; }
private User? User;
private User Recipient = null!;
private readonly List<User> TypingUsers = new();
public SupportChatAdminService(
EventSystem eventSystem,
SupportChatServerService serverService,
IdentityService identityService)
{
Event = eventSystem;
ServerService = serverService;
IdentityService = identityService;
}
public async Task Start(User recipient)
{
User = await IdentityService.Get();
Recipient = recipient;
if (User != null)
{
await Event.On<SupportChatMessage>($"supportChat.{Recipient.Id}.message", this, async message =>
{
if(OnMessage != null)
await OnMessage.Invoke(message);
});
await Event.On<User>($"supportChat.{Recipient.Id}.typing", this, async user =>
{
await HandleTyping(user);
});
}
}
public async Task<SupportChatMessage[]> GetMessages()
{
if (User == null)
return Array.Empty<SupportChatMessage>();
return await ServerService.GetMessages(Recipient);
}
public async Task SendMessage(string content)
{
if (User != null)
{
await ServerService.SendMessage(Recipient, content, User);
}
}
private Task HandleTyping(User user)
{
lock (TypingUsers)
{
if (!TypingUsers.Contains(user))
{
TypingUsers.Add(user);
if (OnTypingChanged != null)
{
OnTypingChanged.Invoke(
TypingUsers
.Where(x => x.Id != User!.Id)
.Select(x => $"{x.FirstName} {x.LastName}")
.ToArray()
);
}
Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(5));
if (TypingUsers.Contains(user))
{
TypingUsers.Remove(user);
if (OnTypingChanged != null)
{
await OnTypingChanged.Invoke(
TypingUsers
.Where(x => x.Id != User!.Id)
.Select(x => $"{x.FirstName} {x.LastName}")
.ToArray()
);
}
}
});
}
}
return Task.CompletedTask;
}
public async Task SendTyping()
{
await Event.Emit($"supportChat.{Recipient.Id}.typing", User);
}
public async Task Close()
{
await ServerService.CloseChat(Recipient);
}
public async void Dispose()
{
if (User != null)
{
await Event.Off($"supportChat.{Recipient.Id}.message", this);
await Event.Off($"supportChat.{Recipient.Id}.typing", this);
}
}
}

View File

@@ -0,0 +1,121 @@
using Logging.Net;
using Moonlight.App.Database.Entities;
using Moonlight.App.Events;
using Moonlight.App.Services.Sessions;
namespace Moonlight.App.Services.SupportChat;
public class SupportChatClientService : IDisposable
{
private readonly EventSystem Event;
private readonly IdentityService IdentityService;
private readonly SupportChatServerService ServerService;
public Func<SupportChatMessage, Task>? OnMessage { get; set; }
public Func<string[], Task>? OnTypingChanged { get; set; }
private User? User;
private readonly List<User> TypingUsers = new();
public SupportChatClientService(
EventSystem eventSystem,
SupportChatServerService serverService,
IdentityService identityService)
{
Event = eventSystem;
ServerService = serverService;
IdentityService = identityService;
}
public async Task Start()
{
User = await IdentityService.Get();
if (User != null)
{
await Event.On<SupportChatMessage>($"supportChat.{User.Id}.message", this, async message =>
{
if(OnMessage != null)
await OnMessage.Invoke(message);
});
await Event.On<User>($"supportChat.{User.Id}.typing", this, async user =>
{
await HandleTyping(user);
});
}
}
public async Task<SupportChatMessage[]> GetMessages()
{
if (User == null)
return Array.Empty<SupportChatMessage>();
return await ServerService.GetMessages(User);
}
public async Task SendMessage(string content)
{
if (User != null)
{
await ServerService.SendMessage(User, content, User);
}
}
private Task HandleTyping(User user)
{
lock (TypingUsers)
{
if (!TypingUsers.Contains(user))
{
TypingUsers.Add(user);
if (OnTypingChanged != null)
{
OnTypingChanged.Invoke(
TypingUsers
.Where(x => x.Id != User!.Id)
.Select(x => $"{x.FirstName} {x.LastName}")
.ToArray()
);
}
Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(5));
if (TypingUsers.Contains(user))
{
TypingUsers.Remove(user);
if (OnTypingChanged != null)
{
await OnTypingChanged.Invoke(
TypingUsers
.Where(x => x.Id != User!.Id)
.Select(x => $"{x.FirstName} {x.LastName}")
.ToArray()
);
}
}
});
}
}
return Task.CompletedTask;
}
public async Task SendTyping()
{
await Event.Emit($"supportChat.{User!.Id}.typing", User);
}
public async void Dispose()
{
if (User != null)
{
await Event.Off($"supportChat.{User.Id}.message", this);
await Event.Off($"supportChat.{User.Id}.typing", this);
}
}
}

View File

@@ -0,0 +1,144 @@
using Microsoft.EntityFrameworkCore;
using Moonlight.App.Database.Entities;
using Moonlight.App.Events;
using Moonlight.App.Repositories;
namespace Moonlight.App.Services.SupportChat;
public class SupportChatServerService
{
private readonly IServiceScopeFactory ServiceScopeFactory;
private readonly DateTimeService DateTimeService;
private readonly EventSystem Event;
public SupportChatServerService(
IServiceScopeFactory serviceScopeFactory,
DateTimeService dateTimeService,
EventSystem eventSystem)
{
ServiceScopeFactory = serviceScopeFactory;
DateTimeService = dateTimeService;
Event = eventSystem;
}
public Task<SupportChatMessage[]> GetMessages(User recipient)
{
using var scope = ServiceScopeFactory.CreateScope();
var msgRepo = scope.ServiceProvider.GetRequiredService<Repository<SupportChatMessage>>();
var messages = msgRepo
.Get()
.Include(x => x.Recipient)
.Include(x => x.Sender)
.Where(x => x.Recipient.Id == recipient.Id)
.OrderByDescending(x => x.CreatedAt)
.AsEnumerable()
.Take(50)
.ToArray();
return Task.FromResult(messages);
}
public async Task SendMessage(User recipient, string content, User? sender, string? attachment = null)
{
using var scope = ServiceScopeFactory.CreateScope();
var msgRepo = scope.ServiceProvider.GetRequiredService<Repository<SupportChatMessage>>();
var userRepo = scope.ServiceProvider.GetRequiredService<Repository<User>>();
var message = new SupportChatMessage()
{
CreatedAt = DateTimeService.GetCurrent(),
IsQuestion = false,
Sender = sender == null ? null : userRepo.Get().First(x => x.Id == sender.Id),
Recipient = userRepo.Get().First(x => x.Id == recipient.Id),
Answer = "",
Attachment = attachment ?? "",
Content = content,
UpdatedAt = DateTimeService.GetCurrent()
};
var finalMessage = msgRepo.Add(message);
await Event.Emit($"supportChat.{recipient.Id}.message", finalMessage);
await Event.Emit("supportChat.message", finalMessage);
if (!userRepo.Get().First(x => x.Id == recipient.Id).SupportPending)
{
var ticketStart = new SupportChatMessage()
{
CreatedAt = DateTimeService.GetCurrent(),
IsQuestion = false,
Sender = null,
Recipient = userRepo.Get().First(x => x.Id == recipient.Id),
Answer = "",
Attachment = "",
Content = "Support ticket open", //TODO: Config
UpdatedAt = DateTimeService.GetCurrent()
};
var ticketStartFinal = msgRepo.Add(ticketStart);
var user = userRepo.Get().First(x => x.Id == recipient.Id);
user.SupportPending = true;
userRepo.Update(user);
await Event.Emit($"supportChat.{recipient.Id}.message", ticketStartFinal);
await Event.Emit("supportChat.message", ticketStartFinal);
await Event.Emit("supportChat.new", recipient);
}
}
public Task<Dictionary<User, SupportChatMessage?>> GetOpenChats()
{
var result = new Dictionary<User, SupportChatMessage?>();
using var scope = ServiceScopeFactory.CreateScope();
var userRepo = scope.ServiceProvider.GetRequiredService<Repository<User>>();
var msgRepo = scope.ServiceProvider.GetRequiredService<Repository<SupportChatMessage>>();
foreach (var user in userRepo.Get().Where(x => x.SupportPending).ToArray())
{
var lastMessage = msgRepo
.Get()
.Include(x => x.Recipient)
.Include(x => x.Sender)
.Where(x => x.Recipient.Id == user.Id)
.OrderByDescending(x => x.CreatedAt)
.AsEnumerable()
.FirstOrDefault();
result.Add(user, lastMessage);
}
return Task.FromResult(result);
}
public async Task CloseChat(User recipient)
{
using var scope = ServiceScopeFactory.CreateScope();
var msgRepo = scope.ServiceProvider.GetRequiredService<Repository<SupportChatMessage>>();
var userRepo = scope.ServiceProvider.GetRequiredService<Repository<User>>();
var ticketEnd = new SupportChatMessage()
{
CreatedAt = DateTimeService.GetCurrent(),
IsQuestion = false,
Sender = null,
Recipient = userRepo.Get().First(x => x.Id == recipient.Id),
Answer = "",
Attachment = "",
Content = "Support ticket closed", //TODO: Config
UpdatedAt = DateTimeService.GetCurrent()
};
var ticketEndFinal = msgRepo.Add(ticketEnd);
var user = userRepo.Get().First(x => x.Id == recipient.Id);
user.SupportPending = false;
userRepo.Update(user);
await Event.Emit($"supportChat.{recipient.Id}.message", ticketEndFinal);
await Event.Emit("supportChat.message", ticketEndFinal);
await Event.Emit("supportChat.close", recipient);
}
}

View File

@@ -18,8 +18,8 @@
<PackageReference Include="BlazorTable" Version="1.17.0" />
<PackageReference Include="CloudFlare.Client" Version="6.1.4" />
<PackageReference Include="CurrieTechnologies.Razor.SweetAlert2" Version="5.4.0" />
<PackageReference Include="Discord.Net" Version="3.9.0" />
<PackageReference Include="DiscordWebhooks" Version="1.0.4" />
<PackageReference Include="Discord.Net" Version="3.10.0" />
<PackageReference Include="Discord.Net.Webhook" Version="3.10.0" />
<PackageReference Include="FluentFTP" Version="46.0.2" />
<PackageReference Include="GravatarSharp.Core" Version="1.0.1.2" />
<PackageReference Include="JWT" Version="10.0.2" />

View File

@@ -20,6 +20,7 @@ using Moonlight.App.Services.OAuth2;
using Moonlight.App.Services.Sessions;
using Moonlight.App.Services.Statistics;
using Moonlight.App.Services.Support;
using Moonlight.App.Services.SupportChat;
namespace Moonlight
{
@@ -124,10 +125,15 @@ namespace Moonlight
builder.Services.AddScoped<MailService>();
builder.Services.AddSingleton<TrashMailDetectorService>();
// Support
// Support TODO: Remove
builder.Services.AddSingleton<SupportServerService>();
builder.Services.AddScoped<SupportAdminService>();
builder.Services.AddScoped<SupportClientService>();
// Support chat
builder.Services.AddSingleton<SupportChatServerService>();
builder.Services.AddScoped<SupportChatClientService>();
builder.Services.AddScoped<SupportChatAdminService>();
// Helpers
builder.Services.AddSingleton<SmartTranslateHelper>();
@@ -143,6 +149,7 @@ namespace Moonlight
// Background services
builder.Services.AddSingleton<DiscordBotService>();
builder.Services.AddSingleton<StatisticsCaptureService>();
builder.Services.AddSingleton<DiscordNotificationService>();
// Third party services
builder.Services.AddBlazorTable();
@@ -176,6 +183,7 @@ namespace Moonlight
_ = app.Services.GetRequiredService<CleanupService>();
_ = app.Services.GetRequiredService<DiscordBotService>();
_ = app.Services.GetRequiredService<StatisticsCaptureService>();
_ = app.Services.GetRequiredService<DiscordNotificationService>();
// Discord bot service
//var discordBotService = app.Services.GetRequiredService<DiscordBotService>();

View File

@@ -1,13 +1,11 @@
@page "/admin/support"
@using Moonlight.App.Repositories
@using Moonlight.App.Database.Entities
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Database
@using Moonlight.App.Services
@using Moonlight.App.Events
@using Moonlight.App.Services.SupportChat
@inject SupportChatServerService ServerService
@inject EventSystem Event
@inject SupportMessageRepository SupportMessageRepository
@inject ConfigService ConfigService
@inject MessageService MessageService
@implements IDisposable
<OnlyAdmin>
@@ -18,13 +16,13 @@
<div class="flex-lg-row-fluid me-xl-15 mb-20 mb-xl-0">
<div class="mb-0">
<h1 class="text-dark mb-6">
<TL>Open tickets</TL>
<TL>Open chats</TL>
</h1>
<div class="separator"></div>
<div class="mb-5">
@if (Users.Any())
@if (OpenChats.Any())
{
foreach (var user in Users)
foreach (var chat in OpenChats)
{
<div class="d-flex mt-3 mb-3 ms-2 me-2">
<table>
@@ -35,25 +33,21 @@
</span>
</td>
<td>
<a href="/admin/support/view/@(user.Id)" class="text-dark text-hover-primary fs-4 me-3 fw-semibold">
@(user.FirstName) @(user.LastName)
<a href="/admin/support/view/@(chat.Key.Id)" class="text-dark text-hover-primary fs-4 me-3 fw-semibold">
@(chat.Key.FirstName) @(chat.Key.LastName)
</a>
</td>
</tr>
<tr>
<td>
<span class="text-muted fw-semibold fs-6">
@{
var lastMessage = MessageCache.ContainsKey(user) ? MessageCache[user] : null;
}
@if (lastMessage == null)
@if (chat.Value == null)
{
<TL>No message sent yet</TL>
}
else
{
@(lastMessage.Message)
@(chat.Value.Content)
}
</span>
</td>
@@ -66,7 +60,7 @@
else
{
<div class="alert alert-info">
<TL>No support ticket is currently open</TL>
<TL>No support chat is currently open</TL>
</div>
}
</div>
@@ -80,53 +74,28 @@
@code
{
private User[] Users;
private Dictionary<User, SupportMessage?> MessageCache;
private LazyLoader? LazyLoader;
private Dictionary<User, SupportChatMessage?> OpenChats = new();
protected override void OnInitialized()
protected override async Task OnInitializedAsync()
{
MessageCache = new();
MessageService.Subscribe<Index, User>("support.new", this, async user =>
await Event.On<User>("supportChat.new", this, async user =>
{
if (LazyLoader != null)
await LazyLoader.Reload();
});
MessageService.Subscribe<Index, User>("support.close", this, async user =>
{
if (LazyLoader != null)
await LazyLoader.Reload();
//TODO: Play sound or smth. Add a config option
OpenChats = await ServerService.GetOpenChats();
await InvokeAsync(StateHasChanged);
});
}
private Task Load(LazyLoader arg)
private async Task Load(LazyLoader arg) // Only for initial load
{
// We dont want cache here
Users = (new UserRepository(new DataContext(ConfigService)))
.Get()
.Where(x => x.SupportPending)
.ToArray();
foreach (var user in Users)
{
var lastMessage = SupportMessageRepository
.Get()
.Include(x => x.Recipient)
.OrderByDescending(x => x.Id)
.FirstOrDefault(x => x.Recipient!.Id == user.Id);
MessageCache.Add(user, lastMessage);
}
return Task.CompletedTask;
OpenChats = await ServerService.GetOpenChats();
}
public void Dispose()
public async void Dispose()
{
MessageService.Unsubscribe("support.new", this);
MessageService.Unsubscribe("support.close", this);
await Event.Off("supportChat.new", this);
}
}

View File

@@ -1,11 +1,11 @@
@page "/admin/support/view/{Id:int}"
@using Moonlight.App.Services.Support
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Repositories
@using Moonlight.App.Services
@using Moonlight.App.Services.SupportChat
@inject SupportAdminService SupportAdminService
@inject SupportChatAdminService AdminService
@inject UserRepository UserRepository
@inject SmartTranslateService SmartTranslateService
@inject ResourceService ResourceService
@@ -29,7 +29,7 @@
<div class="scroll-y me-n5 pe-5" style="max-height: 55vh; display: flex; flex-direction: column-reverse;">
@foreach (var message in Messages)
{
if (message.IsSystem || message.IsSupport)
if (message.Sender == null || message.Sender.Id != User.Id)
{
<div class="d-flex justify-content-end mb-10 ">
<div class="d-flex flex-column align-items-end">
@@ -37,29 +37,29 @@
<div class="me-3">
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
<a class="fs-5 fw-bold text-gray-900 text-hover-primary ms-1">
@if (message.IsSupport && !message.IsSystem)
@if (message.Sender != null)
{
<span>@(message.Sender!.FirstName) @(message.Sender!.LastName)</span>
<span>@(message.Sender.FirstName) @(message.Sender.LastName)</span>
}
else
{
<span>System</span>
<span><TL>System</TL></span>
}
</a>
</div>
<div class="symbol symbol-35px symbol-circle ">
<div class="symbol symbol-35px symbol-circle ">
<img alt="Logo" src="@(ResourceService.Image("logo.svg"))">
</div>
</div>
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
@if (message.IsSystem)
@if (message.Sender == null)
{
<TL>@(message.Message)</TL>
<TL>@(message.Content)</TL>
}
else
{
@(message.Message)
@(message.Content)
}
</div>
</div>
@@ -75,14 +75,14 @@
</div>
<div class="ms-3">
<a class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">
<span>@(message.Sender!.FirstName) @(message.Sender!.LastName)</span>
<span>@(message.Sender.FirstName) @(message.Sender.LastName)</span>
</a>
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
</div>
</div>
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
@(message.Message)
@(message.Content)
</div>
</div>
</div>
@@ -92,11 +92,7 @@
</LazyLoader>
</div>
<div class="card-footer">
@{
var typingUsers = SupportAdminService.GetTypingUsers();
}
@if (typingUsers.Any())
@if (Typing.Any())
{
<span class="mb-5 fs-5 d-flex flex-row">
<div class="wave me-1">
@@ -104,16 +100,16 @@
<div class="dot h-5px w-5px" style="margin-right: 1px;"></div>
<div class="dot h-5px w-5px"></div>
</div>
@if (typingUsers.Length > 1)
@if (Typing.Length > 1)
{
<span>
@(typingUsers.Aggregate((current, next) => current + ", " + next)) <TL>are typing</TL>
@(Typing.Aggregate((current, next) => current + ", " + next)) <TL>are typing</TL>
</span>
}
else
{
<span>
@(typingUsers.First()) <TL>is typing</TL>
@(Typing.First()) <TL>is typing</TL>
</span>
}
</span>
@@ -157,7 +153,7 @@
</div>
<div class="d-flex align-items-center mb-2">
<span class="fw-semibold text-gray-800 fs-5 m-0">
<TL>Email</TL>: <a href="/admin/users/view/@User.Id">@(User.Email)</a>
<TL>Email</TL>: <a href="/admin/users/view/@User.Id">@(User.Email)</a>
</span>
</div>
<div class="align-items-center mt-3">
@@ -183,9 +179,11 @@
public int Id { get; set; }
private User? User;
private SupportMessage[] Messages;
private string Content = "";
private SupportChatMessage[] Messages = Array.Empty<SupportChatMessage>();
private string[] Typing = Array.Empty<string>();
private string Content = "";
private DateTime LastTypingTimestamp = DateTime.UtcNow.AddMinutes(-10);
private async Task Load(LazyLoader arg)
@@ -196,55 +194,54 @@
if (User != null)
{
SupportAdminService.OnNewMessage += OnNewMessage;
SupportAdminService.OnUpdateTyping += OnUpdateTyping;
AdminService.OnMessage += OnMessage;
AdminService.OnTypingChanged += OnTypingChanged;
await SupportAdminService.Start(User);
await AdminService.Start(User);
}
}
#region Message handling
private async void OnNewMessage(object? sender, SupportMessage e)
private async Task LoadMessages(LazyLoader arg)
{
Messages = (await SupportAdminService.GetMessages()).Reverse().ToArray();
Messages = await AdminService.GetMessages();
}
private async Task OnTypingChanged(string[] typing)
{
Typing = typing;
await InvokeAsync(StateHasChanged);
}
private async Task LoadMessages(LazyLoader arg)
private async Task OnMessage(SupportChatMessage arg)
{
Messages = (await SupportAdminService.GetMessages()).Reverse().ToArray();
Messages = await AdminService.GetMessages();
//TODO: Sound when message from system or admin
await InvokeAsync(StateHasChanged);
}
#endregion
private async Task Send()
{
await SupportAdminService.SendMessage(Content);
await AdminService.SendMessage(Content);
Content = "";
await InvokeAsync(StateHasChanged);
}
private async Task CloseTicket()
{
await SupportAdminService.Close();
await AdminService.Close();
}
#region Typing
private async Task OnTyping()
{
if ((DateTime.UtcNow - LastTypingTimestamp).TotalSeconds > 5)
{
LastTypingTimestamp = DateTime.UtcNow;
await SupportAdminService.TriggerTyping();
await AdminService.SendTyping();
}
}
private async void OnUpdateTyping(object? sender, EventArgs e)
{
await InvokeAsync(StateHasChanged);
}
#endregion
}

View File

@@ -2,10 +2,10 @@
@using Moonlight.App.Services
@using Moonlight.App.Database.Entities
@using Moonlight.App.Helpers
@using Moonlight.App.Services.Support
@using Moonlight.App.Services.SupportChat
@inject ResourceService ResourceService
@inject SupportClientService SupportClientService
@inject SupportChatClientService ClientService
@inject SmartTranslateService SmartTranslateService
<LazyLoader Load="Load">
@@ -16,7 +16,7 @@
<div class="scroll-y me-n5 pe-5" style="max-height: 55vh; display: flex; flex-direction: column-reverse;">
@foreach (var message in Messages)
{
if (message.IsSystem || message.IsSupport)
if (message.Sender == null || message.Sender.Id != User.Id)
{
<div class="d-flex justify-content-start mb-10 ">
<div class="d-flex flex-column align-items-start">
@@ -26,13 +26,13 @@
</div>
<div class="ms-3">
<a class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">
@if (message.IsSupport && !message.IsSystem)
@if (message.Sender != null)
{
<span>@(message.Sender!.FirstName) @(message.Sender!.LastName)</span>
<span>@(message.Sender.FirstName) @(message.Sender.LastName)</span>
}
else
{
<span>System</span>
<span><TL>System</TL></span>
}
</a>
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
@@ -40,13 +40,13 @@
</div>
<div class="p-5 rounded bg-light-info text-dark fw-semibold mw-lg-400px text-start">
@if (message.IsSystem)
@if (message.Sender == null)
{
<TL>@(message.Message)</TL>
<TL>@(message.Content)</TL>
}
else
{
@(message.Message)
@(message.Content)
}
</div>
</div>
@@ -60,7 +60,7 @@
<div class="me-3">
<span class="text-muted fs-7 mb-1">@(Formatter.FormatAgoFromDateTime(message.CreatedAt, SmartTranslateService))</span>
<a class="fs-5 fw-bold text-gray-900 text-hover-primary ms-1">
<span>@(message.Sender!.FirstName) @(message.Sender!.LastName)</span>
<span>@(message.Sender.FirstName) @(message.Sender.LastName)</span>
</a>
</div>
<div class="symbol symbol-35px symbol-circle ">
@@ -69,7 +69,7 @@
</div>
<div class="p-5 rounded bg-light-primary text-dark fw-semibold mw-lg-400px text-end">
@(message.Message)
@(message.Content)
</div>
</div>
</div>
@@ -83,7 +83,7 @@
</div>
<div class="ms-3">
<a class="fs-5 fw-bold text-gray-900 text-hover-primary me-1">
<span>System</span>
<span><TL>System</TL></span>
</a>
</div>
</div>
@@ -97,11 +97,7 @@
</LazyLoader>
</div>
<div class="card-footer">
@{
var typingUsers = SupportClientService.GetTypingUsers();
}
@if (typingUsers.Any())
@if (Typing.Any())
{
<span class="mb-5 fs-5 d-flex flex-row">
<div class="wave me-1">
@@ -109,13 +105,13 @@
<div class="dot h-5px w-5px" style="margin-right: 1px;"></div>
<div class="dot h-5px w-5px"></div>
</div>
@if (typingUsers.Length > 1)
@if (Typing.Length > 1)
{
<span>@(typingUsers.Aggregate((current, next) => current + ", " + next)) <TL>are typing</TL></span>
<span>@(Typing.Aggregate((current, next) => current + ", " + next)) <TL>are typing</TL></span>
}
else
{
<span>@(typingUsers.First()) <TL>is typing</TL></span>
<span>@(Typing.First()) <TL>is typing</TL></span>
}
</span>
}
@@ -149,59 +145,60 @@
@code
{
private SupportMessage[] Messages;
private string Content = "";
[CascadingParameter]
public User User { get; set; }
private SupportChatMessage[] Messages = Array.Empty<SupportChatMessage>();
private string[] Typing = Array.Empty<string>();
private string Content = "";
private DateTime LastTypingTimestamp = DateTime.UtcNow.AddMinutes(-10);
private async Task Load(LazyLoader lazyLoader)
{
await lazyLoader.SetText("Starting chat client");
ClientService.OnMessage += OnMessage;
ClientService.OnTypingChanged += OnTypingChanged;
SupportClientService.OnNewMessage += OnNewMessage;
SupportClientService.OnUpdateTyping += OnUpdateTyping;
await ClientService.Start();
}
private async Task LoadMessages(LazyLoader arg)
{
Messages = await ClientService.GetMessages();
}
await SupportClientService.Start();
private async Task OnTypingChanged(string[] typing)
{
Typing = typing;
await InvokeAsync(StateHasChanged);
}
private async Task OnMessage(SupportChatMessage message)
{
Messages = await ClientService.GetMessages();
//TODO: Sound when message from system or admin
await InvokeAsync(StateHasChanged);
}
private async Task Send()
{
await SupportClientService.SendMessage(Content);
await ClientService.SendMessage(Content);
Content = "";
await InvokeAsync(StateHasChanged);
}
#region Message handling
private async void OnNewMessage(object? sender, SupportMessage e)
{
Messages = (await SupportClientService.GetMessages()).Reverse().ToArray();
await InvokeAsync(StateHasChanged);
}
private async Task LoadMessages(LazyLoader arg)
{
Messages = (await SupportClientService.GetMessages()).Reverse().ToArray();
}
#endregion
#region Typing
private async void OnUpdateTyping(object? sender, EventArgs e)
{
await InvokeAsync(StateHasChanged);
}
private async void OnTyping()
{
if ((DateTime.UtcNow - LastTypingTimestamp).TotalSeconds > 5)
{
LastTypingTimestamp = DateTime.UtcNow;
await SupportClientService.TriggerTyping();
await ClientService.SendTyping();
}
}
#endregion
}