refactor: separate connection services and message services

This change also fixes long messages from Discord breaking the bot in VP, and allows customisation of console message style.
This commit is contained in:
Oliver Booth 2023-08-26 14:11:43 +01:00
parent 7d5eb0f2b2
commit 2d917bd3af
Signed by: oliverbooth
GPG Key ID: B89D139977693FED
13 changed files with 342 additions and 201 deletions

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

@ -11,6 +11,12 @@ public sealed class VirtualParadiseConfiguration
/// <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 ChatConfiguration { get; } = new();
/// <summary>
/// Gets or sets the password with which to log in to Virtual Paradise.
/// </summary>

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; }
}

View File

@ -35,8 +35,11 @@ builder.Services.AddSingleton(new DiscordSocketConfig
});
builder.Services.AddHostedSingleton<IAvatarService, AvatarService>();
builder.Services.AddHostedSingleton<IVirtualParadiseService, VirtualParadiseService>();
builder.Services.AddHostedSingleton<IDiscordService, DiscordService>();
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,162 @@
using System.Diagnostics.CodeAnalysis;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Text;
using System.Text.RegularExpressions;
using Cysharp.Text;
using Discord;
using Discord.WebSocket;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using VPLink.Configuration;
using VPLink.Data;
using VpSharp.Entities;
namespace VPLink.Services;
/// <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}";
}
_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

@ -1,7 +1,3 @@
using System.Diagnostics.CodeAnalysis;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Text.RegularExpressions;
using Discord;
using Discord.Interactions;
using Discord.WebSocket;
@ -9,22 +5,16 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using VPLink.Commands;
using VPLink.Configuration;
using VpSharp.Entities;
namespace VPLink.Services;
/// <inheritdoc cref="IDiscordService" />
internal sealed partial class DiscordService : BackgroundService, IDiscordService
internal sealed class DiscordService : BackgroundService
{
private static readonly Regex UnescapeRegex = GetUnescapeRegex();
private static readonly Regex EscapeRegex = GetEscapeRegex();
private readonly ILogger<DiscordService> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly IConfigurationService _configurationService;
private readonly InteractionService _interactionService;
private readonly DiscordSocketClient _discordClient;
private readonly Subject<IUserMessage> _messageReceived = new();
/// <summary>
/// Initializes a new instance of the <see cref="DiscordService" /> class.
@ -47,9 +37,6 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic
_discordClient = discordClient;
}
/// <inheritdoc />
public IObservable<IUserMessage> OnMessageReceived => _messageReceived.AsObservable();
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
@ -60,7 +47,6 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic
_discordClient.Ready += OnReady;
_discordClient.InteractionCreated += OnInteractionCreated;
_discordClient.MessageReceived += OnDiscordMessageReceived;
DiscordConfiguration configuration = _configurationService.DiscordConfiguration;
string token = configuration.Token ?? throw new InvalidOperationException("Token is not set.");
@ -70,19 +56,6 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic
await _discordClient.StartAsync();
}
private Task OnDiscordMessageReceived(SocketMessage arg)
{
if (arg is not IUserMessage message)
return Task.CompletedTask;
DiscordConfiguration configuration = _configurationService.DiscordConfiguration;
if (message.Channel.Id != configuration.ChannelId)
return Task.CompletedTask;
_messageReceived.OnNext(message);
return Task.CompletedTask;
}
private async Task OnInteractionCreated(SocketInteraction interaction)
{
try
@ -109,87 +82,4 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic
_logger.LogInformation("Discord client ready");
return _interactionService.RegisterCommandsGloballyAsync();
}
/// <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(VirtualParadiseMessage message)
{
if (message is null) throw new ArgumentNullException(nameof(message));
if (string.IsNullOrWhiteSpace(message.Content)) return Task.CompletedTask;
if (message.Author is not { } author)
{
_logger.LogWarning("Received message without author, ignoring message");
return Task.CompletedTask;
}
if (author.IsBot && !_configurationService.BotConfiguration.RelayBotMessages)
{
_logger.LogDebug("Bot messages are disabled, ignoring message");
return Task.CompletedTask;
}
_logger.LogInformation("Message by {Author}: {Content}", author, message.Content);
if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask;
string unescaped = UnescapeRegex.Replace(message.Content, "$1");
string escaped = EscapeRegex.Replace(unescaped, "\\$1");
string displayName = author.Name;
return channel.SendMessageAsync($"**{displayName}**: {escaped}");
}
private bool TryGetRelayChannel([NotNullWhen(true)] out ITextChannel? channel)
{
DiscordConfiguration configuration = _configurationService.DiscordConfiguration;
ulong channelId = configuration.ChannelId;
if (_discordClient.GetChannel(channelId) is ITextChannel textChannel)
{
channel = textChannel;
return true;
}
_logger.LogError("Channel {ChannelId} does not exist", channelId);
channel = null;
return false;
}
[GeneratedRegex(@"\\(\*|_|`|~|\\)", RegexOptions.Compiled)]
private static partial Regex GetUnescapeRegex();
[GeneratedRegex(@"(\*|_|`|~|\\)", RegexOptions.Compiled)]
private static partial Regex GetEscapeRegex();
}

