Merge branch 'release/1.2.0' into main

This commit is contained in:
Oliver Booth 2023-08-26 14:44:05 +01:00
commit 0e527b8e05
Signed by: oliverbooth
GPG Key ID: B89D139977693FED
35 changed files with 1048 additions and 365 deletions

47
CONTRIBUTING.md Normal file
View File

@ -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)

21
LICENSE.md Normal file
View File

@ -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.

View File

@ -1,3 +1,38 @@
# VpBridge
<p align="center">
<img src="banner.png">
<a href="https://github.com/oliverbooth/VPLink/actions/workflows/dotnet.yml"><img src="https://img.shields.io/github/actions/workflow/status/oliverbooth/VPLink/dotnet.yml?style=flat-square" alt="GitHub Workflow Status" title="GitHub Workflow Status"></a>
<a href="https://github.com/oliverbooth/VPLink/issues"><img src="https://img.shields.io/github/issues/oliverbooth/VPLink?style=flat-square" alt="GitHub Issues" title="GitHub Issues"></a>
<a href="https://github.com/oliverbooth/VPLink/releases"><img alt="GitHub release" src="https://img.shields.io/github/v/release/oliverbooth/VPLink?style=flat-square"></a>
<a href="https://github.com/oliverbooth/VPLink/blob/master/LICENSE.md"><img src="https://img.shields.io/github/license/oliverbooth/VPLink?style=flat-square" alt="MIT License" title="MIT License"></a>
</p>
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.
### 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.

33
VPLink.sln Normal file
View File

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

View File

@ -0,0 +1,73 @@
using Cysharp.Text;
using Discord;
using Discord.Interactions;
using VpSharp;
using VpSharp.Entities;
namespace VPLink.Commands;
/// <summary>
/// Represents a class which implements the <c>who</c> command.
/// </summary>
internal sealed class WhoCommand : InteractionModuleBase<SocketInteractionContext>
{
private readonly VirtualParadiseClient _virtualParadiseClient;
/// <summary>
/// Initializes a new instance of the <see cref="WhoCommand" /> class.
/// </summary>
/// <param name="virtualParadiseClient">The Virtual Paradise client.</param>
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());
}
}

View File

@ -0,0 +1,33 @@
namespace VPLink.Configuration;
/// <summary>
/// Represents the bot configuration.
/// </summary>
public sealed class BotConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether the bot should announce avatar events.
/// </summary>
/// <value>
/// <see langword="true" /> if the bot should announce avatar events; otherwise, <see langword="false" />.
/// </value>
public bool AnnounceAvatarEvents { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether the bot should announce avatar events for bots.
/// </summary>
/// <value>
/// <see langword="true" /> if the bot should announce avatar events for bots; otherwise,
/// <see langword="false" />.
/// </value>
public bool AnnounceBots { get; set; } = false;
/// <summary>
/// Gets or sets a value indicating whether the bot should relay messages from other bots.
/// </summary>
/// <value>
/// <see langword="true" /> if the bot should relay messages from other bots; otherwise,
/// <see langword="false" />.
/// </value>
public bool RelayBotMessages { get; set; } = false;
}

View File

@ -0,0 +1,21 @@
using VpSharp;
namespace VPLink.Configuration;
/// <summary>
/// Represents the chat configuration.
/// </summary>
public sealed class ChatConfiguration
{
/// <summary>
/// Gets or sets the color of the message.
/// </summary>
/// <value>The message color.</value>
public uint Color { get; set; } = 0x191970;
/// <summary>
/// Gets or sets the font style of the message.
/// </summary>
/// <value>The font style.</value>
public FontStyle Style { get; set; } = FontStyle.Regular;
}

View File

@ -0,0 +1,19 @@
namespace VPLink.Configuration;
/// <summary>
/// Represents the Discord configuration.
/// </summary>
public sealed class DiscordConfiguration
{
/// <summary>
/// Gets or sets the channel ID to which the bot should relay messages.
/// </summary>
/// <value>The channel ID.</value>
public ulong ChannelId { get; set; }
/// <summary>
/// Gets or sets the Discord token.
/// </summary>
/// <value>The Discord token.</value>
public string Token { get; set; } = string.Empty;
}

