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) 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. diff --git a/README.md b/README.md index 8f98b30..126a22a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,38 @@ -# VpBridge +

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

-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 +### 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 new file mode 100644 index 0000000..104038c --- /dev/null +++ b/VPLink.sln @@ -0,0 +1,33 @@ + +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 + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CD488A1E-0232-4EB5-A381-38A42B267B11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD488A1E-0232-4EB5-A381-38A42B267B11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD488A1E-0232-4EB5-A381-38A42B267B11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD488A1E-0232-4EB5-A381-38A42B267B11}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/VPLink/Commands/WhoCommand.cs b/VPLink/Commands/WhoCommand.cs new file mode 100644 index 0000000..2f23e63 --- /dev/null +++ b/VPLink/Commands/WhoCommand.cs @@ -0,0 +1,73 @@ +using Cysharp.Text; +using Discord; +using Discord.Interactions; +using VpSharp; +using VpSharp.Entities; + +namespace VPLink.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/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/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/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..53b8f09 --- /dev/null +++ b/VPLink/Configuration/VirtualParadiseConfiguration.cs @@ -0,0 +1,37 @@ +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 chat configuration. + /// + /// The chat configuration. + public ChatConfiguration Chat { get; } = new(); + + /// + /// 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/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/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 56% rename from VpBridge/Program.cs rename to VPLink/Program.cs index 49e4679..48613e0 100644 --- a/VpBridge/Program.cs +++ b/VPLink/Program.cs @@ -1,11 +1,12 @@ -using Discord; +using Discord; +using Discord.Interactions; using Discord.WebSocket; using Microsoft.Extensions.DependencyInjection; 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; @@ -24,13 +25,21 @@ 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(); +builder.Services.AddSingleton(new DiscordSocketConfig { GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent -})); +}); -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/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/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/DiscordMessageService.cs b/VPLink/Services/DiscordMessageService.cs new file mode 100644 index 0000000..5d34ec9 --- /dev/null +++ b/VPLink/Services/DiscordMessageService.cs @@ -0,0 +1,163 @@ +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}"; + } + + content = content.Trim(); + _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 new file mode 100644 index 0000000..e01f909 --- /dev/null +++ b/VPLink/Services/DiscordService.cs @@ -0,0 +1,85 @@ +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using VPLink.Commands; +using VPLink.Configuration; + +namespace VPLink.Services; + +internal sealed class DiscordService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IServiceProvider _serviceProvider; + private readonly IConfigurationService _configurationService; + private readonly InteractionService _interactionService; + private readonly DiscordSocketClient _discordClient; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The service provider. + /// The configuration service. + /// The interaction service. + /// The Discord client. + public DiscordService(ILogger logger, + IServiceProvider serviceProvider, + IConfigurationService configurationService, + InteractionService interactionService, + DiscordSocketClient discordClient) + { + _logger = logger; + _serviceProvider = serviceProvider; + _configurationService = configurationService; + _interactionService = interactionService; + _discordClient = discordClient; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Establishing relay"); + + _logger.LogInformation("Adding command modules"); + await _interactionService.AddModuleAsync(_serviceProvider).ConfigureAwait(false); + + _discordClient.Ready += OnReady; + _discordClient.InteractionCreated += OnInteractionCreated; + + 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); + await _discordClient.StartAsync(); + } + + 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(); + } +} 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/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/IDiscordMessageService.cs b/VPLink/Services/IDiscordMessageService.cs new file mode 100644 index 0000000..b338686 --- /dev/null +++ b/VPLink/Services/IDiscordMessageService.cs @@ -0,0 +1,32 @@ +using VPLink.Data; +using VpSharp.Entities; + +namespace VPLink.Services; + +/// +/// Represents a service that listens for messages from the Discord bridge channel. +/// +public interface IDiscordMessageService : IRelayTarget +{ + /// + /// Gets an observable that is triggered when a valid message is received from the Discord bridge channel. + /// + /// + /// 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. + /// + /// 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); +} 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/RelayService.cs b/VPLink/Services/RelayService.cs new file mode 100644 index 0000000..791e640 --- /dev/null +++ b/VPLink/Services/RelayService.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using VpSharp.Extensions; + +namespace VPLink.Services; + +internal sealed class RelayService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IAvatarService _avatarService; + private readonly IDiscordMessageService _discordService; + private readonly IVirtualParadiseMessageService _virtualParadiseService; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The Discord service. + /// The avatar service. + /// The Virtual Paradise service. + public RelayService(ILogger logger, + IAvatarService avatarService, + IDiscordMessageService discordService, + IVirtualParadiseMessageService virtualParadiseService) + { + _logger = logger; + _discordService = discordService; + _avatarService = avatarService; + _virtualParadiseService = virtualParadiseService; + } + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Establishing relay"); + + _avatarService.OnAvatarJoined.SubscribeAsync(_discordService.AnnounceArrival); + _avatarService.OnAvatarLeft.SubscribeAsync(_discordService.AnnounceDeparture); + _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..cec294f --- /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.Chat; + + 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 new file mode 100644 index 0000000..dc15115 --- /dev/null +++ b/VPLink/Services/VirtualParadiseService.cs @@ -0,0 +1,52 @@ +using System.Reactive.Subjects; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using VpSharp; +using VpSharp.Entities; +using VirtualParadiseConfiguration = VPLink.Configuration.VirtualParadiseConfiguration; + +namespace VPLink.Services; + +internal sealed class VirtualParadiseService : BackgroundService +{ + 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 VirtualParadiseService(ILogger logger, + IConfigurationService configurationService, + VirtualParadiseClient virtualParadiseClient) + { + _logger = logger; + _configurationService = configurationService; + _virtualParadiseClient = virtualParadiseClient; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Establishing relay"); + _virtualParadiseClient.MessageReceived.Subscribe(_messageReceived); + + 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); + await _virtualParadiseClient.LoginAsync(username, password, botName).ConfigureAwait(false); + + _logger.LogInformation("Entering world {World}", world); + await _virtualParadiseClient.EnterAsync(world).ConfigureAwait(false); + } +} diff --git a/VpBridge/VpBridge.csproj b/VPLink/VPLink.csproj similarity index 95% rename from VpBridge/VpBridge.csproj rename to VPLink/VPLink.csproj index 6141c23..4f4a5ac 100644 --- a/VpBridge/VpBridge.csproj +++ b/VPLink/VPLink.csproj @@ -9,7 +9,7 @@ Oliver Booth https://github.com/oliverbooth/VpBridge git - 1.1.0 + 1.2.0 @@ -52,6 +52,7 @@ + diff --git a/VpBridge.sln b/VpBridge.sln deleted file mode 100644 index fddd12c..0000000 --- a/VpBridge.sln +++ /dev/null @@ -1,16 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VpBridge", "VpBridge\VpBridge.csproj", "{CD488A1E-0232-4EB5-A381-38A42B267B11}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {CD488A1E-0232-4EB5-A381-38A42B267B11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CD488A1E-0232-4EB5-A381-38A42B267B11}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CD488A1E-0232-4EB5-A381-38A42B267B11}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CD488A1E-0232-4EB5-A381-38A42B267B11}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal 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/VpBridge/Services/DiscordService.cs b/VpBridge/Services/DiscordService.cs deleted file mode 100644 index f75c9c0..0000000 --- a/VpBridge/Services/DiscordService.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System.Reactive.Linq; -using System.Reactive.Subjects; -using System.Text; -using System.Text.RegularExpressions; -using Discord; -using Discord.WebSocket; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using VpSharp; -using VpSharp.Entities; - -namespace VpBridge.Services; - -/// -internal sealed partial class DiscordService : BackgroundService, IDiscordService -{ - private static readonly Regex UnescapeRegex = GetUnescapeRegex(); - private static readonly Regex EscapeRegex = GetEscapeRegex(); - - private readonly ILogger _logger; - private readonly IConfiguration _configuration; - private readonly DiscordSocketClient _discordClient; - private readonly VirtualParadiseClient _virtualParadiseClient; - private readonly Subject _messageReceived = new(); - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The configuration. - /// The Discord client. - /// The Virtual Paradise client. - public DiscordService(ILogger logger, - IConfiguration configuration, - DiscordSocketClient discordClient, - VirtualParadiseClient virtualParadiseClient) - { - _logger = logger; - _configuration = configuration; - _discordClient = discordClient; - _virtualParadiseClient = virtualParadiseClient; - } - - /// - public IObservable OnMessageReceived => _messageReceived.AsObservable(); - - /// - 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; - - 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; - }; - - string token = _configuration.GetSection("Discord:Token").Value ?? - throw new InvalidOperationException("Token is not set."); - - _logger.LogDebug("Connecting to Discord"); - await _discordClient.LoginAsync(TokenType.Bot, token); - await _discordClient.StartAsync(); - } - - /// - 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 && !_configuration.GetSection("Bot:RelayBotMessages").Get()) - { - _logger.LogDebug("Bot messages are disabled, ignoring message"); - return Task.CompletedTask; - } - - _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; - } - - string unescaped = UnescapeRegex.Replace(message.Content, "$1"); - string escaped = EscapeRegex.Replace(unescaped, "\\$1"); - - string displayName = author.Name; - return channel.SendMessageAsync($"**{displayName}**: {escaped}"); - } - - [GeneratedRegex(@"\\(\*|_|`|~|\\)", RegexOptions.Compiled)] - private static partial Regex GetUnescapeRegex(); - - [GeneratedRegex(@"(\*|_|`|~|\\)", RegexOptions.Compiled)] - private static partial Regex GetEscapeRegex(); -} diff --git a/VpBridge/Services/IDiscordService.cs b/VpBridge/Services/IDiscordService.cs deleted file mode 100644 index e39c8f7..0000000 --- a/VpBridge/Services/IDiscordService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Discord; -using VpSharp.Entities; - -namespace VpBridge.Services; - -/// -/// Represents a service that sends messages to the Discord channel. -/// -public interface IDiscordService -{ - /// - /// Gets an observable that is triggered when a message is received from the Discord channel. - /// - /// An observable that is triggered when a message is received from the Discord channel. - IObservable OnMessageReceived { get; } - - /// - /// 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/VpBridge/Services/IVirtualParadiseService.cs b/VpBridge/Services/IVirtualParadiseService.cs deleted file mode 100644 index bf2ae01..0000000 --- a/VpBridge/Services/IVirtualParadiseService.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Discord; -using VpSharp.Entities; - -namespace VpBridge.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/VpBridge/Services/RelayService.cs b/VpBridge/Services/RelayService.cs deleted file mode 100644 index 57a0524..0000000 --- a/VpBridge/Services/RelayService.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Reactive.Linq; -using Discord.WebSocket; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using VpSharp; -using VpSharp.Extensions; - -namespace VpBridge.Services; - -internal sealed class RelayService : BackgroundService -{ - private readonly ILogger _logger; - private readonly IDiscordService _discordService; - private readonly IVirtualParadiseService _virtualParadiseService; - private readonly DiscordSocketClient _discordClient; - private readonly VirtualParadiseClient _virtualParadiseClient; - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The Discord service. - /// The Virtual Paradise service. - /// The Discord client. - /// The Virtual Paradise client. - public RelayService(ILogger logger, - IDiscordService discordService, - IVirtualParadiseService virtualParadiseService, - DiscordSocketClient discordClient, - VirtualParadiseClient virtualParadiseClient) - { - _logger = logger; - _discordService = discordService; - _virtualParadiseService = virtualParadiseService; - _discordClient = discordClient; - _virtualParadiseClient = virtualParadiseClient; - } - - /// - protected override Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("Establishing relay"); - - _discordService.OnMessageReceived - .Where(m => m.Author != _discordClient.CurrentUser) - .SubscribeAsync(_virtualParadiseService.SendMessageAsync); - - _virtualParadiseService.OnMessageReceived - .Where(m => m.Author != _virtualParadiseClient.CurrentAvatar) - .SubscribeAsync(_discordService.SendMessageAsync); - - return Task.CompletedTask; - } -} diff --git a/VpBridge/Services/VirtualParadiseService.cs b/VpBridge/Services/VirtualParadiseService.cs deleted file mode 100644 index 2407e67..0000000 --- a/VpBridge/Services/VirtualParadiseService.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Reactive.Linq; -using System.Reactive.Subjects; -using Discord; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using VpSharp; -using VpSharp.Entities; -using Color = System.Drawing.Color; - -namespace VpBridge.Services; - -/// -internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadiseService -{ - private readonly ILogger _logger; - private readonly IConfiguration _configuration; - private readonly VirtualParadiseClient _virtualParadiseClient; - private readonly Subject _messageReceived = new(); - - /// - /// Initializes a new instance of the class. - /// - /// The logger. - /// The configuration. - /// The Virtual Paradise client. - public VirtualParadiseService(ILogger logger, - IConfiguration configuration, - VirtualParadiseClient virtualParadiseClient) - { - _logger = logger; - _configuration = configuration; - _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 && !_configuration.GetSection("Bot:RelayBotMessages").Get()) - { - _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) - { - _logger.LogInformation("Establishing relay"); - _virtualParadiseClient.MessageReceived.Subscribe(_messageReceived); - - 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."); - - _logger.LogDebug("Connecting to Virtual Paradise"); - await _virtualParadiseClient.ConnectAsync().ConfigureAwait(false); - await _virtualParadiseClient.LoginAsync(username, password, botName).ConfigureAwait(false); - - _logger.LogInformation("Entering world {World}", world); - await _virtualParadiseClient.EnterAsync(world).ConfigureAwait(false); - } -} diff --git a/banner.png b/banner.png new file mode 100644 index 0000000..bb04f2c Binary files /dev/null and b/banner.png differ diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..c2bf0aa --- /dev/null +++ b/config.example.toml @@ -0,0 +1,18 @@ +[VirtualParadise] +BotName = "" +Username = "" +Password = "" +World = "" + +[VirtualParadise.Chat] +Color = 1644912 +Style = 0 + +[Discord] +Token = "" +ChannelId = 0 + +[Bot] +AnnounceAvatarEvents = true +AnnounceBots = false +RelayBotMessages = false 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