diff --git a/VPLink/Services/DiscordService.cs b/VPLink/Services/DiscordService.cs index 757a865..eec3982 100644 --- a/VPLink/Services/DiscordService.cs +++ b/VPLink/Services/DiscordService.cs @@ -1,6 +1,6 @@ +using System.Diagnostics.CodeAnalysis; using System.Reactive.Linq; using System.Reactive.Subjects; -using System.Text; using System.Text.RegularExpressions; using Discord; using Discord.Interactions; @@ -9,7 +9,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using VPLink.Commands; -using VpSharp; using VpSharp.Entities; namespace VPLink.Services; @@ -25,7 +24,6 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic private readonly IConfiguration _configuration; private readonly InteractionService _interactionService; private readonly DiscordSocketClient _discordClient; - private readonly VirtualParadiseClient _virtualParadiseClient; private readonly Subject _messageReceived = new(); /// @@ -36,20 +34,17 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic /// The configuration. /// The interaction service. /// The Discord client. - /// The Virtual Paradise client. public DiscordService(ILogger logger, IServiceProvider serviceProvider, IConfiguration configuration, InteractionService interactionService, - DiscordSocketClient discordClient, - VirtualParadiseClient virtualParadiseClient) + DiscordSocketClient discordClient) { _logger = logger; _serviceProvider = serviceProvider; _configuration = configuration; _interactionService = interactionService; _discordClient = discordClient; - _virtualParadiseClient = virtualParadiseClient; } /// @@ -114,6 +109,38 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic return _interactionService.RegisterCommandsGloballyAsync(); } + /// + public Task AnnounceArrival(VirtualParadiseAvatar avatar) + { + if (avatar is null) throw new ArgumentNullException(nameof(avatar)); + if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask; + + var embed = new EmbedBuilder(); + embed.WithColor(0x00FF00); + embed.WithTitle("📥 Avatar Joined"); + embed.WithDescription(avatar.Name); + embed.WithTimestamp(DateTimeOffset.UtcNow); + embed.WithFooter($"Session {avatar.Session}"); + + return channel.SendMessageAsync(embed: embed.Build()); + } + + /// + public Task AnnounceDeparture(VirtualParadiseAvatar avatar) + { + if (avatar is null) throw new ArgumentNullException(nameof(avatar)); + if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask; + + var embed = new EmbedBuilder(); + embed.WithColor(0xFF0000); + embed.WithTitle("📤 Avatar Left"); + embed.WithDescription(avatar.Name); + embed.WithTimestamp(DateTimeOffset.UtcNow); + embed.WithFooter($"Session {avatar.Session}"); + + return channel.SendMessageAsync(embed: embed.Build()); + } + /// public Task SendMessageAsync(VirtualParadiseMessage message) { @@ -134,12 +161,7 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic _logger.LogInformation("Message by {Author}: {Content}", author, message.Content); - var channelId = _configuration.GetSection("Discord:ChannelId").Get(); - if (_discordClient.GetChannel(channelId) is not ITextChannel channel) - { - _logger.LogError("Channel {ChannelId} does not exist", channelId); - return Task.CompletedTask; - } + if (!TryGetRelayChannel(out ITextChannel? channel)) return Task.CompletedTask; string unescaped = UnescapeRegex.Replace(message.Content, "$1"); string escaped = EscapeRegex.Replace(unescaped, "\\$1"); @@ -148,6 +170,20 @@ internal sealed partial class DiscordService : BackgroundService, IDiscordServic return channel.SendMessageAsync($"**{displayName}**: {escaped}"); } + private bool TryGetRelayChannel([NotNullWhen(true)] out ITextChannel? channel) + { + var channelId = _configuration.GetValue("Discord:ChannelId"); + if (_discordClient.GetChannel(channelId) is ITextChannel textChannel) + { + channel = textChannel; + return true; + } + + _logger.LogError("Channel {ChannelId} does not exist", channelId); + channel = null; + return false; + } + [GeneratedRegex(@"\\(\*|_|`|~|\\)", RegexOptions.Compiled)] private static partial Regex GetUnescapeRegex(); diff --git a/VPLink/Services/IDiscordService.cs b/VPLink/Services/IDiscordService.cs index 1683892..f442d1f 100644 --- a/VPLink/Services/IDiscordService.cs +++ b/VPLink/Services/IDiscordService.cs @@ -14,10 +14,24 @@ public interface IDiscordService /// An observable that is triggered when a message is received from the Discord channel. IObservable OnMessageReceived { get; } + /// + /// Announces the arrival of an avatar. + /// + /// The avatar. + /// A representing the asynchronous operation. + Task AnnounceArrival(VirtualParadiseAvatar avatar); + + /// + /// Announces the arrival of an avatar. + /// + /// The avatar. + /// A representing the asynchronous operation. + Task AnnounceDeparture(VirtualParadiseAvatar avatar); + /// /// Sends a message to the Discord channel. /// /// The message to send. /// A representing the asynchronous operation. Task SendMessageAsync(VirtualParadiseMessage message); -} \ No newline at end of file +} diff --git a/VPLink/Services/IVirtualParadiseService.cs b/VPLink/Services/IVirtualParadiseService.cs index 1668fd6..625d93a 100644 --- a/VPLink/Services/IVirtualParadiseService.cs +++ b/VPLink/Services/IVirtualParadiseService.cs @@ -8,6 +8,22 @@ namespace VPLink.Services; /// public interface IVirtualParadiseService { + /// + /// Gets an observable that is triggered when an avatar enters the Virtual Paradise world. + /// + /// + /// An observable that is triggered when an avatar enters the Virtual Paradise world. + /// + IObservable OnAvatarJoined { get; } + + /// + /// Gets an observable that is triggered when an avatar exits the Virtual Paradise world. + /// + /// + /// An observable that is triggered when an avatar exits the Virtual Paradise world. + /// + IObservable OnAvatarLeft { get; } + /// /// Gets an observable that is triggered when a message is received from the Virtual Paradise world server. /// diff --git a/VPLink/Services/RelayService.cs b/VPLink/Services/RelayService.cs index 1c6c3b0..02034f1 100644 --- a/VPLink/Services/RelayService.cs +++ b/VPLink/Services/RelayService.cs @@ -45,6 +45,9 @@ internal sealed class RelayService : BackgroundService .Where(m => m.Author != _discordClient.CurrentUser) .SubscribeAsync(_virtualParadiseService.SendMessageAsync); + _virtualParadiseService.OnAvatarJoined.SubscribeAsync(_discordService.AnnounceArrival); + _virtualParadiseService.OnAvatarLeft.SubscribeAsync(_discordService.AnnounceDeparture); + _virtualParadiseService.OnMessageReceived .Where(m => m.Author != _virtualParadiseClient.CurrentAvatar) .SubscribeAsync(_discordService.SendMessageAsync); diff --git a/VPLink/Services/VirtualParadiseService.cs b/VPLink/Services/VirtualParadiseService.cs index 53c1e1f..c459ffe 100644 --- a/VPLink/Services/VirtualParadiseService.cs +++ b/VPLink/Services/VirtualParadiseService.cs @@ -17,6 +17,8 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi private readonly IConfiguration _configuration; private readonly VirtualParadiseClient _virtualParadiseClient; private readonly Subject _messageReceived = new(); + private readonly Subject _avatarJoined = new(); + private readonly Subject _avatarLeft = new(); /// /// Initializes a new instance of the class. @@ -33,6 +35,12 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi _virtualParadiseClient = virtualParadiseClient; } + /// + public IObservable OnAvatarJoined => _avatarJoined.AsObservable(); + + /// + public IObservable OnAvatarLeft => _avatarJoined.AsObservable(); + /// public IObservable OnMessageReceived => _messageReceived.AsObservable(); @@ -59,6 +67,8 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi { _logger.LogInformation("Establishing relay"); _virtualParadiseClient.MessageReceived.Subscribe(_messageReceived); + _virtualParadiseClient.AvatarJoined.Subscribe(OnVirtualParadiseAvatarJoined); + _virtualParadiseClient.AvatarLeft.Subscribe(OnVirtualParadiseAvatarLeft); string username = _configuration.GetSection("VirtualParadise:Username").Value ?? throw new InvalidOperationException("Username is not set."); @@ -76,4 +86,38 @@ internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadi _logger.LogInformation("Entering world {World}", world); await _virtualParadiseClient.EnterAsync(world).ConfigureAwait(false); } + + private void OnVirtualParadiseAvatarJoined(VirtualParadiseAvatar avatar) + { + if (!_configuration.GetValue("Bot:AnnounceAvatarEvents")) + { + _logger.LogDebug("Join/leave events are disabled, ignoring event"); + return; + } + + if (avatar.IsBot && !_configuration.GetSection("Bot:AnnounceBots").Get()) + { + _logger.LogDebug("Bot events are disabled, ignoring event"); + return; + } + + _avatarJoined.OnNext(avatar); + } + + private void OnVirtualParadiseAvatarLeft(VirtualParadiseAvatar avatar) + { + if (!_configuration.GetValue("Bot:AnnounceAvatarEvents")) + { + _logger.LogDebug("Join/leave events are disabled, ignoring event"); + return; + } + + if (avatar.IsBot && !_configuration.GetSection("Bot:AnnounceBots").Get()) + { + _logger.LogDebug("Bot events are disabled, ignoring event"); + return; + } + + _avatarLeft.OnNext(avatar); + } }