View File

@ -0,0 +1,37 @@
namespace VPLink.Configuration;
/// <summary>
/// Represents the Virtual Paradise configuration.
/// </summary>
public sealed class VirtualParadiseConfiguration
{
/// <summary>
/// Gets or sets the display name of the bot.
/// </summary>
/// <value>The display name.</value>
public string BotName { get; set; } = "VPLink";
/// <summary>
/// Gets or sets the chat configuration.
/// </summary>
/// <value>The chat configuration.</value>
public ChatConfiguration Chat { get; } = new();
/// <summary>
/// Gets or sets the password with which to log in to Virtual Paradise.
/// </summary>
/// <value>The login password.</value>
public string Password { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the username with which to log in to Virtual Paradise.
/// </summary>
/// <value>The login username.</value>
public string Username { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the world into which the bot should enter.
/// </summary>
/// <value>The world to enter.</value>
public string World { get; set; } = string.Empty;
}

View File

@ -0,0 +1,30 @@
namespace VPLink.Data;
/// <summary>
/// Represents a message that is relayed between Discord and Virtual Paradise.
/// </summary>
public readonly struct RelayedMessage
{
/// <summary>
/// Initializes a new instance of the <see cref="RelayedMessage" /> struct.
/// </summary>
/// <param name="author">The author.</param>
/// <param name="content">The content.</param>
public RelayedMessage(string author, string content)
{
Author = author;
Content = content;
}
/// <summary>
/// Gets the message content.
/// </summary>
/// <value>The message content.</value>
public string Content { get; }
/// <summary>
/// Gets the user that sent the message.
/// </summary>
/// <value>The user that sent the message.</value>
public string Author { get; }
}

20
VPLink/Dockerfile Normal file
View File

@ -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"]

View File

@ -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<VirtualParadiseClient>();
builder.Services.AddSingleton(new DiscordSocketClient(new DiscordSocketConfig
builder.Services.AddSingleton<IConfigurationService, ConfigurationService>();
builder.Services.AddSingleton<InteractionService>();
builder.Services.AddSingleton<DiscordSocketClient>();
builder.Services.AddSingleton(new DiscordSocketConfig
{
GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent
}));
});
builder.Services.AddHostedSingleton<IVirtualParadiseService, VirtualParadiseService>();
builder.Services.AddHostedSingleton<IDiscordService, DiscordService>();
builder.Services.AddHostedSingleton<IAvatarService, AvatarService>();
builder.Services.AddHostedSingleton<IDiscordMessageService, DiscordMessageService>();
builder.Services.AddHostedSingleton<IVirtualParadiseMessageService, VirtualParadiseMessageService>();
builder.Services.AddHostedSingleton<DiscordService>();
builder.Services.AddHostedSingleton<VirtualParadiseService>();
builder.Services.AddHostedSingleton<RelayService>();
await builder.Build().RunAsync();

View File

@ -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;
/// <inheritdoc cref="IAvatarService" />
internal sealed class AvatarService : BackgroundService, IAvatarService
{
private readonly ILogger<AvatarService> _logger;
private readonly IConfigurationService _configurationService;
private readonly VirtualParadiseClient _virtualParadiseClient;
private readonly Subject<VirtualParadiseAvatar> _avatarJoined = new();
private readonly Subject<VirtualParadiseAvatar> _avatarLeft = new();
/// <summary>
/// Initializes a new instance of the <see cref="AvatarService" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="configurationService">The configuration service.</param>
/// <param name="virtualParadiseClient">The Virtual Paradise client.</param>
public AvatarService(ILogger<AvatarService> logger,
IConfigurationService configurationService,
VirtualParadiseClient virtualParadiseClient)
{
_logger = logger;
_configurationService = configurationService;
_virtualParadiseClient = virtualParadiseClient;
}
/// <inheritdoc />
public IObservable<VirtualParadiseAvatar> OnAvatarJoined => _avatarJoined.AsObservable();
/// <inheritdoc />
public IObservable<VirtualParadiseAvatar> OnAvatarLeft => _avatarLeft.AsObservable();
/// <inheritdoc />
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);
}
}

