From a1086a834c68abfaed9595058123d8d6975f2fd3 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 26 Aug 2023 12:34:54 +0100 Subject: [PATCH 01/13] chore: bump to 1.2.0 --- VpBridge/VpBridge.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VpBridge/VpBridge.csproj b/VpBridge/VpBridge.csproj index 6141c23..e61747e 100644 --- a/VpBridge/VpBridge.csproj +++ b/VpBridge/VpBridge.csproj @@ -9,7 +9,7 @@ Oliver Booth https://github.com/oliverbooth/VpBridge git - 1.1.0 + 1.2.0 From bb03b68c17c828ffb87af67676628ba9f527f42c Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 26 Aug 2023 12:45:30 +0100 Subject: [PATCH 02/13] feat: replace !who with /who slash command --- VpBridge/Commands/WhoCommand.cs | 73 ++++++++++++++++++++++++ VpBridge/Program.cs | 10 +++- VpBridge/Services/DiscordService.cs | 88 ++++++++++++++++++----------- VpBridge/VpBridge.csproj | 1 + 4 files changed, 136 insertions(+), 36 deletions(-) create mode 100644 VpBridge/Commands/WhoCommand.cs diff --git a/VpBridge/Commands/WhoCommand.cs b/VpBridge/Commands/WhoCommand.cs new file mode 100644 index 0000000..e1c4d1d --- /dev/null +++ b/VpBridge/Commands/WhoCommand.cs @@ -0,0 +1,73 @@ +using Cysharp.Text; +using Discord; +using Discord.Interactions; +using VpSharp; +using VpSharp.Entities; + +namespace VpBridge.Commands; + +/// +/// Represents a class which implements the who command. +/// +internal sealed class WhoCommand : InteractionModuleBase +{ + private readonly VirtualParadiseClient _virtualParadiseClient; + + /// + /// Initializes a new instance of the class. + /// + /// The Virtual Paradise client. + public WhoCommand(VirtualParadiseClient virtualParadiseClient) + { + _virtualParadiseClient = virtualParadiseClient; + } + + [SlashCommand("who", "Displays a list of active users in Virtual Paradise.")] + [RequireContext(ContextType.Guild)] + public async Task HandleAsync() + { + var embed = new EmbedBuilder(); + embed.WithColor(0x1E88E5); + embed.WithAuthor($"🌎 {_virtualParadiseClient.CurrentWorld?.Name}"); + embed.WithTitle("Active Users"); + embed.WithTimestamp(DateTimeOffset.UtcNow); + + using Utf8ValueStringBuilder userBuilder = ZString.CreateUtf8StringBuilder(); + using Utf8ValueStringBuilder botsBuilder = ZString.CreateUtf8StringBuilder(); + var userCount = 0; + var botCount = 0; + + foreach (VirtualParadiseAvatar avatar in _virtualParadiseClient.Avatars) + { + if (avatar.IsBot) + { + botsBuilder.AppendLine($"* {avatar.Name} ({avatar.Session})"); + botCount++; + } + else + { + userBuilder.AppendLine($"* {avatar.Name} ({avatar.Session})"); + userCount++; + } + } + + string userTitle = userCount switch + { + 0 => "Users", + 1 => "1 User", + _ => $"{userCount} Users" + }; + + string botTitle = botCount switch + { + 0 => "Bots", + 1 => "1 Bot", + _ => $"{botCount} Bots" + }; + + embed.AddField($"👤 {userTitle}", userCount > 0 ? userBuilder.ToString() : "*None*", true); + embed.AddField($"🤖 {botTitle}", botCount > 0 ? botsBuilder.ToString() : "*None*", true); + + await RespondAsync(embed: embed.Build()); + } +} diff --git a/VpBridge/Program.cs b/VpBridge/Program.cs index 49e4679..b63efc9 100644 --- a/VpBridge/Program.cs +++ b/VpBridge/Program.cs @@ -1,4 +1,5 @@ -using Discord; +using Discord; +using Discord.Interactions; using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -24,10 +25,13 @@ builder.Logging.ClearProviders(); builder.Logging.AddSerilog(); builder.Services.AddSingleton(); -builder.Services.AddSingleton(new DiscordSocketClient(new DiscordSocketConfig + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(new DiscordSocketConfig { GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent -})); +}); builder.Services.AddHostedSingleton(); builder.Services.AddHostedSingleton(); diff --git a/VpBridge/Services/DiscordService.cs b/VpBridge/Services/DiscordService.cs index f75c9c0..52c8669 100644 --- a/VpBridge/Services/DiscordService.cs +++ b/VpBridge/Services/DiscordService.cs @@ -1,12 +1,14 @@ -using System.Reactive.Linq; +using System.Reactive.Linq; using System.Reactive.Subjects; using System.Text; using System.Text.RegularExpressions; using Discord; +using Discord.Interactions; using Discord.WebSocket; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using VpBridge.Commands; using VpSharp; using VpSharp.Entities; @@ -19,7 +21,9 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic private static readonly Regex EscapeRegex = GetEscapeRegex(); private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; private readonly IConfiguration _configuration; + private readonly InteractionService _interactionService; private readonly DiscordSocketClient _discordClient; private readonly VirtualParadiseClient _virtualParadiseClient; private readonly Subject _messageReceived = new(); @@ -28,16 +32,22 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic /// Initializes a new instance of the class. /// /// The logger. + /// The service provider. /// The configuration. + /// The interaction service. /// The Discord client. /// The Virtual Paradise client. public DiscordService(ILogger logger, + IServiceProvider serviceProvider, IConfiguration configuration, + InteractionService interactionService, DiscordSocketClient discordClient, VirtualParadiseClient virtualParadiseClient) { _logger = logger; + _serviceProvider = serviceProvider; _configuration = configuration; + _interactionService = interactionService; _discordClient = discordClient; _virtualParadiseClient = virtualParadiseClient; } @@ -49,40 +59,13 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("Establishing relay"); - _discordClient.MessageReceived += arg => - { - if (arg is not IUserMessage message) - return Task.CompletedTask; - if (message.Channel.Id != _configuration.GetSection("Discord:ChannelId").Get()) - return Task.CompletedTask; + _logger.LogInformation("Adding command modules"); + await _interactionService.AddModuleAsync(_serviceProvider).ConfigureAwait(false); - if (message.Content.Equals("!who")) - { - VirtualParadiseAvatar[] avatars = _virtualParadiseClient.Avatars.Where(a => !a.IsBot).ToArray(); - int count = avatars.Length; - - if (count > 0) - { - var builder = new StringBuilder(); - builder.AppendLine("**Users In World 🌎**"); - foreach (VirtualParadiseAvatar avatar in _virtualParadiseClient.Avatars) - { - if (avatar.IsBot || avatar == _virtualParadiseClient.CurrentAvatar) - continue; - - builder.AppendLine($"• {avatar.Name}"); - } - - return message.ReplyAsync(builder.ToString()); - } - - return message.ReplyAsync("**No Users In World 🚫**"); - } - - _messageReceived.OnNext(message); - return Task.CompletedTask; - }; + _discordClient.Ready += OnReady; + _discordClient.InteractionCreated += OnInteractionCreated; + _discordClient.MessageReceived += OnDiscordMessageReceived; string token = _configuration.GetSection("Discord:Token").Value ?? throw new InvalidOperationException("Token is not set."); @@ -92,6 +75,45 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic await _discordClient.StartAsync(); } + private Task OnDiscordMessageReceived(SocketMessage arg) + { + if (arg is not IUserMessage message) + return Task.CompletedTask; + + if (message.Channel.Id != _configuration.GetSection("Discord:ChannelId").Get()) + return Task.CompletedTask; + + _messageReceived.OnNext(message); + return Task.CompletedTask; + } + + private async Task OnInteractionCreated(SocketInteraction interaction) + { + try + { + var context = new SocketInteractionContext(_discordClient, interaction); + IResult result = await _interactionService.ExecuteCommandAsync(context, _serviceProvider); + + if (!result.IsSuccess) + switch (result.Error) + { + case InteractionCommandError.UnmetPrecondition: + break; + } + } + catch + { + if (interaction.Type is InteractionType.ApplicationCommand) + await interaction.GetOriginalResponseAsync().ContinueWith(async msg => await msg.Result.DeleteAsync()); + } + } + + private Task OnReady() + { + _logger.LogInformation("Discord client ready"); + return _interactionService.RegisterCommandsGloballyAsync(); + } + /// public Task SendMessageAsync(VirtualParadiseMessage message) { diff --git a/VpBridge/VpBridge.csproj b/VpBridge/VpBridge.csproj index e61747e..a302290 100644 --- a/VpBridge/VpBridge.csproj +++ b/VpBridge/VpBridge.csproj @@ -52,6 +52,7 @@ + From 90684abe850210ca93e7af7c8ed460e4e64fce06 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 26 Aug 2023 12:45:49 +0100 Subject: [PATCH 03/13] style: remove UTF8 BOM --- VpBridge/Services/IDiscordService.cs | 2 +- VpBridge/Services/IVirtualParadiseService.cs | 2 +- VpBridge/Services/RelayService.cs | 2 +- VpBridge/Services/VirtualParadiseService.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/VpBridge/Services/IDiscordService.cs b/VpBridge/Services/IDiscordService.cs index e39c8f7..2531925 100644 --- a/VpBridge/Services/IDiscordService.cs +++ b/VpBridge/Services/IDiscordService.cs @@ -1,4 +1,4 @@ -using Discord; +using Discord; using VpSharp.Entities; namespace VpBridge.Services; diff --git a/VpBridge/Services/IVirtualParadiseService.cs b/VpBridge/Services/IVirtualParadiseService.cs index bf2ae01..5a01cf6 100644 --- a/VpBridge/Services/IVirtualParadiseService.cs +++ b/VpBridge/Services/IVirtualParadiseService.cs @@ -1,4 +1,4 @@ -using Discord; +using Discord; using VpSharp.Entities; namespace VpBridge.Services; diff --git a/VpBridge/Services/RelayService.cs b/VpBridge/Services/RelayService.cs index 57a0524..53ca06d 100644 --- a/VpBridge/Services/RelayService.cs +++ b/VpBridge/Services/RelayService.cs @@ -1,4 +1,4 @@ -using System.Reactive.Linq; +using System.Reactive.Linq; using Discord.WebSocket; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; diff --git a/VpBridge/Services/VirtualParadiseService.cs b/VpBridge/Services/VirtualParadiseService.cs index 2407e67..f9e6a11 100644 --- a/VpBridge/Services/VirtualParadiseService.cs +++ b/VpBridge/Services/VirtualParadiseService.cs @@ -1,4 +1,4 @@ -using System.Reactive.Linq; +using System.Reactive.Linq; using System.Reactive.Subjects; using Discord; using Microsoft.Extensions.Configuration; From 69edcfe3f516ba3cdf1e8d6a094fda85ff70f30c Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 26 Aug 2023 12:50:06 +0100 Subject: [PATCH 04/13] refactor!: rename to VPLink --- README.md | 4 ++-- VpBridge.sln => VPLink.sln | 2 +- {VpBridge => VPLink}/Commands/WhoCommand.cs | 2 +- VPLink/Dockerfile | 20 +++++++++++++++++++ {VpBridge => VPLink}/Program.cs | 2 +- .../Services/DiscordService.cs | 4 ++-- .../Services/IDiscordService.cs | 2 +- .../Services/IVirtualParadiseService.cs | 2 +- {VpBridge => VPLink}/Services/RelayService.cs | 2 +- .../Services/VirtualParadiseService.cs | 2 +- .../VpBridge.csproj => VPLink/VPLink.csproj | 2 +- VpBridge/Dockerfile | 20 ------------------- docker-compose.yml | 10 +++++----- 13 files changed, 37 insertions(+), 37 deletions(-) rename VpBridge.sln => VPLink.sln (82%) rename {VpBridge => VPLink}/Commands/WhoCommand.cs (98%) create mode 100644 VPLink/Dockerfile rename {VpBridge => VPLink}/Program.cs (98%) rename {VpBridge => VPLink}/Services/DiscordService.cs (99%) rename {VpBridge => VPLink}/Services/IDiscordService.cs (96%) rename {VpBridge => VPLink}/Services/IVirtualParadiseService.cs (96%) rename {VpBridge => VPLink}/Services/RelayService.cs (98%) rename {VpBridge => VPLink}/Services/VirtualParadiseService.cs (99%) rename VpBridge/VpBridge.csproj => VPLink/VPLink.csproj (97%) delete mode 100644 VpBridge/Dockerfile diff --git a/README.md b/README.md index 8f98b30..383d8c3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# VpBridge +# VPLink -VpBridge is a simple bot for both Discord and Virtual Paradise which bridges chat messages from a designated Discord channel, to a world in Virtual Paradise. \ No newline at end of file +VPLink is a simple bot for both Discord and Virtual Paradise which bridges chat messages from a designated Discord channel, to a world in Virtual Paradise. \ No newline at end of file diff --git a/VpBridge.sln b/VPLink.sln similarity index 82% rename from VpBridge.sln rename to VPLink.sln index fddd12c..fd2e270 100644 --- a/VpBridge.sln +++ b/VPLink.sln @@ -1,6 +1,6 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VpBridge", "VpBridge\VpBridge.csproj", "{CD488A1E-0232-4EB5-A381-38A42B267B11}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPLink", "VPLink\VPLink.csproj", "{CD488A1E-0232-4EB5-A381-38A42B267B11}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/VpBridge/Commands/WhoCommand.cs b/VPLink/Commands/WhoCommand.cs similarity index 98% rename from VpBridge/Commands/WhoCommand.cs rename to VPLink/Commands/WhoCommand.cs index e1c4d1d..2f23e63 100644 --- a/VpBridge/Commands/WhoCommand.cs +++ b/VPLink/Commands/WhoCommand.cs @@ -4,7 +4,7 @@ using Discord.Interactions; using VpSharp; using VpSharp.Entities; -namespace VpBridge.Commands; +namespace VPLink.Commands; /// /// Represents a class which implements the who command. diff --git a/VPLink/Dockerfile b/VPLink/Dockerfile new file mode 100644 index 0000000..f947853 --- /dev/null +++ b/VPLink/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +WORKDIR /src +COPY ["VPLink/VPLink.csproj", "VpBridge/"] +RUN dotnet restore "VPLink/VPLink.csproj" +COPY . . +WORKDIR "/src/VpBridge" +RUN dotnet build "VPLink.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "VPLink.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "VPLink.dll"] diff --git a/VpBridge/Program.cs b/VPLink/Program.cs similarity index 98% rename from VpBridge/Program.cs rename to VPLink/Program.cs index b63efc9..2fc3c2e 100644 --- a/VpBridge/Program.cs +++ b/VPLink/Program.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Serilog; using Tomlyn.Extensions.Configuration; -using VpBridge.Services; +using VPLink.Services; using VpSharp; using X10D.Hosting.DependencyInjection; diff --git a/VpBridge/Services/DiscordService.cs b/VPLink/Services/DiscordService.cs similarity index 99% rename from VpBridge/Services/DiscordService.cs rename to VPLink/Services/DiscordService.cs index 52c8669..757a865 100644 --- a/VpBridge/Services/DiscordService.cs +++ b/VPLink/Services/DiscordService.cs @@ -8,11 +8,11 @@ using Discord.WebSocket; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using VpBridge.Commands; +using VPLink.Commands; using VpSharp; using VpSharp.Entities; -namespace VpBridge.Services; +namespace VPLink.Services; /// internal sealed partial class DiscordService : BackgroundService, IDiscordService diff --git a/VpBridge/Services/IDiscordService.cs b/VPLink/Services/IDiscordService.cs similarity index 96% rename from VpBridge/Services/IDiscordService.cs rename to VPLink/Services/IDiscordService.cs index 2531925..1683892 100644 --- a/VpBridge/Services/IDiscordService.cs +++ b/VPLink/Services/IDiscordService.cs @@ -1,7 +1,7 @@ using Discord; using VpSharp.Entities; -namespace VpBridge.Services; +namespace VPLink.Services; /// /// Represents a service that sends messages to the Discord channel. diff --git a/VpBridge/Services/IVirtualParadiseService.cs b/VPLink/Services/IVirtualParadiseService.cs similarity index 96% rename from VpBridge/Services/IVirtualParadiseService.cs rename to VPLink/Services/IVirtualParadiseService.cs index 5a01cf6..1668fd6 100644 --- a/VpBridge/Services/IVirtualParadiseService.cs +++ b/VPLink/Services/IVirtualParadiseService.cs @@ -1,7 +1,7 @@ using Discord; using VpSharp.Entities; -namespace VpBridge.Services; +namespace VPLink.Services; /// /// Represents a service that sends messages to the Virtual Paradise world server. diff --git a/VpBridge/Services/RelayService.cs b/VPLink/Services/RelayService.cs similarity index 98% rename from VpBridge/Services/RelayService.cs rename to VPLink/Services/RelayService.cs index 53ca06d..1c6c3b0 100644 --- a/VpBridge/Services/RelayService.cs +++ b/VPLink/Services/RelayService.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging; using VpSharp; using VpSharp.Extensions; -namespace VpBridge.Services; +namespace VPLink.Services; internal sealed class RelayService : BackgroundService { diff --git a/VpBridge/Services/VirtualParadiseService.cs b/VPLink/Services/VirtualParadiseService.cs similarity index 99% rename from VpBridge/Services/VirtualParadiseService.cs rename to VPLink/Services/VirtualParadiseService.cs index f9e6a11..53c1e1f 100644 --- a/VpBridge/Services/VirtualParadiseService.cs +++ b/VPLink/Services/VirtualParadiseService.cs @@ -8,7 +8,7 @@ using VpSharp; using VpSharp.Entities; using Color = System.Drawing.Color; -namespace VpBridge.Services; +namespace VPLink.Services; /// internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadiseService diff --git a/VpBridge/VpBridge.csproj b/VPLink/VPLink.csproj similarity index 97% rename from VpBridge/VpBridge.csproj rename to VPLink/VPLink.csproj index a302290..4f4a5ac 100644 --- a/VpBridge/VpBridge.csproj +++ b/VPLink/VPLink.csproj @@ -52,7 +52,7 @@ - + diff --git a/VpBridge/Dockerfile b/VpBridge/Dockerfile deleted file mode 100644 index 511f9c0..0000000 --- a/VpBridge/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base -WORKDIR /app -EXPOSE 80 -EXPOSE 443 - -FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build -WORKDIR /src -COPY ["VpBridge/VpBridge.csproj", "VpBridge/"] -RUN dotnet restore "VpBridge/VpBridge.csproj" -COPY . . -WORKDIR "/src/VpBridge" -RUN dotnet build "VpBridge.csproj" -c Release -o /app/build - -FROM build AS publish -RUN dotnet publish "VpBridge.csproj" -c Release -o /app/publish /p:UseAppHost=false - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "VpBridge.dll"] diff --git a/docker-compose.yml b/docker-compose.yml index 7633b19..cc0142c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,16 @@ version: '3.9' services: - vpbridge: - container_name: VpBridge + vplink: + container_name: VPLink pull_policy: build build: context: . - dockerfile: VpBridge/Dockerfile + dockerfile: VPLink/Dockerfile volumes: - type: bind - source: /var/log/vp/vp-bridge + source: /var/log/vp/vplink target: /app/logs - type: bind - source: /etc/vp/vp-bridge + source: /etc/vp/vplink target: /app/data restart: always From 7a6ae083da42e1028d7da396aa6ea4bf2a1cfc35 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 26 Aug 2023 13:03:42 +0100 Subject: [PATCH 05/13] feat: announce avatar events --- VPLink/Services/DiscordService.cs | 62 +++++++++++++++++----- VPLink/Services/IDiscordService.cs | 16 +++++- VPLink/Services/IVirtualParadiseService.cs | 16 ++++++ VPLink/Services/RelayService.cs | 3 ++ VPLink/Services/VirtualParadiseService.cs | 44 +++++++++++++++ 5 files changed, 127 insertions(+), 14 deletions(-) diff --git a/VPLink/Services/DiscordService.cs b/VPLink/Services/DiscordService.cs index 757a865..eec3982 100644 --- a/VPLink/Services/DiscordService.cs +++ b/VPLink/Services/DiscordService.cs @@ -1,6 +1,6 @@ +using System.Diagnostics.CodeAnalysis; using System.Reactive.Linq; using System.Reactive.Subjects; -using System.Text; using System.Text.RegularExpressions; using Discord; using Discord.Interactions; @@ -9,7 +9,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using VPLink.Commands; -using VpSharp; using VpSharp.Entities; namespace VPLink.Services; @@ -25,7 +24,6 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic private readonly IConfiguration _configuration; private readonly InteractionService _interactionService; private readonly DiscordSocketClient _discordClient; - private readonly VirtualParadiseClient _virtualParadiseClient; private readonly Subject _messageReceived = new(); /// @@ -36,20 +34,17 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic /// The configuration. /// The interaction service. /// The Discord client. - /// The Virtual Paradise client. public DiscordService(ILogger logger, IServiceProvider serviceProvider, IConfiguration configuration, InteractionService interactionService, - DiscordSocketClient discordClient, - VirtualParadiseClient virtualParadiseClient) + DiscordSocketClient discordClient) { _logger = logger; _serviceProvider = serviceProvider; _configuration = configuration; _interactionService = interactionService; _discordClient = discordClient; - _virtualParadiseClient = virtualParadiseClient; } /// @@ -114,6 +109,38 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic return _interactionService.RegisterCommandsGloballyAsync(); } + /// + public Task AnnounceArrival(VirtualParadiseAvatar avatar) + { + if (avatar is null) throw new ArgumentNullException(nameof(avatar)); + if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask; + + var embed = new EmbedBuilder(); + embed.WithColor(0x00FF00); + embed.WithTitle("📥 Avatar Joined"); + embed.WithDescription(avatar.Name); + embed.WithTimestamp(DateTimeOffset.UtcNow); + embed.WithFooter($"Session {avatar.Session}"); + + return channel.SendMessageAsync(embed: embed.Build()); + } + + /// + public Task AnnounceDeparture(VirtualParadiseAvatar avatar) + { + if (avatar is null) throw new ArgumentNullException(nameof(avatar)); + if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask; + + var embed = new EmbedBuilder(); + embed.WithColor(0xFF0000); + embed.WithTitle("📤 Avatar Left"); + embed.WithDescription(avatar.Name); + embed.WithTimestamp(DateTimeOffset.UtcNow); + embed.WithFooter($"Session {avatar.Session}"); + + return channel.SendMessageAsync(embed: embed.Build()); + } + /// public Task SendMessageAsync(VirtualParadiseMessage message) { @@ -134,12 +161,7 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic _logger.LogInformation("Message by {Author}: {Content}", author, message.Content); - var channelId = _configuration.GetSection("Discord:ChannelId").Get(); - if (_discordClient.GetChannel(channelId) is not ITextChannel channel) - { - _logger.LogError("Channel {ChannelId} does not exist", channelId); - return Task.CompletedTask; - } + if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask; string unescaped = UnescapeRegex.Replace(message.Content, "$1"); string escaped = EscapeRegex.Replace(unescaped, "\\$1"); @@ -148,6 +170,20 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic return channel.SendMessageAsync($"**{displayName}**: {escaped}"); } + private bool TryGetRelayChannel([NotNullWhen(true)] out ITextChannel? channel) + { + var channelId = _configuration.GetValue("Discord:ChannelId"); + if (_discordClient.GetChannel(channelId) is ITextChannel textChannel) + { + channel = textChannel; + return true; + } + + _logger.LogError("Channel {ChannelId} does not exist", channelId); + channel = null; + return false; + } + [GeneratedRegex(@"\\(\*|_|`|~|\\)", RegexOptions.Compiled)] private static partial Regex GetUnescapeRegex(); diff --git a/VPLink/Services/IDiscordService.cs b/VPLink/Services/IDiscordService.cs index 1683892..f442d1f 100644 --- a/VPLink/Services/IDiscordService.cs +++ b/VPLink/Services/IDiscordService.cs @@ -14,10 +14,24 @@ public interface IDiscordService /// An observable that is triggered when a message is received from the Discord channel. IObservable OnMessageReceived { get; } + /// + /// Announces the arrival of an avatar. + /// + /// The avatar. + /// A representing the asynchronous operation. + Task AnnounceArrival(VirtualParadiseAvatar avatar); + + /// + /// Announces the arrival of an avatar. + /// + /// The avatar. + /// A representing the asynchronous operation. + Task AnnounceDeparture(VirtualParadiseAvatar avatar); + /// /// Sends a message to the Discord channel. /// /// The message to send. /// A representing the asynchronous operation. Task SendMessageAsync(VirtualParadiseMessage message); -} \ No newline at end of file +} diff --git a/VPLink/Services/IVirtualParadiseService.cs b/VPLink/Services/IVirtualParadiseService.cs index 1668fd6..625d93a 100644 --- a/VPLink/Services/IVirtualParadiseService.cs +++ b/VPLink/Services/IVirtualParadiseService.cs @@ -8,6 +8,22 @@ namespace VPLink.Services; /// public interface IVirtualParadiseService { + /// + /// Gets an observable that is triggered when an avatar enters the Virtual Paradise world. + /// + /// + /// An observable that is triggered when an avatar enters the Virtual Paradise world. + /// + IObservable OnAvatarJoined { get; } + + /// + /// Gets an observable that is triggered when an avatar exits the Virtual Paradise world. + /// + /// + /// An observable that is triggered when an avatar exits the Virtual Paradise world. + /// + IObservable OnAvatarLeft { get; } + /// /// Gets an observable that is triggered when a message is received from the Virtual Paradise world server. /// diff --git a/VPLink/Services/RelayService.cs b/VPLink/Services/RelayService.cs index 1c6c3b0..02034f1 100644 --- a/VPLink/Services/RelayService.cs +++ b/VPLink/Services/RelayService.cs @@ -45,6 +45,9 @@ internal sealed class RelayService : BackgroundService .Where(m => m.Author != _discordClient.CurrentUser) .SubscribeAsync(_virtualParadiseService.SendMessageAsync); + _virtualParadiseService.OnAvatarJoined.SubscribeAsync(_discordService.AnnounceArrival); + _virtualParadiseService.OnAvatarLeft.SubscribeAsync(_discordService.AnnounceDeparture); + _virtualParadiseService.OnMessageReceived .Where(m => m.Author != _virtualParadiseClient.CurrentAvatar) .SubscribeAsync(_discordService.SendMessageAsync); diff --git a/VPLink/Services/VirtualParadiseService.cs b/VPLink/Services/VirtualParadiseService.cs index 53c1e1f..c459ffe 100644 --- a/VPLink/Services/VirtualParadiseService.cs +++ b/VPLink/Services/VirtualParadiseService.cs @@ -17,6 +17,8 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi private readonly IConfiguration _configuration; private readonly VirtualParadiseClient _virtualParadiseClient; private readonly Subject _messageReceived = new(); + private readonly Subject _avatarJoined = new(); + private readonly Subject _avatarLeft = new(); /// /// Initializes a new instance of the class. @@ -33,6 +35,12 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi _virtualParadiseClient = virtualParadiseClient; } + /// + public IObservable OnAvatarJoined => _avatarJoined.AsObservable(); + + /// + public IObservable OnAvatarLeft => _avatarJoined.AsObservable(); + /// public IObservable OnMessageReceived => _messageReceived.AsObservable(); @@ -59,6 +67,8 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi { _logger.LogInformation("Establishing relay"); _virtualParadiseClient.MessageReceived.Subscribe(_messageReceived); + _virtualParadiseClient.AvatarJoined.Subscribe(OnVirtualParadiseAvatarJoined); + _virtualParadiseClient.AvatarLeft.Subscribe(OnVirtualParadiseAvatarLeft); string username = _configuration.GetSection("VirtualParadise:Username").Value ?? throw new InvalidOperationException("Username is not set."); @@ -76,4 +86,38 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi _logger.LogInformation("Entering world {World}", world); await _virtualParadiseClient.EnterAsync(world).ConfigureAwait(false); } + + private void OnVirtualParadiseAvatarJoined(VirtualParadiseAvatar avatar) + { + if (!_configuration.GetValue("Bot:AnnounceAvatarEvents")) + { + _logger.LogDebug("Join/leave events are disabled, ignoring event"); + return; + } + + if (avatar.IsBot && !_configuration.GetSection("Bot:AnnounceBots").Get()) + { + _logger.LogDebug("Bot events are disabled, ignoring event"); + return; + } + + _avatarJoined.OnNext(avatar); + } + + private void OnVirtualParadiseAvatarLeft(VirtualParadiseAvatar avatar) + { + if (!_configuration.GetValue("Bot:AnnounceAvatarEvents")) + { + _logger.LogDebug("Join/leave events are disabled, ignoring event"); + return; + } + + if (avatar.IsBot && !_configuration.GetSection("Bot:AnnounceBots").Get()) + { + _logger.LogDebug("Bot events are disabled, ignoring event"); + return; + } + + _avatarLeft.OnNext(avatar); + } } From d442e4e9b30d66415d1278a86ee87d31fbe62538 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 26 Aug 2023 13:21:01 +0100 Subject: [PATCH 06/13] refactor: clean up config reads Configuration models are now defined and generated by the ConfigurationService which makes reading config a much cleaner process. --- VPLink/Configuration/BotConfiguration.cs | 33 +++++++++++++ VPLink/Configuration/DiscordConfiguration.cs | 19 ++++++++ .../VirtualParadiseConfiguration.cs | 31 ++++++++++++ VPLink/Program.cs | 1 + VPLink/Services/ConfigurationService.cs | 37 ++++++++++++++ VPLink/Services/DiscordService.cs | 23 +++++---- VPLink/Services/IConfigurationService.cs | 27 +++++++++++ VPLink/Services/VirtualParadiseService.cs | 48 ++++++++----------- 8 files changed, 180 insertions(+), 39 deletions(-) create mode 100644 VPLink/Configuration/BotConfiguration.cs create mode 100644 VPLink/Configuration/DiscordConfiguration.cs create mode 100644 VPLink/Configuration/VirtualParadiseConfiguration.cs create mode 100644 VPLink/Services/ConfigurationService.cs create mode 100644 VPLink/Services/IConfigurationService.cs diff --git a/VPLink/Configuration/BotConfiguration.cs b/VPLink/Configuration/BotConfiguration.cs new file mode 100644 index 0000000..c1b92e5 --- /dev/null +++ b/VPLink/Configuration/BotConfiguration.cs @@ -0,0 +1,33 @@ +namespace VPLink.Configuration; + +/// +/// Represents the bot configuration. +/// +public sealed class BotConfiguration +{ + /// + /// Gets or sets a value indicating whether the bot should announce avatar events. + /// + /// + /// if the bot should announce avatar events; otherwise, . + /// + public bool AnnounceAvatarEvents { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the bot should announce avatar events for bots. + /// + /// + /// if the bot should announce avatar events for bots; otherwise, + /// . + /// + public bool AnnounceBots { get; set; } = false; + + /// + /// Gets or sets a value indicating whether the bot should relay messages from other bots. + /// + /// + /// if the bot should relay messages from other bots; otherwise, + /// . + /// + public bool RelayBotMessages { get; set; } = false; +} diff --git a/VPLink/Configuration/DiscordConfiguration.cs b/VPLink/Configuration/DiscordConfiguration.cs new file mode 100644 index 0000000..e9b473a --- /dev/null +++ b/VPLink/Configuration/DiscordConfiguration.cs @@ -0,0 +1,19 @@ +namespace VPLink.Configuration; + +/// +/// Represents the Discord configuration. +/// +public sealed class DiscordConfiguration +{ + /// + /// Gets or sets the channel ID to which the bot should relay messages. + /// + /// The channel ID. + public ulong ChannelId { get; set; } + + /// + /// Gets or sets the Discord token. + /// + /// The Discord token. + public string Token { get; set; } = string.Empty; +} diff --git a/VPLink/Configuration/VirtualParadiseConfiguration.cs b/VPLink/Configuration/VirtualParadiseConfiguration.cs new file mode 100644 index 0000000..e806157 --- /dev/null +++ b/VPLink/Configuration/VirtualParadiseConfiguration.cs @@ -0,0 +1,31 @@ +namespace VPLink.Configuration; + +/// +/// Represents the Virtual Paradise configuration. +/// +public sealed class VirtualParadiseConfiguration +{ + /// + /// Gets or sets the display name of the bot. + /// + /// The display name. + public string BotName { get; set; } = "VPLink"; + + /// + /// Gets or sets the password with which to log in to Virtual Paradise. + /// + /// The login password. + public string Password { get; set; } = string.Empty; + + /// + /// Gets or sets the username with which to log in to Virtual Paradise. + /// + /// The login username. + public string Username { get; set; } = string.Empty; + + /// + /// Gets or sets the world into which the bot should enter. + /// + /// The world to enter. + public string World { get; set; } = string.Empty; +} diff --git a/VPLink/Program.cs b/VPLink/Program.cs index 2fc3c2e..c4cdbef 100644 --- a/VPLink/Program.cs +++ b/VPLink/Program.cs @@ -25,6 +25,7 @@ builder.Logging.ClearProviders(); builder.Logging.AddSerilog(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/VPLink/Services/ConfigurationService.cs b/VPLink/Services/ConfigurationService.cs new file mode 100644 index 0000000..1c30c18 --- /dev/null +++ b/VPLink/Services/ConfigurationService.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Configuration; +using VPLink.Configuration; + +namespace VPLink.Services; + +/// +internal sealed class ConfigurationService : IConfigurationService +{ + private readonly IConfiguration _configuration; + + /// + /// Initializes a new instance of the class. + /// + /// + public ConfigurationService(IConfiguration configuration) + { + _configuration = configuration; + } + + /// + public BotConfiguration BotConfiguration + { + get => _configuration.GetSection("Bot").Get()!; + } + + /// + public DiscordConfiguration DiscordConfiguration + { + get => _configuration.GetSection("Discord").Get()!; + } + + /// + public VirtualParadiseConfiguration VirtualParadiseConfiguration + { + get => _configuration.GetSection("VirtualParadise").Get()!; + } +} diff --git a/VPLink/Services/DiscordService.cs b/VPLink/Services/DiscordService.cs index eec3982..071bdc5 100644 --- a/VPLink/Services/DiscordService.cs +++ b/VPLink/Services/DiscordService.cs @@ -5,10 +5,10 @@ using System.Text.RegularExpressions; using Discord; using Discord.Interactions; using Discord.WebSocket; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using VPLink.Commands; +using VPLink.Configuration; using VpSharp.Entities; namespace VPLink.Services; @@ -21,7 +21,7 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; - private readonly IConfiguration _configuration; + private readonly IConfigurationService _configurationService; private readonly InteractionService _interactionService; private readonly DiscordSocketClient _discordClient; private readonly Subject _messageReceived = new(); @@ -31,18 +31,18 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic /// /// The logger. /// The service provider. - /// The configuration. + /// The configuration service. /// The interaction service. /// The Discord client. public DiscordService(ILogger logger, IServiceProvider serviceProvider, - IConfiguration configuration, + IConfigurationService configurationService, InteractionService interactionService, DiscordSocketClient discordClient) { _logger = logger; _serviceProvider = serviceProvider; - _configuration = configuration; + _configurationService = configurationService; _interactionService = interactionService; _discordClient = discordClient; } @@ -62,8 +62,8 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic _discordClient.InteractionCreated += OnInteractionCreated; _discordClient.MessageReceived += OnDiscordMessageReceived; - string token = _configuration.GetSection("Discord:Token").Value ?? - throw new InvalidOperationException("Token is not set."); + DiscordConfiguration configuration = _configurationService.DiscordConfiguration; + string token = configuration.Token ?? throw new InvalidOperationException("Token is not set."); _logger.LogDebug("Connecting to Discord"); await _discordClient.LoginAsync(TokenType.Bot, token); @@ -75,7 +75,8 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic if (arg is not IUserMessage message) return Task.CompletedTask; - if (message.Channel.Id != _configuration.GetSection("Discord:ChannelId").Get()) + DiscordConfiguration configuration = _configurationService.DiscordConfiguration; + if (message.Channel.Id != configuration.ChannelId) return Task.CompletedTask; _messageReceived.OnNext(message); @@ -153,7 +154,7 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic return Task.CompletedTask; } - if (author.IsBot && !_configuration.GetSection("Bot:RelayBotMessages").Get()) + if (author.IsBot && !_configurationService.BotConfiguration.RelayBotMessages) { _logger.LogDebug("Bot messages are disabled, ignoring message"); return Task.CompletedTask; @@ -172,7 +173,9 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic private bool TryGetRelayChannel([NotNullWhen(true)] out ITextChannel? channel) { - var channelId = _configuration.GetValue("Discord:ChannelId"); + DiscordConfiguration configuration = _configurationService.DiscordConfiguration; + ulong channelId = configuration.ChannelId; + if (_discordClient.GetChannel(channelId) is ITextChannel textChannel) { channel = textChannel; diff --git a/VPLink/Services/IConfigurationService.cs b/VPLink/Services/IConfigurationService.cs new file mode 100644 index 0000000..c8d657e --- /dev/null +++ b/VPLink/Services/IConfigurationService.cs @@ -0,0 +1,27 @@ +using VPLink.Configuration; + +namespace VPLink.Services; + +/// +/// Represents the configuration service. +/// +public interface IConfigurationService +{ + /// + /// Gets the bot configuration. + /// + /// The bot configuration. + BotConfiguration BotConfiguration { get; } + + /// + /// Gets the Discord configuration. + /// + /// The Discord configuration. + DiscordConfiguration DiscordConfiguration { get; } + + /// + /// Gets the Virtual Paradise configuration. + /// + /// The Virtual Paradise configuration. + VirtualParadiseConfiguration VirtualParadiseConfiguration { get; } +} diff --git a/VPLink/Services/VirtualParadiseService.cs b/VPLink/Services/VirtualParadiseService.cs index c459ffe..f28bc03 100644 --- a/VPLink/Services/VirtualParadiseService.cs +++ b/VPLink/Services/VirtualParadiseService.cs @@ -1,12 +1,13 @@ using System.Reactive.Linq; using System.Reactive.Subjects; using Discord; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using VPLink.Configuration; using VpSharp; using VpSharp.Entities; using Color = System.Drawing.Color; +using VirtualParadiseConfiguration = VPLink.Configuration.VirtualParadiseConfiguration; namespace VPLink.Services; @@ -14,7 +15,7 @@ namespace VPLink.Services; internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadiseService { private readonly ILogger _logger; - private readonly IConfiguration _configuration; + private readonly IConfigurationService _configurationService; private readonly VirtualParadiseClient _virtualParadiseClient; private readonly Subject _messageReceived = new(); private readonly Subject _avatarJoined = new(); @@ -24,14 +25,14 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi /// Initializes a new instance of the class. /// /// The logger. - /// The configuration. + /// The configuration service. /// The Virtual Paradise client. public VirtualParadiseService(ILogger logger, - IConfiguration configuration, + IConfigurationService configurationService, VirtualParadiseClient virtualParadiseClient) { _logger = logger; - _configuration = configuration; + _configurationService = configurationService; _virtualParadiseClient = virtualParadiseClient; } @@ -50,7 +51,7 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi if (message is null) throw new ArgumentNullException(nameof(message)); if (string.IsNullOrWhiteSpace(message.Content)) return Task.CompletedTask; - if (message.Author.IsBot && !_configuration.GetSection("Bot:RelayBotMessages").Get()) + if (message.Author.IsBot && !_configurationService.BotConfiguration.RelayBotMessages) { _logger.LogDebug("Bot messages are disabled, ignoring message"); return Task.CompletedTask; @@ -59,7 +60,8 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi _logger.LogInformation("Message by {Author}: {Content}", message.Author, message.Content); string displayName = message.Author.GlobalName ?? message.Author.Username; - return _virtualParadiseClient.SendMessageAsync(displayName, message.Content, FontStyle.Bold, Color.MidnightBlue); + return _virtualParadiseClient.SendMessageAsync(displayName, message.Content, FontStyle.Bold, + Color.MidnightBlue); } /// @@ -70,14 +72,12 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi _virtualParadiseClient.AvatarJoined.Subscribe(OnVirtualParadiseAvatarJoined); _virtualParadiseClient.AvatarLeft.Subscribe(OnVirtualParadiseAvatarLeft); - string username = _configuration.GetSection("VirtualParadise:Username").Value ?? - throw new InvalidOperationException("Username is not set."); - string password = _configuration.GetSection("VirtualParadise:Password").Value ?? - throw new InvalidOperationException("Password is not set."); - string world = _configuration.GetSection("VirtualParadise:World").Value ?? - throw new InvalidOperationException("World is not set."); - string botName = _configuration.GetSection("VirtualParadise:BotName").Value ?? - throw new InvalidOperationException("Bot name is not set."); + VirtualParadiseConfiguration configuration = _configurationService.VirtualParadiseConfiguration; + + string username = configuration.Username ?? throw new InvalidOperationException("Username is not set."); + string password = configuration.Password ?? throw new InvalidOperationException("Password is not set."); + string world = configuration.World ?? throw new InvalidOperationException("World is not set."); + string botName = configuration.BotName ?? throw new InvalidOperationException("Bot name is not set."); _logger.LogDebug("Connecting to Virtual Paradise"); await _virtualParadiseClient.ConnectAsync().ConfigureAwait(false); @@ -89,15 +89,10 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi private void OnVirtualParadiseAvatarJoined(VirtualParadiseAvatar avatar) { - if (!_configuration.GetValue("Bot:AnnounceAvatarEvents")) - { - _logger.LogDebug("Join/leave events are disabled, ignoring event"); - return; - } + BotConfiguration configuration = _configurationService.BotConfiguration; - if (avatar.IsBot && !_configuration.GetSection("Bot:AnnounceBots").Get()) + if (!configuration.AnnounceAvatarEvents || avatar.IsBot && !configuration.AnnounceBots) { - _logger.LogDebug("Bot events are disabled, ignoring event"); return; } @@ -106,15 +101,10 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi private void OnVirtualParadiseAvatarLeft(VirtualParadiseAvatar avatar) { - if (!_configuration.GetValue("Bot:AnnounceAvatarEvents")) - { - _logger.LogDebug("Join/leave events are disabled, ignoring event"); - return; - } + BotConfiguration configuration = _configurationService.BotConfiguration; - if (avatar.IsBot && !_configuration.GetSection("Bot:AnnounceBots").Get()) + if (!configuration.AnnounceAvatarEvents || avatar.IsBot && !configuration.AnnounceBots) { - _logger.LogDebug("Bot events are disabled, ignoring event"); return; } From 7d5eb0f2b278f933012a4019df6237ba10d1f80c Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 26 Aug 2023 13:29:02 +0100 Subject: [PATCH 07/13] refactor: move avatar events to AvatarService --- VPLink/Program.cs | 1 + VPLink/Services/AvatarService.cs | 70 ++++++++++++++++++++++ VPLink/Services/IAvatarService.cs | 25 ++++++++ VPLink/Services/IVirtualParadiseService.cs | 16 ----- VPLink/Services/RelayService.cs | 8 ++- VPLink/Services/VirtualParadiseService.cs | 35 ----------- 6 files changed, 102 insertions(+), 53 deletions(-) create mode 100644 VPLink/Services/AvatarService.cs create mode 100644 VPLink/Services/IAvatarService.cs diff --git a/VPLink/Program.cs b/VPLink/Program.cs index c4cdbef..fce6fae 100644 --- a/VPLink/Program.cs +++ b/VPLink/Program.cs @@ -34,6 +34,7 @@ builder.Services.AddSingleton(new DiscordSocketConfig GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent }); +builder.Services.AddHostedSingleton(); builder.Services.AddHostedSingleton(); builder.Services.AddHostedSingleton(); builder.Services.AddHostedSingleton(); diff --git a/VPLink/Services/AvatarService.cs b/VPLink/Services/AvatarService.cs new file mode 100644 index 0000000..15c74d1 --- /dev/null +++ b/VPLink/Services/AvatarService.cs @@ -0,0 +1,70 @@ +using System.Reactive.Linq; +using System.Reactive.Subjects; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using VPLink.Configuration; +using VpSharp; +using VpSharp.Entities; + +namespace VPLink.Services; + +/// +internal sealed class AvatarService : BackgroundService, IAvatarService +{ + private readonly ILogger _logger; + private readonly IConfigurationService _configurationService; + private readonly VirtualParadiseClient _virtualParadiseClient; + private readonly Subject _avatarJoined = new(); + private readonly Subject _avatarLeft = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The configuration service. + /// The Virtual Paradise client. + public AvatarService(ILogger logger, + IConfigurationService configurationService, + VirtualParadiseClient virtualParadiseClient) + { + _logger = logger; + _configurationService = configurationService; + _virtualParadiseClient = virtualParadiseClient; + } + + /// + public IObservable OnAvatarJoined => _avatarJoined.AsObservable(); + + /// + public IObservable OnAvatarLeft => _avatarLeft.AsObservable(); + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + _virtualParadiseClient.AvatarJoined.Subscribe(OnVPAvatarJoined); + _virtualParadiseClient.AvatarLeft.Subscribe(OnVPAvatarLeft); + return Task.CompletedTask; + } + + private void OnVPAvatarJoined(VirtualParadiseAvatar avatar) + { + _logger.LogInformation("{Avatar} joined", avatar); + + BotConfiguration configuration = _configurationService.BotConfiguration; + if (!configuration.AnnounceAvatarEvents || avatar.IsBot && !configuration.AnnounceBots) + return; + + _avatarJoined.OnNext(avatar); + } + + private void OnVPAvatarLeft(VirtualParadiseAvatar avatar) + { + _logger.LogInformation("{Avatar} left", avatar); + + BotConfiguration configuration = _configurationService.BotConfiguration; + if (!configuration.AnnounceAvatarEvents || avatar.IsBot && !configuration.AnnounceBots) + return; + + _avatarLeft.OnNext(avatar); + } +} diff --git a/VPLink/Services/IAvatarService.cs b/VPLink/Services/IAvatarService.cs new file mode 100644 index 0000000..0bee759 --- /dev/null +++ b/VPLink/Services/IAvatarService.cs @@ -0,0 +1,25 @@ +using VpSharp.Entities; + +namespace VPLink.Services; + +/// +/// Represents a service that listens for, and triggers, avatar events. +/// +public interface IAvatarService +{ + /// + /// Gets an observable that is triggered when an avatar enters the Virtual Paradise world. + /// + /// + /// An observable that is triggered when an avatar enters the Virtual Paradise world. + /// + IObservable OnAvatarJoined { get; } + + /// + /// Gets an observable that is triggered when an avatar exits the Virtual Paradise world. + /// + /// + /// An observable that is triggered when an avatar exits the Virtual Paradise world. + /// + IObservable OnAvatarLeft { get; } +} diff --git a/VPLink/Services/IVirtualParadiseService.cs b/VPLink/Services/IVirtualParadiseService.cs index 625d93a..1668fd6 100644 --- a/VPLink/Services/IVirtualParadiseService.cs +++ b/VPLink/Services/IVirtualParadiseService.cs @@ -8,22 +8,6 @@ namespace VPLink.Services; /// public interface IVirtualParadiseService { - /// - /// Gets an observable that is triggered when an avatar enters the Virtual Paradise world. - /// - /// - /// An observable that is triggered when an avatar enters the Virtual Paradise world. - /// - IObservable OnAvatarJoined { get; } - - /// - /// Gets an observable that is triggered when an avatar exits the Virtual Paradise world. - /// - /// - /// An observable that is triggered when an avatar exits the Virtual Paradise world. - /// - IObservable OnAvatarLeft { get; } - /// /// Gets an observable that is triggered when a message is received from the Virtual Paradise world server. /// diff --git a/VPLink/Services/RelayService.cs b/VPLink/Services/RelayService.cs index 02034f1..dd23d7f 100644 --- a/VPLink/Services/RelayService.cs +++ b/VPLink/Services/RelayService.cs @@ -11,6 +11,7 @@ internal sealed class RelayService : BackgroundService { private readonly ILogger _logger; private readonly IDiscordService _discordService; + private readonly IAvatarService _avatarService; private readonly IVirtualParadiseService _virtualParadiseService; private readonly DiscordSocketClient _discordClient; private readonly VirtualParadiseClient _virtualParadiseClient; @@ -20,17 +21,20 @@ internal sealed class RelayService : BackgroundService /// /// The logger. /// The Discord service. + /// The avatar service. /// The Virtual Paradise service. /// The Discord client. /// The Virtual Paradise client. public RelayService(ILogger logger, IDiscordService discordService, + IAvatarService avatarService, IVirtualParadiseService virtualParadiseService, DiscordSocketClient discordClient, VirtualParadiseClient virtualParadiseClient) { _logger = logger; _discordService = discordService; + _avatarService = avatarService; _virtualParadiseService = virtualParadiseService; _discordClient = discordClient; _virtualParadiseClient = virtualParadiseClient; @@ -45,8 +49,8 @@ internal sealed class RelayService : BackgroundService .Where(m => m.Author != _discordClient.CurrentUser) .SubscribeAsync(_virtualParadiseService.SendMessageAsync); - _virtualParadiseService.OnAvatarJoined.SubscribeAsync(_discordService.AnnounceArrival); - _virtualParadiseService.OnAvatarLeft.SubscribeAsync(_discordService.AnnounceDeparture); + _avatarService.OnAvatarJoined.SubscribeAsync(_discordService.AnnounceArrival); + _avatarService.OnAvatarLeft.SubscribeAsync(_discordService.AnnounceDeparture); _virtualParadiseService.OnMessageReceived .Where(m => m.Author != _virtualParadiseClient.CurrentAvatar) diff --git a/VPLink/Services/VirtualParadiseService.cs b/VPLink/Services/VirtualParadiseService.cs index f28bc03..bc3365f 100644 --- a/VPLink/Services/VirtualParadiseService.cs +++ b/VPLink/Services/VirtualParadiseService.cs @@ -3,7 +3,6 @@ using System.Reactive.Subjects; using Discord; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using VPLink.Configuration; using VpSharp; using VpSharp.Entities; using Color = System.Drawing.Color; @@ -18,8 +17,6 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi private readonly IConfigurationService _configurationService; private readonly VirtualParadiseClient _virtualParadiseClient; private readonly Subject _messageReceived = new(); - private readonly Subject _avatarJoined = new(); - private readonly Subject _avatarLeft = new(); /// /// Initializes a new instance of the class. @@ -36,12 +33,6 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi _virtualParadiseClient = virtualParadiseClient; } - /// - public IObservable OnAvatarJoined => _avatarJoined.AsObservable(); - - /// - public IObservable OnAvatarLeft => _avatarJoined.AsObservable(); - /// public IObservable OnMessageReceived => _messageReceived.AsObservable(); @@ -69,8 +60,6 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi { _logger.LogInformation("Establishing relay"); _virtualParadiseClient.MessageReceived.Subscribe(_messageReceived); - _virtualParadiseClient.AvatarJoined.Subscribe(OnVirtualParadiseAvatarJoined); - _virtualParadiseClient.AvatarLeft.Subscribe(OnVirtualParadiseAvatarLeft); VirtualParadiseConfiguration configuration = _configurationService.VirtualParadiseConfiguration; @@ -86,28 +75,4 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi _logger.LogInformation("Entering world {World}", world); await _virtualParadiseClient.EnterAsync(world).ConfigureAwait(false); } - - private void OnVirtualParadiseAvatarJoined(VirtualParadiseAvatar avatar) - { - BotConfiguration configuration = _configurationService.BotConfiguration; - - if (!configuration.AnnounceAvatarEvents || avatar.IsBot && !configuration.AnnounceBots) - { - return; - } - - _avatarJoined.OnNext(avatar); - } - - private void OnVirtualParadiseAvatarLeft(VirtualParadiseAvatar avatar) - { - BotConfiguration configuration = _configurationService.BotConfiguration; - - if (!configuration.AnnounceAvatarEvents || avatar.IsBot && !configuration.AnnounceBots) - { - return; - } - - _avatarLeft.OnNext(avatar); - } } From 2d917bd3af75aa3e71678239af62532f3615f7b1 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 26 Aug 2023 14:11:43 +0100 Subject: [PATCH 08/13] refactor: separate connection services and message services This change also fixes long messages from Discord breaking the bot in VP, and allows customisation of console message style. --- VPLink/Configuration/ChatConfiguration.cs | 21 +++ .../VirtualParadiseConfiguration.cs | 6 + VPLink/Data/RelayedMessage.cs | 30 ++++ VPLink/Program.cs | 7 +- VPLink/Services/DiscordMessageService.cs | 162 ++++++++++++++++++ VPLink/Services/DiscordService.cs | 112 +----------- ...rdService.cs => IDiscordMessageService.cs} | 21 +-- VPLink/Services/IRelayTarget.cs | 16 ++ .../IVirtualParadiseMessageService.cs | 17 ++ VPLink/Services/IVirtualParadiseService.cs | 25 --- VPLink/Services/RelayService.cs | 29 +--- .../Services/VirtualParadiseMessageService.cs | 69 ++++++++ VPLink/Services/VirtualParadiseService.cs | 28 +-- 13 files changed, 342 insertions(+), 201 deletions(-) create mode 100644 VPLink/Configuration/ChatConfiguration.cs create mode 100644 VPLink/Data/RelayedMessage.cs create mode 100644 VPLink/Services/DiscordMessageService.cs rename VPLink/Services/{IDiscordService.cs => IDiscordMessageService.cs} (50%) create mode 100644 VPLink/Services/IRelayTarget.cs create mode 100644 VPLink/Services/IVirtualParadiseMessageService.cs delete mode 100644 VPLink/Services/IVirtualParadiseService.cs create mode 100644 VPLink/Services/VirtualParadiseMessageService.cs diff --git a/VPLink/Configuration/ChatConfiguration.cs b/VPLink/Configuration/ChatConfiguration.cs new file mode 100644 index 0000000..dd99157 --- /dev/null +++ b/VPLink/Configuration/ChatConfiguration.cs @@ -0,0 +1,21 @@ +using VpSharp; + +namespace VPLink.Configuration; + +/// +/// Represents the chat configuration. +/// +public sealed class ChatConfiguration +{ + /// + /// Gets or sets the color of the message. + /// + /// The message color. + public uint Color { get; set; } = 0x191970; + + /// + /// Gets or sets the font style of the message. + /// + /// The font style. + public FontStyle Style { get; set; } = FontStyle.Regular; +} diff --git a/VPLink/Configuration/VirtualParadiseConfiguration.cs b/VPLink/Configuration/VirtualParadiseConfiguration.cs index e806157..c402a73 100644 --- a/VPLink/Configuration/VirtualParadiseConfiguration.cs +++ b/VPLink/Configuration/VirtualParadiseConfiguration.cs @@ -11,6 +11,12 @@ public sealed class VirtualParadiseConfiguration /// The display name. public string BotName { get; set; } = "VPLink"; + /// + /// Gets or sets the chat configuration. + /// + /// The chat configuration. + public ChatConfiguration ChatConfiguration { get; } = new(); + /// /// Gets or sets the password with which to log in to Virtual Paradise. /// diff --git a/VPLink/Data/RelayedMessage.cs b/VPLink/Data/RelayedMessage.cs new file mode 100644 index 0000000..07b1469 --- /dev/null +++ b/VPLink/Data/RelayedMessage.cs @@ -0,0 +1,30 @@ +namespace VPLink.Data; + +/// +/// Represents a message that is relayed between Discord and Virtual Paradise. +/// +public readonly struct RelayedMessage +{ + /// + /// Initializes a new instance of the struct. + /// + /// The author. + /// The content. + public RelayedMessage(string author, string content) + { + Author = author; + Content = content; + } + + /// + /// Gets the message content. + /// + /// The message content. + public string Content { get; } + + /// + /// Gets the user that sent the message. + /// + /// The user that sent the message. + public string Author { get; } +} diff --git a/VPLink/Program.cs b/VPLink/Program.cs index fce6fae..48613e0 100644 --- a/VPLink/Program.cs +++ b/VPLink/Program.cs @@ -35,8 +35,11 @@ builder.Services.AddSingleton(new DiscordSocketConfig }); builder.Services.AddHostedSingleton(); -builder.Services.AddHostedSingleton(); -builder.Services.AddHostedSingleton(); +builder.Services.AddHostedSingleton(); +builder.Services.AddHostedSingleton(); + +builder.Services.AddHostedSingleton(); +builder.Services.AddHostedSingleton(); builder.Services.AddHostedSingleton(); await builder.Build().RunAsync(); diff --git a/VPLink/Services/DiscordMessageService.cs b/VPLink/Services/DiscordMessageService.cs new file mode 100644 index 0000000..11c78bb --- /dev/null +++ b/VPLink/Services/DiscordMessageService.cs @@ -0,0 +1,162 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Text; +using System.Text.RegularExpressions; +using Cysharp.Text; +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using VPLink.Configuration; +using VPLink.Data; +using VpSharp.Entities; + +namespace VPLink.Services; + +/// +internal sealed partial class DiscordMessageService : BackgroundService, IDiscordMessageService +{ + private static readonly Encoding Utf8Encoding = new UTF8Encoding(false, false); + private static readonly Regex UnescapeRegex = GetUnescapeRegex(); + private static readonly Regex EscapeRegex = GetEscapeRegex(); + + private readonly ILogger _logger; + private readonly IConfigurationService _configurationService; + private readonly DiscordSocketClient _discordClient; + private readonly Subject _messageReceived = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The configuration service. + /// The Discord client. + public DiscordMessageService(ILogger logger, + IConfigurationService configurationService, + DiscordSocketClient discordClient) + { + _logger = logger; + _configurationService = configurationService; + _discordClient = discordClient; + } + + /// + public IObservable OnMessageReceived => _messageReceived.AsObservable(); + + /// + public Task AnnounceArrival(VirtualParadiseAvatar avatar) + { + if (avatar is null) throw new ArgumentNullException(nameof(avatar)); + if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask; + + var embed = new EmbedBuilder(); + embed.WithColor(0x00FF00); + embed.WithTitle("📥 Avatar Joined"); + embed.WithDescription(avatar.Name); + embed.WithTimestamp(DateTimeOffset.UtcNow); + embed.WithFooter($"Session {avatar.Session}"); + + return channel.SendMessageAsync(embed: embed.Build()); + } + + /// + public Task AnnounceDeparture(VirtualParadiseAvatar avatar) + { + if (avatar is null) throw new ArgumentNullException(nameof(avatar)); + if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask; + + var embed = new EmbedBuilder(); + embed.WithColor(0xFF0000); + embed.WithTitle("📤 Avatar Left"); + embed.WithDescription(avatar.Name); + embed.WithTimestamp(DateTimeOffset.UtcNow); + embed.WithFooter($"Session {avatar.Session}"); + + return channel.SendMessageAsync(embed: embed.Build()); + } + + /// + public Task SendMessageAsync(RelayedMessage message) + { + if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask; + return channel.SendMessageAsync($"**{message.Author}**: {message.Content}"); + } + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + _discordClient.MessageReceived += OnDiscordMessageReceived; + return Task.CompletedTask; + } + + private Task OnDiscordMessageReceived(SocketMessage arg) + { + if (arg is not IUserMessage message) + return Task.CompletedTask; + + IUser author = message.Author; + if (author.Id == _discordClient.CurrentUser.Id) + return Task.CompletedTask; + + if (author.IsBot && !_configurationService.BotConfiguration.RelayBotMessages) + return Task.CompletedTask; + + string displayName = author.GlobalName ?? author.Username; + string unescaped = UnescapeRegex.Replace(message.Content, "$1"); + string content = EscapeRegex.Replace(unescaped, "\\$1"); + + IReadOnlyCollection attachments = message.Attachments; + if (attachments.Count > 0) + { + using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder(); + for (var index = 0; index < attachments.Count; index++) + { + builder.AppendLine(attachments.ElementAt(index).Url); + } + + // += allocates more than necessary, just interpolate + content = $"{content}\n{builder}"; + } + + _logger.LogInformation("Message by {Author}: {Content}", author, content); + + Span buffer = stackalloc byte[255]; // VP message length limit + var messages = new List(); + int byteCount = Utf8Encoding.GetByteCount(content); + + var offset = 0; + while (offset < byteCount) + { + int length = Math.Min(byteCount - offset, 255); + Utf8Encoding.GetBytes(content.AsSpan(offset, length), buffer); + messages.Add(new RelayedMessage(displayName, Utf8Encoding.GetString(buffer))); + offset += length; + } + + messages.ForEach(_messageReceived.OnNext); + return Task.CompletedTask; + } + + private bool TryGetRelayChannel([NotNullWhen(true)] out ITextChannel? channel) + { + DiscordConfiguration configuration = _configurationService.DiscordConfiguration; + ulong channelId = configuration.ChannelId; + + if (_discordClient.GetChannel(channelId) is ITextChannel textChannel) + { + channel = textChannel; + return true; + } + + _logger.LogError("Channel {ChannelId} does not exist", channelId); + channel = null; + return false; + } + + [GeneratedRegex(@"\\(\*|_|`|~|\\)", RegexOptions.Compiled)] + private static partial Regex GetUnescapeRegex(); + + [GeneratedRegex(@"(\*|_|`|~|\\)", RegexOptions.Compiled)] + private static partial Regex GetEscapeRegex(); +} diff --git a/VPLink/Services/DiscordService.cs b/VPLink/Services/DiscordService.cs index 071bdc5..e01f909 100644 --- a/VPLink/Services/DiscordService.cs +++ b/VPLink/Services/DiscordService.cs @@ -1,7 +1,3 @@ -using System.Diagnostics.CodeAnalysis; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text.RegularExpressions; using Discord; using Discord.Interactions; using Discord.WebSocket; @@ -9,22 +5,16 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using VPLink.Commands; using VPLink.Configuration; -using VpSharp.Entities; namespace VPLink.Services; -/// -internal sealed partial class DiscordService : BackgroundService, IDiscordService +internal sealed class DiscordService : BackgroundService { - private static readonly Regex UnescapeRegex = GetUnescapeRegex(); - private static readonly Regex EscapeRegex = GetEscapeRegex(); - private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; private readonly IConfigurationService _configurationService; private readonly InteractionService _interactionService; private readonly DiscordSocketClient _discordClient; - private readonly Subject _messageReceived = new(); /// /// Initializes a new instance of the class. @@ -47,9 +37,6 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic _discordClient = discordClient; } - /// - public IObservable OnMessageReceived => _messageReceived.AsObservable(); - /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { @@ -60,7 +47,6 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic _discordClient.Ready += OnReady; _discordClient.InteractionCreated += OnInteractionCreated; - _discordClient.MessageReceived += OnDiscordMessageReceived; DiscordConfiguration configuration = _configurationService.DiscordConfiguration; string token = configuration.Token ?? throw new InvalidOperationException("Token is not set."); @@ -70,19 +56,6 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic await _discordClient.StartAsync(); } - private Task OnDiscordMessageReceived(SocketMessage arg) - { - if (arg is not IUserMessage message) - return Task.CompletedTask; - - DiscordConfiguration configuration = _configurationService.DiscordConfiguration; - if (message.Channel.Id != configuration.ChannelId) - return Task.CompletedTask; - - _messageReceived.OnNext(message); - return Task.CompletedTask; - } - private async Task OnInteractionCreated(SocketInteraction interaction) { try @@ -109,87 +82,4 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic _logger.LogInformation("Discord client ready"); return _interactionService.RegisterCommandsGloballyAsync(); } - - /// - public Task AnnounceArrival(VirtualParadiseAvatar avatar) - { - if (avatar is null) throw new ArgumentNullException(nameof(avatar)); - if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask; - - var embed = new EmbedBuilder(); - embed.WithColor(0x00FF00); - embed.WithTitle("📥 Avatar Joined"); - embed.WithDescription(avatar.Name); - embed.WithTimestamp(DateTimeOffset.UtcNow); - embed.WithFooter($"Session {avatar.Session}"); - - return channel.SendMessageAsync(embed: embed.Build()); - } - - /// - public Task AnnounceDeparture(VirtualParadiseAvatar avatar) - { - if (avatar is null) throw new ArgumentNullException(nameof(avatar)); - if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask; - - var embed = new EmbedBuilder(); - embed.WithColor(0xFF0000); - embed.WithTitle("📤 Avatar Left"); - embed.WithDescription(avatar.Name); - embed.WithTimestamp(DateTimeOffset.UtcNow); - embed.WithFooter($"Session {avatar.Session}"); - - return channel.SendMessageAsync(embed: embed.Build()); - } - - /// - public Task SendMessageAsync(VirtualParadiseMessage message) - { - if (message is null) throw new ArgumentNullException(nameof(message)); - if (string.IsNullOrWhiteSpace(message.Content)) return Task.CompletedTask; - - if (message.Author is not { } author) - { - _logger.LogWarning("Received message without author, ignoring message"); - return Task.CompletedTask; - } - - if (author.IsBot && !_configurationService.BotConfiguration.RelayBotMessages) - { - _logger.LogDebug("Bot messages are disabled, ignoring message"); - return Task.CompletedTask; - } - - _logger.LogInformation("Message by {Author}: {Content}", author, message.Content); - - if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask; - - string unescaped = UnescapeRegex.Replace(message.Content, "$1"); - string escaped = EscapeRegex.Replace(unescaped, "\\$1"); - - string displayName = author.Name; - return channel.SendMessageAsync($"**{displayName}**: {escaped}"); - } - - private bool TryGetRelayChannel([NotNullWhen(true)] out ITextChannel? channel) - { - DiscordConfiguration configuration = _configurationService.DiscordConfiguration; - ulong channelId = configuration.ChannelId; - - if (_discordClient.GetChannel(channelId) is ITextChannel textChannel) - { - channel = textChannel; - return true; - } - - _logger.LogError("Channel {ChannelId} does not exist", channelId); - channel = null; - return false; - } - - [GeneratedRegex(@"\\(\*|_|`|~|\\)", RegexOptions.Compiled)] - private static partial Regex GetUnescapeRegex(); - - [GeneratedRegex(@"(\*|_|`|~|\\)", RegexOptions.Compiled)] - private static partial Regex GetEscapeRegex(); } diff --git a/VPLink/Services/IDiscordService.cs b/VPLink/Services/IDiscordMessageService.cs similarity index 50% rename from VPLink/Services/IDiscordService.cs rename to VPLink/Services/IDiscordMessageService.cs index f442d1f..b338686 100644 --- a/VPLink/Services/IDiscordService.cs +++ b/VPLink/Services/IDiscordMessageService.cs @@ -1,18 +1,20 @@ -using Discord; +using VPLink.Data; using VpSharp.Entities; namespace VPLink.Services; /// -/// Represents a service that sends messages to the Discord channel. +/// Represents a service that listens for messages from the Discord bridge channel. /// -public interface IDiscordService +public interface IDiscordMessageService : IRelayTarget { /// - /// Gets an observable that is triggered when a message is received from the Discord channel. + /// Gets an observable that is triggered when a valid message is received from the Discord bridge channel. /// - /// An observable that is triggered when a message is received from the Discord channel. - IObservable OnMessageReceived { get; } + /// + /// An observable that is triggered when a valid message is received from the Discord bridge channel. + /// + IObservable OnMessageReceived { get; } /// /// Announces the arrival of an avatar. @@ -27,11 +29,4 @@ public interface IDiscordService /// The avatar. /// A representing the asynchronous operation. Task AnnounceDeparture(VirtualParadiseAvatar avatar); - - /// - /// Sends a message to the Discord channel. - /// - /// The message to send. - /// A representing the asynchronous operation. - Task SendMessageAsync(VirtualParadiseMessage message); } diff --git a/VPLink/Services/IRelayTarget.cs b/VPLink/Services/IRelayTarget.cs new file mode 100644 index 0000000..e5a6cd6 --- /dev/null +++ b/VPLink/Services/IRelayTarget.cs @@ -0,0 +1,16 @@ +using VPLink.Data; + +namespace VPLink.Services; + +/// +/// Represents an object that can be used as a relay target. +/// +public interface IRelayTarget +{ + /// + /// Sends a message to the relay target. + /// + /// The message to send. + /// A representing the asynchronous operation. + Task SendMessageAsync(RelayedMessage message); +} diff --git a/VPLink/Services/IVirtualParadiseMessageService.cs b/VPLink/Services/IVirtualParadiseMessageService.cs new file mode 100644 index 0000000..a08ed6a --- /dev/null +++ b/VPLink/Services/IVirtualParadiseMessageService.cs @@ -0,0 +1,17 @@ +using VPLink.Data; + +namespace VPLink.Services; + +/// +/// Represents a service that listens for messages from the Virtual Paradise world. +/// +public interface IVirtualParadiseMessageService : IRelayTarget +{ + /// + /// Gets an observable that is triggered when a valid message is received from the Virtual Paradise world. + /// + /// + /// An observable that is triggered when a valid message is received from the Virtual Paradise world. + /// + IObservable OnMessageReceived { get; } +} \ No newline at end of file diff --git a/VPLink/Services/IVirtualParadiseService.cs b/VPLink/Services/IVirtualParadiseService.cs deleted file mode 100644 index 1668fd6..0000000 --- a/VPLink/Services/IVirtualParadiseService.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Discord; -using VpSharp.Entities; - -namespace VPLink.Services; - -/// -/// Represents a service that sends messages to the Virtual Paradise world server. -/// -public interface IVirtualParadiseService -{ - /// - /// Gets an observable that is triggered when a message is received from the Virtual Paradise world server. - /// - /// - /// An observable that is triggered when a message is received from the Virtual Paradise world server. - /// - IObservable OnMessageReceived { get; } - - /// - /// Sends a message to the Virtual Paradise world server. - /// - /// The Discord message to send. - /// A representing the asynchronous operation. - Task SendMessageAsync(IUserMessage message); -} diff --git a/VPLink/Services/RelayService.cs b/VPLink/Services/RelayService.cs index dd23d7f..791e640 100644 --- a/VPLink/Services/RelayService.cs +++ b/VPLink/Services/RelayService.cs @@ -1,8 +1,5 @@ -using System.Reactive.Linq; -using Discord.WebSocket; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using VpSharp; using VpSharp.Extensions; namespace VPLink.Services; @@ -10,11 +7,9 @@ namespace VPLink.Services; internal sealed class RelayService : BackgroundService { private readonly ILogger _logger; - private readonly IDiscordService _discordService; private readonly IAvatarService _avatarService; - private readonly IVirtualParadiseService _virtualParadiseService; - private readonly DiscordSocketClient _discordClient; - private readonly VirtualParadiseClient _virtualParadiseClient; + private readonly IDiscordMessageService _discordService; + private readonly IVirtualParadiseMessageService _virtualParadiseService; /// /// Initializes a new instance of the class. @@ -23,21 +18,15 @@ internal sealed class RelayService : BackgroundService /// The Discord service. /// The avatar service. /// The Virtual Paradise service. - /// The Discord client. - /// The Virtual Paradise client. public RelayService(ILogger logger, - IDiscordService discordService, IAvatarService avatarService, - IVirtualParadiseService virtualParadiseService, - DiscordSocketClient discordClient, - VirtualParadiseClient virtualParadiseClient) + IDiscordMessageService discordService, + IVirtualParadiseMessageService virtualParadiseService) { _logger = logger; _discordService = discordService; _avatarService = avatarService; _virtualParadiseService = virtualParadiseService; - _discordClient = discordClient; - _virtualParadiseClient = virtualParadiseClient; } /// @@ -45,16 +34,10 @@ internal sealed class RelayService : BackgroundService { _logger.LogInformation("Establishing relay"); - _discordService.OnMessageReceived - .Where(m => m.Author != _discordClient.CurrentUser) - .SubscribeAsync(_virtualParadiseService.SendMessageAsync); - _avatarService.OnAvatarJoined.SubscribeAsync(_discordService.AnnounceArrival); _avatarService.OnAvatarLeft.SubscribeAsync(_discordService.AnnounceDeparture); - - _virtualParadiseService.OnMessageReceived - .Where(m => m.Author != _virtualParadiseClient.CurrentAvatar) - .SubscribeAsync(_discordService.SendMessageAsync); + _discordService.OnMessageReceived.SubscribeAsync(_virtualParadiseService.SendMessageAsync); + _virtualParadiseService.OnMessageReceived.SubscribeAsync(_discordService.SendMessageAsync); return Task.CompletedTask; } diff --git a/VPLink/Services/VirtualParadiseMessageService.cs b/VPLink/Services/VirtualParadiseMessageService.cs new file mode 100644 index 0000000..3dd4243 --- /dev/null +++ b/VPLink/Services/VirtualParadiseMessageService.cs @@ -0,0 +1,69 @@ +using System.Drawing; +using System.Reactive.Linq; +using System.Reactive.Subjects; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using VPLink.Configuration; +using VPLink.Data; +using VpSharp; +using VpSharp.Entities; +using FontStyle = VpSharp.FontStyle; + +namespace VPLink.Services; + +/// +internal sealed class VirtualParadiseMessageService : BackgroundService, IVirtualParadiseMessageService +{ + private readonly ILogger _logger; + private readonly IConfigurationService _configurationService; + private readonly VirtualParadiseClient _virtualParadiseClient; + private readonly Subject _messageReceived = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The configuration service. + /// The Virtual Paradise client. + public VirtualParadiseMessageService(ILogger logger, + IConfigurationService configurationService, + VirtualParadiseClient virtualParadiseClient) + { + _logger = logger; + _configurationService = configurationService; + _virtualParadiseClient = virtualParadiseClient; + } + + /// + public IObservable OnMessageReceived => _messageReceived.AsObservable(); + + /// + public Task SendMessageAsync(RelayedMessage message) + { + ChatConfiguration configuration = _configurationService.VirtualParadiseConfiguration.ChatConfiguration; + + Color color = Color.FromArgb((int)configuration.Color); + FontStyle style = configuration.Style; + return _virtualParadiseClient.SendMessageAsync(message.Author, message.Content, style, color); + } + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + _virtualParadiseClient.MessageReceived.Subscribe(OnVPMessageReceived); + return Task.CompletedTask; + } + + private void OnVPMessageReceived(VirtualParadiseMessage message) + { + if (message is null) throw new ArgumentNullException(nameof(message)); + if (message.Type != MessageType.ChatMessage) return; + if (message.Author == _virtualParadiseClient.CurrentAvatar) return; + if (message.Author.IsBot && !_configurationService.BotConfiguration.RelayBotMessages) return; + + _logger.LogInformation("Message by {Author}: {Content}", message.Author, message.Content); + + var relayedMessage = new RelayedMessage(message.Author.Name, message.Content); + _messageReceived.OnNext(relayedMessage); + } +} diff --git a/VPLink/Services/VirtualParadiseService.cs b/VPLink/Services/VirtualParadiseService.cs index bc3365f..dc15115 100644 --- a/VPLink/Services/VirtualParadiseService.cs +++ b/VPLink/Services/VirtualParadiseService.cs @@ -1,17 +1,13 @@ -using System.Reactive.Linq; using System.Reactive.Subjects; -using Discord; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using VpSharp; using VpSharp.Entities; -using Color = System.Drawing.Color; using VirtualParadiseConfiguration = VPLink.Configuration.VirtualParadiseConfiguration; namespace VPLink.Services; -/// -internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadiseService +internal sealed class VirtualParadiseService : BackgroundService { private readonly ILogger _logger; private readonly IConfigurationService _configurationService; @@ -33,28 +29,6 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi _virtualParadiseClient = virtualParadiseClient; } - /// - public IObservable OnMessageReceived => _messageReceived.AsObservable(); - - /// - public Task SendMessageAsync(IUserMessage message) - { - if (message is null) throw new ArgumentNullException(nameof(message)); - if (string.IsNullOrWhiteSpace(message.Content)) return Task.CompletedTask; - - if (message.Author.IsBot && !_configurationService.BotConfiguration.RelayBotMessages) - { - _logger.LogDebug("Bot messages are disabled, ignoring message"); - return Task.CompletedTask; - } - - _logger.LogInformation("Message by {Author}: {Content}", message.Author, message.Content); - - string displayName = message.Author.GlobalName ?? message.Author.Username; - return _virtualParadiseClient.SendMessageAsync(displayName, message.Content, FontStyle.Bold, - Color.MidnightBlue); - } - /// protected override async Task ExecuteAsync(CancellationToken stoppingToken) { From a6584594c9714142f75f9d06c7d488f9503f1871 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 26 Aug 2023 14:16:24 +0100 Subject: [PATCH 09/13] fix: rename property to match config section --- VPLink/Configuration/VirtualParadiseConfiguration.cs | 2 +- VPLink/Services/VirtualParadiseMessageService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VPLink/Configuration/VirtualParadiseConfiguration.cs b/VPLink/Configuration/VirtualParadiseConfiguration.cs index c402a73..53b8f09 100644 --- a/VPLink/Configuration/VirtualParadiseConfiguration.cs +++ b/VPLink/Configuration/VirtualParadiseConfiguration.cs @@ -15,7 +15,7 @@ public sealed class VirtualParadiseConfiguration /// Gets or sets the chat configuration. /// /// The chat configuration. - public ChatConfiguration ChatConfiguration { get; } = new(); + public ChatConfiguration Chat { get; } = new(); /// /// Gets or sets the password with which to log in to Virtual Paradise. diff --git a/VPLink/Services/VirtualParadiseMessageService.cs b/VPLink/Services/VirtualParadiseMessageService.cs index 3dd4243..cec294f 100644 --- a/VPLink/Services/VirtualParadiseMessageService.cs +++ b/VPLink/Services/VirtualParadiseMessageService.cs @@ -40,7 +40,7 @@ internal sealed class VirtualParadiseMessageService : BackgroundService, IVirtua /// public Task SendMessageAsync(RelayedMessage message) { - ChatConfiguration configuration = _configurationService.VirtualParadiseConfiguration.ChatConfiguration; + ChatConfiguration configuration = _configurationService.VirtualParadiseConfiguration.Chat; Color color = Color.FromArgb((int)configuration.Color); FontStyle style = configuration.Style; From 90a2501965f607a3456c7383250572db8e2638c7 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 26 Aug 2023 14:20:22 +0100 Subject: [PATCH 10/13] docs: add contrib guidelines --- CONTRIBUTING.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5aae0b4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +## How you can contribute + +Contributions to this project are always welcome. If you spot a bug, or want to request a new extension method, open a new issue +or submit a pull request. + +### Pull request guidelines + +This project uses C# 11.0 language features where feasible, and adheres to StyleCop rules with some minor adjustments. +There is an `.editorconfig` included in this repository. For quick and painless pull requests, ensure that the analyzer does not +throw warnings. + +Please ensure that you follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) +specification, as the GitHub release for this project is automatically generated from the commit history, and formatted using the +convetional commits specification. + +### Code style + +Below are a few pointers to which you may refer, but keep in mind this is not an exhaustive list: + +- Use C# 11.0 features where possible +- Try to ensure code is CLS-compliant. Where this is not possible, decorate methods with `CLSCompliantAttribute` and pass `false` +- Follow all .NET guidelines and coding conventions. + See https://docs.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions + and https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines/ +- Make full use of XMLDoc and be thorough - but concise - with all documentation +- Ensure that no line exceeds 130 characters in length +- Do NOT include file headers in any form +- Declare `using` directives outside of namespace scope +- Avoid using exceptions for flow control where possible +- Use braces, even for single-statement bodies +- Use implicit type when the type is apparent +- Use explicit type otherwise +- Use U.S. English throughout the codebase and documentation + +When in doubt, follow .NET guidelines. + +### Tests + +When introducing a new extension method, you must ensure that you have also defined a unit test that asserts its correct behavior. +The code style guidelines and code-analysis rules apply to the `X10D.Tests` as much as `X10D`, although documentation may +be briefer. Refer to existing tests as a guideline. + +### Disclaimer + +In the event of a code style violation, a pull request may be left open (or closed entirely) without merging. Keep in mind this does +not mean the theory or implementation of the method is inherently bad or rejected entirely (although if this is the case, it will +be outlined) From 9272894e990354216dc05f8ba39c192f64618fd4 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 26 Aug 2023 14:20:29 +0100 Subject: [PATCH 11/13] docs: add MIT license --- LICENSE.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE.md diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..69ec057 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Oliver Booth + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 16a0e3dd85edc851b2a4abac50a45837e8cb4db0 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sat, 26 Aug 2023 14:39:08 +0100 Subject: [PATCH 12/13] docs: add a more fleshed-out README --- README.md | 39 +++++++++++++++++++++++++++++++++++++-- VPLink.sln | 17 +++++++++++++++++ banner.png | Bin 0 -> 24951 bytes config.example.toml | 18 ++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 banner.png create mode 100644 config.example.toml diff --git a/README.md b/README.md index 383d8c3..126a22a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,38 @@ -# VPLink +

