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
+
+
+
+
+
+
+
-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