View File

@ -0,0 +1,37 @@
using Microsoft.Extensions.Configuration;
using VPLink.Configuration;
namespace VPLink.Services;
/// <inheritdoc cref="IConfigurationService" />
internal sealed class ConfigurationService : IConfigurationService
{
private readonly IConfiguration _configuration;
/// <summary>
/// Initializes a new instance of the <see cref="ConfigurationService" /> class.
/// </summary>
/// <param name="configuration"></param>
public ConfigurationService(IConfiguration configuration)
{
_configuration = configuration;
}
/// <inheritdoc />
public BotConfiguration BotConfiguration
{
get => _configuration.GetSection("Bot").Get<BotConfiguration>()!;
}
/// <inheritdoc />
public DiscordConfiguration DiscordConfiguration
{
get => _configuration.GetSection("Discord").Get<DiscordConfiguration>()!;
}
/// <inheritdoc />
public VirtualParadiseConfiguration VirtualParadiseConfiguration
{
get => _configuration.GetSection("VirtualParadise").Get<VirtualParadiseConfiguration>()!;
}
}

View File

@ -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;
/// <inheritdoc cref="IDiscordMessageService" />
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<DiscordMessageService> _logger;
private readonly IConfigurationService _configurationService;
private readonly DiscordSocketClient _discordClient;
private readonly Subject<RelayedMessage> _messageReceived = new();
/// <summary>
/// Initializes a new instance of the <see cref="DiscordMessageService" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="configurationService">The configuration service.</param>
/// <param name="discordClient">The Discord client.</param>
public DiscordMessageService(ILogger<DiscordMessageService> logger,
IConfigurationService configurationService,
DiscordSocketClient discordClient)
{
_logger = logger;
_configurationService = configurationService;
_discordClient = discordClient;
}
/// <inheritdoc />
public IObservable<RelayedMessage> OnMessageReceived => _messageReceived.AsObservable();
/// <inheritdoc />
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());
}
/// <inheritdoc />
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());
}
/// <inheritdoc />
public Task SendMessageAsync(RelayedMessage message)
{
if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask;
return channel.SendMessageAsync($"**{message.Author}**: {message.Content}");
}
/// <inheritdoc />
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<IAttachment> 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<byte> buffer = stackalloc byte[255]; // VP message length limit
var messages = new List<RelayedMessage>();
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();
}

View File

@ -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<DiscordService> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly IConfigurationService _configurationService;
private readonly InteractionService _interactionService;
private readonly DiscordSocketClient _discordClient;
/// <summary>
/// Initializes a new instance of the <see cref="DiscordService" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="serviceProvider">The service provider.</param>
/// <param name="configurationService">The configuration service.</param>
/// <param name="interactionService">The interaction service.</param>
/// <param name="discordClient">The Discord client.</param>
public DiscordService(ILogger<DiscordService> logger,
IServiceProvider serviceProvider,
IConfigurationService configurationService,
InteractionService interactionService,
DiscordSocketClient discordClient)
{
_logger = logger;
_serviceProvider = serviceProvider;
_configurationService = configurationService;
_interactionService = interactionService;
_discordClient = discordClient;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Establishing relay");
_logger.LogInformation("Adding command modules");
await _interactionService.AddModuleAsync<WhoCommand>(_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();
}
}

View File