+ +GitHub Workflow Status +GitHub Issues +GitHub release +MIT License +

-VPLink is a simple bot for both Discord and Virtual Paradise which bridges chat messages from a designated Discord channel, to a world in Virtual Paradise. \ No newline at end of file +### About +VPLink is a simple and customisable bot for both Discord and Virtual Paradise, which bridges chat messages between a +designated Discord channel, and the world where the bot is running. It is written in C# and uses the Discord.NET +library, as well as a [wrapper for the Virtual Paradise SDK](https://github.com/oliverbooth/VpSharp) that I wrote +myself. + +## Installation +### Prerequisites + +- [Visual Studio 2022](https://visualstudio.microsoft.com/vs/) (or later) +- [.NET 7.0 SDK](https://dotnet.microsoft.com/download/dotnet/7.0) (or later) +- A [Virtual Paradise](https://www.virtualparadise.org/) user account +- A [Discord](https://discord.com/) user account, and a bot application + +### Setup (docker-compose) + +1. Clone the repository to your local machine. +2. Edit the `docker-compose.yml` file to your needs, including validating the mount paths. +3. /app/data (relative to the container) must contain a config.toml file with the fields populated, see the + [example config file](config.example.toml) for the available fields. +4. Run `docker-compose up -d` to start the bot. + +### Contributing + +Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. +Please see the [contributing guidelines](CONTRIBUTING.md) for more information. + +### License + +VPlink is licensed under the [MIT License](LICENSE.md). See the LICENSE.md file for more information. \ No newline at end of file diff --git a/VPLink.sln b/VPLink.sln index fd2e270..104038c 100644 --- a/VPLink.sln +++ b/VPLink.sln @@ -2,6 +2,23 @@ Microsoft Visual Studio Solution File, Format Version 12.00 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VPLink", "VPLink\VPLink.csproj", "{CD488A1E-0232-4EB5-A381-38A42B267B11}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B1080228-7E6C-48CB-976B-21EF7F5DBC33}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + CONTRIBUTING.md = CONTRIBUTING.md + LICENSE.md = LICENSE.md + .github\CODE_OF_CONDUCT.md = .github\CODE_OF_CONDUCT.md + .github\FUNDING.yml = .github\FUNDING.yml + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Workflows", "Workflows", "{BBE9420C-B35B-48C4-AF9B-53B0ED50E46B}" + ProjectSection(SolutionItems) = preProject + .github\workflows\docker-release.yml = .github\workflows\docker-release.yml + .github\workflows\dotnet.yml = .github\workflows\dotnet.yml + .github\workflows\prerelease.yml = .github\workflows\prerelease.yml + .github\workflows\release.yml = .github\workflows\release.yml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/banner.png b/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..bb04f2c94acd18f0b83acd2d47e1cf30a3b5718b GIT binary patch literal 24951 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 Date: Sat, 26 Aug 2023 14:43:51 +0100 Subject: [PATCH 13/13] fix: fix attachment links (trim final content) --- VPLink/Services/DiscordMessageService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/VPLink/Services/DiscordMessageService.cs b/VPLink/Services/DiscordMessageService.cs index 11c78bb..5d34ec9 100644 --- a/VPLink/Services/DiscordMessageService.cs +++ b/VPLink/Services/DiscordMessageService.cs @@ -119,6 +119,7 @@ internal sealed partial class DiscordMessageService : BackgroundService, IDiscor content = $"{content}\n{builder}"; } + content = content.Trim(); _logger.LogInformation("Message by {Author}: {Content}", author, content); Span buffer = stackalloc byte[255]; // VP message length limit