From a38fdb4459a255d966d984401738f3604334dc86 Mon Sep 17 00:00:00 2001 From: Earthmark Date: Thu, 12 Feb 2026 19:22:09 -0800 Subject: [PATCH] Updated to dotnet 10 Added design documentation --- .../IntegrationFixtureCollection.cs | 11 +- .../MenteeNeosLegacyJsonFormatTests.cs | 231 ++++++----- .../Integration/MentorJsonFormatTests.cs | 214 +++++----- .../Integration/MentorRestTests.cs | 44 +-- MentorBot.Tests/Integration/PsuedoUser.cs | 113 +++--- .../SignalWebApplicationFactory.cs | 260 ++++++------ MentorBot.Tests/MentorBot.Tests.csproj | 46 +-- MentorBot.Tests/Models/MentorContextTests.cs | 172 ++++---- MentorBot.Tests/Models/TestSignalContext.cs | 51 ++- MentorBot.Tests/UrlEncoderTests.cs | 117 +++--- MentorBot.Tests/WebSocketExtensionsTests.cs | 119 +++--- MentorBot/Controllers/LoginController.cs | 63 ++- MentorBot/Controllers/MenteeController.cs | 69 ++-- MentorBot/Controllers/MentorController.cs | 152 +++---- MentorBot/Controllers/WsMenteeController.cs | 301 +++++++------- MentorBot/Controllers/WsMentorController.cs | 188 +++++---- MentorBot/DateTimeExtensions.cs | 30 +- MentorBot/Discord/DiscordContext.cs | 281 ++++++------- MentorBot/Discord/DiscordContextExtensions.cs | 26 ++ MentorBot/Discord/DiscordHealthCheck.cs | 39 +- .../Discord/DiscordHostedServiceProxy.cs | 18 + MentorBot/Discord/TicketDiscordProxy.cs | 127 ++---- MentorBot/Discord/TicketDiscordProxyHost.cs | 58 +++ MentorBot/EnumerableExtensions.cs | 25 +- MentorBot/Extern/NeosApi.cs | 147 ------- MentorBot/Extern/NeosApiAuth.cs | 43 -- MentorBot/Extern/ResoniteApi.cs | 113 ++++++ MentorBot/Extern/ResoniteApiAuth.cs | 39 ++ MentorBot/Extern/ResoniteApiExtensions.cs | 23 ++ MentorBot/JsonSerializerOptionsExtensions.cs | 27 +- MentorBot/MentorBot.csproj | 50 +-- MentorBot/MentorOptions.cs | 15 +- MentorBot/Models/DbCreator.cs | 15 +- MentorBot/Models/Mentor.cs | 65 +-- MentorBot/Models/MentorContext.cs | 172 ++++---- MentorBot/Models/SignalContext.cs | 156 ++++---- MentorBot/Models/SignalContextExtensions.cs | 21 + MentorBot/Models/Ticket.cs | 371 +++++++++--------- MentorBot/Models/TicketContext.cs | 316 +++++++-------- MentorBot/Models/TicketNotifier.cs | 184 +++++---- MentorBot/Models/User.cs | 26 +- MentorBot/Pages/Index.cshtml | 177 +++++---- MentorBot/Program.cs | 51 +-- MentorBot/ServiceProviderExtensions.cs | 62 +-- MentorBot/ThrottleAttribute.cs | 98 +++-- MentorBot/TokenGenerator.cs | 35 +- MentorBot/UrlEncoder.cs | 58 ++- MentorBot/WebSocketExtensions.cs | 142 ++++--- MentorPanelTranslationPacker/Locales/cs.json | 56 +-- MentorPanelTranslationPacker/Locales/de.json | 47 +-- MentorPanelTranslationPacker/Locales/en.json | 46 +-- MentorPanelTranslationPacker/Locales/fr.json | 46 +-- MentorPanelTranslationPacker/Locales/ja.json | 48 +-- MentorPanelTranslationPacker/Locales/ru.json | 46 +-- .../MentorPanelTranslationPacker.csproj | 24 +- MentorPanelTranslationPacker/Program.cs | 69 ++-- docs/Dependencies.md | 23 ++ docs/LeadFlows.md | 7 + docs/MentorFlows.md | 61 +++ docs/README.md | 46 +++ docs/UserFlows.md | 39 ++ 61 files changed, 2853 insertions(+), 2866 deletions(-) create mode 100644 MentorBot/Discord/DiscordContextExtensions.cs create mode 100644 MentorBot/Discord/DiscordHostedServiceProxy.cs create mode 100644 MentorBot/Discord/TicketDiscordProxyHost.cs delete mode 100644 MentorBot/Extern/NeosApi.cs delete mode 100644 MentorBot/Extern/NeosApiAuth.cs create mode 100644 MentorBot/Extern/ResoniteApi.cs create mode 100644 MentorBot/Extern/ResoniteApiAuth.cs create mode 100644 MentorBot/Extern/ResoniteApiExtensions.cs create mode 100644 MentorBot/Models/SignalContextExtensions.cs create mode 100644 docs/Dependencies.md create mode 100644 docs/LeadFlows.md create mode 100644 docs/MentorFlows.md create mode 100644 docs/README.md create mode 100644 docs/UserFlows.md diff --git a/MentorBot.Tests/Integration/IntegrationFixtureCollection.cs b/MentorBot.Tests/Integration/IntegrationFixtureCollection.cs index f76964b..e364862 100644 --- a/MentorBot.Tests/Integration/IntegrationFixtureCollection.cs +++ b/MentorBot.Tests/Integration/IntegrationFixtureCollection.cs @@ -1,9 +1,6 @@ using Xunit; -namespace MentorBot.Tests.Integration -{ - [CollectionDefinition("Integration collection")] - public class IntegrationFixtureCollection : ICollectionFixture - { - } -} +namespace MentorBot.Tests.Integration; + +[CollectionDefinition("Integration collection")] +public class IntegrationFixtureCollection : ICollectionFixture; \ No newline at end of file diff --git a/MentorBot.Tests/Integration/MenteeNeosLegacyJsonFormatTests.cs b/MentorBot.Tests/Integration/MenteeNeosLegacyJsonFormatTests.cs index 0cc8e64..80453d9 100644 --- a/MentorBot.Tests/Integration/MenteeNeosLegacyJsonFormatTests.cs +++ b/MentorBot.Tests/Integration/MenteeNeosLegacyJsonFormatTests.cs @@ -1,118 +1,113 @@ -using Microsoft.AspNetCore.TestHost; -using System.Threading.Tasks; -using Xunit; - -namespace MentorBot.Tests.Integration -{ - /// - /// This ensures the mentor signal panels are compatible with the service, - /// be careful when changing these tests. - /// - [Collection("Integration collection")] - public class MenteeNeosLegacyJsonFormatTests : IClassFixture - { - private readonly WebSocketClient _wsClient; - - public MenteeNeosLegacyJsonFormatTests(SignalWebApplicationFactory factory) - { - _wsClient = factory.Server.CreateWebSocketClient(); - } - - [Fact] - public async Task CreateAndCancelTicketSequence() - { - string ticket; - using (var mentee = await _wsClient.BindUser("/ws/mentee?userId=U-User")) - { - var msg = await mentee.ReadAsync(); - ticket = Assert.Contains("ticket", msg); - Assert.Equal("requested", Assert.Contains("status", msg)); - } - - using (var mentee = await _wsClient.BindUser($"/ws/mentee/{ticket}")) - { - var msg = await mentee.ReadAsync(); - Assert.Equal(ticket, Assert.Contains("ticket", msg)); - Assert.Equal("requested", Assert.Contains("status", msg)); - - await mentee.SendAsync("type=cancel"); - var msg2 = await mentee.ReadAsync(); - Assert.Equal(ticket, Assert.Contains("ticket", msg2)); - Assert.Equal("canceled", Assert.Contains("status", msg2)); - } - } - - [Fact] - public async Task CreateClaimAndCancelTicketSequence() - { - using var mentor = await _wsClient.BindUser("/ws/mentor/MENTOR"); - - string ticket; - using (var mentee = await _wsClient.BindUser("/ws/mentee?userId=U-User")) - { - var msg = await mentee.ReadAsync(); - ticket = Assert.Contains("ticket", msg); - Assert.Equal("requested", Assert.Contains("status", msg)); - } - - var mentorMsg = await mentor.ReadAsync(ticket); - - using (var mentee = await _wsClient.BindUser($"/ws/mentee/{ticket}")) - { - var msg = await mentee.ReadAsync(); - Assert.Equal(ticket, Assert.Contains("ticket", msg)); - Assert.Equal("requested", Assert.Contains("status", msg)); - - await mentor.SendAsync($"ticket={ticket}&type=claim"); - - var msg2 = await mentee.ReadAsync(); - Assert.Equal(ticket, Assert.Contains("ticket", msg2)); - Assert.Equal("responding", Assert.Contains("status", msg2)); - Assert.Equal("Mentor", Assert.Contains("mentor", msg2)); - - await mentee.SendAsync("type=cancel"); - var msg3 = await mentee.ReadAsync(); - Assert.Equal(ticket, Assert.Contains("ticket", msg3)); - Assert.Equal("canceled", Assert.Contains("status", msg3)); - } - } - - [Fact] - public async Task CreateClaimAndCompleteTicketSequence() - { - using var mentor = await _wsClient.BindUser("/ws/mentor/MENTOR"); - - string ticket; - using (var mentee = await _wsClient.BindUser("/ws/mentee?userId=U-User")) - { - var msg = await mentee.ReadAsync(); - ticket = Assert.Contains("ticket", msg); - Assert.Equal("requested", Assert.Contains("status", msg)); - } - - using (var mentee = await _wsClient.BindUser($"/ws/mentee/{ticket}")) - { - var msg = await mentee.ReadAsync(); - Assert.Equal(2, msg.Count); - Assert.Equal(ticket, Assert.Contains("ticket", msg)); - Assert.Equal("requested", Assert.Contains("status", msg)); - - await mentor.SendAsync($"ticket={ticket}&type=claim"); - - var msg2 = await mentee.ReadAsync(); - Assert.Equal(3, msg2.Count); - Assert.Equal(ticket, Assert.Contains("ticket", msg2)); - Assert.Equal("responding", Assert.Contains("status", msg2)); - Assert.Equal("Mentor", Assert.Contains("mentor", msg2)); - - await mentor.SendAsync($"ticket={ticket}&type=complete"); - - var msg3 = await mentee.ReadAsync(); - Assert.Equal(3, msg3.Count); - Assert.Equal(ticket, Assert.Contains("ticket", msg3)); - Assert.Equal("completed", Assert.Contains("status", msg3)); - Assert.Equal("Mentor", Assert.Contains("mentor", msg3)); - } - } - } -} +using System.Threading.Tasks; +using Microsoft.AspNetCore.TestHost; +using Xunit; + +namespace MentorBot.Tests.Integration; + +/// +/// This ensures the mentor signal panels are compatible with the service, +/// be careful when changing these tests. +/// +[Collection("Integration collection")] +public class MenteeNeosLegacyJsonFormatTests(SignalWebApplicationFactory factory) + : IClassFixture +{ + private readonly WebSocketClient _wsClient = factory.Server.CreateWebSocketClient(); + + [Fact] + public async Task CreateAndCancelTicketSequence() + { + string ticket; + using (var mentee = await _wsClient.BindUser("/ws/mentee?userId=U-User")) + { + var msg = await mentee.ReadAsync(); + ticket = Assert.Contains("ticket", msg); + Assert.Equal("requested", Assert.Contains("status", msg)); + } + + using (var mentee = await _wsClient.BindUser($"/ws/mentee/{ticket}")) + { + var msg = await mentee.ReadAsync(); + Assert.Equal(ticket, Assert.Contains("ticket", msg)); + Assert.Equal("requested", Assert.Contains("status", msg)); + + await mentee.SendAsync("type=cancel"); + var msg2 = await mentee.ReadAsync(); + Assert.Equal(ticket, Assert.Contains("ticket", msg2)); + Assert.Equal("canceled", Assert.Contains("status", msg2)); + } + } + + [Fact] + public async Task CreateClaimAndCancelTicketSequence() + { + using var mentor = await _wsClient.BindUser("/ws/mentor/MENTOR"); + + string ticket; + using (var mentee = await _wsClient.BindUser("/ws/mentee?userId=U-User")) + { + var msg = await mentee.ReadAsync(); + ticket = Assert.Contains("ticket", msg); + Assert.Equal("requested", Assert.Contains("status", msg)); + } + + var mentorMsg = await mentor.ReadAsync(ticket); + + using (var mentee = await _wsClient.BindUser($"/ws/mentee/{ticket}")) + { + var msg = await mentee.ReadAsync(); + Assert.Equal(ticket, Assert.Contains("ticket", msg)); + Assert.Equal("requested", Assert.Contains("status", msg)); + + await mentor.SendAsync($"ticket={ticket}&type=claim"); + + var msg2 = await mentee.ReadAsync(); + Assert.Equal(ticket, Assert.Contains("ticket", msg2)); + Assert.Equal("responding", Assert.Contains("status", msg2)); + Assert.Equal("Mentor", Assert.Contains("mentor", msg2)); + + await mentee.SendAsync("type=cancel"); + var msg3 = await mentee.ReadAsync(); + Assert.Equal(ticket, Assert.Contains("ticket", msg3)); + Assert.Equal("canceled", Assert.Contains("status", msg3)); + } + } + + [Fact] + public async Task CreateClaimAndCompleteTicketSequence() + { + using var mentor = await _wsClient.BindUser("/ws/mentor/MENTOR"); + + string ticket; + using (var mentee = await _wsClient.BindUser("/ws/mentee?userId=U-User")) + { + var msg = await mentee.ReadAsync(); + ticket = Assert.Contains("ticket", msg); + Assert.Equal("requested", Assert.Contains("status", msg)); + } + + using (var mentee = await _wsClient.BindUser($"/ws/mentee/{ticket}")) + { + var msg = await mentee.ReadAsync(); + Assert.Equal(2, msg.Count); + Assert.Equal(ticket, Assert.Contains("ticket", msg)); + Assert.Equal("requested", Assert.Contains("status", msg)); + + await mentor.SendAsync($"ticket={ticket}&type=claim"); + + var msg2 = await mentee.ReadAsync(); + Assert.Equal(3, msg2.Count); + Assert.Equal(ticket, Assert.Contains("ticket", msg2)); + Assert.Equal("responding", Assert.Contains("status", msg2)); + Assert.Equal("Mentor", Assert.Contains("mentor", msg2)); + + await mentor.SendAsync($"ticket={ticket}&type=complete"); + + var msg3 = await mentee.ReadAsync(); + Assert.Equal(3, msg3.Count); + Assert.Equal(ticket, Assert.Contains("ticket", msg3)); + Assert.Equal("completed", Assert.Contains("status", msg3)); + Assert.Equal("Mentor", Assert.Contains("mentor", msg3)); + } + } +} \ No newline at end of file diff --git a/MentorBot.Tests/Integration/MentorJsonFormatTests.cs b/MentorBot.Tests/Integration/MentorJsonFormatTests.cs index a34a031..3916dad 100644 --- a/MentorBot.Tests/Integration/MentorJsonFormatTests.cs +++ b/MentorBot.Tests/Integration/MentorJsonFormatTests.cs @@ -1,110 +1,104 @@ -using Microsoft.AspNetCore.TestHost; -using System; -using System.Threading.Tasks; -using Xunit; - -namespace MentorBot.Tests.Integration -{ - [Collection("Integration collection")] - public class MentorJsonFormatTests : IClassFixture - { - private readonly WebSocketClient _wsClient; - - public MentorJsonFormatTests(SignalWebApplicationFactory factory) - { - _wsClient = factory.Server.CreateWebSocketClient(); - } - - [Fact] - public async Task CreateClaimAndCancelTicketSequence() - { - using var mentor = await _wsClient.BindUser("/ws/mentor/MENTOR"); - - string ticket; - using (var mentee = await _wsClient.BindUser("/ws/mentee?userId=U-User")) - { - var msg = await mentee.ReadAsync(); - ticket = Assert.Contains("ticket", msg); - Assert.Equal("requested", Assert.Contains("status", msg)); - } - - var mentorMsg = await mentor.ReadAsync(ticket); - Assert.Equal(ticket, Assert.Contains("ticket", mentorMsg)); - Assert.Equal("requested", Assert.Contains("status", mentorMsg)); - Assert.True(DateTime.TryParse(Assert.Contains("created", mentorMsg), out var _)); - Assert.Equal("U-User", Assert.Contains("userId", mentorMsg)); - Assert.Equal("User", Assert.Contains("userName", mentorMsg)); - - using (var mentee = await _wsClient.BindUser($"/ws/mentee/{ticket}")) - { - var msg = await mentee.ReadAsync(); - Assert.Equal(ticket, Assert.Contains("ticket", msg)); - Assert.Equal("requested", Assert.Contains("status", msg)); - - await mentor.SendAsync($"ticket={ticket}&type=claim"); - var mentorMsg2 = await mentor.ReadAsync(ticket); - Assert.Equal(ticket, Assert.Contains("ticket", mentorMsg2)); - Assert.Equal("responding", Assert.Contains("status", mentorMsg2)); - Assert.True(DateTime.TryParse(Assert.Contains("created", mentorMsg2), out var _)); - Assert.Equal("U-User", Assert.Contains("userId", mentorMsg2)); - Assert.Equal("User", Assert.Contains("userName", mentorMsg2)); - - var msg2 = await mentee.ReadAsync(); - Assert.Equal(ticket, Assert.Contains("ticket", msg2)); - Assert.Equal("responding", Assert.Contains("status", msg2)); - Assert.Equal("Mentor", Assert.Contains("mentor", msg2)); - - await mentee.SendAsync("type=cancel"); - var msg3 = await mentee.ReadAsync(); - Assert.Equal(ticket, Assert.Contains("ticket", msg3)); - Assert.Equal("canceled", Assert.Contains("status", msg3)); - } - } - - [Fact] - public async Task CreateClaimAndCompleteTicketSequence() - { - using var mentor = await _wsClient.BindUser("/ws/mentor/MENTOR"); - - string ticket; - using (var mentee = await _wsClient.BindUser("/ws/mentee?userId=U-User")) - { - var msg = await mentee.ReadAsync(); - ticket = Assert.Contains("ticket", msg); - Assert.Equal("requested", Assert.Contains("status", msg)); - } - - var mentorMsg = await mentor.ReadAsync(ticket); - Assert.Equal(5, mentorMsg.Count); - Assert.Equal(ticket, Assert.Contains("ticket", mentorMsg)); - Assert.Equal("requested", Assert.Contains("status", mentorMsg)); - Assert.True(DateTime.TryParse(Assert.Contains("created", mentorMsg), out var _)); - Assert.Equal("U-User", Assert.Contains("userId", mentorMsg)); - Assert.Equal("User", Assert.Contains("userName", mentorMsg)); - - using (var mentee = await _wsClient.BindUser($"/ws/mentee/{ticket}")) - { - var msg = await mentee.ReadAsync(); - Assert.Equal(2, msg.Count); - Assert.Equal(ticket, Assert.Contains("ticket", msg)); - Assert.Equal("requested", Assert.Contains("status", msg)); - - await mentor.SendAsync($"ticket={ticket}&type=claim"); - - var msg2 = await mentee.ReadAsync(); - Assert.Equal(3, msg2.Count); - Assert.Equal(ticket, Assert.Contains("ticket", msg2)); - Assert.Equal("responding", Assert.Contains("status", msg2)); - Assert.Equal("Mentor", Assert.Contains("mentor", msg2)); - - await mentor.SendAsync($"ticket={ticket}&type=complete"); - - var msg3 = await mentee.ReadAsync(); - Assert.Equal(3, msg3.Count); - Assert.Equal(ticket, Assert.Contains("ticket", msg3)); - Assert.Equal("completed", Assert.Contains("status", msg3)); - Assert.Equal("Mentor", Assert.Contains("mentor", msg3)); - } - } - } -} +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.TestHost; +using Xunit; + +namespace MentorBot.Tests.Integration; + +[Collection("Integration collection")] +public class MentorJsonFormatTests(SignalWebApplicationFactory factory) : IClassFixture +{ + private readonly WebSocketClient _wsClient = factory.Server.CreateWebSocketClient(); + + [Fact] + public async Task CreateClaimAndCancelTicketSequence() + { + using var mentor = await _wsClient.BindUser("/ws/mentor/MENTOR"); + + string ticket; + using (var mentee = await _wsClient.BindUser("/ws/mentee?userId=U-User")) + { + var msg = await mentee.ReadAsync(); + ticket = Assert.Contains("ticket", msg); + Assert.Equal("requested", Assert.Contains("status", msg)); + } + + var mentorMsg = await mentor.ReadAsync(ticket); + Assert.Equal(ticket, Assert.Contains("ticket", mentorMsg)); + Assert.Equal("requested", Assert.Contains("status", mentorMsg)); + Assert.True(DateTime.TryParse(Assert.Contains("created", mentorMsg), out _)); + Assert.Equal("U-User", Assert.Contains("userId", mentorMsg)); + Assert.Equal("User", Assert.Contains("userName", mentorMsg)); + + using (var mentee = await _wsClient.BindUser($"/ws/mentee/{ticket}")) + { + var msg = await mentee.ReadAsync(); + Assert.Equal(ticket, Assert.Contains("ticket", msg)); + Assert.Equal("requested", Assert.Contains("status", msg)); + + await mentor.SendAsync($"ticket={ticket}&type=claim"); + var mentorMsg2 = await mentor.ReadAsync(ticket); + Assert.Equal(ticket, Assert.Contains("ticket", mentorMsg2)); + Assert.Equal("responding", Assert.Contains("status", mentorMsg2)); + Assert.True(DateTime.TryParse(Assert.Contains("created", mentorMsg2), out _)); + Assert.Equal("U-User", Assert.Contains("userId", mentorMsg2)); + Assert.Equal("User", Assert.Contains("userName", mentorMsg2)); + + var msg2 = await mentee.ReadAsync(); + Assert.Equal(ticket, Assert.Contains("ticket", msg2)); + Assert.Equal("responding", Assert.Contains("status", msg2)); + Assert.Equal("Mentor", Assert.Contains("mentor", msg2)); + + await mentee.SendAsync("type=cancel"); + var msg3 = await mentee.ReadAsync(); + Assert.Equal(ticket, Assert.Contains("ticket", msg3)); + Assert.Equal("canceled", Assert.Contains("status", msg3)); + } + } + + [Fact] + public async Task CreateClaimAndCompleteTicketSequence() + { + using var mentor = await _wsClient.BindUser("/ws/mentor/MENTOR"); + + string ticket; + using (var mentee = await _wsClient.BindUser("/ws/mentee?userId=U-User")) + { + var msg = await mentee.ReadAsync(); + ticket = Assert.Contains("ticket", msg); + Assert.Equal("requested", Assert.Contains("status", msg)); + } + + var mentorMsg = await mentor.ReadAsync(ticket); + Assert.Equal(5, mentorMsg.Count); + Assert.Equal(ticket, Assert.Contains("ticket", mentorMsg)); + Assert.Equal("requested", Assert.Contains("status", mentorMsg)); + Assert.True(DateTime.TryParse(Assert.Contains("created", mentorMsg), out _)); + Assert.Equal("U-User", Assert.Contains("userId", mentorMsg)); + Assert.Equal("User", Assert.Contains("userName", mentorMsg)); + + using (var mentee = await _wsClient.BindUser($"/ws/mentee/{ticket}")) + { + var msg = await mentee.ReadAsync(); + Assert.Equal(2, msg.Count); + Assert.Equal(ticket, Assert.Contains("ticket", msg)); + Assert.Equal("requested", Assert.Contains("status", msg)); + + await mentor.SendAsync($"ticket={ticket}&type=claim"); + + var msg2 = await mentee.ReadAsync(); + Assert.Equal(3, msg2.Count); + Assert.Equal(ticket, Assert.Contains("ticket", msg2)); + Assert.Equal("responding", Assert.Contains("status", msg2)); + Assert.Equal("Mentor", Assert.Contains("mentor", msg2)); + + await mentor.SendAsync($"ticket={ticket}&type=complete"); + + var msg3 = await mentee.ReadAsync(); + Assert.Equal(3, msg3.Count); + Assert.Equal(ticket, Assert.Contains("ticket", msg3)); + Assert.Equal("completed", Assert.Contains("status", msg3)); + Assert.Equal("Mentor", Assert.Contains("mentor", msg3)); + } + } +} \ No newline at end of file diff --git a/MentorBot.Tests/Integration/MentorRestTests.cs b/MentorBot.Tests/Integration/MentorRestTests.cs index ee124b7..8636be2 100644 --- a/MentorBot.Tests/Integration/MentorRestTests.cs +++ b/MentorBot.Tests/Integration/MentorRestTests.cs @@ -1,25 +1,19 @@ -using System.Net.Http; -using System.Threading.Tasks; -using Xunit; - -namespace MentorBot.Tests.Integration -{ - [Collection("Integration collection")] - public class MentorRestTests : IClassFixture - { - private readonly HttpClient _client; - - public MentorRestTests(SignalWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } - - [Fact] - public async Task CreateTicketCreatesExpectedRecord() - { - var response = await _client.GetAsync("/mentor"); - response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadAsStringAsync(); - } - } -} +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace MentorBot.Tests.Integration; + +[Collection("Integration collection")] +public class MentorRestTests(SignalWebApplicationFactory factory) : IClassFixture +{ + private readonly HttpClient _client = factory.CreateClient(); + + [Fact] + public async Task CreateTicketCreatesExpectedRecord() + { + var response = await _client.GetAsync("/mentor"); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsStringAsync(); + } +} \ No newline at end of file diff --git a/MentorBot.Tests/Integration/PsuedoUser.cs b/MentorBot.Tests/Integration/PsuedoUser.cs index 750b073..9abdb5e 100644 --- a/MentorBot.Tests/Integration/PsuedoUser.cs +++ b/MentorBot.Tests/Integration/PsuedoUser.cs @@ -1,58 +1,55 @@ -using Microsoft.AspNetCore.TestHost; -using System; -using System.Collections.Generic; -using System.Net.WebSockets; -using System.Threading; -using System.Threading.Tasks; - -namespace MentorBot.Tests.Integration -{ - public class PsuedoUser : IDisposable - { - private readonly WebSocket _socket; - private readonly CancellationTokenSource _stopToken = new(); - private readonly Func _sender; - - public PsuedoUser(WebSocket socket) - { - _socket = socket; - _sender = _socket.RawMessageSender(_stopToken.Token); - } - - public async Task SendAsync(string message) - { - await _sender(message); - } - - public async Task> ReadAsync(string? ticketId = null) - { - var cancelToken = new CancellationTokenSource(100); - var trueCancel = CancellationTokenSource.CreateLinkedTokenSource(cancelToken.Token, _stopToken.Token); - - await foreach (var msg in _socket.ReadRawMessages(_stopToken.Token).WithCancellation(trueCancel.Token)) - { - var values = UrlEncoder.Decode>(msg); - if (ticketId == null || values["ticket"] == ticketId) - { - return values; - } - } - throw new InvalidOperationException("Mentor disconnected before connection was complete"); - } - - public void Dispose() - { - _stopToken.Cancel(); - _socket.CloseOutputAsync(WebSocketCloseStatus.Empty, null, CancellationToken.None).Wait(); - } - } - - public static class WebSocketClientExtensions - { - public static async Task BindUser(this WebSocketClient client, string route) - { - return new PsuedoUser(await client.ConnectAsync( - new Uri($"http://locahost{route}"), CancellationToken.None)); - } - } -} +using System; +using System.Collections.Generic; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.TestHost; + +namespace MentorBot.Tests.Integration; + +public class PsuedoUser : IDisposable +{ + private readonly Func _sender; + private readonly WebSocket _socket; + private readonly CancellationTokenSource _stopToken = new(); + + public PsuedoUser(WebSocket socket) + { + _socket = socket; + _sender = _socket.RawMessageSender(_stopToken.Token); + } + + public void Dispose() + { + _stopToken.Cancel(); + _socket.CloseOutputAsync(WebSocketCloseStatus.Empty, null, CancellationToken.None).Wait(); + } + + public async Task SendAsync(string message) + { + await _sender(message); + } + + public async Task> ReadAsync(string ticketId = null) + { + var cancelToken = new CancellationTokenSource(100); + var trueCancel = CancellationTokenSource.CreateLinkedTokenSource(cancelToken.Token, _stopToken.Token); + + await foreach (var msg in _socket.ReadRawMessages(_stopToken.Token).WithCancellation(trueCancel.Token)) + { + var values = UrlEncoder.Decode>(msg); + if (ticketId == null || values["ticket"] == ticketId) return values; + } + + throw new InvalidOperationException("Mentor disconnected before connection was complete"); + } +} + +public static class WebSocketClientExtensions +{ + public static async Task BindUser(this WebSocketClient client, string route) + { + return new PsuedoUser(await client.ConnectAsync( + new Uri($"http://locahost{route}"), CancellationToken.None)); + } +} \ No newline at end of file diff --git a/MentorBot.Tests/Integration/SignalWebApplicationFactory.cs b/MentorBot.Tests/Integration/SignalWebApplicationFactory.cs index a062847..caac42e 100644 --- a/MentorBot.Tests/Integration/SignalWebApplicationFactory.cs +++ b/MentorBot.Tests/Integration/SignalWebApplicationFactory.cs @@ -1,129 +1,131 @@ -using MentorBot.Discord; -using MentorBot.Extern; -using MentorBot.Models; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Moq; -using System; -using System.Linq; -using System.Threading; - -namespace MentorBot.Tests.Integration -{ - public class SignalWebApplicationFactory : WebApplicationFactory - { - public Mock NeosApi { get; } = new(); - public Mock Discord { get; } = new(); - public Mock TokenGen { get; } = new(); - - public SignalContext CreateDbContext() => - new(Services.GetRequiredService>()); - - public SignalWebApplicationFactory() - { - TokenGen.Setup(t => t.CreateToken()).Returns("TOKEN"); - AddNeosUser("U-User", "User"); - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - base.ConfigureWebHost(builder); - builder.ConfigureServices(services => - { - // Prevent discord from spinning up. - services.TryRemove(); - - services.Replace(new DbContextOptionsBuilder() - .UseSqlite("Filename=integration.db").Options); - - services.AddDbContext(); - - services.Replace(NeosApi.Object); - services.Replace(Discord.Object); - services.Replace(TokenGen.Object); - - var sp = services.BuildServiceProvider(); - - using var scope = sp.CreateScope(); - var scopedServices = scope.ServiceProvider; - var db = scopedServices.GetRequiredService(); - var logger = scopedServices - .GetRequiredService>(); - - db.Database.EnsureDeleted(); - db.Database.EnsureCreated(); - - try - { - db.Add(new Mentor - { - NeosId = "U-Mentor", - Name = "Mentor", - Token = "MENTOR" - }); - db.SaveChangesAsync().Wait(); - } - catch (Exception ex) - { - logger.LogError(ex, "An error occurred seeding the " + - "database with test messages. Error: {Message}", ex.Message); - } - }); - } - - public void AddNeosUser(string userId, string userName) - { - NeosApi.Setup(a => a.GetUserAsync(userId, It.IsAny())).ReturnsAsync(new User() - { - Id = userId, - Name = userName - }); - } - } - - public static class ServiceCollectionExtensions - { - public static IServiceCollection Remove(this IServiceCollection services) - { - var descriptor = services.Single( - d => d.ServiceType == - typeof(IService)); - services.Remove(descriptor); - return services; - } - public static IServiceCollection Remove(this IServiceCollection services) - { - var descriptor = services.Single( - d => d.ServiceType == - typeof(TInterface) && d.ImplementationType == typeof(TImplementation)); - services.Remove(descriptor); - return services; - } - public static IServiceCollection TryRemove(this IServiceCollection services) - { - var descriptor = services.SingleOrDefault( - d => d.ServiceType == - typeof(IService)); - services.Remove(descriptor); - return services; - } - public static IServiceCollection TryRemove(this IServiceCollection services) - { - var descriptor = services.SingleOrDefault( - d => d.ServiceType == - typeof(TInterface) && d.ImplementationType == typeof(TImplementation)); - services.Remove(descriptor); - return services; - } - public static IServiceCollection Replace(this IServiceCollection services, IService instance) where IService : class - { - services.TryRemove(); - services.AddSingleton(instance); - return services; - } - } -} +using System; +using System.Linq; +using System.Threading; +using MentorBot.Discord; +using MentorBot.Extern; +using MentorBot.Models; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Moq; + +namespace MentorBot.Tests.Integration; + +public class SignalWebApplicationFactory : WebApplicationFactory +{ + public SignalWebApplicationFactory() + { + TokenGen.Setup(t => t.CreateToken()).Returns("TOKEN"); + AddNeosUser("U-User", "User"); + } + + public Mock NeosApi { get; } = new(); + public Mock Discord { get; } = new(); + public Mock TokenGen { get; } = new(); + + public SignalContext CreateDbContext() + { + return new SignalContext(Services.GetRequiredService>()); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + base.ConfigureWebHost(builder); + builder.ConfigureServices(services => + { + // Prevent discord from spinning up. + services.TryRemove(); + + services.Replace(new DbContextOptionsBuilder() + .UseSqlite("Filename=integration.db").Options); + + services.AddDbContext(); + + services.Replace(NeosApi.Object); + services.Replace(Discord.Object); + services.Replace(TokenGen.Object); + + var sp = services.BuildServiceProvider(); + + using var scope = sp.CreateScope(); + var scopedServices = scope.ServiceProvider; + var db = scopedServices.GetRequiredService(); + var logger = scopedServices + .GetRequiredService>(); + + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + + try + { + db.Add(new Mentor + { + ResoUserId = "U-Mentor", + Name = "Mentor", + Token = "MENTOR" + }); + db.SaveChangesAsync().Wait(); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred seeding the " + + "database with test messages. Error: {Message}", ex.Message); + } + }); + } + + public void AddNeosUser(string userId, string userName) + { + NeosApi.Setup(a => a.GetUserAsync(userId, It.IsAny())).ReturnsAsync(new User + { + Id = userId, + Name = userName + }); + } +} + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection Remove(this IServiceCollection services) + { + var descriptor = services.Single(d => d.ServiceType == + typeof(IService)); + services.Remove(descriptor); + return services; + } + + public static IServiceCollection Remove(this IServiceCollection services) + { + var descriptor = services.Single(d => d.ServiceType == + typeof(TInterface) && d.ImplementationType == typeof(TImplementation)); + services.Remove(descriptor); + return services; + } + + public static IServiceCollection TryRemove(this IServiceCollection services) + { + var descriptor = services.SingleOrDefault(d => d.ServiceType == + typeof(IService)); + services.Remove(descriptor); + return services; + } + + public static IServiceCollection TryRemove(this IServiceCollection services) + { + var descriptor = services.SingleOrDefault(d => d.ServiceType == + typeof(TInterface) && d.ImplementationType == typeof(TImplementation)); + services.Remove(descriptor); + return services; + } + + public static IServiceCollection Replace(this IServiceCollection services, IService instance) + where IService : class + { + services.TryRemove(); + services.AddSingleton(instance); + return services; + } +} \ No newline at end of file diff --git a/MentorBot.Tests/MentorBot.Tests.csproj b/MentorBot.Tests/MentorBot.Tests.csproj index 0ba92bb..3922185 100644 --- a/MentorBot.Tests/MentorBot.Tests.csproj +++ b/MentorBot.Tests/MentorBot.Tests.csproj @@ -1,29 +1,31 @@  - - net6.0 + + net10.0 - false - + false - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + disable + - - - + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/MentorBot.Tests/Models/MentorContextTests.cs b/MentorBot.Tests/Models/MentorContextTests.cs index 19ef690..05f8c75 100644 --- a/MentorBot.Tests/Models/MentorContextTests.cs +++ b/MentorBot.Tests/Models/MentorContextTests.cs @@ -1,89 +1,83 @@ -using MentorBot.Extern; -using MentorBot.Models; -using Microsoft.EntityFrameworkCore; -using Moq; -using System.Threading; -using System.Threading.Tasks; -using Xunit; - -namespace MentorBot.Tests.Models -{ - public class MentorContextTests - { - private readonly TestSignalContext _ctx = new(nameof(MentorContextTests)); - private readonly Mock _neosApi = new(); - private readonly Mock _tokenGenerator = new(); - - protected DbContextOptions ContextOptions { get; } - - public MentorContextTests() - { - } - - [Fact] - public async Task SetsRemoteTokenOnAuthorize() - { - _neosApi.Setup(a => a.GetUserAsync("U-ID", It.IsAny())).ReturnsAsync(new User - { - Id = "U-ID", - Name = "User" - }).Verifiable(); - _neosApi.Setup(a => a.SetCloudVarAuthTokenAsync("MAGIC", "U-ID", It.IsAny())) - .Returns(ValueTask.CompletedTask).Verifiable(); - _tokenGenerator.Setup(t => t.CreateToken()).Returns("MAGIC").Verifiable(); - - using ISignalContext ctx = _ctx.CreateContext(); - IMentorContext mentorCtx = new MentorContext(ctx, _neosApi.Object, _tokenGenerator.Object); - await mentorCtx.AddMentorAsync("U-ID"); - - Assert.Equal(new Mentor - { - NeosId = "U-ID", - Name = "User", - Token = "MAGIC" - }, await mentorCtx.GetMentorByNeosIdAsync("U-ID")); - _neosApi.Verify(); - _tokenGenerator.Verify(); - } - - [Fact] - public async Task ResetsRemoteTokenOnAuthorize() - { - using (ISignalContext init = _ctx.CreateContext()) - { - init.Add(new Mentor - { - NeosId = "U-ID", - Name = "User", - Token = "NOT MAGIC", - DiscordId = 1234567890 - }); - await init.SaveChangesAsync(); - } - _neosApi.Setup(a => a.GetUserAsync("U-ID", It.IsAny())).ReturnsAsync(new User - { - Id = "U-ID", - Name = "User" - }).Verifiable(); - _neosApi.Setup(a => a.SetCloudVarAuthTokenAsync("MAGIC", "U-ID", It.IsAny())) - .Returns(ValueTask.CompletedTask).Verifiable(); - _tokenGenerator.Setup(t => t.CreateToken()).Returns("MAGIC").Verifiable(); - - using ISignalContext ctx = _ctx.CreateContext(); - IMentorContext mentorCtx = new MentorContext(ctx, _neosApi.Object, _tokenGenerator.Object); - await mentorCtx.AddMentorAsync("U-ID"); - - Assert.Equal(new Mentor - { - NeosId = "U-ID", - Name = "User", - Token = "MAGIC", - DiscordId = 1234567890 - }, await mentorCtx.GetMentorByNeosIdAsync("U-ID")); - _neosApi.Verify(); - _tokenGenerator.Verify(); - } - - - } -} +using System.Threading; +using System.Threading.Tasks; +using MentorBot.Extern; +using MentorBot.Models; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace MentorBot.Tests.Models; + +public class MentorContextTests +{ + private readonly TestSignalContext _ctx = new(nameof(MentorContextTests)); + private readonly Mock _neosApi = new(); + private readonly Mock _tokenGenerator = new(); + + protected DbContextOptions ContextOptions { get; } + + [Fact] + public async Task SetsRemoteTokenOnAuthorize() + { + _neosApi.Setup(a => a.GetUserAsync("U-ID", It.IsAny())).ReturnsAsync(new User + { + Id = "U-ID", + Name = "User" + }).Verifiable(); + _neosApi.Setup(a => a.SetCloudVarAuthTokenAsync("MAGIC", "U-ID", It.IsAny())) + .Returns(ValueTask.CompletedTask).Verifiable(); + _tokenGenerator.Setup(t => t.CreateToken()).Returns("MAGIC").Verifiable(); + + using var ctx = _ctx.CreateContext(); + IMentorContext mentorCtx = new MentorContext(ctx, _neosApi.Object, _tokenGenerator.Object); + await mentorCtx.AddMentorAsync("U-ID"); + + Assert.Equal(new Mentor + { + ResoUserId = "U-ID", + Name = "User", + Token = "MAGIC" + }, await mentorCtx.GetMentorByResoIdAsync("U-ID")); + _neosApi.Verify(); + _tokenGenerator.Verify(); + } + + [Fact] + public async Task ResetsRemoteTokenOnAuthorize() + { + using (var init = _ctx.CreateContext()) + { + init.Add(new Mentor + { + ResoUserId = "U-ID", + Name = "User", + Token = "NOT MAGIC", + DiscordId = 1234567890 + }); + await init.SaveChangesAsync(); + } + + _neosApi.Setup(a => a.GetUserAsync("U-ID", It.IsAny())).ReturnsAsync(new User + { + Id = "U-ID", + Name = "User" + }).Verifiable(); + _neosApi.Setup(a => a.SetCloudVarAuthTokenAsync("MAGIC", "U-ID", It.IsAny())) + .Returns(ValueTask.CompletedTask).Verifiable(); + _tokenGenerator.Setup(t => t.CreateToken()).Returns("MAGIC").Verifiable(); + + using var ctx = _ctx.CreateContext(); + IMentorContext mentorCtx = new MentorContext(ctx, _neosApi.Object, _tokenGenerator.Object); + await mentorCtx.AddMentorAsync("U-ID"); + + Assert.Equal(new Mentor + { + ResoUserId = "U-ID", + Name = "User", + Token = "MAGIC", + DiscordId = 1234567890 + }, await mentorCtx.GetMentorByResoIdAsync("U-ID")); + _neosApi.Verify(); + _tokenGenerator.Verify(); + } +} \ No newline at end of file diff --git a/MentorBot.Tests/Models/TestSignalContext.cs b/MentorBot.Tests/Models/TestSignalContext.cs index 40f4b31..6698665 100644 --- a/MentorBot.Tests/Models/TestSignalContext.cs +++ b/MentorBot.Tests/Models/TestSignalContext.cs @@ -1,26 +1,25 @@ -using MentorBot.Models; -using Microsoft.EntityFrameworkCore; -using System; - -namespace MentorBot.Tests.Models -{ - internal class TestSignalContext - { - protected DbContextOptions ContextOptions { get; } - - public TestSignalContext(string fileName, Action setup = null) - { - ContextOptions = new DbContextOptionsBuilder() - .UseSqlite($"Filename={fileName}.db").Options; - using SignalContext ctx = new(ContextOptions); - ctx.Database.EnsureDeleted(); - ctx.Database.EnsureCreated(); - setup?.Invoke(ctx); - } - - public ISignalContext CreateContext() - { - return new SignalContext(ContextOptions); - } - } -} +using System; +using MentorBot.Models; +using Microsoft.EntityFrameworkCore; + +namespace MentorBot.Tests.Models; + +internal class TestSignalContext +{ + public TestSignalContext(string fileName, Action setup = null) + { + ContextOptions = new DbContextOptionsBuilder() + .UseSqlite($"Filename={fileName}.db").Options; + using SignalContext ctx = new(ContextOptions); + ctx.Database.EnsureDeleted(); + ctx.Database.EnsureCreated(); + setup?.Invoke(ctx); + } + + protected DbContextOptions ContextOptions { get; } + + public ISignalContext CreateContext() + { + return new SignalContext(ContextOptions); + } +} \ No newline at end of file diff --git a/MentorBot.Tests/UrlEncoderTests.cs b/MentorBot.Tests/UrlEncoderTests.cs index ab665a8..e2d9961 100644 --- a/MentorBot.Tests/UrlEncoderTests.cs +++ b/MentorBot.Tests/UrlEncoderTests.cs @@ -1,59 +1,58 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Xunit; - -namespace MentorBot.Tests -{ - public class UrlEncoderTests - { - private readonly JsonSerializerOptions _opts = new () - { - Converters = - { - new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) - }, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - }; - - public record SerializeObject - { - public string A { get; set; } - public string B { get; set; } - public ValueThing V { get; set; } - } - - public enum ValueThing - { - ItsA, - ItsB - } - - [Fact] - public void EncodeObject() - { - var obj = new SerializeObject - { - A = "Taco", - B = "Legume", - V = ValueThing.ItsA, - }; - var encoded = UrlEncoder.Encode(obj, _opts); - - Assert.Equal("a=Taco&b=Legume&v=itsA", encoded); - } - - [Fact] - public void DecodeObject() - { - var expected = new SerializeObject - { - A = "Taco", - B = "Legume", - V = ValueThing.ItsB, - }; - var obj = UrlEncoder.Decode("a=Taco&b=Legume&v=itsb", _opts); - - Assert.Equal(expected, obj); - } - } -} +using System.Text.Json; +using System.Text.Json.Serialization; +using Xunit; + +namespace MentorBot.Tests; + +public class UrlEncoderTests +{ + public enum ValueThing + { + ItsA, + ItsB + } + + private readonly JsonSerializerOptions _opts = new() + { + Converters = + { + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) + }, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + [Fact] + public void EncodeObject() + { + var obj = new SerializeObject + { + A = "Taco", + B = "Legume", + V = ValueThing.ItsA + }; + var encoded = UrlEncoder.Encode(obj, _opts); + + Assert.Equal("a=Taco&b=Legume&v=itsA", encoded); + } + + [Fact] + public void DecodeObject() + { + var expected = new SerializeObject + { + A = "Taco", + B = "Legume", + V = ValueThing.ItsB + }; + var obj = UrlEncoder.Decode("a=Taco&b=Legume&v=itsb", _opts); + + Assert.Equal(expected, obj); + } + + public record SerializeObject + { + public string A { get; set; } + public string B { get; set; } + public ValueThing V { get; set; } + } +} \ No newline at end of file diff --git a/MentorBot.Tests/WebSocketExtensionsTests.cs b/MentorBot.Tests/WebSocketExtensionsTests.cs index 0e01499..81e0a64 100644 --- a/MentorBot.Tests/WebSocketExtensionsTests.cs +++ b/MentorBot.Tests/WebSocketExtensionsTests.cs @@ -1,61 +1,58 @@ -using Moq; -using System; -using System.Collections.Generic; -using System.Net.WebSockets; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Xunit; - -namespace MentorBot.Tests -{ - public class WebSocketExtensionsTests - { - public WebSocket CreateDataSocket(params string[] messages) - { - var socket = new Mock(); - - void MockReentrant(string[] remaining) - { - if (remaining.Length != 0) - { - var data = Encoding.UTF8.GetBytes(remaining[0]); - var next = remaining[1..]; - - socket.Setup(s => s.ReceiveAsync(It.IsAny>(), It.IsAny())) - .Callback, CancellationToken>((b, c) => - { - c.ThrowIfCancellationRequested(); - data.AsMemory().CopyTo(b.AsMemory()); - MockReentrant(next); - }) - .Returns(() => Task.FromResult(new WebSocketReceiveResult(data.Length, WebSocketMessageType.Text, true))); - } - else - { - socket.Setup(s => s.ReceiveAsync(It.IsAny>(), It.IsAny())) - .Returns(() => Task.FromResult(new WebSocketReceiveResult(0, WebSocketMessageType.Close, true))); - } - } - - MockReentrant(messages); - - return socket.Object; - } - - [Fact(Timeout = 1)] - public async Task ReportsMessages() - { - var expected = new []{ "a", "b", "c"}; - var socket = CreateDataSocket(expected); - - var items = new List(); - await foreach(var item in socket.ReadRawMessages()) - { - items.Add(item); - } - - Assert.Equal(expected, items); - } - } -} +using System; +using System.Collections.Generic; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Xunit; + +namespace MentorBot.Tests; + +public class WebSocketExtensionsTests +{ + public WebSocket CreateDataSocket(params string[] messages) + { + var socket = new Mock(); + + void MockReentrant(string[] remaining) + { + if (remaining.Length != 0) + { + var data = Encoding.UTF8.GetBytes(remaining[0]); + var next = remaining[1..]; + + socket.Setup(s => s.ReceiveAsync(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((b, c) => + { + c.ThrowIfCancellationRequested(); + data.AsMemory().CopyTo(b.AsMemory()); + MockReentrant(next); + }) + .Returns(() => + Task.FromResult(new WebSocketReceiveResult(data.Length, WebSocketMessageType.Text, true))); + } + else + { + socket.Setup(s => s.ReceiveAsync(It.IsAny>(), It.IsAny())) + .Returns(() => Task.FromResult(new WebSocketReceiveResult(0, WebSocketMessageType.Close, true))); + } + } + + MockReentrant(messages); + + return socket.Object; + } + + [Fact(Timeout = 1)] + public async Task ReportsMessages() + { + var expected = new[] { "a", "b", "c" }; + var socket = CreateDataSocket(expected); + + var items = new List(); + await foreach (var item in socket.ReadRawMessages()) items.Add(item); + + Assert.Equal(expected, items); + } +} \ No newline at end of file diff --git a/MentorBot/Controllers/LoginController.cs b/MentorBot/Controllers/LoginController.cs index 1ffd591..a68539b 100644 --- a/MentorBot/Controllers/LoginController.cs +++ b/MentorBot/Controllers/LoginController.cs @@ -1,36 +1,27 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using System.ComponentModel.DataAnnotations; -using System.Security.Claims; -using System.Threading.Tasks; - -namespace MentorBot.Controllers -{ - [ApiController, Route("login")] - public class LoginController : ControllerBase - { - private readonly MentorOptions _config; - - public LoginController(IOptionsSnapshot config) - { - _config = config.Value; - } - - [HttpPost(Name = "Login")] - public async ValueTask Login([FromForm, DataType(DataType.Password)] string accessToken) - { - if (accessToken != _config.ModifyMentorsToken) - { - return BadRequest(); - } - await HttpContext.SignInAsync( - new ClaimsPrincipal( - new ClaimsIdentity(new[] - { - new Claim(ClaimTypes.Role, "lead"), - }, "token"))); - return Redirect("/"); - } - } -} +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace MentorBot.Controllers; + +[ApiController] +[Route("login")] +public class LoginController(IOptionsSnapshot config) : ControllerBase +{ + [HttpPost(Name = "Login")] + public async ValueTask Login([FromForm][DataType(DataType.Password)] string accessToken) + { + if (accessToken != config.Value.ModifyMentorsToken) return BadRequest(); + + await HttpContext.SignInAsync( + new ClaimsPrincipal( + new ClaimsIdentity([ + new Claim(ClaimTypes.Role, "lead") + ], "token"))); + + return Redirect("/"); + } +} \ No newline at end of file diff --git a/MentorBot/Controllers/MenteeController.cs b/MentorBot/Controllers/MenteeController.cs index 1c4b0f6..746b5c1 100644 --- a/MentorBot/Controllers/MenteeController.cs +++ b/MentorBot/Controllers/MenteeController.cs @@ -1,41 +1,28 @@ -using MentorBot.Models; -using Microsoft.AspNetCore.Mvc; -using System.Threading.Tasks; - -namespace MentorBot.Controllers -{ - [ApiController, Route("mentee")] - public class MenteeController : ControllerBase - { - private readonly ITicketContext _store; - - public MenteeController(ITicketContext store) - { - _store = store; - } - - [HttpPost] - public async ValueTask> Create([FromQuery] TicketCreate createArgs) - { - var ticket = await _store.CreateTicketAsync(createArgs, HttpContext.RequestAborted); - if (ticket == null) - { - return NotFound(); - } - - return ticket.ToDto(); - } - - [HttpGet("{ticketId}")] - public async ValueTask> Get(ulong ticketId) - { - var ticket = await _store.GetTicketAsync(ticketId, HttpContext.RequestAborted); - if (ticket == null) - { - return NotFound(); - } - - return ticket.ToDto(); - } - } -} +using System.Threading.Tasks; +using MentorBot.Models; +using Microsoft.AspNetCore.Mvc; + +namespace MentorBot.Controllers; + +[ApiController] +[Route("mentee")] +public class MenteeController(ITicketContext store) : ControllerBase +{ + [HttpPost] + public async ValueTask> Create([FromQuery] TicketCreate createArgs) + { + var ticket = await store.CreateTicketAsync(createArgs, HttpContext.RequestAborted); + if (ticket == null) return NotFound(); + + return ticket.ToDto(); + } + + [HttpGet("{ticketId}")] + public async ValueTask> Get(ulong ticketId) + { + var ticket = await store.GetTicketAsync(ticketId, HttpContext.RequestAborted); + if (ticket == null) return NotFound(); + + return ticket.ToDto(); + } +} \ No newline at end of file diff --git a/MentorBot/Controllers/MentorController.cs b/MentorBot/Controllers/MentorController.cs index 479ec8c..4a3320e 100644 --- a/MentorBot/Controllers/MentorController.cs +++ b/MentorBot/Controllers/MentorController.cs @@ -1,89 +1,63 @@ -using MentorBot.Models; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace MentorBot.Controllers -{ - [ApiController, Route("mentor")] - public class MentorController : ControllerBase - { - private readonly IMentorContext _ctx; - private readonly ITicketContext _ticketCtx; - private readonly MentorOptions _options; - - public MentorController(IMentorContext ctx, ITicketContext ticketCtx, IOptionsSnapshot options) - { - _ctx = ctx; - _ticketCtx = ticketCtx; - _options = options.Value; - } - - [HttpGet] - public IAsyncEnumerable Get() - { - return _ctx.Mentors().Select(m => m.ToDto()); - } - - [HttpGet("{mentorToken}")] - public async ValueTask> Get(string mentorToken) - { - var mentor = await _ctx.GetMentorByTokenAsync(mentorToken, HttpContext.RequestAborted); - if (mentor == null) - { - return NotFound(); - } - - return mentor.ToDto(); - } - - [HttpGet("{mentorToken}/tickets")] - public async ValueTask>> GetTicketsAsMentor(string mentorToken) - { - var mentor = await _ctx.GetMentorByTokenAsync(mentorToken, HttpContext.RequestAborted); - if (mentor == null) - { - return NotFound(); - } - - return Ok(_ticketCtx.GetIncompleteTickets().Select(t => t.ToMentorDto())); - } - - [HttpPost("authorize", Name = "AuthorizeMentor"), Authorize] - public async ValueTask> AuthorizeMentor([FromForm]string neosId) - { - if (string.IsNullOrEmpty(_options.ModifyMentorsToken)) - { - return Forbid(); - } - - var mentor = await _ctx.AddMentorAsync(neosId, HttpContext.RequestAborted); - if (mentor == null) - { - return NotFound(); - } - - return mentor.ToDto(); - } - - [HttpPost("unauthorize", Name = "UnauthorizeMentor"), Authorize] - public async ValueTask> UnauthorizeMentor([FromForm] string neosId) - { - if (string.IsNullOrEmpty(_options.ModifyMentorsToken)) - { - return Forbid(); - } - - var mentor = await _ctx.RemoveMentorAccess(neosId, HttpContext.RequestAborted); - if (mentor == null) - { - return NotFound(); - } - - return mentor.ToDto(); - } - } -} +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using MentorBot.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace MentorBot.Controllers; + +[ApiController] +[Route("mentor")] +public class MentorController(IMentorContext ctx, ITicketContext ticketCtx, IOptionsSnapshot options) + : ControllerBase +{ + [HttpGet] + public IAsyncEnumerable Get() + { + return ctx.Mentors().Select(m => m.ToDto()); + } + + [HttpGet("{mentorToken}")] + public async ValueTask> Get(string mentorToken) + { + var mentor = await ctx.GetMentorByTokenAsync(mentorToken, HttpContext.RequestAborted); + if (mentor == null) return NotFound(); + + return mentor.ToDto(); + } + + [HttpGet("{mentorToken}/tickets")] + public async ValueTask>> GetTicketsAsMentor(string mentorToken) + { + var mentor = await ctx.GetMentorByTokenAsync(mentorToken, HttpContext.RequestAborted); + if (mentor == null) return NotFound(); + + return Ok(ticketCtx.GetIncompleteTickets().Select(t => t.ToMentorDto())); + } + + [HttpPost("authorize", Name = "AuthorizeMentor")] + [Authorize] + public async ValueTask> AuthorizeMentor([FromForm] string resoniteId) + { + if (string.IsNullOrEmpty(options.Value.ModifyMentorsToken)) return Forbid(); + + var mentor = await ctx.AddMentorAsync(resoniteId, HttpContext.RequestAborted); + if (mentor == null) return NotFound(); + + return mentor.ToDto(); + } + + [HttpPost("unauthorize", Name = "UnauthorizeMentor")] + [Authorize] + public async ValueTask> UnauthorizeMentor([FromForm] string resoniteId) + { + if (string.IsNullOrEmpty(options.Value.ModifyMentorsToken)) return Forbid(); + + var mentor = await ctx.RemoveMentorAccess(resoniteId, HttpContext.RequestAborted); + if (mentor == null) return NotFound(); + + return mentor.ToDto(); + } +} \ No newline at end of file diff --git a/MentorBot/Controllers/WsMenteeController.cs b/MentorBot/Controllers/WsMenteeController.cs index a7fac5d..be171fa 100644 --- a/MentorBot/Controllers/WsMenteeController.cs +++ b/MentorBot/Controllers/WsMenteeController.cs @@ -1,163 +1,138 @@ -using MentorBot.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using System; -using System.Net.WebSockets; -using System.Threading; -using System.Threading.Tasks; - -namespace MentorBot.Controllers -{ - [ApiController, Route("ws/mentee")] - public class WsMenteeController : ControllerBase - { - private readonly ILogger _logger; - - private readonly ITicketNotifier _notifier; - private readonly IServiceProvider _provider; - private readonly IOptions _jsonOpts; - - public WsMenteeController(ILogger logger, ITicketNotifier notifier, IServiceProvider provider, IOptions jsonOpts) - { - _logger = logger; - _notifier = notifier; - _provider = provider; - _jsonOpts = jsonOpts; - } - - [HttpGet] - public async ValueTask> CreateTicket([FromQuery] TicketCreate createArgs) - { - if (!HttpContext.WebSockets.IsWebSocketRequest) - { - return BadRequest(); - } - - var ticketId = await _provider.WithScopedServiceAsync(async (ITicketContext ctx) => - { - var ticket = await ctx.CreateTicketAsync(createArgs, HttpContext.RequestAborted); - return ticket?.Id; - }); - - if (ticketId == null) - { - return BadRequest(); - } - - using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); - - await WatchTicket(ticketId.Value, ws, HttpContext.RequestAborted); - - return new EmptyResult(); - } - - [HttpGet("{ticketId}")] - public async ValueTask WatchTicket(ulong ticketId) - { - if (!HttpContext.WebSockets.IsWebSocketRequest) - { - return BadRequest(); - } - - var foundTicketId = await _provider.WithScopedServiceAsync(async (ITicketContext ctx) => - { - var ticket = - await ctx.GetTicketAsync(ticketId, HttpContext.RequestAborted); - return ticket?.Id; - }); - if (foundTicketId == null) - { - return NotFound(); - } - - using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); - - await WatchTicket(ticketId, ws, HttpContext.RequestAborted); - - return new EmptyResult(); - } - - private async ValueTask WatchTicket(ulong ticketId, WebSocket ws, CancellationToken cancellationToken = default) - { - // This token represents if the ticket can continue to allow updates. If this is false we stop trying to change things. - var stopToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - var subToken = stopToken.Token; - - var sender = ws.MessageSender(_jsonOpts.Value.JsonSerializerOptions, cancellationToken); - - // This method is responsible for sending updates to listeners, and for watching when it should no longer send updates. - async ValueTask SendTicket(Ticket tick) - { - await sender(tick.ToDto()); - - if (tick.Status.IsTerminal()) - { - stopToken.Cancel(); - } - } - - using (_notifier.WatchTicketUpdated(ticketId, async t => - { - try - { - await SendTicket(t); - } - catch (Exception e) - { - _logger.LogWarning(e, "Exception while reporting ticket update to websocket."); - } - })) - { - bool ticketDone = await _provider.WithScopedServiceAsync(async (ITicketContext ctx) => - { - var ticket = await ctx.GetTicketAsync(ticketId, cancellationToken); - if (ticket != null) - { - await SendTicket(ticket); - } - return ticket == null; - }); - - try - { - await foreach (var payload in ws.ReadMessages(_jsonOpts.Value.JsonSerializerOptions, subToken)) - { - await _provider.WithScopedServiceAsync(async (ITicketContext ctx) => - { - switch (payload.Type) - { - case MenteeRequestKind.Cancel: - await ctx.TryCancelTicketAsync(ticketId, cancellationToken); - break; - } - }); - } - } - catch (OperationCanceledException) - { - // Ticket was a terminal state. - } - } - - // This is due to a bug with the Neos websocket handler, it won't recieve messages that arrive just before the socket is closed. - // This uses the outer token as we only bail out of the outer handler closes as well. - try - { - await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); - } - catch (OperationCanceledException) { } - } - - public class MenteeRequest - { - public MenteeRequestKind Type { get; init; } - } - - public enum MenteeRequestKind - { - Unknown, - Cancel - } - } -} +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using MentorBot.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace MentorBot.Controllers; + +[ApiController] +[Route("ws/mentee")] +public class WsMenteeController( + ILogger logger, + ITicketNotifier notifier, + IServiceProvider provider, + IOptions jsonOpts) + : ControllerBase +{ + public enum MenteeRequestKind + { + Unknown, + Cancel + } + + [HttpGet] + public async ValueTask> CreateTicket([FromQuery] TicketCreate createArgs) + { + if (!HttpContext.WebSockets.IsWebSocketRequest) return BadRequest(); + + var ticketId = await provider.WithScopedServiceAsync(async (ITicketContext ctx) => + { + var ticket = await ctx.CreateTicketAsync(createArgs, HttpContext.RequestAborted); + return ticket?.Id; + }); + + if (ticketId == null) return BadRequest(); + + using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); + + await WatchTicket(ticketId.Value, ws, HttpContext.RequestAborted); + + return new EmptyResult(); + } + + [HttpGet("{ticketId}")] + public async ValueTask WatchTicket(ulong ticketId) + { + if (!HttpContext.WebSockets.IsWebSocketRequest) return BadRequest(); + + var foundTicketId = await provider.WithScopedServiceAsync(async (ITicketContext ctx) => + { + var ticket = + await ctx.GetTicketAsync(ticketId, HttpContext.RequestAborted); + return ticket?.Id; + }); + if (foundTicketId == null) return NotFound(); + + using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); + + await WatchTicket(ticketId, ws, HttpContext.RequestAborted); + + return new EmptyResult(); + } + + private async ValueTask WatchTicket(ulong ticketId, WebSocket ws, CancellationToken cancellationToken = default) + { + // This token represents if the ticket can continue to allow updates. If this is false, we stop trying to change things. + var stopToken = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var subToken = stopToken.Token; + + var sender = ws.MessageSender(jsonOpts.Value.JsonSerializerOptions, cancellationToken); + + // This method is responsible for sending updates to listeners and for watching when it should no longer send updates. + async ValueTask SendTicket(Ticket tick) + { + await sender(tick.ToDto()); + + if (tick.Status.IsTerminal()) await stopToken.CancelAsync(); + } + + using (notifier.WatchTicketUpdated(ticketId, async t => + { + try + { + await SendTicket(t); + } + catch (Exception e) + { + logger.LogWarning(e, "Exception while reporting ticket update to websocket."); + } + })) + { + var ticketDone = await provider.WithScopedServiceAsync(async (ITicketContext ctx) => + { + var ticket = await ctx.GetTicketAsync(ticketId, cancellationToken); + if (ticket != null) await SendTicket(ticket); + + return ticket == null; + }); + + try + { + await foreach (var payload in ws.ReadMessages(jsonOpts.Value.JsonSerializerOptions, + subToken)) + await provider.WithScopedServiceAsync(async (ITicketContext ctx) => + { + switch (payload.Type) + { + case MenteeRequestKind.Cancel: + await ctx.TryCancelTicketAsync(ticketId, cancellationToken); + break; + } + }); + } + catch (OperationCanceledException) + { + // Ticket was a terminal state. + } + } + + // This is due to a bug with the Neos websocket handler, it won't receive messages that arrive just before the socket is closed. + // This uses the outer token as we only bail out of the outer handler closes as well. + try + { + await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken); + } + catch (OperationCanceledException) + { + } + } + + public class MenteeRequest + { + public MenteeRequestKind Type { get; init; } + } +} \ No newline at end of file diff --git a/MentorBot/Controllers/WsMentorController.cs b/MentorBot/Controllers/WsMentorController.cs index 1806c3e..6fe4015 100644 --- a/MentorBot/Controllers/WsMentorController.cs +++ b/MentorBot/Controllers/WsMentorController.cs @@ -1,100 +1,88 @@ -using MentorBot.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using System; -using System.Linq; -using System.Threading.Tasks; - -namespace MentorBot.Controllers -{ - [ApiController, Route("ws/mentor")] - public class WsMentorController : ControllerBase - { - private readonly ITicketNotifier _notifier; - private readonly IServiceProvider _provider; - private readonly IOptions _jsonOpts; - - public WsMentorController(ITicketNotifier notifier, IServiceProvider provider, IOptions jsonOpts) - { - _notifier = notifier; - _provider = provider; - _jsonOpts = jsonOpts; - } - - [HttpGet("{mentorToken}")] - public async ValueTask MentorWatcher([FromRoute] string mentorToken) - { - if (!HttpContext.WebSockets.IsWebSocketRequest) - { - return BadRequest(); - } - - if (await _provider.WithScopedServiceAsync(async (IMentorContext ctx) => - await ctx.GetMentorByTokenAsync(mentorToken, HttpContext.RequestAborted) == null)) - { - return Unauthorized(); - } - - using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); - - var sender = ws.MessageSender(_jsonOpts.Value.JsonSerializerOptions, HttpContext.RequestAborted); - - using (_notifier.WatchTicketsUpdated(ticket => sender(ticket.ToMentorDto()))) - { - try - { - // Pulse all existing tickets - await _provider.WithScopedServiceAsync(async (ITicketContext ctx) => - { - await foreach(var ticket in ctx.GetIncompleteTickets().WithCancellation(HttpContext.RequestAborted)) - { - await sender(ticket.ToMentorDto()); - } - }); - - await foreach (var payload in ws.ReadMessages(_jsonOpts.Value.JsonSerializerOptions, HttpContext.RequestAborted)) - { - await _provider.WithScopedServiceAsync(async (ITicketContext ctx) => - { - switch(payload.Type) - { - case MentorRequestKind.Claim: - await ctx.TryClaimTicketAsync(payload.Ticket, mentorToken, HttpContext.RequestAborted); - break; - case MentorRequestKind.Unclaim: - await ctx.TryUnclaimTicketAsync(payload.Ticket, mentorToken, HttpContext.RequestAborted); - break; - case MentorRequestKind.Complete: - await ctx.TryCompleteTicketAsync(payload.Ticket, mentorToken, HttpContext.RequestAborted); - break; - }; - }); - } - } - catch (OperationCanceledException) - { - // Ticket was a terminal state. - } - - return new EmptyResult(); - } - } - - public class MentorRequest - { - [JsonConverter(typeof(StringEnumConverter))] - public MentorRequestKind Type { get; set; } - public ulong Ticket { get; set; } - } - - public enum MentorRequestKind - { - Unknown, - Claim, - Unclaim, - Complete - } - } -} +using System; +using System.Threading.Tasks; +using MentorBot.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace MentorBot.Controllers; + +[ApiController] +[Route("ws/mentor")] +public class WsMentorController(ITicketNotifier notifier, IServiceProvider provider, IOptions jsonOpts) + : ControllerBase +{ + public enum MentorRequestKind + { + Unknown, + Claim, + Unclaim, + Complete + } + + [HttpGet("{mentorToken}")] + public async ValueTask MentorWatcher([FromRoute] string mentorToken) + { + if (!HttpContext.WebSockets.IsWebSocketRequest) return BadRequest(); + + if (await provider.WithScopedServiceAsync(async (IMentorContext ctx) => + await ctx.GetMentorByTokenAsync(mentorToken, HttpContext.RequestAborted) == null)) + return Unauthorized(); + + using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); + + var sender = + ws.MessageSender(jsonOpts.Value.JsonSerializerOptions, HttpContext.RequestAborted); + + using (notifier.WatchTicketsUpdated(ticket => sender(ticket.ToMentorDto()))) + { + try + { + // Pulse all existing tickets + await provider.WithScopedServiceAsync(async (ITicketContext ctx) => + { + await foreach + (var ticket in ctx.GetIncompleteTickets().WithCancellation(HttpContext.RequestAborted)) + await sender(ticket.ToMentorDto()); + }); + + await foreach (var payload in ws.ReadMessages(jsonOpts.Value.JsonSerializerOptions, + HttpContext.RequestAborted)) + await provider.WithScopedServiceAsync(async (ITicketContext ctx) => + { + switch (payload.Type) + { + case MentorRequestKind.Claim: + await ctx.TryClaimTicketAsync(payload.Ticket, mentorToken, HttpContext.RequestAborted); + break; + case MentorRequestKind.Unclaim: + await ctx.TryUnclaimTicketAsync(payload.Ticket, mentorToken, + HttpContext.RequestAborted); + break; + case MentorRequestKind.Complete: + await ctx.TryCompleteTicketAsync(payload.Ticket, mentorToken, + HttpContext.RequestAborted); + break; + } + + ; + }); + } + catch (OperationCanceledException) + { + // Ticket was a terminal state. + } + + return new EmptyResult(); + } + } + + public class MentorRequest + { + [JsonConverter(typeof(StringEnumConverter))] + public MentorRequestKind Type { get; set; } + + public ulong Ticket { get; set; } + } +} \ No newline at end of file diff --git a/MentorBot/DateTimeExtensions.cs b/MentorBot/DateTimeExtensions.cs index 1130ee7..94272fe 100644 --- a/MentorBot/DateTimeExtensions.cs +++ b/MentorBot/DateTimeExtensions.cs @@ -1,26 +1,20 @@ using System; -namespace MentorBot +namespace MentorBot; + +public static class DateTimeExtensions { - public static class DateTimeExtensions - { /// - /// + /// t: Short time (e.g 9:41 PM) + /// T: Long Time(e.g. 9:41:30 PM) + /// d: Short Date(e.g. 30/06/2021) + /// D: Long Date(e.g. 30 June 2021) + /// f(default): Short Date/Time(e.g. 30 June 2021 9:41 PM) + /// F: Long Date/Time(e.g.Wednesday, June, 30, 2021 9:41 PM) + /// R: Relative Time(e.g. 2 months ago, in an hour) /// - /// - /// t: Short time (e.g 9:41 PM) - /// T: Long Time(e.g. 9:41:30 PM) - /// d: Short Date(e.g. 30/06/2021) - /// D: Long Date(e.g. 30 June 2021) - /// f(default) : Short Date/Time(e.g. 30 June 2021 9:41 PM) - /// F: Long Date/Time(e.g.Wednesday, June, 30, 2021 9:41 PM) - /// R: Relative Time(e.g. 2 months ago, in an hour) - /// - /// - /// public static string ToDiscordTimecode(this DateTime dateTime, string format) { - return $""; + return $""; } - } -} +} \ No newline at end of file diff --git a/MentorBot/Discord/DiscordContext.cs b/MentorBot/Discord/DiscordContext.cs index 2a6b4a6..d9d18c2 100644 --- a/MentorBot/Discord/DiscordContext.cs +++ b/MentorBot/Discord/DiscordContext.cs @@ -1,167 +1,114 @@ -using Discord; -using Discord.WebSocket; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace MentorBot.Discord -{ - public class DiscordOptions - { - public bool UpdateTickets { get; set; } = false; - public string Token { get; set; } = string.Empty; - public ulong Channel { get; set; } - } - - public interface IDiscordContext - { - bool ConnectedAndReady { get; } - ValueTask SendMessageAsync(Embed embed, CancellationToken cancellationToken = default); - ValueTask UpdateMessageAsync(ulong id, Embed embed, CancellationToken cancellationToken = default); - } - - public class DiscordContext : IDiscordContext, IHostedService, IDisposable - { - private readonly DiscordOptions _options; - private readonly ILogger _logger; - - public DiscordSocketClient Client { get; } - public ITextChannel? Channel { get; private set; } - public bool Ready { get; private set; } - - public bool ConnectedAndReady => Client.ConnectionState == ConnectionState.Connected && Ready; - - public DiscordContext(IOptions options, ILogger logger) - { - Client = new DiscordSocketClient(new DiscordSocketConfig - { - MessageCacheSize = 50, - LogLevel = LogSeverity.Debug - }); - Client.Log += Client_Log; - _options = options.Value; - _logger = logger; - Client.Ready += () => Task.FromResult(Ready = true); - } - - private Task Client_Log(LogMessage arg) - { - switch (arg.Severity) - { - case LogSeverity.Critical: - _logger.Log(LogLevel.Critical, arg.Exception, arg.Message); - break; - case LogSeverity.Error: - _logger.Log(LogLevel.Error, arg.Exception, arg.Message); - break; - case LogSeverity.Warning: - _logger.Log(LogLevel.Warning, arg.Exception, arg.Message); - break; - case LogSeverity.Info: - _logger.Log(LogLevel.Information, arg.Exception, arg.Message); - break; - case LogSeverity.Verbose: - _logger.Log(LogLevel.Trace, arg.Exception, arg.Message); - break; - case LogSeverity.Debug: - _logger.Log(LogLevel.Debug, arg.Exception, arg.Message); - break; - } - return Task.CompletedTask; - } - - public async Task StartAsync(CancellationToken cancellationToken) - { - await Client.LoginAsync(TokenType.Bot, _options.Token); - await Client.StartAsync(); - Channel = await Client.Rest.GetChannelAsync(_options.Channel) as ITextChannel; - } - - public async Task StopAsync(CancellationToken cancellationToken) - { - await Client.StopAsync(); - await Client.LogoutAsync(); - } - - public void Dispose() - { - Client.Dispose(); - } - - public async ValueTask SendMessageAsync(Embed embed, CancellationToken cancellationToken = default) - { - if (Channel == null) - { - throw new InvalidOperationException("channel not bound to discord context."); - } - var msg = await Channel.SendMessageAsync(embed: embed, options: new RequestOptions - { - CancelToken = cancellationToken - }); - return msg; - } - - public async ValueTask UpdateMessageAsync(ulong id, Embed embed, CancellationToken cancellationToken = default) - { - if (Channel == null) - { - throw new InvalidOperationException("channel not bound to discord context."); - } - return await Channel.ModifyMessageAsync(id, props => props.Embed = embed, new RequestOptions - { - CancelToken = cancellationToken - }); - } - } - - public class DiscordHostedServiceProxy : IHostedService - { - private readonly DiscordContext _context; - - public DiscordHostedServiceProxy(DiscordContext ctx) - { - _context = ctx; - } - - public Task StartAsync(CancellationToken cancellationToken) - { - return _context.StartAsync(cancellationToken); - } - - public Task StopAsync(CancellationToken cancellationToken) - { - return _context.StopAsync(cancellationToken); - } - } -} - -namespace Microsoft.Extensions.DependencyInjection -{ - using MentorBot.Discord; - using Microsoft.Extensions.Configuration; - - public static class DiscordContextExtensions - { - public static IServiceCollection AddDiscordContext(this IServiceCollection services, IConfiguration config) - { - var discordContext = config.GetSection("Discord"); - services.Configure(discordContext); - var options = discordContext.Get(); - if (options?.UpdateTickets ?? false) - { - services.AddSingleton() - .AddSingleton(o => o.GetRequiredService()) - .AddHostedService() - .AddHostedService() - .AddTransient(); - - services.AddHealthChecks().AddCheck("discord"); - } - - return services; - } - } -} +using System; +using System.Threading; +using System.Threading.Tasks; +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace MentorBot.Discord; + +public class DiscordOptions +{ + public bool UpdateTickets { get; set; } = false; + public string Token { get; set; } = string.Empty; + public ulong Channel { get; set; } +} + +public interface IDiscordContext +{ + bool ConnectedAndReady { get; } + ValueTask SendMessageAsync(Embed embed, CancellationToken cancellationToken = default); + ValueTask UpdateMessageAsync(ulong id, Embed embed, CancellationToken cancellationToken = default); +} + +public class DiscordContext : IDiscordContext, IHostedService, IDisposable +{ + private readonly ILogger _logger; + private readonly DiscordOptions _options; + + public DiscordContext(IOptions options, ILogger logger) + { + Client = new DiscordSocketClient(new DiscordSocketConfig + { + MessageCacheSize = 50, + LogLevel = LogSeverity.Debug + }); + Client.Log += Client_Log; + _options = options.Value; + _logger = logger; + Client.Ready += () => Task.FromResult(Ready = true); + } + + public DiscordSocketClient Client { get; } + public ITextChannel? Channel { get; private set; } + public bool Ready { get; private set; } + + public bool ConnectedAndReady => Client.ConnectionState == ConnectionState.Connected && Ready; + + public async ValueTask SendMessageAsync(Embed embed, CancellationToken cancellationToken = default) + { + if (Channel == null) throw new InvalidOperationException("channel not bound to discord context."); + var msg = await Channel.SendMessageAsync(embed: embed, options: new RequestOptions + { + CancelToken = cancellationToken + }); + return msg; + } + + public async ValueTask UpdateMessageAsync(ulong id, Embed embed, + CancellationToken cancellationToken = default) + { + if (Channel == null) throw new InvalidOperationException("channel not bound to discord context."); + return await Channel.ModifyMessageAsync(id, props => props.Embed = embed, new RequestOptions + { + CancelToken = cancellationToken + }); + } + + public void Dispose() + { + Client.Dispose(); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + await Client.LoginAsync(TokenType.Bot, _options.Token); + await Client.StartAsync(); + Channel = await Client.Rest.GetChannelAsync(_options.Channel) as ITextChannel; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await Client.StopAsync(); + await Client.LogoutAsync(); + } + + private Task Client_Log(LogMessage arg) + { + switch (arg.Severity) + { + case LogSeverity.Critical: + _logger.Log(LogLevel.Critical, arg.Exception, arg.Message); + break; + case LogSeverity.Error: + _logger.Log(LogLevel.Error, arg.Exception, arg.Message); + break; + case LogSeverity.Warning: + _logger.Log(LogLevel.Warning, arg.Exception, arg.Message); + break; + case LogSeverity.Info: + _logger.Log(LogLevel.Information, arg.Exception, arg.Message); + break; + case LogSeverity.Verbose: + _logger.Log(LogLevel.Trace, arg.Exception, arg.Message); + break; + case LogSeverity.Debug: + _logger.Log(LogLevel.Debug, arg.Exception, arg.Message); + break; + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/MentorBot/Discord/DiscordContextExtensions.cs b/MentorBot/Discord/DiscordContextExtensions.cs new file mode 100644 index 0000000..e8e1397 --- /dev/null +++ b/MentorBot/Discord/DiscordContextExtensions.cs @@ -0,0 +1,26 @@ +using MentorBot.Discord; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class DiscordContextExtensions +{ + public static IServiceCollection AddDiscordContext(this IServiceCollection services, IConfiguration config) + { + var discordContext = config.GetSection("Discord"); + services.Configure(discordContext); + var options = discordContext.Get(); + if (options?.UpdateTickets ?? false) + { + services.AddSingleton() + .AddSingleton(o => o.GetRequiredService()) + .AddHostedService() + .AddHostedService() + .AddTransient(); + + services.AddHealthChecks().AddCheck("discord"); + } + + return services; + } +} \ No newline at end of file diff --git a/MentorBot/Discord/DiscordHealthCheck.cs b/MentorBot/Discord/DiscordHealthCheck.cs index eae7da6..936cf98 100644 --- a/MentorBot/Discord/DiscordHealthCheck.cs +++ b/MentorBot/Discord/DiscordHealthCheck.cs @@ -1,23 +1,16 @@ -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System.Threading; -using System.Threading.Tasks; - -namespace MentorBot.Discord -{ - public class DiscordHealthCheck : IHealthCheck - { - private readonly IDiscordContext _ctx; - - public DiscordHealthCheck(IDiscordContext ctx) - { - _ctx = ctx; - } - - public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - return Task.FromResult(_ctx.ConnectedAndReady ? - HealthCheckResult.Healthy("Discord service is ready and bound.") : - HealthCheckResult.Unhealthy("Discord bot api has disconnected.")); - } - } -} +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace MentorBot.Discord; + +public class DiscordHealthCheck(IDiscordContext ctx) : IHealthCheck +{ + public Task CheckHealthAsync(HealthCheckContext context, + CancellationToken cancellationToken = default) + { + return Task.FromResult(ctx.ConnectedAndReady + ? HealthCheckResult.Healthy("Discord service is ready and bound.") + : HealthCheckResult.Unhealthy("Discord bot api has disconnected.")); + } +} \ No newline at end of file diff --git a/MentorBot/Discord/DiscordHostedServiceProxy.cs b/MentorBot/Discord/DiscordHostedServiceProxy.cs new file mode 100644 index 0000000..98a8ea2 --- /dev/null +++ b/MentorBot/Discord/DiscordHostedServiceProxy.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; + +namespace MentorBot.Discord; + +public class DiscordHostedServiceProxy(DiscordContext ctx) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + return ctx.StartAsync(cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return ctx.StopAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/MentorBot/Discord/TicketDiscordProxy.cs b/MentorBot/Discord/TicketDiscordProxy.cs index 7ca60b3..1e91bd7 100644 --- a/MentorBot/Discord/TicketDiscordProxy.cs +++ b/MentorBot/Discord/TicketDiscordProxy.cs @@ -1,99 +1,28 @@ -using MentorBot.Models; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace MentorBot.Discord -{ - public class TicketDiscordProxyHost : IHostedService - { - private readonly ITicketNotifier _notifier; - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - private readonly CancellationTokenSource _cancelSource = new(); - - private IDisposable? _watchToken; - - public TicketDiscordProxyHost(IServiceProvider serviceProvider, ITicketNotifier notifier, ILogger logger) - { - _serviceProvider = serviceProvider; - _notifier = notifier; - _logger = logger; - } - - public Task StartAsync(CancellationToken cancellationToken) - { - Interlocked.Exchange(ref _watchToken, _notifier.WatchTicketsUpdated(TicketUpdated))?.Dispose(); - return Task.CompletedTask; - } - - public async void TicketUpdated(Ticket ticket) - { - try - { - await UpdateTicketInternal(ticket, _cancelSource.Token); - - } - catch (Exception e) - { - _logger.LogWarning(e, "Error while updating an internal ticket."); - } - // This warning is not always true. -#pragma warning disable CS1058 // A previous catch clause already catches all exceptions - catch -#pragma warning restore CS1058 // A previous catch clause already catches all exceptions - { - _logger.LogWarning("Abstract error while updating internal ticket, this is bad."); - } - } - - private async ValueTask UpdateTicketInternal(Ticket ticket, CancellationToken cancellationToken) - { - using var scope = _serviceProvider.CreateScope(); - await scope.ServiceProvider.GetRequiredService().RectifyTicket(ticket, cancellationToken); - } - - public Task StopAsync(CancellationToken cancellationToken) - { - Interlocked.Exchange(ref _watchToken, null)?.Dispose(); - _cancelSource.Cancel(); - return Task.CompletedTask; - } - } - - public interface ITicketDiscordProxy - { - ValueTask RectifyTicket(Ticket ticket, CancellationToken cancellationToken = default); - } - public class TicketDiscordProxy : ITicketDiscordProxy - { - private readonly IDiscordContext _discCtx; - private readonly ITicketContext _tickCtx; - public TicketDiscordProxy(IDiscordContext discCtx, ITicketContext tickCtx) - { - _discCtx = discCtx; - _tickCtx = tickCtx; - } - - public async ValueTask RectifyTicket(Ticket ticket, CancellationToken cancellationToken = default) - { - if (ticket.DiscordId != null) - { - await _discCtx.UpdateMessageAsync(ticket.DiscordId.Value, ticket.ToEmbed(), cancellationToken); - } - else - { - var msg = await _discCtx.SendMessageAsync(ticket.ToEmbed(), cancellationToken); - if (msg == null) - { - return; - } - ticket.DiscordId = msg.Id; - await _tickCtx.AssignDiscordIdAsync(ticket.Id, msg.Id, cancellationToken); - } - } - } -} +using System.Threading; +using System.Threading.Tasks; +using MentorBot.Models; + +namespace MentorBot.Discord; + +public interface ITicketDiscordProxy +{ + ValueTask RectifyTicket(Ticket ticket, CancellationToken cancellationToken = default); +} + +public class TicketDiscordProxy(IDiscordContext discCtx, ITicketContext tickCtx) : ITicketDiscordProxy +{ + public async ValueTask RectifyTicket(Ticket ticket, CancellationToken cancellationToken = default) + { + if (ticket.DiscordId != null) + { + await discCtx.UpdateMessageAsync(ticket.DiscordId.Value, ticket.ToEmbed(), cancellationToken); + } + else + { + var msg = await discCtx.SendMessageAsync(ticket.ToEmbed(), cancellationToken); + if (msg == null) return; + ticket.DiscordId = msg.Id; + await tickCtx.AssignDiscordIdAsync(ticket.Id, msg.Id, cancellationToken); + } + } +} \ No newline at end of file diff --git a/MentorBot/Discord/TicketDiscordProxyHost.cs b/MentorBot/Discord/TicketDiscordProxyHost.cs new file mode 100644 index 0000000..36aa7ff --- /dev/null +++ b/MentorBot/Discord/TicketDiscordProxyHost.cs @@ -0,0 +1,58 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using MentorBot.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MentorBot.Discord; + +public class TicketDiscordProxyHost( + IServiceProvider serviceProvider, + ITicketNotifier notifier, + ILogger logger) + : IHostedService +{ + private readonly CancellationTokenSource _cancelSource = new(); + + private IDisposable? _watchToken; + + public Task StartAsync(CancellationToken cancellationToken) + { + Interlocked.Exchange(ref _watchToken, notifier.WatchTicketsUpdated(TicketUpdated))?.Dispose(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + Interlocked.Exchange(ref _watchToken, null)?.Dispose(); + _cancelSource.Cancel(); + return Task.CompletedTask; + } + + private async void TicketUpdated(Ticket ticket) + { + try + { + await UpdateTicketInternal(ticket, _cancelSource.Token); + } + catch (Exception e) + { + logger.LogWarning(e, "Error while updating an internal ticket."); + } + // This warning is not always true. +#pragma warning disable CS1058 // A previous catch clause already catches all exceptions + catch +#pragma warning restore CS1058 // A previous catch clause already catches all exceptions + { + logger.LogWarning("Abstract error while updating internal ticket, this is bad."); + } + } + + private async ValueTask UpdateTicketInternal(Ticket ticket, CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + await scope.ServiceProvider.GetRequiredService().RectifyTicket(ticket, cancellationToken); + } +} \ No newline at end of file diff --git a/MentorBot/EnumerableExtensions.cs b/MentorBot/EnumerableExtensions.cs index 5cf8237..8fc89bd 100644 --- a/MentorBot/EnumerableExtensions.cs +++ b/MentorBot/EnumerableExtensions.cs @@ -1,13 +1,12 @@ -using System.Collections.Generic; -using System.Linq; - -namespace MentorBot -{ - public static class EnumerableExtensions - { - public static IEnumerable OnlyNotNull(this IEnumerable enumerable) where T : struct - { - return enumerable.Where(e => e.HasValue).Select(e => e!.Value); - } - } -} +using System.Collections.Generic; +using System.Linq; + +namespace MentorBot; + +public static class EnumerableExtensions +{ + public static IEnumerable OnlyNotNull(this IEnumerable enumerable) where T : struct + { + return enumerable.Where(e => e.HasValue).Select(e => e!.Value); + } +} \ No newline at end of file diff --git a/MentorBot/Extern/NeosApi.cs b/MentorBot/Extern/NeosApi.cs deleted file mode 100644 index 26fd7d8..0000000 --- a/MentorBot/Extern/NeosApi.cs +++ /dev/null @@ -1,147 +0,0 @@ -using MentorBot.Models; -using System; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using System.Net.Http.Json; -using Microsoft.Extensions.Options; -using System.Net.Http.Headers; -using Microsoft.Extensions.Logging; - -namespace MentorBot.Extern -{ - public class NeosApiOptions - { - public string VariableName { get; set; } = string.Empty; - public string UserId { get; set; } = string.Empty; - public string UserName { get; set; } = string.Empty; - public string Password { get; set; } = string.Empty; - } - - public interface INeosApi - { - ValueTask GetUserAsync(string userId, CancellationToken cancellationToken = default); - ValueTask SetCloudVarAuthTokenAsync(string token, string user, CancellationToken cancellationToken = default); - } - - public class NeosApi : INeosApi - { - private readonly HttpClient _client; - private readonly INeosApiAuthKeeper _authManager; - private readonly IOptions _options; - private readonly ILogger _logger; - - public NeosApi(HttpClient client, INeosApiAuthKeeper authManager, IOptions options, ILogger logger) - { - _client = client; - _authManager = authManager; - _options = options; - _logger = logger; - } - - public async ValueTask GetUserAsync(string userId, CancellationToken cancellationToken = default) - { - try - { - var user = await _client.GetFromJsonAsync($"api/users/{Uri.EscapeDataString(userId)}", cancellationToken); - if (user != null && !string.IsNullOrWhiteSpace(user.Id) && !string.IsNullOrWhiteSpace(user.Username)) - { - return new User - { - Id = user.Id, - Name = user.Username - }; - } - } - catch - { - } - return null; - } - - private class NeosUser - { - public string? Id { get; set; } - public string? Username { get; set; } - } - - public async ValueTask SetCloudVarAuthTokenAsync(string token, string user, CancellationToken cancellationToken) - { - var authToken = await _authManager.GetOrRefreshToken(RefreshToken, cancellationToken); - if (string.IsNullOrWhiteSpace(authToken)) - { - _logger.LogWarning("Failed to set ID for user."); - throw new InvalidOperationException("Failed to set token cloud variable for user."); - } - var opts = _options.Value; - _client.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(authToken); - - var url = $"api/groups/{user}/vars/{opts.VariableName}"; - var resp = await _client.PutAsJsonAsync(url, new NeosSetCloudVar - { - OwnerId = user, - Value = token, - }, cancellationToken); - resp.EnsureSuccessStatusCode(); - } - - private class NeosSetCloudVar - { - public string OwnerId { get; set; } = string.Empty; - public string Value { get; set; } = string.Empty; - } - - private async Task<(string token, DateTime expiry)> RefreshToken() - { - var opts = _options.Value; - var response = await _client.PostAsJsonAsync($"api/userSessions", new LoginRequest - { - Username = opts.UserName, - Password = opts.Password, - }); - response.EnsureSuccessStatusCode(); - var resp = await response.Content.ReadFromJsonAsync(); - if (resp == null) - { - return ("", DateTime.MinValue); - } - return ($"neos {resp.UserId}:{resp.Token}", resp.Expire); - } - - private class LoginRequest - { - public string Username { get; set; } = string.Empty; - public string Password { get; set; } = string.Empty; - } - - private class LoginResponse - { - public string? Token { get; set; } - public DateTime Expire { get; set; } - public string? UserId { get; set; } - } - } -} - -namespace Microsoft.Extensions.DependencyInjection -{ - using MentorBot.Extern; - using Microsoft.Extensions.Configuration; - - public static class NeosApiExtensions - { - public static IServiceCollection AddNeosHttpClient(this IServiceCollection services, IConfiguration configuration) - { - services.AddHttpClient(c => - { - c.BaseAddress = new Uri("https://api.neos.com/"); - c.DefaultRequestHeaders.Add("User-Agent", "MentorBotService"); - }); - services.AddSingleton(); - - services.Configure(configuration.GetSection("neosApi")); - - return services; - } - } -} diff --git a/MentorBot/Extern/NeosApiAuth.cs b/MentorBot/Extern/NeosApiAuth.cs deleted file mode 100644 index 94f4af3..0000000 --- a/MentorBot/Extern/NeosApiAuth.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace MentorBot.Extern -{ - public interface INeosApiAuthKeeper - { - ValueTask GetOrRefreshToken(Func> tokenGetter, CancellationToken cancellationToken = default); - } - - public class NeosApiAuthKeeper : INeosApiAuthKeeper - { - private readonly object _refreshLock = new(); - - private volatile Task<(string token, DateTime expiry)> _lastToken = Task.FromResult(("", DateTime.MinValue)); - - public async ValueTask GetOrRefreshToken(Func> tokenGetter, CancellationToken cancellationToken) - { - while (!cancellationToken.IsCancellationRequested) - { - cancellationToken.ThrowIfCancellationRequested(); - var lastTask = _lastToken; - var (token, expiry) = await lastTask; - - if (expiry >= DateTime.UtcNow + TimeSpan.FromMinutes(3)) - { - return token; - } - - lock (_refreshLock) - { - if (_lastToken == lastTask) - { - _lastToken = tokenGetter(); - } - } - } - cancellationToken.ThrowIfCancellationRequested(); - throw new InvalidOperationException("This should not be reachable."); - } - } -} diff --git a/MentorBot/Extern/ResoniteApi.cs b/MentorBot/Extern/ResoniteApi.cs new file mode 100644 index 0000000..5260549 --- /dev/null +++ b/MentorBot/Extern/ResoniteApi.cs @@ -0,0 +1,113 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; +using MentorBot.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace MentorBot.Extern; + +public class ResoniteApiOptions +{ + public string VariableName { get; set; } = string.Empty; + public string UserId { get; set; } = string.Empty; + public string UserName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} + +public interface IResoniteApi +{ + ValueTask GetUserAsync(string userId, CancellationToken cancellationToken = default); + ValueTask SetCloudVarAuthTokenAsync(string token, string user, CancellationToken cancellationToken = default); +} + +public class ResoniteApi( + HttpClient client, + IResoniteApiAuthKeeper authManager, + IOptions options, + ILogger logger) + : IResoniteApi +{ + public async ValueTask GetUserAsync(string userId, CancellationToken cancellationToken = default) + { + try + { + var user = await client.GetFromJsonAsync($"api/users/{Uri.EscapeDataString(userId)}", + cancellationToken); + if (user != null && !string.IsNullOrWhiteSpace(user.Id) && !string.IsNullOrWhiteSpace(user.Username)) + return new User + { + Id = user.Id, + Name = user.Username + }; + } + catch + { + } + + return null; + } + + public async ValueTask SetCloudVarAuthTokenAsync(string token, string user, CancellationToken cancellationToken) + { + var authToken = await authManager.GetOrRefreshToken(RefreshToken, cancellationToken); + if (string.IsNullOrWhiteSpace(authToken)) + { + logger.LogWarning("Failed to set ID for user."); + throw new InvalidOperationException("Failed to set token cloud variable for user."); + } + + var opts = options.Value; + client.DefaultRequestHeaders.Authorization = AuthenticationHeaderValue.Parse(authToken); + + var url = $"api/groups/{user}/vars/{opts.VariableName}"; + var resp = await client.PutAsJsonAsync(url, new NeosSetCloudVar + { + OwnerId = user, + Value = token + }, cancellationToken); + resp.EnsureSuccessStatusCode(); + } + + private async Task<(string token, DateTime expiry)> RefreshToken() + { + var opts = options.Value; + var response = await client.PostAsJsonAsync("api/userSessions", new LoginRequest + { + Username = opts.UserName, + Password = opts.Password + }); + response.EnsureSuccessStatusCode(); + var resp = await response.Content.ReadFromJsonAsync(); + if (resp == null) return ("", DateTime.MinValue); + return ($"neos {resp.UserId}:{resp.Token}", resp.Expire); + } + + private class ResoniteUser + { + public string? Id { get; set; } + public string? Username { get; set; } + } + + private class NeosSetCloudVar + { + public string OwnerId { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + } + + private class LoginRequest + { + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + } + + private class LoginResponse + { + public string? Token { get; set; } + public DateTime Expire { get; set; } + public string? UserId { get; set; } + } +} \ No newline at end of file diff --git a/MentorBot/Extern/ResoniteApiAuth.cs b/MentorBot/Extern/ResoniteApiAuth.cs new file mode 100644 index 0000000..254403b --- /dev/null +++ b/MentorBot/Extern/ResoniteApiAuth.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MentorBot.Extern; + +public interface IResoniteApiAuthKeeper +{ + ValueTask GetOrRefreshToken(Func> tokenGetter, + CancellationToken cancellationToken = default); +} + +public class ResoniteApiAuthKeeper : IResoniteApiAuthKeeper +{ + private readonly Lock _refreshLock = new(); + + private volatile Task<(string token, DateTime expiry)> _lastToken = Task.FromResult(("", DateTime.MinValue)); + + public async ValueTask GetOrRefreshToken(Func> tokenGetter, + CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + var lastTask = _lastToken; + var (token, expiry) = await lastTask; + + if (expiry >= DateTime.UtcNow + TimeSpan.FromMinutes(3)) return token; + + lock (_refreshLock) + { + if (_lastToken == lastTask) _lastToken = tokenGetter(); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + throw new InvalidOperationException("This should not be reachable."); + } +} \ No newline at end of file diff --git a/MentorBot/Extern/ResoniteApiExtensions.cs b/MentorBot/Extern/ResoniteApiExtensions.cs new file mode 100644 index 0000000..59246d6 --- /dev/null +++ b/MentorBot/Extern/ResoniteApiExtensions.cs @@ -0,0 +1,23 @@ +using System; +using MentorBot.Extern; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class ResoniteApiExtensions +{ + public static IServiceCollection AddResoniteHttpClient(this IServiceCollection services, + IConfiguration configuration) + { + services.AddHttpClient(c => + { + c.BaseAddress = new Uri("https://api.neos.com/"); + c.DefaultRequestHeaders.Add("User-Agent", "MentorBotService"); + }); + services.AddSingleton(); + + services.AddOptions().BindConfiguration("ResoniteApi"); + + return services; + } +} \ No newline at end of file diff --git a/MentorBot/JsonSerializerOptionsExtensions.cs b/MentorBot/JsonSerializerOptionsExtensions.cs index 78ed863..73573fb 100644 --- a/MentorBot/JsonSerializerOptionsExtensions.cs +++ b/MentorBot/JsonSerializerOptionsExtensions.cs @@ -1,14 +1,13 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace MentorBot -{ - public static class JsonSerializerOptionsExtensions - { - public static JsonSerializerOptions ConfigureForMentor(this JsonSerializerOptions options) - { - options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); - return options; - } - } -} +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MentorBot; + +public static class JsonSerializerOptionsExtensions +{ + public static JsonSerializerOptions ConfigureForMentor(this JsonSerializerOptions options) + { + options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return options; + } +} \ No newline at end of file diff --git a/MentorBot/MentorBot.csproj b/MentorBot/MentorBot.csproj index 12cec8a..5f4adef 100644 --- a/MentorBot/MentorBot.csproj +++ b/MentorBot/MentorBot.csproj @@ -1,28 +1,32 @@  - - net6.0 - Linux - . - 27f6215c-d795-4dce-9074-f7872fb32c97 - enable - + + net10.0 + Linux + . + 27f6215c-d795-4dce-9074-f7872fb32c97 + enable + - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + compile + + diff --git a/MentorBot/MentorOptions.cs b/MentorBot/MentorOptions.cs index 093cea4..749208a 100644 --- a/MentorBot/MentorOptions.cs +++ b/MentorBot/MentorOptions.cs @@ -1,8 +1,7 @@ -namespace MentorBot -{ - public class MentorOptions - { - public string ModifyMentorsToken { get; set; } = string.Empty; - public bool EnableSwagger { get; set; } = true; - } -} +namespace MentorBot; + +public class MentorOptions +{ + public string ModifyMentorsToken { get; set; } = string.Empty; + public bool EnableSwagger { get; set; } = true; +} \ No newline at end of file diff --git a/MentorBot/Models/DbCreator.cs b/MentorBot/Models/DbCreator.cs index a7eac89..929c580 100644 --- a/MentorBot/Models/DbCreator.cs +++ b/MentorBot/Models/DbCreator.cs @@ -1,15 +1,14 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace MentorBot.Models +namespace MentorBot.Models; + +public static class DbCreator { - public static class DbCreator - { public static void EnsureDatabaseCreated(this IHost host) { - using var scope = host.Services.CreateScope(); - scope.ServiceProvider.GetRequiredService() - .EnsureCreated(); + using var scope = host.Services.CreateScope(); + scope.ServiceProvider.GetRequiredService() + .EnsureCreated(); } - } -} +} \ No newline at end of file diff --git a/MentorBot/Models/Mentor.cs b/MentorBot/Models/Mentor.cs index d95aede..02929b5 100644 --- a/MentorBot/Models/Mentor.cs +++ b/MentorBot/Models/Mentor.cs @@ -1,31 +1,34 @@ -using Microsoft.EntityFrameworkCore; -using System.ComponentModel.DataAnnotations; - -namespace MentorBot.Models -{ - [Index(nameof(Token), IsUnique = true)] - public record Mentor - { - [Key] - public string NeosId { get; set; } = string.Empty; - public ulong? DiscordId { get; set; } - public string Name { get; set; } = string.Empty; - [MaxLength(60)] - public string? Token { get; set; } = string.Empty; - - public MentorDto ToDto() => new(this); - } - - public record MentorDto - { - private readonly Mentor _mentor; - public MentorDto(Mentor mentor) - { - _mentor = mentor; - } - - public string Id => _mentor.NeosId; - public string Name => _mentor.Name; - public bool InProgram => !string.IsNullOrWhiteSpace(_mentor.Token); - } -} +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; + +namespace MentorBot.Models; + +[Index(nameof(Token), IsUnique = true)] +public record Mentor +{ + [Key] public string ResoUserId { get; set; } = string.Empty; + + public ulong? DiscordId { get; set; } + public string Name { get; set; } = string.Empty; + + [MaxLength(60)] public string? Token { get; set; } = string.Empty; + + public MentorDto ToDto() + { + return new MentorDto(this); + } +} + +public record MentorDto +{ + private readonly Mentor _mentor; + + public MentorDto(Mentor mentor) + { + _mentor = mentor; + } + + public string Id => _mentor.ResoUserId; + public string Name => _mentor.Name; + public bool InProgram => !string.IsNullOrWhiteSpace(_mentor.Token); +} \ No newline at end of file diff --git a/MentorBot/Models/MentorContext.cs b/MentorBot/Models/MentorContext.cs index 772c286..e2b1056 100644 --- a/MentorBot/Models/MentorContext.cs +++ b/MentorBot/Models/MentorContext.cs @@ -1,87 +1,85 @@ -using MentorBot.Extern; -using Microsoft.EntityFrameworkCore; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace MentorBot.Models -{ - public interface IMentorContext - { - IAsyncEnumerable Mentors(); - ValueTask GetMentorByNeosIdAsync(string neosId, CancellationToken cancellationToken = default); - ValueTask GetMentorByTokenAsync(string token, CancellationToken cancellationToken = default); - ValueTask AddMentorAsync(string neosId, CancellationToken cancellationToken = default); - ValueTask RemoveMentorAccess(string neosId, CancellationToken cancellationToken = default); - } - - public class MentorContext : IMentorContext - { - private readonly ISignalContext _ctx; - private readonly INeosApi _neosApi; - private readonly ITokenGenerator _tokenGen; - - public MentorContext(ISignalContext ctx, INeosApi neosApi, ITokenGenerator tokenGen) - { - _ctx = ctx; - _neosApi = neosApi; - _tokenGen = tokenGen; - } - - public IAsyncEnumerable Mentors() - { - return _ctx.Mentors.AsAsyncEnumerable(); - } - - public async ValueTask GetMentorByNeosIdAsync(string neosId, CancellationToken cancellationToken) - { - return await _ctx.Mentors.SingleOrDefaultAsync(t => t.NeosId == neosId, cancellationToken); - } - - public async ValueTask GetMentorByTokenAsync(string token, CancellationToken cancellationToken) - { - return await _ctx.Mentors.SingleOrDefaultAsync(t => t.Token == token, cancellationToken); - } - - public async ValueTask AddMentorAsync(string neosId, CancellationToken cancellationToken) - { - var mentorUserTask = _neosApi.GetUserAsync(neosId, cancellationToken); - var existingMentorTask = GetMentorByNeosIdAsync(neosId, cancellationToken); - - var mentorUser = await mentorUserTask; - if (mentorUser == null) - { - return null; - } - - var mentor = await existingMentorTask; - if (mentor == null) - { - mentor = new() - { - NeosId = neosId, - Name = mentorUser.Name, - }; - _ctx.Add(mentor); - } - mentor.Token = _tokenGen.CreateToken(); - - await _neosApi.SetCloudVarAuthTokenAsync(mentor.Token, neosId, cancellationToken); - - await _ctx.SaveChangesAsync(cancellationToken); - return mentor; - } - - public async ValueTask RemoveMentorAccess(string neosId, CancellationToken cancellationToken) - { - var mentor = await GetMentorByNeosIdAsync(neosId, cancellationToken); - if(mentor != null) - { - mentor.Token = null; - _ctx.Update(mentor); - await _ctx.SaveChangesAsync(cancellationToken); - } - return mentor; - } - } -} +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MentorBot.Extern; +using Microsoft.EntityFrameworkCore; + +namespace MentorBot.Models; + +public interface IMentorContext +{ + IAsyncEnumerable Mentors(); + ValueTask GetMentorByResoIdAsync(string resoUserId, CancellationToken cancellationToken = default); + ValueTask GetMentorByTokenAsync(string token, CancellationToken cancellationToken = default); + ValueTask AddMentorAsync(string resoUserId, CancellationToken cancellationToken = default); + ValueTask RemoveMentorAccess(string resoUserId, CancellationToken cancellationToken = default); +} + +public class MentorContext : IMentorContext +{ + private readonly ISignalContext _ctx; + private readonly IResoniteApi _neosApi; + private readonly ITokenGenerator _tokenGen; + + public MentorContext(ISignalContext ctx, IResoniteApi neosApi, ITokenGenerator tokenGen) + { + _ctx = ctx; + _neosApi = neosApi; + _tokenGen = tokenGen; + } + + public IAsyncEnumerable Mentors() + { + return _ctx.Mentors.AsAsyncEnumerable(); + } + + public async ValueTask GetMentorByResoIdAsync(string resoUserId, CancellationToken cancellationToken) + { + return await _ctx.Mentors.SingleOrDefaultAsync(t => t.ResoUserId == resoUserId, cancellationToken); + } + + public async ValueTask GetMentorByTokenAsync(string token, CancellationToken cancellationToken) + { + return await _ctx.Mentors.SingleOrDefaultAsync(t => t.Token == token, cancellationToken); + } + + public async ValueTask AddMentorAsync(string resoUserId, CancellationToken cancellationToken) + { + var mentorUserTask = _neosApi.GetUserAsync(resoUserId, cancellationToken); + var existingMentorTask = GetMentorByResoIdAsync(resoUserId, cancellationToken); + + var mentorUser = await mentorUserTask; + if (mentorUser == null) return null; + + var mentor = await existingMentorTask; + if (mentor == null) + { + mentor = new Mentor + { + ResoUserId = resoUserId, + Name = mentorUser.Name + }; + _ctx.Add(mentor); + } + + mentor.Token = _tokenGen.CreateToken(); + + await _neosApi.SetCloudVarAuthTokenAsync(mentor.Token, resoUserId, cancellationToken); + + await _ctx.SaveChangesAsync(cancellationToken); + return mentor; + } + + public async ValueTask RemoveMentorAccess(string resoUserId, CancellationToken cancellationToken) + { + var mentor = await GetMentorByResoIdAsync(resoUserId, cancellationToken); + if (mentor != null) + { + mentor.Token = null; + _ctx.Update(mentor); + await _ctx.SaveChangesAsync(cancellationToken); + } + + return mentor; + } +} \ No newline at end of file diff --git a/MentorBot/Models/SignalContext.cs b/MentorBot/Models/SignalContext.cs index edd2fb1..338eb87 100644 --- a/MentorBot/Models/SignalContext.cs +++ b/MentorBot/Models/SignalContext.cs @@ -1,91 +1,67 @@ -using Microsoft.EntityFrameworkCore; -using System; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace MentorBot.Models -{ - public interface ISignalContext : IDisposable - { - IQueryable Tickets { get; } - IQueryable Mentors { get; } - void Add(Ticket ticket); - void Update(Ticket ticket); - void Add(Mentor ticket); - void Update(Mentor ticket); - Task SaveChangesAsync(CancellationToken cancellationToken = default); - void EnsureCreated(); - } - - public class SignalContext : DbContext, ISignalContext - { - public DbSet Tickets { get; set; } - - public DbSet Mentors { get; set; } - -#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - public SignalContext(DbContextOptions options) -#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. - : base(options) - { - } - - IQueryable ISignalContext.Tickets => Tickets.Include(t => t.Mentor); - - IQueryable ISignalContext.Mentors => Mentors; - - void ISignalContext.Add(Ticket ticket) - { - Tickets.Add(ticket); - } - - void ISignalContext.Add(Mentor mentor) - { - Mentors.Add(mentor); - } - - void ISignalContext.Update(Ticket ticket) - { - Tickets.Update(ticket); - } - - void ISignalContext.Update(Mentor mentor) - { - Mentors.Update(mentor); - } - - Task ISignalContext.SaveChangesAsync(CancellationToken cancellationToken) - { - return SaveChangesAsync(cancellationToken); - } - - public void EnsureCreated() - { - Database.EnsureCreated(); - } - } -} - -namespace Microsoft.Extensions.DependencyInjection -{ - using MentorBot.Models; - using Microsoft.EntityFrameworkCore; - using Microsoft.Extensions.Configuration; - - public static class SignalContextExtensions - { - public static IServiceCollection AddSignalContexts(this IServiceCollection services, IConfiguration configuration) - { - return services.AddDbContext(o => - o.UseSqlServer(configuration.GetConnectionString("SqlDb"))) - .AddTransient() - .AddTransient(); - } - - public static IHealthChecksBuilder AddSignalHealthChecks(this IHealthChecksBuilder builder) - { - return builder.AddDbContextCheck(); - } - } +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace MentorBot.Models; + +public interface ISignalContext : IDisposable +{ + IQueryable Tickets { get; } + IQueryable Mentors { get; } + void Add(Ticket ticket); + void Update(Ticket ticket); + void Add(Mentor ticket); + void Update(Mentor ticket); + Task SaveChangesAsync(CancellationToken cancellationToken = default); + void EnsureCreated(); +} + +public class SignalContext : DbContext, ISignalContext +{ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public SignalContext(DbContextOptions options) +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + : base(options) + { + } + + public DbSet Tickets { get; set; } + + public DbSet Mentors { get; set; } + + IQueryable ISignalContext.Tickets => Tickets.Include(t => t.Mentor); + + IQueryable ISignalContext.Mentors => Mentors; + + void ISignalContext.Add(Ticket ticket) + { + Tickets.Add(ticket); + } + + void ISignalContext.Add(Mentor mentor) + { + Mentors.Add(mentor); + } + + void ISignalContext.Update(Ticket ticket) + { + Tickets.Update(ticket); + } + + void ISignalContext.Update(Mentor mentor) + { + Mentors.Update(mentor); + } + + Task ISignalContext.SaveChangesAsync(CancellationToken cancellationToken) + { + return SaveChangesAsync(cancellationToken); + } + + public void EnsureCreated() + { + Database.EnsureCreated(); + } } \ No newline at end of file diff --git a/MentorBot/Models/SignalContextExtensions.cs b/MentorBot/Models/SignalContextExtensions.cs new file mode 100644 index 0000000..eb1d3e7 --- /dev/null +++ b/MentorBot/Models/SignalContextExtensions.cs @@ -0,0 +1,21 @@ +using MentorBot.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class SignalContextExtensions +{ + public static IServiceCollection AddSignalContexts(this IServiceCollection services, IConfiguration configuration) + { + return services.AddDbContext(o => + o.UseSqlServer(configuration.GetConnectionString("SqlDb"))) + .AddTransient() + .AddTransient(); + } + + public static IHealthChecksBuilder AddSignalHealthChecks(this IHealthChecksBuilder builder) + { + return builder.AddDbContextCheck(); + } +} \ No newline at end of file diff --git a/MentorBot/Models/Ticket.cs b/MentorBot/Models/Ticket.cs index 955edb6..0ebf413 100644 --- a/MentorBot/Models/Ticket.cs +++ b/MentorBot/Models/Ticket.cs @@ -1,183 +1,188 @@ -using Discord; -using Microsoft.AspNetCore.Mvc; -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; - -namespace MentorBot.Models -{ - public class TicketCreate - { - [FromQuery] - public string? UserId { get; init; } - [FromQuery] - public string? Lang { get; init; } - [FromQuery] - public string? Desc { get; init; } - [FromQuery] - public string? Session { get; init; } - [FromQuery] - public string? SessionId { get; init; } - [FromQuery] - public string? SessionUrl { get; init; } - [FromQuery] - public string? SessionWebUrl { get; init; } - } - - public record Ticket - { - public TicketStatus Status { get; set; } = TicketStatus.Requested; - public User User { get; set; } = new User(); - public string? Lang { get; set; } - public string? Desc { get; set; } - public string? Session { get; set; } - public string? SessionId { get; set; } - public string? SessionUrl { get; set; } - public string? SessionWebUrl { get; set; } - - [Key] - public ulong Id { get; set; } - - public ulong? DiscordId { get; set; } - - public Mentor? Mentor { get; set; } - - public DateTime Created { get; set; } = DateTime.UtcNow; - public DateTime? Claimed { get; set; } - public DateTime? Complete { get; set; } - public DateTime? Canceled { get; set; } - - public Ticket() - { - } - - public Ticket(TicketCreate createArgs, User user) - { - User = user; - Lang = createArgs.Lang; - Desc = createArgs.Desc; - Session = createArgs.Session; - SessionId = createArgs.SessionId; - SessionUrl = createArgs.SessionUrl; - SessionWebUrl = createArgs.SessionWebUrl; - } - - private static string StatusToTitle(TicketStatus status) - { - return status switch - { - TicketStatus.Requested => "Mentor Requested", - TicketStatus.Responding => "Mentor Responding", - TicketStatus.Completed => "Request Completed", - TicketStatus.Canceled => "Request Canceled", - _ => throw new InvalidOperationException("No mapping of enum"), - }; - } - - public Embed ToEmbed() - { - return new EmbedBuilder - { - Title = StatusToTitle(Status), - Fields = EmbedFields().ToList(), - }.Build(); - } - - private IEnumerable EmbedFields() - { - foreach(var (name, value, inline) in Fields()) - { - yield return new EmbedFieldBuilder - { - Name = name, - Value = value, - IsInline = inline, - }; - } - } - - private static (string Name, string Value, bool Inline)? Field(string name, string? value, bool inline = false) - { - return !string.IsNullOrWhiteSpace(value) ? (name, value, inline) : null; - } - - public IEnumerable<(string Name, string Value, bool Inline)> Fields() - { - IEnumerable<(string Name, string Value, bool Inline)?> NullableFields() - { - yield return Field("Ticket Number", Id.ToString()); - yield return Field("User", User.Name, true); - yield return Field("User Neos Id", User.Id, true); - yield return Field("Language", Lang); - yield return Field("Description", Desc); - yield return Field("Session", Session); - yield return Field("Session ID", SessionId); - yield return Field("Session Url", SessionUrl); - yield return Field("Session Web Url", SessionWebUrl); - yield return Field("Mentor Name", Mentor?.Name, true); - yield return Field("Mentor Discord Link", Mentor?.DiscordId?.ToString(), true); - yield return Field("Mentor Neos ID", Mentor?.NeosId.ToString(), true); - yield return Field("Created", Created.ToDiscordTimecode("f")); - yield return Field("Claimed", Claimed?.ToDiscordTimecode("f")); - yield return Field("Completed", Complete?.ToDiscordTimecode("f")); - yield return Field("Canceled", Canceled?.ToDiscordTimecode("f")); - } - return NullableFields().OnlyNotNull(); - } - - public TicketDto ToDto() => new(this); - public MentorTicketDto ToMentorDto() => new(this); - } - - public record TicketDto { - protected readonly Ticket _ticket; - public TicketDto(Ticket ticket) - { - _ticket = ticket; - } - - public ulong Ticket => _ticket.Id; - public string? Mentor => _ticket.Mentor?.Name; - public TicketStatus Status => _ticket.Status; - } - - public record MentorTicketDto : TicketDto - { - public MentorTicketDto(Ticket ticket) : base(ticket) - { - } - - public DateTime Created => _ticket.Created; - public string? Lang => _ticket.Lang; - public string? Desc => _ticket.Desc; - public string? Session => _ticket.Session; - public string? SessionId => _ticket.SessionId; - public string? MentorId => _ticket.Mentor?.NeosId; - public string? UserId => _ticket.User.Id; - public string UserName => _ticket.User.Name; - } - - public enum TicketStatus - { - Requested, - Responding, - Completed, - Canceled - } - - public static class TicketStatusExtensions - { - public static bool IsTerminal(this TicketStatus status) - { - return status switch - { - TicketStatus.Requested => false, - TicketStatus.Responding => false, - TicketStatus.Completed => true, - TicketStatus.Canceled => true, - _ => false, - }; - } - } -} +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Discord; +using Microsoft.AspNetCore.Mvc; + +namespace MentorBot.Models; + +public class TicketCreate +{ + [FromQuery] public string? UserId { get; init; } + + [FromQuery] public string? Lang { get; init; } + + [FromQuery] public string? Desc { get; init; } + + [FromQuery] public string? Session { get; init; } + + [FromQuery] public string? SessionId { get; init; } + + [FromQuery] public string? SessionUrl { get; init; } + + [FromQuery] public string? SessionWebUrl { get; init; } +} + +public record Ticket +{ + public Ticket() + { + } + + public Ticket(TicketCreate createArgs, User user) + { + User = user; + Lang = createArgs.Lang; + Desc = createArgs.Desc; + Session = createArgs.Session; + SessionId = createArgs.SessionId; + SessionUrl = createArgs.SessionUrl; + SessionWebUrl = createArgs.SessionWebUrl; + } + + public TicketStatus Status { get; set; } = TicketStatus.Requested; + public User User { get; set; } = new(); + public string? Lang { get; set; } + public string? Desc { get; set; } + public string? Session { get; set; } + public string? SessionId { get; set; } + public string? SessionUrl { get; set; } + public string? SessionWebUrl { get; set; } + + [Key] public ulong Id { get; set; } + + public ulong? DiscordId { get; set; } + + public Mentor? Mentor { get; set; } + + public DateTime Created { get; set; } = DateTime.UtcNow; + public DateTime? Claimed { get; set; } + public DateTime? Complete { get; set; } + public DateTime? Canceled { get; set; } + + private static string StatusToTitle(TicketStatus status) + { + return status switch + { + TicketStatus.Requested => "Mentor Requested", + TicketStatus.Responding => "Mentor Responding", + TicketStatus.Completed => "Request Completed", + TicketStatus.Canceled => "Request Canceled", + _ => throw new InvalidOperationException("No mapping of enum") + }; + } + + public Embed ToEmbed() + { + return new EmbedBuilder + { + Title = StatusToTitle(Status), + Fields = EmbedFields().ToList() + }.Build(); + } + + private IEnumerable EmbedFields() + { + foreach (var (name, value, inline) in Fields()) + yield return new EmbedFieldBuilder + { + Name = name, + Value = value, + IsInline = inline + }; + } + + private static (string Name, string Value, bool Inline)? Field(string name, string? value, bool inline = false) + { + return !string.IsNullOrWhiteSpace(value) ? (name, value, inline) : null; + } + + public IEnumerable<(string Name, string Value, bool Inline)> Fields() + { + IEnumerable<(string Name, string Value, bool Inline)?> NullableFields() + { + yield return Field("Ticket Number", Id.ToString()); + yield return Field("User", User.Name, true); + yield return Field("User Neos Id", User.Id, true); + yield return Field("Language", Lang); + yield return Field("Description", Desc); + yield return Field("Session", Session); + yield return Field("Session ID", SessionId); + yield return Field("Session Url", SessionUrl); + yield return Field("Session Web Url", SessionWebUrl); + yield return Field("Mentor Name", Mentor?.Name, true); + yield return Field("Mentor Discord Link", Mentor?.DiscordId?.ToString(), true); + yield return Field("Mentor Neos ID", Mentor?.ResoUserId, true); + yield return Field("Created", Created.ToDiscordTimecode("f")); + yield return Field("Claimed", Claimed?.ToDiscordTimecode("f")); + yield return Field("Completed", Complete?.ToDiscordTimecode("f")); + yield return Field("Canceled", Canceled?.ToDiscordTimecode("f")); + } + + return NullableFields().OnlyNotNull(); + } + + public TicketDto ToDto() + { + return new TicketDto(this); + } + + public MentorTicketDto ToMentorDto() + { + return new MentorTicketDto(this); + } +} + +public record TicketDto +{ + protected readonly Ticket _ticket; + + public TicketDto(Ticket ticket) + { + _ticket = ticket; + } + + public ulong Ticket => _ticket.Id; + public string? Mentor => _ticket.Mentor?.Name; + public TicketStatus Status => _ticket.Status; +} + +public record MentorTicketDto : TicketDto +{ + public MentorTicketDto(Ticket ticket) : base(ticket) + { + } + + public DateTime Created => _ticket.Created; + public string? Lang => _ticket.Lang; + public string? Desc => _ticket.Desc; + public string? Session => _ticket.Session; + public string? SessionId => _ticket.SessionId; + public string? MentorId => _ticket.Mentor?.ResoUserId; + public string? UserId => _ticket.User.Id; + public string UserName => _ticket.User.Name; +} + +public enum TicketStatus +{ + Requested, + Responding, + Completed, + Canceled +} + +public static class TicketStatusExtensions +{ + public static bool IsTerminal(this TicketStatus status) + { + return status switch + { + TicketStatus.Requested => false, + TicketStatus.Responding => false, + TicketStatus.Completed => true, + TicketStatus.Canceled => true, + _ => false + }; + } +} \ No newline at end of file diff --git a/MentorBot/Models/TicketContext.cs b/MentorBot/Models/TicketContext.cs index 226219b..b2e8c32 100644 --- a/MentorBot/Models/TicketContext.cs +++ b/MentorBot/Models/TicketContext.cs @@ -1,165 +1,151 @@ -using MentorBot.Extern; -using Microsoft.EntityFrameworkCore; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; - -namespace MentorBot.Models -{ - public interface ITicketContext - { - IAsyncEnumerable GetIncompleteTickets(); - ValueTask GetTicketAsync(ulong ticketId, CancellationToken cancellationToken = default); - ValueTask CreateTicketAsync(TicketCreate createArgs, CancellationToken cancellationToken = default); - ValueTask TryCompleteTicketAsync(ulong ticketId, string mentorToken, CancellationToken cancellationToken = default); - ValueTask TryCancelTicketAsync(ulong ticketId, CancellationToken cancellationToken = default); - ValueTask TryClaimTicketAsync(ulong ticketId, string mentorToken, CancellationToken cancellationToken = default); - ValueTask TryUnclaimTicketAsync(ulong ticketId, string mentorToken, CancellationToken cancellationToken = default); - ValueTask AssignDiscordIdAsync(ulong ticket, ulong discordId, CancellationToken cancellationToken = default); - } - - public class TicketContext : ITicketContext - { - private readonly ISignalContext _ctx; - private readonly IMentorContext _mentorCtx; - private readonly ITicketNotifier _notifier; - private readonly INeosApi _neosApi; - - public TicketContext(ISignalContext ctx, IMentorContext mentorCtx, ITicketNotifier notifier, INeosApi neosApi) - { - _ctx = ctx; - _mentorCtx = mentorCtx; - _notifier = notifier; - _neosApi = neosApi; - } - - public IAsyncEnumerable GetIncompleteTickets() - { - return _ctx.Tickets.Where(t => t.Status == TicketStatus.Requested || t.Status == TicketStatus.Responding).AsAsyncEnumerable(); - } - - public async ValueTask GetTicketAsync(ulong ticketId, CancellationToken cancellationToken = default) - { - return await _ctx.Tickets.SingleOrDefaultAsync(t => t.Id == ticketId, cancellationToken); - } - - public async ValueTask CreateTicketAsync(TicketCreate createArgs, CancellationToken cancellationToken = default) - { - if (createArgs.UserId == null) - { - return null; - } - - User? user = await _neosApi.GetUserAsync(createArgs.UserId, cancellationToken); - if (user == null) - { - return null; - } - - Ticket ticket = new(createArgs, user) - { - Status = TicketStatus.Requested, - Created = DateTime.UtcNow - }; - - _ctx.Add(ticket); - await _ctx.SaveChangesAsync(cancellationToken); - _notifier.NotifyNewTicket(ticket); - return ticket; - } - - public async ValueTask TryClaimTicketAsync(ulong ticketId, string mentorToken, CancellationToken cancellationToken = default) - { - var ticket = await GetTicketAsync(ticketId, cancellationToken); - var mentor = await _mentorCtx.GetMentorByTokenAsync(mentorToken, cancellationToken); - if (ticket == null || mentor == null) - { - return null; - } - if (ticket.Status == TicketStatus.Requested) - { - ticket.Mentor = mentor; - ticket.Status = TicketStatus.Responding; - ticket.Claimed = DateTime.UtcNow; - - _ctx.Update(ticket); - await _ctx.SaveChangesAsync(cancellationToken); - _notifier.NotifyUpdatedTicket(ticket); - } - return ticket; - } - - public async ValueTask TryUnclaimTicketAsync(ulong ticketId, string mentorToken, CancellationToken cancellationToken = default) - { - var ticket = await GetTicketAsync(ticketId, cancellationToken); - if (ticket == null || ticket.Mentor?.Token != mentorToken) - { - return null; - } - if (ticket.Status == TicketStatus.Responding) - { - ticket.Status = TicketStatus.Requested; - ticket.Claimed = null; - ticket.Mentor = null; - - _ctx.Update(ticket); - await _ctx.SaveChangesAsync(cancellationToken); - _notifier.NotifyUpdatedTicket(ticket); - } - return ticket; - } - - public async ValueTask TryCompleteTicketAsync(ulong ticketId, string mentorToken, CancellationToken cancellationToken = default) - { - var ticket = await GetTicketAsync(ticketId, cancellationToken); - if (ticket == null || ticket.Mentor?.Token != mentorToken) - { - return null; - } - if (ticket.Status == TicketStatus.Responding) - { - ticket.Status = TicketStatus.Completed; - ticket.Complete = DateTime.UtcNow; - - _ctx.Update(ticket); - await _ctx.SaveChangesAsync(cancellationToken); - _notifier.NotifyUpdatedTicket(ticket); - } - return ticket; - } - - public async ValueTask TryCancelTicketAsync(ulong ticketId, CancellationToken cancellationToken = default) - { - var ticket = await GetTicketAsync(ticketId, cancellationToken); - if (ticket == null) - { - return null; - } - if (!ticket.Status.IsTerminal()) - { - ticket.Status = TicketStatus.Canceled; - ticket.Canceled = DateTime.UtcNow; - - _ctx.Update(ticket); - await _ctx.SaveChangesAsync(cancellationToken); - _notifier.NotifyUpdatedTicket(ticket); - } - return ticket; - } - - public async ValueTask AssignDiscordIdAsync(ulong ticketId, ulong discordId, CancellationToken cancellationToken = default) - { - var ticket = await GetTicketAsync(ticketId, cancellationToken); - if (ticket == null) - { - return null; - } - ticket.DiscordId = discordId; - _ctx.Update(ticket); - await _ctx.SaveChangesAsync(cancellationToken); - return ticket; - } - } -} +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MentorBot.Extern; +using Microsoft.EntityFrameworkCore; + +namespace MentorBot.Models; + +public interface ITicketContext +{ + IAsyncEnumerable GetIncompleteTickets(); + ValueTask GetTicketAsync(ulong ticketId, CancellationToken cancellationToken = default); + ValueTask CreateTicketAsync(TicketCreate createArgs, CancellationToken cancellationToken = default); + + ValueTask TryCompleteTicketAsync(ulong ticketId, string mentorToken, + CancellationToken cancellationToken = default); + + ValueTask TryCancelTicketAsync(ulong ticketId, CancellationToken cancellationToken = default); + + ValueTask TryClaimTicketAsync(ulong ticketId, string mentorToken, + CancellationToken cancellationToken = default); + + ValueTask TryUnclaimTicketAsync(ulong ticketId, string mentorToken, + CancellationToken cancellationToken = default); + + ValueTask AssignDiscordIdAsync(ulong ticket, ulong discordId, + CancellationToken cancellationToken = default); +} + +public class TicketContext(ISignalContext ctx, IMentorContext mentorCtx, ITicketNotifier notifier, IResoniteApi neosApi) + : ITicketContext +{ + public IAsyncEnumerable GetIncompleteTickets() + { + return ctx.Tickets.Where(t => t.Status == TicketStatus.Requested || t.Status == TicketStatus.Responding) + .AsAsyncEnumerable(); + } + + public async ValueTask GetTicketAsync(ulong ticketId, CancellationToken cancellationToken = default) + { + return await ctx.Tickets.SingleOrDefaultAsync(t => t.Id == ticketId, cancellationToken); + } + + public async ValueTask CreateTicketAsync(TicketCreate createArgs, + CancellationToken cancellationToken = default) + { + if (createArgs.UserId == null) return null; + + var user = await neosApi.GetUserAsync(createArgs.UserId, cancellationToken); + if (user == null) return null; + + Ticket ticket = new(createArgs, user) + { + Status = TicketStatus.Requested, + Created = DateTime.UtcNow + }; + + ctx.Add(ticket); + await ctx.SaveChangesAsync(cancellationToken); + notifier.NotifyNewTicket(ticket); + return ticket; + } + + public async ValueTask TryClaimTicketAsync(ulong ticketId, string mentorToken, + CancellationToken cancellationToken = default) + { + var ticket = await GetTicketAsync(ticketId, cancellationToken); + var mentor = await mentorCtx.GetMentorByTokenAsync(mentorToken, cancellationToken); + + if (ticket == null || mentor == null) return null; + if (ticket.Status != TicketStatus.Requested) return ticket; + + ticket.Mentor = mentor; + ticket.Status = TicketStatus.Responding; + ticket.Claimed = DateTime.UtcNow; + + ctx.Update(ticket); + await ctx.SaveChangesAsync(cancellationToken); + notifier.NotifyUpdatedTicket(ticket); + + return ticket; + } + + public async ValueTask TryUnclaimTicketAsync(ulong ticketId, string mentorToken, + CancellationToken cancellationToken = default) + { + var ticket = await GetTicketAsync(ticketId, cancellationToken); + + if (ticket == null || ticket.Mentor?.Token != mentorToken) return null; + if (ticket.Status != TicketStatus.Responding) return ticket; + + ticket.Status = TicketStatus.Requested; + ticket.Claimed = null; + ticket.Mentor = null; + + ctx.Update(ticket); + await ctx.SaveChangesAsync(cancellationToken); + notifier.NotifyUpdatedTicket(ticket); + + return ticket; + } + + public async ValueTask TryCompleteTicketAsync(ulong ticketId, string mentorToken, + CancellationToken cancellationToken = default) + { + var ticket = await GetTicketAsync(ticketId, cancellationToken); + + if (ticket == null || ticket.Mentor?.Token != mentorToken) return null; + if (ticket.Status != TicketStatus.Responding) return ticket; + + ticket.Status = TicketStatus.Completed; + ticket.Complete = DateTime.UtcNow; + + ctx.Update(ticket); + await ctx.SaveChangesAsync(cancellationToken); + notifier.NotifyUpdatedTicket(ticket); + + return ticket; + } + + public async ValueTask TryCancelTicketAsync(ulong ticketId, CancellationToken cancellationToken = default) + { + var ticket = await GetTicketAsync(ticketId, cancellationToken); + + if (ticket == null) return null; + if (ticket.Status.IsTerminal()) return ticket; + + ticket.Status = TicketStatus.Canceled; + ticket.Canceled = DateTime.UtcNow; + + ctx.Update(ticket); + await ctx.SaveChangesAsync(cancellationToken); + notifier.NotifyUpdatedTicket(ticket); + + return ticket; + } + + public async ValueTask AssignDiscordIdAsync(ulong ticketId, ulong discordId, + CancellationToken cancellationToken = default) + { + var ticket = await GetTicketAsync(ticketId, cancellationToken); + if (ticket == null) return null; + + ticket.DiscordId = discordId; + ctx.Update(ticket); + await ctx.SaveChangesAsync(cancellationToken); + return ticket; + } +} \ No newline at end of file diff --git a/MentorBot/Models/TicketNotifier.cs b/MentorBot/Models/TicketNotifier.cs index 7786364..f783571 100644 --- a/MentorBot/Models/TicketNotifier.cs +++ b/MentorBot/Models/TicketNotifier.cs @@ -1,93 +1,91 @@ -using System; -using System.Collections.Concurrent; -using System.Threading; - -namespace MentorBot.Models -{ - public interface ITicketNotifier - { - int GlobalWatchers { get; } - IDisposable WatchTicketAdded(Action handler); - IDisposable WatchTicketsUpdated(Action handler); - IDisposable WatchTicketUpdated(ulong ticketId, Action handler); - void NotifyNewTicket(Ticket ticket); - void NotifyUpdatedTicket(Ticket ticket); - } - - // Notifies subscribers to new and updated tickets. - public class TicketNotifier : ITicketNotifier - { - private class TicketWatcher - { - public event Action? TicketUpdated; - - public void InvokeTicketUpdated(Ticket ticket) - { - TicketUpdated?.Invoke(ticket); - } - } - - private readonly ConcurrentDictionary _ticketMonitor = new(); - - private int _globalWatchers; - - public int GlobalWatchers => _globalWatchers; - - private event Action? TicketAdded; - private event Action? TicketUpdated; - - public void NotifyNewTicket(Ticket ticket) - { - TicketAdded?.Invoke(ticket); - NotifyUpdatedTicket(ticket); - } - - public void NotifyUpdatedTicket(Ticket ticket) - { - TicketUpdated?.Invoke(ticket); - if (_ticketMonitor.TryGetValue(ticket.Id, out var watcher)) - { - watcher.InvokeTicketUpdated(ticket); - } - } - - private class DisposeableFunc : IDisposable - { - private readonly Action _disposer; - - public DisposeableFunc(Action disposer) - { - _disposer = disposer; - } - - public void Dispose() - { - _disposer?.Invoke(); - } - } - - public IDisposable WatchTicketAdded(Action handler) - { - TicketAdded += handler; - Interlocked.Increment(ref _globalWatchers); - return new DisposeableFunc(() => - { - Interlocked.Decrement(ref _globalWatchers); - TicketAdded -= handler; - }); - } - - public IDisposable WatchTicketsUpdated(Action handler) - { - TicketUpdated += handler; - return new DisposeableFunc(() => TicketUpdated -= handler); - } - - public IDisposable WatchTicketUpdated(ulong ticketId, Action handler) - { - var monitor = _ticketMonitor.GetOrAdd(ticketId, id => new TicketWatcher()); - monitor.TicketUpdated += handler; - return new DisposeableFunc(() => monitor.TicketUpdated -= handler); - } - } -} +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace MentorBot.Models; + +public interface ITicketNotifier +{ + int GlobalWatchers { get; } + IDisposable WatchTicketAdded(Action handler); + IDisposable WatchTicketsUpdated(Action handler); + IDisposable WatchTicketUpdated(ulong ticketId, Action handler); + void NotifyNewTicket(Ticket ticket); + void NotifyUpdatedTicket(Ticket ticket); +} + +// Notifies subscribers to new and updated tickets. +public class TicketNotifier : ITicketNotifier +{ + private readonly ConcurrentDictionary _ticketMonitor = new(); + + private int _globalWatchers; + + public int GlobalWatchers => _globalWatchers; + + public void NotifyNewTicket(Ticket ticket) + { + TicketAdded?.Invoke(ticket); + NotifyUpdatedTicket(ticket); + } + + public void NotifyUpdatedTicket(Ticket ticket) + { + TicketUpdated?.Invoke(ticket); + if (_ticketMonitor.TryGetValue(ticket.Id, out var watcher)) watcher.InvokeTicketUpdated(ticket); + } + + public IDisposable WatchTicketAdded(Action handler) + { + TicketAdded += handler; + Interlocked.Increment(ref _globalWatchers); + return new DisposeableFunc(() => + { + Interlocked.Decrement(ref _globalWatchers); + TicketAdded -= handler; + }); + } + + public IDisposable WatchTicketsUpdated(Action handler) + { + TicketUpdated += handler; + return new DisposeableFunc(() => TicketUpdated -= handler); + } + + public IDisposable WatchTicketUpdated(ulong ticketId, Action handler) + { + var monitor = _ticketMonitor.GetOrAdd(ticketId, id => new TicketWatcher()); + monitor.TicketUpdated += handler; + return new DisposeableFunc(() => monitor.TicketUpdated -= handler); + } + + private event Action? TicketAdded; + private event Action? TicketUpdated; + + private class TicketWatcher + { + public event Action? TicketUpdated; + + public void InvokeTicketUpdated(Ticket ticket) + { + TicketUpdated?.Invoke(ticket); + } + } + + private class DisposeableFunc(Action disposer) : IDisposable + { + public void Dispose() + { + disposer.Invoke(); + } + } + + private class DisposeableAsyncFunc(Func disposer) : IAsyncDisposable + { + public ValueTask DisposeAsync() + { + return disposer(); + } + } +} \ No newline at end of file diff --git a/MentorBot/Models/User.cs b/MentorBot/Models/User.cs index dc07d70..3ea5e39 100644 --- a/MentorBot/Models/User.cs +++ b/MentorBot/Models/User.cs @@ -1,14 +1,12 @@ -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; - -namespace MentorBot.Models -{ - [Owned] - public record User - { - [JsonProperty("id")] - public string? Id { get; set; } - [JsonProperty("name")] - public string Name { get; set; } = string.Empty; - } -} +using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json; + +namespace MentorBot.Models; + +[Owned] +public record User +{ + [JsonProperty("id")] public string? Id { get; set; } + + [JsonProperty("name")] public string Name { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/MentorBot/Pages/Index.cshtml b/MentorBot/Pages/Index.cshtml index 67d6f5e..31ae8a9 100644 --- a/MentorBot/Pages/Index.cshtml +++ b/MentorBot/Pages/Index.cshtml @@ -1,113 +1,122 @@ @page -@using System.Security.Claims -@inject MentorBot.Models.IMentorContext ctx -@inject Microsoft.Extensions.Options.IOptionsSnapshot options +@using MentorBot +@using MentorBot.Models +@using Microsoft.Extensions.Options +@inject IMentorContext Ctx +@inject IOptionsSnapshot Options @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @{ var loggedIn = HttpContext.User.Identity?.IsAuthenticated ?? false; - var showChangeable = !string.IsNullOrWhiteSpace(options.Value.ModifyMentorsToken); + var showChangeable = !string.IsNullOrWhiteSpace(Options.Value.ModifyMentorsToken); } - + Mentor Bot Web Portal - - + + - - + diff --git a/MentorBot/Program.cs b/MentorBot/Program.cs index 2ba27b7..007d837 100644 --- a/MentorBot/Program.cs +++ b/MentorBot/Program.cs @@ -1,46 +1,41 @@ +using System; using MentorBot; using MentorBot.Models; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http.Json; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.OpenApi.Models; -using System; +using Microsoft.OpenApi; var builder = WebApplication.CreateBuilder(args); -var mentorConfig = builder.Configuration.GetSection("mentors"); -builder.Services.Configure(mentorConfig); -var hasSwagger = mentorConfig.Get()?.EnableSwagger ?? false; +builder.Services.AddOptions().BindConfiguration("Mentors"); builder.Services.AddSingleton(); builder.Services.AddDiscordContext(builder.Configuration); -builder.Services.AddNeosHttpClient(builder.Configuration); +builder.Services.AddResoniteHttpClient(builder.Configuration); builder.Services.AddSignalContexts(builder.Configuration); builder.Services.AddTransient(); -if (hasSwagger) -{ - builder.Services.AddSwaggerGen(c => c.SwaggerDoc("v1", new OpenApiInfo { Title = "Mentor Signal", Version = "v1" })); -} +if (builder.Environment.IsDevelopment()) + builder.Services.AddOpenApi(o => o.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1); builder.Services.AddHealthChecks() - .AddSignalHealthChecks(); + .AddSignalHealthChecks(); builder.Services.Configure(options => - options.SerializerOptions.ConfigureForMentor()); + options.SerializerOptions.ConfigureForMentor()); builder.Services.AddControllers().AddJsonOptions(opts => - opts.JsonSerializerOptions.ConfigureForMentor()); + opts.JsonSerializerOptions.ConfigureForMentor()); builder.Services.AddAuthentication(c => c.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(c => c.ExpireTimeSpan = TimeSpan.FromHours(3)); + .AddCookie(c => c.ExpireTimeSpan = TimeSpan.FromHours(3)); builder.Services.AddRazorPages(); @@ -50,39 +45,31 @@ if (!app.Environment.IsDevelopment()) { - app.UseExceptionHandler("/error"); - app.UseHsts(); - app.UseHttpsRedirection(); + app.UseExceptionHandler("/error"); + app.UseHsts(); + app.UseHttpsRedirection(); } else { - app.UseDeveloperExceptionPage(); + app.UseDeveloperExceptionPage(); } app.UseAuthentication(); app.UseAuthorization(); -if (hasSwagger) +if (app.Environment.IsDevelopment()) { - app.UseSwagger(); - app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "Mentor Signal v1")); + app.MapOpenApi(); + app.UseSwaggerUI(c => c.SwaggerEndpoint("/openapi/v1.json", "Mentor Signal v1")); } app.UseWebSockets(new WebSocketOptions { - KeepAliveInterval = TimeSpan.FromSeconds(30) + KeepAliveInterval = TimeSpan.FromSeconds(30) }); app.MapHealthChecks("/health"); app.MapControllers(); app.MapRazorPages(); -if (hasSwagger) -{ - app.MapSwagger(); -} - -app.Run(); - -// This is needed so integration tests succeed. -public partial class Program { } +app.Run(); \ No newline at end of file diff --git a/MentorBot/ServiceProviderExtensions.cs b/MentorBot/ServiceProviderExtensions.cs index e41f2e2..3cdbf45 100644 --- a/MentorBot/ServiceProviderExtensions.cs +++ b/MentorBot/ServiceProviderExtensions.cs @@ -1,30 +1,32 @@ -using Microsoft.Extensions.DependencyInjection; -using System; -using System.Threading.Tasks; - -namespace MentorBot -{ - public static class ServiceProviderExtensions - { - public static async ValueTask WithScopedServiceAsync(this IServiceProvider provider, Func body) where TService : notnull - { - using var scope = provider.CreateScope(); - var service = scope.ServiceProvider.GetRequiredService(); - await body(service); - } - - public static async ValueTask WithScopedServiceAsync(this IServiceProvider provider, Func> body) where TService : notnull - { - using var scope = provider.CreateScope(); - var service = scope.ServiceProvider.GetRequiredService(); - return await body(service); - } - - public static void WithScopedService(this IServiceProvider provider, Action body) where TService : notnull - { - using var scope = provider.CreateScope(); - var service = scope.ServiceProvider.GetRequiredService(); - body(service); - } - } -} +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace MentorBot; + +public static class ServiceProviderExtensions +{ + public static async ValueTask WithScopedServiceAsync(this IServiceProvider provider, + Func body) where TService : notnull + { + using var scope = provider.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await body(service); + } + + public static async ValueTask WithScopedServiceAsync(this IServiceProvider provider, + Func> body) where TService : notnull + { + using var scope = provider.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + return await body(service); + } + + public static void WithScopedService(this IServiceProvider provider, Action body) + where TService : notnull + { + using var scope = provider.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + body(service); + } +} \ No newline at end of file diff --git a/MentorBot/ThrottleAttribute.cs b/MentorBot/ThrottleAttribute.cs index 7c22cf7..7e93ffc 100644 --- a/MentorBot/ThrottleAttribute.cs +++ b/MentorBot/ThrottleAttribute.cs @@ -1,53 +1,45 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Caching.Memory; -using System; -using System.Net; - -namespace MentorBot -{ - [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] - public class ThrottleAttribute : ActionFilterAttribute - { - public string Name { get; set; } = string.Empty; - - private readonly int _seconds; - - public string Message { get; set; } = "You may only perform this action every {0} seconds."; - - private static MemoryCache Cache { get; } = new MemoryCache(new MemoryCacheOptions()); - - public ThrottleAttribute(int seconds) - { - _seconds = seconds; - } - - public override void OnActionExecuting(ActionExecutingContext c) - { - var key = string.Concat(Name, "-", c.HttpContext.Connection.RemoteIpAddress); - var key2 = string.Concat(Name, "-", c.HttpContext.Connection.RemoteIpAddress, "-2"); - - if (!Cache.TryGetValue(key, out bool _)) - { - var cacheEntryOptions = new MemoryCacheEntryOptions() - .SetAbsoluteExpiration(TimeSpan.FromSeconds(_seconds)); - - Cache.Set(key, true, cacheEntryOptions); - } - else if(!Cache.TryGetValue(key2, out bool _)) - { - var cacheEntryOptions = new MemoryCacheEntryOptions() - .SetAbsoluteExpiration(TimeSpan.FromSeconds(_seconds)); - - Cache.Set(key2, true, cacheEntryOptions); - } - else - { - c.Result = new ObjectResult(string.Format(Message, _seconds)) - { - StatusCode = (int)HttpStatusCode.Conflict, - }; - } - } - } -} +using System; +using System.Net; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Caching.Memory; + +namespace MentorBot; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class ThrottleAttribute(int seconds) : ActionFilterAttribute +{ + public string Name { get; set; } = string.Empty; + + public string Message { get; set; } = "You may only perform this action every {0} seconds."; + + private static MemoryCache Cache { get; } = new(new MemoryCacheOptions()); + + public override void OnActionExecuting(ActionExecutingContext c) + { + var key = string.Concat(Name, "-", c.HttpContext.Connection.RemoteIpAddress); + var key2 = string.Concat(Name, "-", c.HttpContext.Connection.RemoteIpAddress, "-2"); + + if (!Cache.TryGetValue(key, out bool _)) + { + var cacheEntryOptions = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(TimeSpan.FromSeconds(seconds)); + + Cache.Set(key, true, cacheEntryOptions); + } + else if (!Cache.TryGetValue(key2, out bool _)) + { + var cacheEntryOptions = new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(TimeSpan.FromSeconds(seconds)); + + Cache.Set(key2, true, cacheEntryOptions); + } + else + { + c.Result = new ObjectResult(string.Format(Message, seconds)) + { + StatusCode = (int)HttpStatusCode.Conflict + }; + } + } +} \ No newline at end of file diff --git a/MentorBot/TokenGenerator.cs b/MentorBot/TokenGenerator.cs index 7b244e3..561537f 100644 --- a/MentorBot/TokenGenerator.cs +++ b/MentorBot/TokenGenerator.cs @@ -1,18 +1,17 @@ -using Microsoft.AspNetCore.WebUtilities; -using System.Security.Cryptography; - -namespace MentorBot -{ - public interface ITokenGenerator - { - public string CreateToken(); - } - - public class TokenGenerator : ITokenGenerator - { - public string CreateToken() - { - return Base64UrlTextEncoder.Encode(RandomNumberGenerator.GetBytes(45)); - } - } -} +using System.Security.Cryptography; +using Microsoft.AspNetCore.WebUtilities; + +namespace MentorBot; + +public interface ITokenGenerator +{ + public string CreateToken(); +} + +public class TokenGenerator : ITokenGenerator +{ + public string CreateToken() + { + return Base64UrlTextEncoder.Encode(RandomNumberGenerator.GetBytes(45)); + } +} \ No newline at end of file diff --git a/MentorBot/UrlEncoder.cs b/MentorBot/UrlEncoder.cs index fcf9a3c..8706b92 100644 --- a/MentorBot/UrlEncoder.cs +++ b/MentorBot/UrlEncoder.cs @@ -1,31 +1,27 @@ -using Microsoft.AspNetCore.WebUtilities; -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace MentorBot -{ - public static class UrlEncoder - { - public static string Encode(TValue o, JsonSerializerOptions? serializerOptions = null) - { - var node = JsonSerializer.SerializeToNode(o, serializerOptions); - var fields = node?.AsObject() - .Select(kvp => KeyValuePair.Create(kvp.Key, kvp.Value?.ToString())) - .Where(kvp => kvp.Value != null); - var query = QueryHelpers.AddQueryString("", fields!).Remove(0, 1); - return query; - } - - public static TValue? Decode(string query, JsonSerializerOptions? serializerOptions = null) - { - JsonObject obj = new(); - foreach(var field in QueryHelpers.ParseQuery(query)) - { - obj.Add(field.Key, field.Value.ToString()); - } - return JsonSerializer.Deserialize(obj, serializerOptions); - } - } -} +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.WebUtilities; + +namespace MentorBot; + +public static class UrlEncoder +{ + public static string Encode(TValue o, JsonSerializerOptions? serializerOptions = null) + { + var node = JsonSerializer.SerializeToNode(o, serializerOptions); + var fields = node?.AsObject() + .Select(kvp => KeyValuePair.Create(kvp.Key, kvp.Value?.ToString())) + .Where(kvp => kvp.Value != null); + var query = QueryHelpers.AddQueryString("", fields!).Remove(0, 1); + return query; + } + + public static TValue? Decode(string query, JsonSerializerOptions? serializerOptions = null) + { + JsonObject obj = new(); + foreach (var field in QueryHelpers.ParseQuery(query)) obj.Add(field.Key, field.Value.ToString()); + return obj.Deserialize(serializerOptions); + } +} \ No newline at end of file diff --git a/MentorBot/WebSocketExtensions.cs b/MentorBot/WebSocketExtensions.cs index 66767fc..c106776 100644 --- a/MentorBot/WebSocketExtensions.cs +++ b/MentorBot/WebSocketExtensions.cs @@ -1,75 +1,67 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net.WebSockets; -using System.Runtime.CompilerServices; -using System.Text; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; - -namespace MentorBot -{ - public static class WebSocketExtensions - { - public static async IAsyncEnumerable ReadRawMessages(this WebSocket socket, [EnumeratorCancellation] CancellationToken cancelToken = default) - { - var buffer = new byte[1024 * 4]; - WebSocketReceiveResult result; - do - { - using var memStream = new MemoryStream(); - do - { - result = await socket.ReceiveAsync(new ArraySegment(buffer), cancelToken); - memStream.Write(buffer, 0, result.Count); - } while (result.MessageType != WebSocketMessageType.Close && !result.EndOfMessage); - if (result.MessageType != WebSocketMessageType.Close) - { - yield return Encoding.UTF8.GetString(memStream.GetBuffer(), 0, (int)memStream.Length); - } - } while (result.MessageType != WebSocketMessageType.Close); - } - - public static async IAsyncEnumerable ReadMessages(this WebSocket socket, JsonSerializerOptions? serializerOptions = null, [EnumeratorCancellation] CancellationToken cancelToken = default) - { - await foreach (var message in ReadRawMessages(socket, cancelToken)) - { - var resp = UrlEncoder.Decode(message, serializerOptions); - if (resp != null) - { - yield return resp; - } - } - } - - public static Func RawMessageSender(this WebSocket socket, CancellationToken cancellationToken = default) - { - Task t = Task.CompletedTask; - - async Task NextSequenced(Func action) - { - var tcs = new TaskCompletionSource(); - try - { - await Interlocked.Exchange(ref t, tcs.Task); - - await action(); - } - finally - { - tcs.SetResult(); - } - } - - return msg => NextSequenced(() => - socket.SendAsync(Encoding.UTF8.GetBytes(msg), WebSocketMessageType.Text, true, cancellationToken)); - } - - public static Func MessageSender(this WebSocket socket, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default) - { - var sender = RawMessageSender(socket, cancellationToken); - return obj => sender(obj != null ? UrlEncoder.Encode(obj, serializerOptions) : ""); - } - } -} +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.WebSockets; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace MentorBot; + +public static class WebSocketExtensions +{ + public static async IAsyncEnumerable ReadRawMessages(this WebSocket socket, + [EnumeratorCancellation] CancellationToken cancelToken = default) + { + while (socket.State != WebSocketState.Open) + { + await using var reader = WebSocketStream.CreateReadableMessageStream(socket); + using var textReader = new StreamReader(reader, Encoding.UTF8); + yield return await textReader.ReadToEndAsync(cancelToken); + } + } + + public static async IAsyncEnumerable ReadMessages(this WebSocket socket, + JsonSerializerOptions? serializerOptions = null, + [EnumeratorCancellation] CancellationToken cancelToken = default) + { + await foreach (var message in socket.ReadRawMessages(cancelToken)) + { + var resp = UrlEncoder.Decode(message, serializerOptions); + if (resp != null) yield return resp; + } + } + + public static Func RawMessageSender(this WebSocket socket, + CancellationToken cancellationToken = default) + { + var t = Task.CompletedTask; + + async Task NextSequenced(Func action) + { + var tcs = new TaskCompletionSource(); + try + { + await Interlocked.Exchange(ref t, tcs.Task); + + await action(); + } + finally + { + tcs.SetResult(); + } + } + + return msg => NextSequenced(() => + socket.SendAsync(Encoding.UTF8.GetBytes(msg), WebSocketMessageType.Text, true, cancellationToken)); + } + + public static Func MessageSender(this WebSocket socket, JsonSerializerOptions? serializerOptions = null, + CancellationToken cancellationToken = default) + { + var sender = socket.RawMessageSender(cancellationToken); + return obj => sender(obj != null ? UrlEncoder.Encode(obj, serializerOptions) : ""); + } +} \ No newline at end of file diff --git a/MentorPanelTranslationPacker/Locales/cs.json b/MentorPanelTranslationPacker/Locales/cs.json index 27df386..c56cdce 100644 --- a/MentorPanelTranslationPacker/Locales/cs.json +++ b/MentorPanelTranslationPacker/Locales/cs.json @@ -1,28 +1,30 @@ { - "localeCode": "cs", - "authors": ["rampa_3"], - "messages": { - "locale/title.intro":"Cítíte se ztraceni?
Dobrovolní mentoři mohou pomoci", - "locale/title.offline":"Cítíte se ztraceni?
Požádejte o pomoc na Discordu", - "locale/title.ready":"Za chvíli budete požádáni o povolení webového požadavku.", - "locale/title.requested":"Požadavek byl odeslán", - "locale/title.responding":"Mentor je na cestě", - "locale/title.canceled":"Tiket byl zrušen", - "locale/title.completed":"Tiket byl označen jako dokončený", - "locale/description.ready":"Webový požadavek je nutný pro vytvoření tiketu, potvrďte prosíme požadavek v dialogu, který se objeví po kliknutí na tlačítko Vytvořit tiket.", - "locale/description.requested":"Mentoři jsou dobrovolníci - můžete být kontaktováni později, pokud nebude tiket teď vyřízen.{2}{3}{4}", - "locale/description.responding":"{0} přijal(a) tiket. Mentor by se měl brzy připojit do světa, nebo vás kontaktovat skrz seznam kontaktů na dashboardu.{2}{3}{4}", - "locale/description.canceled":"Pokud stále potřebujete pomoc, prosíme zeptejte se na Discordu nebo ve veřejných světech. Můžete být později kontaktováni za účelem zjištění, zda stále potřebujete asistenci.", - "locale/description.completed":"Pokud to je potřeba, vytvořte další tiket, nebo požádejte o pomoc na Discordu. Komunita často ráda pomůže!", - "locale/description.2.disconnected":"

Tento panel ztratil připojení ke službě mentorů: Panel se automaticky pokusí o znovu připojení. Zkontrolujte, zda není připojení blokováno v dialogu Dashboard -> Domů -> Diagnostika -> Web Hosts. Pokud vše ostatní selže, prosíme zrušte a znovu vytvořte tiket.
Pokud tento problém přetrvává, dejte nám prosíme vědět na Discordu.", - "locale/description.3.full":"

Svět je aktuálně plný: Požádejte hostitele o zvednutí limitu počtu hráčů/přímé pozvání mentora, nebo v koordinaci s mentorem najděte pracovní svět.", - "locale/description.4.no access":"

Svět není aktuálně veřejně přístupný: Aby se mentor mohl do světa připojit bez přímého pozvání, je třeba aby svět byl nastaven na úroveň přístupu Registrovaní uživatelé nebo Kdokoliv, a nesmí být skryt ze seznamu relací.
Pokud potřebujete soukromí: Kontaktujte mentora skrz seznam kontaktů na dashboardu, aby jste jej mohli pozvat přímo.", - "locale/cancel.text":"Zrušit", - "locale/back.text":"Zpět", - "locale/reset.text":"Vytvořit nový požadavek", - "locale/next.text.intro":"Požádat o pomoc", - "locale/next.text.create":"Vytvořit tiket", - "locale/requestJustify":"Tento webový požadavek je potřeba pro vytvoření mentor tiketu. Prosíme, potvrďte požadavek o připojení.", - "locale/discord.text": "Připojte se na Neos Discord" - } - } \ No newline at end of file + "localeCode": "cs", + "authors": [ + "rampa_3" + ], + "messages": { + "locale/title.intro": "Cítíte se ztraceni?
Dobrovolní mentoři mohou pomoci", + "locale/title.offline": "Cítíte se ztraceni?
Požádejte o pomoc na Discordu", + "locale/title.ready": "Za chvíli budete požádáni o povolení webového požadavku.", + "locale/title.requested": "Požadavek byl odeslán", + "locale/title.responding": "Mentor je na cestě", + "locale/title.canceled": "Tiket byl zrušen", + "locale/title.completed": "Tiket byl označen jako dokončený", + "locale/description.ready": "Webový požadavek je nutný pro vytvoření tiketu, potvrďte prosíme požadavek v dialogu, který se objeví po kliknutí na tlačítko Vytvořit tiket.", + "locale/description.requested": "Mentoři jsou dobrovolníci - můžete být kontaktováni později, pokud nebude tiket teď vyřízen.{2}{3}{4}", + "locale/description.responding": "{0} přijal(a) tiket. Mentor by se měl brzy připojit do světa, nebo vás kontaktovat skrz seznam kontaktů na dashboardu.{2}{3}{4}", + "locale/description.canceled": "Pokud stále potřebujete pomoc, prosíme zeptejte se na Discordu nebo ve veřejných světech. Můžete být později kontaktováni za účelem zjištění, zda stále potřebujete asistenci.", + "locale/description.completed": "Pokud to je potřeba, vytvořte další tiket, nebo požádejte o pomoc na Discordu. Komunita často ráda pomůže!", + "locale/description.2.disconnected": "

Tento panel ztratil připojení ke službě mentorů: Panel se automaticky pokusí o znovu připojení. Zkontrolujte, zda není připojení blokováno v dialogu Dashboard -> Domů -> Diagnostika -> Web Hosts. Pokud vše ostatní selže, prosíme zrušte a znovu vytvořte tiket.
Pokud tento problém přetrvává, dejte nám prosíme vědět na Discordu.", + "locale/description.3.full": "

Svět je aktuálně plný: Požádejte hostitele o zvednutí limitu počtu hráčů/přímé pozvání mentora, nebo v koordinaci s mentorem najděte pracovní svět.", + "locale/description.4.no access": "

Svět není aktuálně veřejně přístupný: Aby se mentor mohl do světa připojit bez přímého pozvání, je třeba aby svět byl nastaven na úroveň přístupu Registrovaní uživatelé nebo Kdokoliv, a nesmí být skryt ze seznamu relací.
Pokud potřebujete soukromí: Kontaktujte mentora skrz seznam kontaktů na dashboardu, aby jste jej mohli pozvat přímo.", + "locale/cancel.text": "Zrušit", + "locale/back.text": "Zpět", + "locale/reset.text": "Vytvořit nový požadavek", + "locale/next.text.intro": "Požádat o pomoc", + "locale/next.text.create": "Vytvořit tiket", + "locale/requestJustify": "Tento webový požadavek je potřeba pro vytvoření mentor tiketu. Prosíme, potvrďte požadavek o připojení.", + "locale/discord.text": "Připojte se na Neos Discord" + } +} \ No newline at end of file diff --git a/MentorPanelTranslationPacker/Locales/de.json b/MentorPanelTranslationPacker/Locales/de.json index f9616ef..7690ae3 100644 --- a/MentorPanelTranslationPacker/Locales/de.json +++ b/MentorPanelTranslationPacker/Locales/de.json @@ -1,28 +1,31 @@ { "localeCode": "de", - "authors": ["Holy", "m1nt_"], + "authors": [ + "Holy", + "m1nt_" + ], "messages": { - "locale/title.intro":"Brauchst du Hilfe?
Freiwillige Mentoren können helfen", - "locale/title.offline":"Brauchst du Hilfe?
Frage im Discord nach Hilfe", - "locale/title.ready":"Du wirst jetzt aufgefordert, eine Web-Anfrage zu erlauben.", - "locale/title.requested":"Die Anfrage wurde gesendet", - "locale/title.responding":"Ein Mentor ist auf dem Weg", - "locale/title.canceled":"Ticket wurde abgebrochen", - "locale/title.completed":"Ticket wurde als fertig markiert", - "locale/description.ready":"Eine Web-Anfrage nötig um ein Ticket zu erstellen, bitte akzeptiere die Anfrage, die erscheint wenn du ein Ticket erstellst", - "locale/description.requested":"Mentoren sind Freiwillige, du wirst vielleicht später kontaktiert wenn das Ticket im Moment nicht beantwortet wird.{2}{3}{4}", - "locale/description.responding":"{0} hat das Ticket angenommen, der Mentor sollte bald deiner Welt beitreten oder dich per Kontaktliste benachrichtigen.{2}{3}{4}", - "locale/description.canceled":"Wenn du immer noch Hilfe brauchst, bitte wende dich an den Discord-Server oder an öffentliche Welten. Du wirst möglicherweise später kontaktiert, um zu bestätigen ob du noch Hilfe benötigst.", - "locale/description.completed":"Erstelle ruhig ein weiteres Ticket, falls mehr Hilfe benötigt wird oder frage auf Discord. Die Community hilft dir sicher gern!", - "locale/description.2.disconnected":"

Dieses Panel hat Verbindung mit dem Mentor-Service verloren: Das Panel wird versuchen, sich automatisch erneut zu verbinden. Prüfe, dass die Verbindung nicht blockiert wird in Dashboard -> Home -> Debug -> Web Hosts. Wenn alles scheitert, bitte brich das Ticket ab und erstelle ein neues.
Wenn das Problem besteht, bitte lass es uns auf Discord wissen.", - "locale/description.3.full":"

Die Welt is momentan voll: Frage den Host nach einer Erhöhung des Limits, lade den Mentor direkt ein, oder koordiniere mit dem Mentor, um eine Arbeitswelt zu finden.", - "locale/description.4.no access":"

Die Welt ist nicht öffentlich zugänglich: Damit der Mentor ohne direkte Einladung beitreten kann, muss die Welt auf \"Registrierte Benutzer\" oder \"Jeder\" gesetzt werden und nicht von der Sitzungs-Liste versteckt sein.
Wenn du Privatsphäre brauchst: Kontaktiere den Mentor durch die Kontaktliste um ihn direkt einzuladen.", - "locale/cancel.text":"Abbrechen", - "locale/back.text":"Zurück", - "locale/reset.text":"Weiteres erstellen", - "locale/next.text.intro":"Hilfe bekommen", - "locale/next.text.create":"Ticket erstellen", - "locale/requestJustify":"Diese Web-Anfrage ist nötig um ein Ticket zu kreieren, bitte akzeptiere die Verbindungsanfrage.", + "locale/title.intro": "Brauchst du Hilfe?
Freiwillige Mentoren können helfen", + "locale/title.offline": "Brauchst du Hilfe?
Frage im Discord nach Hilfe", + "locale/title.ready": "Du wirst jetzt aufgefordert, eine Web-Anfrage zu erlauben.", + "locale/title.requested": "Die Anfrage wurde gesendet", + "locale/title.responding": "Ein Mentor ist auf dem Weg", + "locale/title.canceled": "Ticket wurde abgebrochen", + "locale/title.completed": "Ticket wurde als fertig markiert", + "locale/description.ready": "Eine Web-Anfrage nötig um ein Ticket zu erstellen, bitte akzeptiere die Anfrage, die erscheint wenn du ein Ticket erstellst", + "locale/description.requested": "Mentoren sind Freiwillige, du wirst vielleicht später kontaktiert wenn das Ticket im Moment nicht beantwortet wird.{2}{3}{4}", + "locale/description.responding": "{0} hat das Ticket angenommen, der Mentor sollte bald deiner Welt beitreten oder dich per Kontaktliste benachrichtigen.{2}{3}{4}", + "locale/description.canceled": "Wenn du immer noch Hilfe brauchst, bitte wende dich an den Discord-Server oder an öffentliche Welten. Du wirst möglicherweise später kontaktiert, um zu bestätigen ob du noch Hilfe benötigst.", + "locale/description.completed": "Erstelle ruhig ein weiteres Ticket, falls mehr Hilfe benötigt wird oder frage auf Discord. Die Community hilft dir sicher gern!", + "locale/description.2.disconnected": "

Dieses Panel hat Verbindung mit dem Mentor-Service verloren: Das Panel wird versuchen, sich automatisch erneut zu verbinden. Prüfe, dass die Verbindung nicht blockiert wird in Dashboard -> Home -> Debug -> Web Hosts. Wenn alles scheitert, bitte brich das Ticket ab und erstelle ein neues.
Wenn das Problem besteht, bitte lass es uns auf Discord wissen.", + "locale/description.3.full": "

Die Welt is momentan voll: Frage den Host nach einer Erhöhung des Limits, lade den Mentor direkt ein, oder koordiniere mit dem Mentor, um eine Arbeitswelt zu finden.", + "locale/description.4.no access": "

Die Welt ist nicht öffentlich zugänglich: Damit der Mentor ohne direkte Einladung beitreten kann, muss die Welt auf \"Registrierte Benutzer\" oder \"Jeder\" gesetzt werden und nicht von der Sitzungs-Liste versteckt sein.
Wenn du Privatsphäre brauchst: Kontaktiere den Mentor durch die Kontaktliste um ihn direkt einzuladen.", + "locale/cancel.text": "Abbrechen", + "locale/back.text": "Zurück", + "locale/reset.text": "Weiteres erstellen", + "locale/next.text.intro": "Hilfe bekommen", + "locale/next.text.create": "Ticket erstellen", + "locale/requestJustify": "Diese Web-Anfrage ist nötig um ein Ticket zu kreieren, bitte akzeptiere die Verbindungsanfrage.", "locale/discord.text": "Tritt dem Neos Discord bei" } } diff --git a/MentorPanelTranslationPacker/Locales/en.json b/MentorPanelTranslationPacker/Locales/en.json index f8a83a6..5a4e00c 100644 --- a/MentorPanelTranslationPacker/Locales/en.json +++ b/MentorPanelTranslationPacker/Locales/en.json @@ -1,28 +1,30 @@ { "localeCode": "en", - "authors": ["Earthmark"], + "authors": [ + "Earthmark" + ], "messages": { - "locale/title.intro":"Feeling Lost?
Mentor Volunteers Can Help", - "locale/title.offline":"Feeling Lost?
Ask in the Discord for help", - "locale/title.ready":"You are about to be prompted to allow a web request.", - "locale/title.requested":"The Request Has Gone Out", - "locale/title.responding":"A Mentor Is On The Way", - "locale/title.canceled":"Ticket Was Canceled", - "locale/title.completed":"Ticket Was Marked As Completed", - "locale/description.ready":"A web request is needed to create a ticket, please accept the prompt that appears when you click Create Ticket.", - "locale/description.requested":"Mentors are volunteers, you may be contacted later if the ticket doesn't get answered at this time.{2}{3}{4}", - "locale/description.responding":"{0} has claimed the ticket, soon they should either join the world, or contact you through the Contacts list.{2}{3}{4}", - "locale/description.canceled":"If you still need help, please reach out to the Discord or in public worlds. You may be contacted later to see if you still need assistance.", - "locale/description.completed":"Feel free to create another ticket if required, or ask for help on the Discord. The community is often happy to help!", - "locale/description.2.disconnected":"

This panel has lost connection with the mentor service: The panel will attempt to reconnect automatically. Check to ensure the connection is not blocked in Dashboard -> Home -> Debug -> Web Hosts. If all else fails, please cancel and re-create the ticket.
If this problem persists please let us known on the Discord.", - "locale/description.3.full":"

The world is currently full: Ask the host to raise the player limit, invite the mentor directly, or coordinate with the mentor to find a work world.", - "locale/description.4.no access":"

The world is not publically accessible: For the mentor to join without an direct invitation the world must be Registered or Anyone, and must not be hidden from the session list.
If you need privacy: Contact the mentor through the Contacts list so they can be invited directly.", - "locale/cancel.text":"Cancel", - "locale/back.text":"Back", - "locale/reset.text":"Create Another", - "locale/next.text.intro":"Get Help", - "locale/next.text.create":"Create Ticket", - "locale/requestJustify":"This web request is required to create the mentor ticket, please accept the connection request.", + "locale/title.intro": "Feeling Lost?
Mentor Volunteers Can Help", + "locale/title.offline": "Feeling Lost?
Ask in the Discord for help", + "locale/title.ready": "You are about to be prompted to allow a web request.", + "locale/title.requested": "The Request Has Gone Out", + "locale/title.responding": "A Mentor Is On The Way", + "locale/title.canceled": "Ticket Was Canceled", + "locale/title.completed": "Ticket Was Marked As Completed", + "locale/description.ready": "A web request is needed to create a ticket, please accept the prompt that appears when you click Create Ticket.", + "locale/description.requested": "Mentors are volunteers, you may be contacted later if the ticket doesn't get answered at this time.{2}{3}{4}", + "locale/description.responding": "{0} has claimed the ticket, soon they should either join the world, or contact you through the Contacts list.{2}{3}{4}", + "locale/description.canceled": "If you still need help, please reach out to the Discord or in public worlds. You may be contacted later to see if you still need assistance.", + "locale/description.completed": "Feel free to create another ticket if required, or ask for help on the Discord. The community is often happy to help!", + "locale/description.2.disconnected": "

This panel has lost connection with the mentor service: The panel will attempt to reconnect automatically. Check to ensure the connection is not blocked in Dashboard -> Home -> Debug -> Web Hosts. If all else fails, please cancel and re-create the ticket.
If this problem persists please let us known on the Discord.", + "locale/description.3.full": "

The world is currently full: Ask the host to raise the player limit, invite the mentor directly, or coordinate with the mentor to find a work world.", + "locale/description.4.no access": "

The world is not publically accessible: For the mentor to join without an direct invitation the world must be Registered or Anyone, and must not be hidden from the session list.
If you need privacy: Contact the mentor through the Contacts list so they can be invited directly.", + "locale/cancel.text": "Cancel", + "locale/back.text": "Back", + "locale/reset.text": "Create Another", + "locale/next.text.intro": "Get Help", + "locale/next.text.create": "Create Ticket", + "locale/requestJustify": "This web request is required to create the mentor ticket, please accept the connection request.", "locale/discord.text": "Join the Neos Discord" } } \ No newline at end of file diff --git a/MentorPanelTranslationPacker/Locales/fr.json b/MentorPanelTranslationPacker/Locales/fr.json index 9819ebf..9178d85 100644 --- a/MentorPanelTranslationPacker/Locales/fr.json +++ b/MentorPanelTranslationPacker/Locales/fr.json @@ -1,28 +1,30 @@ { "localeCode": "fr", - "authors": ["Sopra"], + "authors": [ + "Sopra" + ], "messages": { - "locale/title.intro":"Besoin D'aide?
Des Mentors volontaires peuvent vous aider!", - "locale/title.offline":"Besoin D'aide?
Posez vos questions dans notre discord!", - "locale/title.ready":"Nous allons vous demander d'accepter une demande Web", - "locale/title.requested":"Votre demande a été envoyée!", - "locale/title.responding":"Un Mentor est en chemin...", - "locale/title.canceled":"Ticket Annulé.", - "locale/title.completed":"Ticket marqué comme complété", - "locale/description.ready":"Une demande Web est requise pour créer un ticket, Veuillez bien accepter la demande qui apparaît devant vous.", - "locale/description.requested":"Les mentors sont volontaires, Il est possible qu'ils vous contactent plus tard si votre ticket n'est pas traité dans les prochaines minutes.{2}{3}{4}", - "locale/description.responding":"{0} a récupéré votre ticket, dans peut de temps, ce mentor rejoindra votre monde ou vous enverra une demande de contact dans peu de temps.{2}{3}{4}", - "locale/description.canceled":"Si vous avez encore besoin d'aide, n'hésitez pas à demander plus d'aide sur Discord ou dans un monde public. Il est possible que des mentors vous contactent plus tard dans le cas où vous avez besoin d'un peu plus d'aides.", - "locale/description.completed":"N'hésitez pas à créer un nouveau ticket si besoin, ou demander de l'aide sur Discord! La plupart de la communauté adore aider ceux qui en on besoin!", - "locale/description.2.disconnected":"

La connexion au service Mentor fut perdue: ce panel tenté de se reconnecter automatiquement. Vérifiez que votre connexion n'est pas bloquée dans Dashboard -> Home -> Debug -> Web Hosts. Si cela ne fonctionne pas, veuillez annuler votre ticket, et renouveler la création de celui-ci.
Si le problème persisté veuillez nous le faire savoir sur Discord.", - "locale/description.3.full":"

Le monde a atteint sa capacité maximale: Demandez au créateur du monde d'augmenter la limite de joueur, invitez le mentor directement ou communiquez avec le mentor pour trouver un monde de travail.", - "locale/description.4.no access":"

Le monde n'est pas accessible au public: Pour que le mentor vous rejoignes sans invitation directe, le monde doit être enregistré ou joignable par tout le monde, et ne dois pas être caché dans la liste des sessions.
Si vous avez besoin d'intimité: Contactez le mentor via votre liste de contacts pour qu'il/elle puisse être invités.", - "locale/cancel.text":"Annuler", - "locale/back.text":"Retour", - "locale/reset.text":"Créer un nouveau ticket", - "locale/next.text.intro":"Demander de l'aide", - "locale/next.text.create":"Créer un ticket", - "locale/requestJustify":"Cette demande Web est nécessaire pour créer le ticket, Veuillez accepter la demande de connexion.", + "locale/title.intro": "Besoin D'aide?
Des Mentors volontaires peuvent vous aider!", + "locale/title.offline": "Besoin D'aide?
Posez vos questions dans notre discord!", + "locale/title.ready": "Nous allons vous demander d'accepter une demande Web", + "locale/title.requested": "Votre demande a été envoyée!", + "locale/title.responding": "Un Mentor est en chemin...", + "locale/title.canceled": "Ticket Annulé.", + "locale/title.completed": "Ticket marqué comme complété", + "locale/description.ready": "Une demande Web est requise pour créer un ticket, Veuillez bien accepter la demande qui apparaît devant vous.", + "locale/description.requested": "Les mentors sont volontaires, Il est possible qu'ils vous contactent plus tard si votre ticket n'est pas traité dans les prochaines minutes.{2}{3}{4}", + "locale/description.responding": "{0} a récupéré votre ticket, dans peut de temps, ce mentor rejoindra votre monde ou vous enverra une demande de contact dans peu de temps.{2}{3}{4}", + "locale/description.canceled": "Si vous avez encore besoin d'aide, n'hésitez pas à demander plus d'aide sur Discord ou dans un monde public. Il est possible que des mentors vous contactent plus tard dans le cas où vous avez besoin d'un peu plus d'aides.", + "locale/description.completed": "N'hésitez pas à créer un nouveau ticket si besoin, ou demander de l'aide sur Discord! La plupart de la communauté adore aider ceux qui en on besoin!", + "locale/description.2.disconnected": "

La connexion au service Mentor fut perdue: ce panel tenté de se reconnecter automatiquement. Vérifiez que votre connexion n'est pas bloquée dans Dashboard -> Home -> Debug -> Web Hosts. Si cela ne fonctionne pas, veuillez annuler votre ticket, et renouveler la création de celui-ci.
Si le problème persisté veuillez nous le faire savoir sur Discord.", + "locale/description.3.full": "

Le monde a atteint sa capacité maximale: Demandez au créateur du monde d'augmenter la limite de joueur, invitez le mentor directement ou communiquez avec le mentor pour trouver un monde de travail.", + "locale/description.4.no access": "

Le monde n'est pas accessible au public: Pour que le mentor vous rejoignes sans invitation directe, le monde doit être enregistré ou joignable par tout le monde, et ne dois pas être caché dans la liste des sessions.
Si vous avez besoin d'intimité: Contactez le mentor via votre liste de contacts pour qu'il/elle puisse être invités.", + "locale/cancel.text": "Annuler", + "locale/back.text": "Retour", + "locale/reset.text": "Créer un nouveau ticket", + "locale/next.text.intro": "Demander de l'aide", + "locale/next.text.create": "Créer un ticket", + "locale/requestJustify": "Cette demande Web est nécessaire pour créer le ticket, Veuillez accepter la demande de connexion.", "locale/discord.text": "Rejoindre le discord de Neos" } } diff --git a/MentorPanelTranslationPacker/Locales/ja.json b/MentorPanelTranslationPacker/Locales/ja.json index 3463c87..b883379 100644 --- a/MentorPanelTranslationPacker/Locales/ja.json +++ b/MentorPanelTranslationPacker/Locales/ja.json @@ -1,28 +1,32 @@ { "localeCode": "ja", - "authors": ["kazu", "orange", "aesc"], + "authors": [ + "kazu", + "orange", + "aesc" + ], "messages": { - "locale/title.intro":"助けが必要ですか?
メンターにヘルプを求めることが出来ます", - "locale/title.offline":"助けが必要ですか?
Discordで質問してみましょう", - "locale/title.ready":"メンターに ヘルプを求めましょう", - "locale/title.requested":"ヘルプを呼びました!", - "locale/title.responding":"メンターがもうすぐ来ます", - "locale/title.canceled":"ヘルプをキャンセルしました", - "locale/title.completed":"要求が完了しました", - "locale/description.ready":"メンターはコミュニティのボランティアです。 もし数分経っても 誰もチケットを承認しない場合は、 Neos Discordや公開セッションで 質問してください!", - "locale/description.requested":"メンターはボランティアです、 すぐには誰も来られないかもしれません。 その場合は後日連絡が来ることがあります。 {2}{3}{4}", - "locale/description.responding":"{0} がリクエストを確認しました。 すぐにワールドに参加して来るか、 「フレンド」からメッセージが飛んでくるかもしれません。 確認してみてください。 {2} {3} {4}", - "locale/description.canceled":"まだヘルプが必要な場合は、Discordや パブリックワールドで改めて声をかけてください。 後日、ヘルプが必要かどうかの連絡があるかもしれません。", - "locale/description.completed":"必要に応じて改めてヘルプを求めたり、 Discordで助けを求めてください。 コミュニティが喜んでお手伝いいたします!", - "locale/description.2.disconnected":"

現在、メンターサービスとの接続が切れているようです: 自動的に再接続を試みます。ダッシュメニュー -> ホーム -> デバッグ -> Web Hostsで接続がブロックされていないか確認してください。
もしブロックしていないのにこの表示が 出る場合は、Discordで知らせてください。", - "locale/description.3.full":"

このワールドは満員のようです。 可能であればホストに最大ユーザー数を上げてもらうか、メンターを直接招待してください。 あるいは、メンターと別ワールドを開くことを検討してください。", - "locale/description.4.no access":"

このワールドは誰でも入れないようになっています (またはリストに表示されていません) : もし応答したメンターがフレンドでない場合は、「このワールドに入れる人」を「登録ユーザー」または「誰でも」に設定してください。
セキュリティ上の 問題がある場合 (アバターの初期セットアップなど): 「セッション」から「セッションリストに表示しない」にチェックを入れることで メンター以外はアクセスが困難になります。 もしくはメンターとフレンドになり 直接招待することもできます。", - "locale/cancel.text":"キャンセル", - "locale/back.text":"戻る", - "locale/reset.text":"最初に戻る", - "locale/next.text.intro":"ヘルプを呼ぶ", - "locale/next.text.create":"ヘルプを呼ぶ", - "locale/requestJustify":"このWebリクエストは、メンターがヘルプリクエストを確認するために必要です。 ホストアクセスを許可することで メンターに連絡されます。 特に問題がない場合は「許可」を選択してください", + "locale/title.intro": "助けが必要ですか?
メンターにヘルプを求めることが出来ます", + "locale/title.offline": "助けが必要ですか?
Discordで質問してみましょう", + "locale/title.ready": "メンターに ヘルプを求めましょう", + "locale/title.requested": "ヘルプを呼びました!", + "locale/title.responding": "メンターがもうすぐ来ます", + "locale/title.canceled": "ヘルプをキャンセルしました", + "locale/title.completed": "要求が完了しました", + "locale/description.ready": "メンターはコミュニティのボランティアです。 もし数分経っても 誰もチケットを承認しない場合は、 Neos Discordや公開セッションで 質問してください!", + "locale/description.requested": "メンターはボランティアです、 すぐには誰も来られないかもしれません。 その場合は後日連絡が来ることがあります。 {2}{3}{4}", + "locale/description.responding": "{0} がリクエストを確認しました。 すぐにワールドに参加して来るか、 「フレンド」からメッセージが飛んでくるかもしれません。 確認してみてください。 {2} {3} {4}", + "locale/description.canceled": "まだヘルプが必要な場合は、Discordや パブリックワールドで改めて声をかけてください。 後日、ヘルプが必要かどうかの連絡があるかもしれません。", + "locale/description.completed": "必要に応じて改めてヘルプを求めたり、 Discordで助けを求めてください。 コミュニティが喜んでお手伝いいたします!", + "locale/description.2.disconnected": "

現在、メンターサービスとの接続が切れているようです: 自動的に再接続を試みます。ダッシュメニュー -> ホーム -> デバッグ -> Web Hostsで接続がブロックされていないか確認してください。
もしブロックしていないのにこの表示が 出る場合は、Discordで知らせてください。", + "locale/description.3.full": "

このワールドは満員のようです。 可能であればホストに最大ユーザー数を上げてもらうか、メンターを直接招待してください。 あるいは、メンターと別ワールドを開くことを検討してください。", + "locale/description.4.no access": "

このワールドは誰でも入れないようになっています (またはリストに表示されていません) : もし応答したメンターがフレンドでない場合は、「このワールドに入れる人」を「登録ユーザー」または「誰でも」に設定してください。
セキュリティ上の 問題がある場合 (アバターの初期セットアップなど): 「セッション」から「セッションリストに表示しない」にチェックを入れることで メンター以外はアクセスが困難になります。 もしくはメンターとフレンドになり 直接招待することもできます。", + "locale/cancel.text": "キャンセル", + "locale/back.text": "戻る", + "locale/reset.text": "最初に戻る", + "locale/next.text.intro": "ヘルプを呼ぶ", + "locale/next.text.create": "ヘルプを呼ぶ", + "locale/requestJustify": "このWebリクエストは、メンターがヘルプリクエストを確認するために必要です。 ホストアクセスを許可することで メンターに連絡されます。 特に問題がない場合は「許可」を選択してください", "locale/discord.text": "Neos Discord に参加しましょう!" } } \ No newline at end of file diff --git a/MentorPanelTranslationPacker/Locales/ru.json b/MentorPanelTranslationPacker/Locales/ru.json index 3274f4b..9a6f93f 100644 --- a/MentorPanelTranslationPacker/Locales/ru.json +++ b/MentorPanelTranslationPacker/Locales/ru.json @@ -1,28 +1,30 @@ { "localeCode": "ru", - "authors": ["Shadow Panther"], + "authors": [ + "Shadow Panther" + ], "messages": { - "locale/title.intro":"Потерялись?
Добровольцы-Менторы могут помочь", - "locale/title.offline":"Потерялись?
Спросите совета в Discord", - "locale/title.ready":"Сейчас появится окно с подтверждением внешнего веб запроса.", - "locale/title.requested":"Запрос отправлен", - "locale/title.responding":"Ментор спешит на помощь", - "locale/title.canceled":"Заявка отменена", - "locale/title.completed":"Заявка помечена как выполненная", - "locale/description.ready":"Веб запрос нужен чтобы создать заявку, пожалуйста подтвердите согласие во всплывающем окне, которое появится когда Вы нажмёте Создать заявку.", - "locale/description.requested":"Менторы - это добровольцы, с Вами могут связаться позже если на заявку сейчас никто не ответит.{2}{3}{4}", - "locale/description.responding":"{0} принял заявку, вскоре он(а) подключится к сессии, или свяжется с Вами через список Контактов.{2}{3}{4}", - "locale/description.canceled":"Если Вам всё ещё нужна помощь, попробуйте задать вопрос в Discord или в публичных сессиях. С Вами могут связаться позже, если ваш вопрос ещё не будет решен.", - "locale/description.completed":"Не стесняйтесь завести новую заявку, или попросите помощи в Discord. Сообщество обычно готово помочь!", - "locale/description.2.disconnected":"

Эта панель потеряла связь с сервисом заявок: Панель попытается переподключиться автоматически. Проверьте что соединение не заблокировано в Дэш -> Дом -> Отладка -> Web Hosts. Если ничего не помогает, попробуйте отменить и пересоздать заявку.
Если проблема сохраняется, сообщите нам через Discord.", - "locale/description.3.full":"

Сессия в настоящий момент заполнена: Попросите владельца сессии поднять лимит пользователей, пригласите ментора напрямую, или договоритесь с ментором встретиться в другой сессии.", - "locale/description.4.no access":"

Эта сессия недоступна для публики: Чтобы ментор мог подключиться без прямого приглашения, доступ в сессию должен быть Все или Зарегистрированные, и сессия не должна быть спрятана из списка сессий.
Если Вам нужна приватность: Свяжитесь с ментором через список Контактов чтобы пригласить его(её) напрямую.", - "locale/cancel.text":"Отменить", - "locale/back.text":"Назад", - "locale/reset.text":"Создать ещё", - "locale/next.text.intro":"Получить помощь", - "locale/next.text.create":"Создать заявку", - "locale/requestJustify":"Этот веб запрос необходим чтобы создать заявку на помощь ментора, пожалуйста подтвердите согласие с установкой соединения.", + "locale/title.intro": "Потерялись?
Добровольцы-Менторы могут помочь", + "locale/title.offline": "Потерялись?
Спросите совета в Discord", + "locale/title.ready": "Сейчас появится окно с подтверждением внешнего веб запроса.", + "locale/title.requested": "Запрос отправлен", + "locale/title.responding": "Ментор спешит на помощь", + "locale/title.canceled": "Заявка отменена", + "locale/title.completed": "Заявка помечена как выполненная", + "locale/description.ready": "Веб запрос нужен чтобы создать заявку, пожалуйста подтвердите согласие во всплывающем окне, которое появится когда Вы нажмёте Создать заявку.", + "locale/description.requested": "Менторы - это добровольцы, с Вами могут связаться позже если на заявку сейчас никто не ответит.{2}{3}{4}", + "locale/description.responding": "{0} принял заявку, вскоре он(а) подключится к сессии, или свяжется с Вами через список Контактов.{2}{3}{4}", + "locale/description.canceled": "Если Вам всё ещё нужна помощь, попробуйте задать вопрос в Discord или в публичных сессиях. С Вами могут связаться позже, если ваш вопрос ещё не будет решен.", + "locale/description.completed": "Не стесняйтесь завести новую заявку, или попросите помощи в Discord. Сообщество обычно готово помочь!", + "locale/description.2.disconnected": "

Эта панель потеряла связь с сервисом заявок: Панель попытается переподключиться автоматически. Проверьте что соединение не заблокировано в Дэш -> Дом -> Отладка -> Web Hosts. Если ничего не помогает, попробуйте отменить и пересоздать заявку.
Если проблема сохраняется, сообщите нам через Discord.", + "locale/description.3.full": "

Сессия в настоящий момент заполнена: Попросите владельца сессии поднять лимит пользователей, пригласите ментора напрямую, или договоритесь с ментором встретиться в другой сессии.", + "locale/description.4.no access": "

Эта сессия недоступна для публики: Чтобы ментор мог подключиться без прямого приглашения, доступ в сессию должен быть Все или Зарегистрированные, и сессия не должна быть спрятана из списка сессий.
Если Вам нужна приватность: Свяжитесь с ментором через список Контактов чтобы пригласить его(её) напрямую.", + "locale/cancel.text": "Отменить", + "locale/back.text": "Назад", + "locale/reset.text": "Создать ещё", + "locale/next.text.intro": "Получить помощь", + "locale/next.text.create": "Создать заявку", + "locale/requestJustify": "Этот веб запрос необходим чтобы создать заявку на помощь ментора, пожалуйста подтвердите согласие с установкой соединения.", "locale/discord.text": "Подключиться к Neos Discord" } } \ No newline at end of file diff --git a/MentorPanelTranslationPacker/MentorPanelTranslationPacker.csproj b/MentorPanelTranslationPacker/MentorPanelTranslationPacker.csproj index 7792b3a..aa64c6f 100644 --- a/MentorPanelTranslationPacker/MentorPanelTranslationPacker.csproj +++ b/MentorPanelTranslationPacker/MentorPanelTranslationPacker.csproj @@ -1,18 +1,18 @@ - - Exe - net6.0 - + + Exe + net10.0 + - - - + + + - - - PreserveNewest - - + + + PreserveNewest + + diff --git a/MentorPanelTranslationPacker/Program.cs b/MentorPanelTranslationPacker/Program.cs index 7e902e6..e7fd625 100644 --- a/MentorPanelTranslationPacker/Program.cs +++ b/MentorPanelTranslationPacker/Program.cs @@ -1,35 +1,34 @@ -using Newtonsoft.Json; -using System.Collections.Generic; -using System.IO; -using System.Linq; - -namespace MentorPanelTranslationPacker -{ - class Program - { - static void Main(string[] args) - { - using var file = File.CreateText("encodedLang.urlencoded.txt"); - if (Directory.Exists("mod-loc")) { Directory.Delete("mod-loc", true); } - Directory.CreateDirectory("mod-loc"); - foreach(var jsonFile in Directory.EnumerateFiles("./Locales", "*.json")) - { - var doc = JsonConvert.DeserializeObject(File.ReadAllText(jsonFile)); - doc.Messages = doc.Messages.ToDictionary(kvp => - "Earthenworks.MentorSignal." + - string.Join(".", - kvp.Key.Remove(0, "locale/".Length) - .Split(".").Select(s => - s.Substring(0, 1).ToUpper() + s.Remove(0, 1))), kvp => kvp.Value); - File.WriteAllText($"mod-loc/{doc.LocaleCode}.json", JsonConvert.SerializeObject(doc, Formatting.Indented)); - } - } - } - - public class LocaleDoc - { - public string LocaleCode { get; set; } - public List Authors { get; set; } = new List(); - public Dictionary Messages { get; set; } = new Dictionary(); - } -} +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; + +namespace MentorPanelTranslationPacker; + +internal class Program +{ + private static void Main(string[] args) + { + using var file = File.CreateText("encodedLang.urlencoded.txt"); + if (Directory.Exists("mod-loc")) Directory.Delete("mod-loc", true); + Directory.CreateDirectory("mod-loc"); + foreach (var jsonFile in Directory.EnumerateFiles("./Locales", "*.json")) + { + var doc = JsonConvert.DeserializeObject(File.ReadAllText(jsonFile)); + doc.Messages = doc.Messages.ToDictionary(kvp => + "Earthenworks.MentorSignal." + + string.Join(".", + kvp.Key.Remove(0, "locale/".Length) + .Split(".").Select(s => + s.Substring(0, 1).ToUpper() + s.Remove(0, 1))), kvp => kvp.Value); + File.WriteAllText($"mod-loc/{doc.LocaleCode}.json", JsonConvert.SerializeObject(doc, Formatting.Indented)); + } + } +} + +public class LocaleDoc +{ + public string LocaleCode { get; set; } + public List Authors { get; set; } = new(); + public Dictionary Messages { get; set; } = new(); +} \ No newline at end of file diff --git a/docs/Dependencies.md b/docs/Dependencies.md new file mode 100644 index 0000000..b0dec0b --- /dev/null +++ b/docs/Dependencies.md @@ -0,0 +1,23 @@ +# Technical Dependencies + +[Root Doc (Start Here)](README.md) + +The services the bot depends on, and the mechanisms that are expected to work. + +## 1. Resonite API + +1. Setting the bot-group cloud variable (in-resonite [mentor](MentorFlows.md) actions) +2. OAuth login of users ([login](LoginFlow.md)) +3. Getting the user ID of an oauth logged in user ([login](LoginFlow.md)) + +## 2. Resonite Client + +1. Making a Websocket request to the mentor api (in-resonite [mentor](MentorFlows.md) and [user](UserFlows.md) actions) +2. Reading a cloud variable from the Resonite API (in-resonite [mentor](MentorFlows.md) actions) +3. Making a POST request to the mentor api (in-resonite [mentor](MentorFlows.md) and [user](UserFlows.md) actions) + +## Other + +1. A database, probably postgres or mysql. +2. A web browser. +3. A hosting environment able to run dotnet. diff --git a/docs/LeadFlows.md b/docs/LeadFlows.md new file mode 100644 index 0000000..2863403 --- /dev/null +++ b/docs/LeadFlows.md @@ -0,0 +1,7 @@ +# Leads Flows + +[Root Doc (Start Here)](README.md) + +Leads provide a token to the UI to act as a form of login. + +This is really not good security practice, but it worked at the time. The website side didn't exist in the first place. diff --git a/docs/MentorFlows.md b/docs/MentorFlows.md new file mode 100644 index 0000000..2cf57e2 --- /dev/null +++ b/docs/MentorFlows.md @@ -0,0 +1,61 @@ +# Mentor Flows + +[Root Doc (Start Here)](README.md) + +It is assumed a mentor has [logged in](LoginFlow.md). + +## Adding a Mentor + +Leads convert a User into a Mentor via their dashboard, all registered users show up in that dashboard. + +The process is covered in [Lead Flows](LeadFlows.md). + +## Mentor Actions + +### Resonite Authorization + +Resonite-originating actions do not have header access, and there is no way for a Mentor to login from inside of Resonite. To get around this a protected cloudvar is used as a bearer token. + +When a Mentor logs in, or when a token will expire 'soon'. Checking for expired tokens is done with a cronjob. Expired tokens are deleted by a cronjob. + +Tokens are a binary blob, likely a large base64 cryptorandom number, but it may be a JWT in the future. +*The token pattern is not web-best-practice and may be stolen, the difficulty of JWT revocation leans away from that approach, even if it has less DB queries.* + +The cloud var is configured as `read:variable_owner`, `write:definition_owner_only`. Writing is done using the group-owner credentials. + +```mermaid +sequenceDiagram + actor CronOrLogin + participant MentorBot + participant MentorBotDB + participant ResoniteAPI + CronOrLogin->>MentorBot: Refresh/Rebuild Token for user X + MentorBot->>MentorBotDB: Insert a new token expiring in a while. + MentorBot->>ResoniteAPI: Set cloud variable to the new token. +``` + +When sending an authentication-requiring web reuqest, the `auth` query argument is used. The `auth` parameter only allows a subset of actions, such as claiming tickets. **Lead actions are explicilty not supported via this auth mechanism.** + +## Claiming a Ticket + +A post is made in Discord with emoji responses, responding to an emoji claims the ticket for that mentor. + +## Watching for Tickets (Resonite Facet) + +The mentor facet or web UI use a websocket to keep the UI in sync with the tickets in the service. + +When a mentor claims a ticket, the WS connection updates the UI, not the POST method. + +```mermaid +sequenceDiagram + actor Mentor + participant Resonite + participant MentorBot + Mentor->>Resonite: Watch Tickets (go-on-call) + Resonite->>MentorBot: /api/ticket?auth={token}&format=urlencoded + MentorBot->>Resonite: Ticket Updates (json or urlEncoded) + MentorBot->>Resonite: + MentorBot->>Resonite: + Mentor->>Resonite: Stop Watching (close WS) +``` + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..776a560 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,46 @@ +# Mentor Bot + +[Technical dependencies](Dependencies.md) + +## Entities + +### [Users](UserFlows.md) + +Create or close tickets. + +### [Mentors](MentorFlows.md) + +Claim, unclaim, and complete tickets. + +### [Leads](LeadFlows.md) + +Add and remove mentors. + +Adding and removing leads currently requires a manual DB mutation. + +### Tickets + +A request for help from a user to a mentor. These are filled out from inside Resonite. + +| Property | Type | Required | Description | +|-------------|----------|----------|------------------------------------------------------------| +| User | string | true | The ID of the user requesting help | +| Mentor | string | false | The ID of the mentor assigned to the ticket, if one is | +| Created | DateTime | true | The timestamp of when the ticket was created | +| Language | string | true | The local language of the user. | +| Description | string | false | A user provided description of the ticket. | +| Status | Enum | true | The current status of the ticket, see the flowchart below. | + +```mermaid +--- +title: Status +--- +flowchart + created((Help Requested)) + claimed[Claimed] + closed(Resolved / Closed) + created -->|Mentor Claims Ticket| claimed + created -->|User closes ticket| closed + claimed -->|Mentor releases ticket| created + claimed -->|User or Mentor close ticket| closed +``` diff --git a/docs/UserFlows.md b/docs/UserFlows.md new file mode 100644 index 0000000..0164524 --- /dev/null +++ b/docs/UserFlows.md @@ -0,0 +1,39 @@ +# Mentor Flows + +[Root Doc (Start Here)](README.md) + +Users do not need to log in to use the API. + +## Creating a Ticket (Resonite UI) + +The user is prompted to create a ticket using a UI wizard in-engine. + +The Resonite client makes a websocket connection to the mentor bot, which sends back ticket updates. + +In practice once the client gets the first update (the one saying the ticket was created) the websocket is closed, and the route with a ticket_id is used instead. This was easier to implement in resonite, although it is not good practice. + +There is a rate limit on the ticket create call, and additional protections may be needed in the future (such as including the user ID). + +```mermaid +sequenceDiagram + actor User + participant Resonite + participant MentorBot + actor Mentor + User->>Resonite: Get Help + Resonite->>MentorBot: WS /api/mentee?userId=_&lang=_&session=_&... + Resonite->>MentorBot: WS /api/mentee/{ticket_id} + alt User cancels ticket + User->>Resonite: Close Ticket + Resonite->>MentorBot: urlencoded WS message with "type=cancel" + MentorBot-->>Resonite: Close Websocket + else Mentor responds to ticket + Mentor->>MentorBot: Claim Ticket + MentorBot->>Resonite: Ticket Claimed (WS message) + Resonite->>User: Shown Mentor's contact info + Mentor->>Resonite: Connects to User's session + Mentor->>MentorBot: Resolve Ticket + MentorBot-->>Resonite: Close Websocket + end +``` +