@ -0,0 +1,25 @@
using VpSharp.Entities;
namespace VPLink.Services;
/// <summary>
/// Represents a service that listens for, and triggers, avatar events.
/// </summary>
public interface IAvatarService
{
/// <summary>
/// Gets an observable that is triggered when an avatar enters the Virtual Paradise world.
/// </summary>
/// <value>
/// An observable that is triggered when an avatar enters the Virtual Paradise world.
/// </value>
IObservable<VirtualParadiseAvatar> OnAvatarJoined { get; }
/// <summary>
/// Gets an observable that is triggered when an avatar exits the Virtual Paradise world.
/// </summary>
/// <value>
/// An observable that is triggered when an avatar exits the Virtual Paradise world.
/// </value>
IObservable<VirtualParadiseAvatar> OnAvatarLeft { get; }
}

View File

@ -0,0 +1,27 @@
using VPLink.Configuration;
namespace VPLink.Services;
/// <summary>
/// Represents the configuration service.
/// </summary>
public interface IConfigurationService
{
/// <summary>
/// Gets the bot configuration.
/// </summary>
/// <value>The bot configuration.</value>
BotConfiguration BotConfiguration { get; }
/// <summary>
/// Gets the Discord configuration.
/// </summary>
/// <value>The Discord configuration.</value>
DiscordConfiguration DiscordConfiguration { get; }
/// <summary>
/// Gets the Virtual Paradise configuration.
/// </summary>
/// <value>The Virtual Paradise configuration.</value>
VirtualParadiseConfiguration VirtualParadiseConfiguration { get; }
}

View File

@ -0,0 +1,32 @@
using VPLink.Data;
using VpSharp.Entities;
namespace VPLink.Services;
/// <summary>
/// Represents a service that listens for messages from the Discord bridge channel.
/// </summary>
public interface IDiscordMessageService : IRelayTarget
{
/// <summary>
/// Gets an observable that is triggered when a valid message is received from the Discord bridge channel.
/// </summary>
/// <value>
/// An observable that is triggered when a valid message is received from the Discord bridge channel.
/// </value>
IObservable<RelayedMessage> OnMessageReceived { get; }
/// <summary>
/// Announces the arrival of an avatar.
/// </summary>
/// <param name="avatar">The avatar.</param>
/// <returns>A <see cref="Task" /> representing the asynchronous operation.</returns>
Task AnnounceArrival(VirtualParadiseAvatar avatar);
/// <summary>
/// Announces the arrival of an avatar.
/// </summary>
/// <param name="avatar">The avatar.</param>
/// <returns>A <see cref="Task" /> representing the asynchronous operation.</returns>
Task AnnounceDeparture(VirtualParadiseAvatar avatar);
}

View File

@ -0,0 +1,16 @@
using VPLink.Data;
namespace VPLink.Services;
/// <summary>
/// Represents an object that can be used as a relay target.
/// </summary>
public interface IRelayTarget
{
/// <summary>
/// Sends a message to the relay target.
/// </summary>
/// <param name="message">The message to send.</param>
/// <returns>A <see cref="Task" /> representing the asynchronous operation.</returns>
Task SendMessageAsync(RelayedMessage message);
}

View File

@ -0,0 +1,17 @@
using VPLink.Data;
namespace VPLink.Services;
/// <summary>
/// Represents a service that listens for messages from the Virtual Paradise world.
/// </summary>
public interface IVirtualParadiseMessageService : IRelayTarget
{
/// <summary>
/// Gets an observable that is triggered when a valid message is received from the Virtual Paradise world.
/// </summary>
/// <value>
/// An observable that is triggered when a valid message is received from the Virtual Paradise world.
/// </value>
IObservable<RelayedMessage> OnMessageReceived { get; }
}

View File

@ -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<RelayService> _logger;
private readonly IAvatarService _avatarService;
private readonly IDiscordMessageService _discordService;
private readonly IVirtualParadiseMessageService _virtualParadiseService;
/// <summary>
/// Initializes a new instance of the <see cref="RelayService" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="discordService">The Discord service.</param>
/// <param name="avatarService">The avatar service.</param>
/// <param name="virtualParadiseService">The Virtual Paradise service.</param>
public RelayService(ILogger<RelayService> logger,
IAvatarService avatarService,
IDiscordMessageService discordService,
IVirtualParadiseMessageService virtualParadiseService)
{
_logger = logger;
_discordService = discordService;
_avatarService = avatarService;
_virtualParadiseService = virtualParadiseService;
}
/// <inheritdoc />
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;
}
}