View File

@ -1,18 +1,20 @@
using Discord;
using VPLink.Data;
using VpSharp.Entities;
namespace VPLink.Services;
/// <summary>
/// Represents a service that sends messages to the Discord channel.
/// Represents a service that listens for messages from the Discord bridge channel.
/// </summary>
public interface IDiscordService
public interface IDiscordMessageService : IRelayTarget
{
/// <summary>
/// Gets an observable that is triggered when a message is received from the Discord channel.
/// Gets an observable that is triggered when a valid message is received from the Discord bridge channel.
/// </summary>
/// <value>An observable that is triggered when a message is received from the Discord channel.</value>
IObservable<IUserMessage> OnMessageReceived { get; }
/// <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.
@ -27,11 +29,4 @@ public interface IDiscordService
/// <param name="avatar">The avatar.</param>
/// <returns>A <see cref="Task" /> representing the asynchronous operation.</returns>
Task AnnounceDeparture(VirtualParadiseAvatar avatar);
/// <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

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

@ -1,25 +0,0 @@
using Discord;
using VpSharp.Entities;
namespace VPLink.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,8 +1,5 @@
using System.Reactive.Linq;
using Discord.WebSocket;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using VpSharp;
using VpSharp.Extensions;
namespace VPLink.Services;
@ -10,11 +7,9 @@ namespace VPLink.Services;
internal sealed class RelayService : BackgroundService
{
private readonly ILogger<RelayService> _logger;
private readonly IDiscordService _discordService;
private readonly IAvatarService _avatarService;
private readonly IVirtualParadiseService _virtualParadiseService;
private readonly DiscordSocketClient _discordClient;
private readonly VirtualParadiseClient _virtualParadiseClient;
private readonly IDiscordMessageService _discordService;
private readonly IVirtualParadiseMessageService _virtualParadiseService;
/// <summary>
/// Initializes a new instance of the <see cref="RelayService" /> class.
@ -23,21 +18,15 @@ internal sealed class RelayService : BackgroundService
/// <param name="discordService">The Discord service.</param>
/// <param name="avatarService">The avatar 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,
IAvatarService avatarService,
IVirtualParadiseService virtualParadiseService,
DiscordSocketClient discordClient,
VirtualParadiseClient virtualParadiseClient)
IDiscordMessageService discordService,
IVirtualParadiseMessageService virtualParadiseService)
{
_logger = logger;
_discordService = discordService;
_avatarService = avatarService;
_virtualParadiseService = virtualParadiseService;
_discordClient = discordClient;
_virtualParadiseClient = virtualParadiseClient;
}
/// <inheritdoc />
@ -45,16 +34,10 @@ internal sealed class RelayService : BackgroundService
{
_logger.LogInformation("Establishing relay");
_discordService.OnMessageReceived
.Where(m => m.Author != _discordClient.CurrentUser)
.SubscribeAsync(_virtualParadiseService.SendMessageAsync);
_avatarService.OnAvatarJoined.SubscribeAsync(_discordService.AnnounceArrival);
_avatarService.OnAvatarLeft.SubscribeAsync(_discordService.AnnounceDeparture);
_virtualParadiseService.OnMessageReceived
.Where(m => m.Author != _virtualParadiseClient.CurrentAvatar)
.SubscribeAsync(_discordService.SendMessageAsync);
_discordService.OnMessageReceived.SubscribeAsync(_virtualParadiseService.SendMessageAsync);
_virtualParadiseService.OnMessageReceived.SubscribeAsync(_discordService.SendMessageAsync);
return Task.CompletedTask;
}

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.ChatConfiguration;
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

@ -1,17 +1,13 @@
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Discord;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using VpSharp;
using VpSharp.Entities;
using Color = System.Drawing.Color;
using VirtualParadiseConfiguration = VPLink.Configuration.VirtualParadiseConfiguration;
namespace VPLink.Services;
/// <inheritdoc cref="IVirtualParadiseService" />
internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadiseService
internal sealed class VirtualParadiseService : BackgroundService
{
private readonly ILogger<VirtualParadiseService> _logger;
private readonly IConfigurationService _configurationService;
@ -33,28 +29,6 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi
_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 && !_configurationService.BotConfiguration.RelayBotMessages)
{
_logger.LogDebug("Bot messages are disabled, ignoring message");
return Task.CompletedTask;
}
_logger.LogInformation("Message by {Author}: {Content}", message.Author, message.Content);
string displayName = message.Author.GlobalName ?? message.Author.Username;
return _virtualParadiseClient.SendMessageAsync(displayName, message.Content, FontStyle.Bold,
Color.MidnightBlue);
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{