View File

@ -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;
/// <inheritdoc cref="VPLink.Services.IVirtualParadiseMessageService" />
internal sealed class VirtualParadiseMessageService : BackgroundService, IVirtualParadiseMessageService
{
private readonly ILogger<VirtualParadiseMessageService> _logger;
private readonly IConfigurationService _configurationService;
private readonly VirtualParadiseClient _virtualParadiseClient;
private readonly Subject<RelayedMessage> _messageReceived = new();
/// <summary>
/// Initializes a new instance of the <see cref="VirtualParadiseMessageService" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="configurationService">The configuration service.</param>
/// <param name="virtualParadiseClient">The Virtual Paradise client.</param>
public VirtualParadiseMessageService(ILogger<VirtualParadiseMessageService> logger,
IConfigurationService configurationService,
VirtualParadiseClient virtualParadiseClient)
{
_logger = logger;
_configurationService = configurationService;
_virtualParadiseClient = virtualParadiseClient;
}
/// <inheritdoc />
public IObservable<RelayedMessage> OnMessageReceived => _messageReceived.AsObservable();
/// <inheritdoc />
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);
}
/// <inheritdoc />
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);
}
}

View File

@ -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<VirtualParadiseService> _logger;
private readonly IConfigurationService _configurationService;
private readonly VirtualParadiseClient _virtualParadiseClient;
private readonly Subject<VirtualParadiseMessage> _messageReceived = new();
/// <summary>
/// Initializes a new instance of the <see cref="VirtualParadiseService" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="configurationService">The configuration service.</param>
/// <param name="virtualParadiseClient">The Virtual Paradise client.</param>
public VirtualParadiseService(ILogger<VirtualParadiseService> logger,
IConfigurationService configurationService,
VirtualParadiseClient virtualParadiseClient)
{
_logger = logger;
_configurationService = configurationService;
_virtualParadiseClient = virtualParadiseClient;
}
/// <inheritdoc />
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);
}
}

View File

@ -9,7 +9,7 @@
<Authors>Oliver Booth</Authors>
<RepositoryUrl>https://github.com/oliverbooth/VpBridge</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<VersionPrefix>1.1.0</VersionPrefix>
<VersionPrefix>1.2.0</VersionPrefix>
</PropertyGroup>
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true'">
@ -52,6 +52,7 @@
<PackageReference Include="VpSharp" Version="0.1.0-nightly.43"/>
<PackageReference Include="X10D" Version="3.3.1"/>
<PackageReference Include="X10D.Hosting" Version="3.3.1"/>
<PackageReference Include="ZString" Version="2.5.0"/>
</ItemGroup>
</Project>

View File

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

View File

@ -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"]

View File

@ -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;
/// <inheritdoc cref="IDiscordService" />
internal sealed partial class DiscordService : BackgroundService, IDiscordService
{
private static readonly Regex UnescapeRegex = GetUnescapeRegex();
private static readonly Regex EscapeRegex = GetEscapeRegex();
private readonly ILogger<DiscordService> _logger;
private readonly IConfiguration _configuration;
private readonly DiscordSocketClient _discordClient;
private readonly VirtualParadiseClient _virtualParadiseClient;
private readonly Subject<IUserMessage> _messageReceived = new();
/// <summary>
/// Initializes a new instance of the <see cref="DiscordService" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="discordClient">The Discord client.</param>
/// <param name="virtualParadiseClient">The Virtual Paradise client.</param>
public DiscordService(ILogger<DiscordService> logger,
IConfiguration configuration,
DiscordSocketClient discordClient,
VirtualParadiseClient virtualParadiseClient)
{
_logger = logger;
_configuration = configuration;
_discordClient = discordClient;
_virtualParadiseClient = virtualParadiseClient;
}
/// <inheritdoc />
public IObservable<IUserMessage> OnMessageReceived => _messageReceived.AsObservable();
/// <inheritdoc />
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<ulong>())
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();
}
/// <inheritdoc />
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<bool>())
{
_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<ulong>();
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();
}

View File

@ -1,23 +0,0 @@
using Discord;
using VpSharp.Entities;
namespace VpBridge.Services;
/// <summary>
/// Represents a service that sends messages to the Discord channel.
/// </summary>
public interface IDiscordService
{
/// <summary>
/// Gets an observable that is triggered when a message is received from the Discord channel.
/// </summary>
/// <value>An observable that is triggered when a message is received from the Discord channel.</value>
IObservable<IUserMessage> OnMessageReceived { get; }
/// <summary>
/// Sends a message to the Discord channel.
/// </summary>
/// <param name="message">The message to send.</param>
/// <returns>A <see cref="Task" /> representing the asynchronous operation.</returns>
Task SendMessageAsync(VirtualParadiseMessage message);
}

View File

@ -1,25 +0,0 @@
using Discord;
using VpSharp.Entities;
namespace VpBridge.Services;
/// <summary>
/// Represents a service that sends messages to the Virtual Paradise world server.
/// </summary>
public interface IVirtualParadiseService
{
/// <summary>
/// Gets an observable that is triggered when a message is received from the Virtual Paradise world server.
/// </summary>
/// <value>
/// An observable that is triggered when a message is received from the Virtual Paradise world server.
/// </value>
IObservable<VirtualParadiseMessage> OnMessageReceived { get; }
/// <summary>
/// Sends a message to the Virtual Paradise world server.
/// </summary>
/// <param name="message">The Discord message to send.</param>
/// <returns>A <see cref="Task" /> representing the asynchronous operation.</returns>
Task SendMessageAsync(IUserMessage message);
}

View File

@ -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<RelayService> _logger;
private readonly IDiscordService _discordService;
private readonly IVirtualParadiseService _virtualParadiseService;
private readonly DiscordSocketClient _discordClient;
private readonly VirtualParadiseClient _virtualParadiseClient;
/// <summary>
/// Initializes a new instance of the <see cref="RelayService" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="discordService">The Discord service.</param>
/// <param name="virtualParadiseService">The Virtual Paradise service.</param>
/// <param name="discordClient">The Discord client.</param>
/// <param name="virtualParadiseClient">The Virtual Paradise client.</param>
public RelayService(ILogger<RelayService> logger,
IDiscordService discordService,
IVirtualParadiseService virtualParadiseService,
DiscordSocketClient discordClient,
VirtualParadiseClient virtualParadiseClient)
{
_logger = logger;
_discordService = discordService;
_virtualParadiseService = virtualParadiseService;
_discordClient = discordClient;
_virtualParadiseClient = virtualParadiseClient;
}
/// <inheritdoc />
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;
}
}

View File

@ -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;
/// <inheritdoc cref="IVirtualParadiseService" />
internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadiseService
{
private readonly ILogger<VirtualParadiseService> _logger;
private readonly IConfiguration _configuration;
private readonly VirtualParadiseClient _virtualParadiseClient;
private readonly Subject<VirtualParadiseMessage> _messageReceived = new();
/// <summary>
/// Initializes a new instance of the <see cref="VirtualParadiseService" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="virtualParadiseClient">The Virtual Paradise client.</param>
public VirtualParadiseService(ILogger<VirtualParadiseService> logger,
IConfiguration configuration,
VirtualParadiseClient virtualParadiseClient)
{
_logger = logger;
_configuration = configuration;
_virtualParadiseClient = virtualParadiseClient;
}
/// <inheritdoc />
public IObservable<VirtualParadiseMessage> OnMessageReceived => _messageReceived.AsObservable();
/// <inheritdoc />
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<bool>())
{
_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);
}
/// <inheritdoc />
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);
}
}

BIN
banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

18
config.example.toml Normal file
View File

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

View File

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