diff --git a/VPLink.Common/Configuration/IChatConfiguration.cs b/VPLink.Common/Configuration/IChatConfiguration.cs index 86351a5..4edf066 100644 --- a/VPLink.Common/Configuration/IChatConfiguration.cs +++ b/VPLink.Common/Configuration/IChatConfiguration.cs @@ -18,4 +18,16 @@ public interface IChatConfiguration /// /// The font style. FontStyle Style { get; set; } + + /// + /// Gets or sets the color of a reply message. + /// + /// The reply message color. + uint ReplyColor { get; set; } + + /// + /// Gets or sets the font style of a reply message. + /// + /// The reply font style. + FontStyle ReplyStyle { get; set; } } diff --git a/VPLink.Common/Data/PlainTextMessageBuilder.cs b/VPLink.Common/Data/PlainTextMessageBuilder.cs new file mode 100644 index 0000000..a1f5ff0 --- /dev/null +++ b/VPLink.Common/Data/PlainTextMessageBuilder.cs @@ -0,0 +1,107 @@ +using Cysharp.Text; +using Humanizer; + +namespace VPLink.Common.Data; + +/// +/// Represents a plain text message builder. +/// +public struct PlainTextMessageBuilder : IDisposable +{ + private Utf8ValueStringBuilder _builder; + + /// + /// Initializes a new instance of the struct. + /// + public PlainTextMessageBuilder() + { + _builder = ZString.CreateUtf8StringBuilder(); + } + + /// + /// Appends the specified word. + /// + /// The word. + /// The trailing whitespace trivia. + public void AddWord(ReadOnlySpan word, char whitespace = ' ') + { + _builder.Append(word); + if (whitespace != '\0') _builder.Append(whitespace); + } + + /// + /// Appends the specified word. + /// + /// The timestamp. + /// The format. + /// The trailing whitespace trivia. + public void AddTimestamp(DateTimeOffset timestamp, TimestampFormat format = TimestampFormat.None, + char whitespace = ' ') + { + switch (format) + { + case TimestampFormat.Relative: + AddWord(timestamp.Humanize(), whitespace); + break; + + case TimestampFormat.None: + AddWord(timestamp.ToString("d MMM yyyy HH:mm")); + AddWord("UTC", whitespace); + break; + + case TimestampFormat.LongDate: + AddWord(timestamp.ToString("dd MMMM yyyy")); + AddWord("UTC", whitespace); + break; + + case TimestampFormat.ShortDate: + AddWord(timestamp.ToString("dd/MM/yyyy")); + AddWord("UTC", whitespace); + break; + + case TimestampFormat.ShortTime: + AddWord(timestamp.ToString("HH:mm")); + AddWord("UTC", whitespace); + break; + + case TimestampFormat.LongTime: + AddWord(timestamp.ToString("HH:mm:ss")); + AddWord("UTC", whitespace); + break; + + case TimestampFormat.ShortDateTime: + AddWord(timestamp.ToString("dd MMMM yyyy HH:mm")); + AddWord("UTC", whitespace); + break; + + case TimestampFormat.LongDateTime: + AddWord(timestamp.ToString("dddd, dd MMMM yyyy HH:mm")); + AddWord("UTC", whitespace); + break; + + default: + AddWord($"", whitespace); + break; + } + } + + /// + /// Clears the builder. + /// + public void Clear() + { + _builder.Clear(); + } + + /// + public void Dispose() + { + _builder.Dispose(); + } + + /// + public override string ToString() + { + return _builder.ToString().Trim(); + } +} diff --git a/VPLink.Common/Data/RelayedMessage.cs b/VPLink.Common/Data/RelayedMessage.cs index b4f9886..281d96f 100644 --- a/VPLink.Common/Data/RelayedMessage.cs +++ b/VPLink.Common/Data/RelayedMessage.cs @@ -10,12 +10,20 @@ public readonly struct RelayedMessage /// /// The author. /// The content. - public RelayedMessage(string author, string content) + /// A value indicating whether this message is a reply. + public RelayedMessage(string? author, string content, bool isReply) { Author = author; Content = content; + IsReply = isReply; } + /// + /// Gets the user that sent the message. + /// + /// The user that sent the message. + public string? Author { get; } + /// /// Gets the message content. /// @@ -23,8 +31,8 @@ public readonly struct RelayedMessage public string Content { get; } /// - /// Gets the user that sent the message. + /// Gets a value indicating whether this message is a reply. /// - /// The user that sent the message. - public string Author { get; } + /// if this message is a reply; otherwise, . + public bool IsReply { get; } } diff --git a/VPLink.Common/Data/TimestampFormat.cs b/VPLink.Common/Data/TimestampFormat.cs new file mode 100644 index 0000000..f6091d4 --- /dev/null +++ b/VPLink.Common/Data/TimestampFormat.cs @@ -0,0 +1,13 @@ +namespace VPLink.Common.Data; + +public enum TimestampFormat +{ + None = '\0', + ShortTime = 't', + LongTime = 'T', + ShortDate = 'd', + LongDate = 'D', + ShortDateTime = 'f', + LongDateTime = 'F', + Relative = 'R' +} diff --git a/VPLink.Common/Extensions/UserExtensions.cs b/VPLink.Common/Extensions/UserExtensions.cs new file mode 100644 index 0000000..868beb0 --- /dev/null +++ b/VPLink.Common/Extensions/UserExtensions.cs @@ -0,0 +1,27 @@ +using Discord; + +namespace VPLink.Common.Extensions; + +/// +/// Provides extension methods for the interface. +/// +public static class UserExtensions +{ + /// + /// Gets the display name of the user. + /// + /// The user. + /// The display name. + /// is null. + public static string GetDisplayName(this IUser user) + { + string displayName = user switch + { + null => throw new ArgumentNullException(nameof(user)), + IGuildUser member => member.Nickname ?? member.GlobalName ?? member.Username, + _ => user.GlobalName ?? user.Username + }; + + return user.IsBot ? $"[{displayName}]" : displayName; + } +} diff --git a/VPLink.Common/MentionUtility.cs b/VPLink.Common/MentionUtility.cs new file mode 100644 index 0000000..5bc355d --- /dev/null +++ b/VPLink.Common/MentionUtility.cs @@ -0,0 +1,123 @@ +using System.Globalization; +using System.Text; +using Cysharp.Text; +using Discord; +using VPLink.Common.Data; + +namespace VPLink.Common; + +public static class MentionUtility +{ + public static void ParseTag(IGuild guild, + ReadOnlySpan contents, + ref PlainTextMessageBuilder builder, + char whitespaceTrivia) + { + if (contents[..2].Equals("@&", StringComparison.Ordinal)) // role mention + { + ParseRoleMention(guild, contents, ref builder, whitespaceTrivia); + } + else if (contents[..2].Equals("t:", StringComparison.Ordinal)) // timestamp + { + ParseTimestamp(contents, ref builder, whitespaceTrivia); + } + else + switch (contents[0]) + { + // user mention + case '@': + ParseUserMention(guild, contents, ref builder, whitespaceTrivia); + break; + + // channel mention + case '#': + ParseChannelMention(guild, contents, ref builder, whitespaceTrivia); + break; + + default: + builder.AddWord($"<{contents.ToString()}>", whitespaceTrivia); + break; + } + } + + private static void ParseChannelMention(IGuild guild, + ReadOnlySpan contents, + ref PlainTextMessageBuilder builder, + char whitespaceTrivia) + { + ulong channelId = ulong.Parse(contents[1..]); + ITextChannel? channel = guild.GetTextChannelAsync(channelId).GetAwaiter().GetResult(); + builder.AddWord(channel is null ? $"<{contents}>" : $"#{channel.Name}", whitespaceTrivia); + } + + private static void ParseRoleMention(IGuild guild, + ReadOnlySpan contents, + ref PlainTextMessageBuilder builder, + char whitespaceTrivia) + { + ulong roleId = ulong.Parse(contents[2..]); + IRole? role = guild.GetRole(roleId); + builder.AddWord(role is null ? $"<{contents}>" : $"@{role.Name}", whitespaceTrivia); + } + + private static void ParseUserMention(IGuild guild, + ReadOnlySpan contents, + ref PlainTextMessageBuilder builder, + char whitespaceTrivia) + { + ulong userId = ulong.Parse(contents[1..]); + IGuildUser? user = guild.GetUserAsync(userId).GetAwaiter().GetResult(); + builder.AddWord(user is null ? $"<{contents}>" : $"@{user.Nickname ?? user.GlobalName ?? user.Username}", + whitespaceTrivia); + } + + private static void ParseTimestamp(ReadOnlySpan contents, + ref PlainTextMessageBuilder builder, + char whitespaceTrivia) + { + using Utf8ValueStringBuilder buffer = ZString.CreateUtf8StringBuilder(); + var formatSpecifier = '\0'; + var isEscaped = false; + var breakLoop = false; + + for (var index = 2; index < contents.Length; index++) + { + if (breakLoop) + { + break; + } + + char current = contents[index]; + switch (current) + { + case '\\': + isEscaped = !isEscaped; + break; + + case ':' when !isEscaped && index + 1 < contents.Length: + formatSpecifier = contents[index + 1]; + if (formatSpecifier == '>') formatSpecifier = '\0'; // ignore closing tag + breakLoop = true; + break; + + case '>' when !isEscaped: + break; + + case var _ when char.IsDigit(current): + buffer.Append(current); + break; + + default: + return; + } + } + + ReadOnlySpan bytes = buffer.AsSpan(); + int charCount = Encoding.UTF8.GetCharCount(bytes); + Span chars = stackalloc char[charCount]; + Encoding.UTF8.GetChars(bytes, chars); + + DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(long.Parse(chars, CultureInfo.InvariantCulture)); + builder.AddTimestamp(timestamp, (TimestampFormat)formatSpecifier, whitespaceTrivia); + } +} diff --git a/VPLink.Common/VPLink.Common.csproj b/VPLink.Common/VPLink.Common.csproj index 4c50db9..d052804 100644 --- a/VPLink.Common/VPLink.Common.csproj +++ b/VPLink.Common/VPLink.Common.csproj @@ -7,6 +7,8 @@ + + diff --git a/VPLink/Commands/InfoCommand.cs b/VPLink/Commands/InfoCommand.cs new file mode 100644 index 0000000..6f34be0 --- /dev/null +++ b/VPLink/Commands/InfoCommand.cs @@ -0,0 +1,57 @@ +using System.Text; +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using VPLink.Services; + +namespace VPLink.Commands; + +/// +/// Represents a class which implements the info command. +/// +internal sealed class InfoCommand : InteractionModuleBase +{ + private readonly BotService _botService; + private readonly DiscordSocketClient _discordClient; + + /// + /// Initializes a new instance of the class. + /// + /// The bot service. + /// + public InfoCommand(BotService botService, DiscordSocketClient discordClient) + { + _botService = botService; + _discordClient = discordClient; + } + + [SlashCommand("info", "Displays information about the bot.")] + [RequireContext(ContextType.Guild)] + public async Task InfoAsync() + { + SocketGuildUser member = Context.Guild.GetUser(_discordClient.CurrentUser.Id); + string pencilVersion = _botService.Version; + + SocketRole? highestRole = member.Roles.Where(r => r.Color != Color.Default).MaxBy(r => r.Position); + + var embed = new EmbedBuilder(); + embed.WithAuthor(member); + embed.WithColor(highestRole?.Color ?? Color.Default); + embed.WithThumbnailUrl(member.GetAvatarUrl()); + embed.WithTitle($"VPLink v{pencilVersion}"); + embed.WithDescription("Created by <@94248427663130624>, hosted [on GitHub](https://github.com/oliverbooth/VPLink)."); + embed.AddField("Ping", $"{_discordClient.Latency} ms", true); + embed.AddField("Started", $"", true); + + var builder = new StringBuilder(); + builder.AppendLine($"VPLink: {pencilVersion}"); + builder.AppendLine($"Discord.Net: {_botService.DiscordNetVersion}"); + builder.AppendLine($"VP#: {_botService.VpSharpVersion}"); + builder.AppendLine($"CLR: {Environment.Version.ToString(3)}"); + builder.AppendLine($"Host: {Environment.OSVersion}"); + + embed.AddField("Version", $"```\n{builder}\n```"); + + await RespondAsync(embed: embed.Build(), ephemeral: true).ConfigureAwait(false); + } +} diff --git a/VPLink/Commands/WhoCommand.cs b/VPLink/Commands/WhoCommand.cs index 2f23e63..401a96c 100644 --- a/VPLink/Commands/WhoCommand.cs +++ b/VPLink/Commands/WhoCommand.cs @@ -41,12 +41,12 @@ internal sealed class WhoCommand : InteractionModuleBase public FontStyle Style { get; set; } = FontStyle.Regular; + + /// + public uint ReplyColor { get; set; } = 0x808080; + + /// + public FontStyle ReplyStyle { get; set; } = FontStyle.Italic; } diff --git a/VPLink/Program.cs b/VPLink/Program.cs index 2cb9a15..621a79f 100644 --- a/VPLink/Program.cs +++ b/VPLink/Program.cs @@ -25,6 +25,8 @@ builder.Configuration.AddTomlFile("data/config.toml", true, true); builder.Logging.ClearProviders(); builder.Logging.AddSerilog(); +builder.Services.AddHostedSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/VPLink/Services/AvatarService.cs b/VPLink/Services/AvatarService.cs index f2d5a80..8f90ef2 100644 --- a/VPLink/Services/AvatarService.cs +++ b/VPLink/Services/AvatarService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Reactive.Linq; using System.Reactive.Subjects; using Microsoft.Extensions.Hosting; @@ -17,6 +18,7 @@ internal sealed class AvatarService : BackgroundService, IAvatarService private readonly VirtualParadiseClient _virtualParadiseClient; private readonly Subject _avatarJoined = new(); private readonly Subject _avatarLeft = new(); + private readonly ConcurrentDictionary _cachedAvatars = new(); /// /// Initializes a new instance of the class. @@ -49,23 +51,42 @@ internal sealed class AvatarService : BackgroundService, IAvatarService private void OnVPAvatarJoined(VirtualParadiseAvatar avatar) { - _logger.LogInformation("{Avatar} joined", avatar); + _logger.LogInformation("{Avatar} joined ({User})", avatar, avatar.User); IBotConfiguration configuration = _configurationService.BotConfiguration; if (!configuration.AnnounceAvatarEvents || avatar.IsBot && !configuration.AnnounceBots) return; - _avatarJoined.OnNext(avatar); + if (AddCachedAvatar(avatar)) + _avatarJoined.OnNext(avatar); } private void OnVPAvatarLeft(VirtualParadiseAvatar avatar) { - _logger.LogInformation("{Avatar} left", avatar); + _logger.LogInformation("{Avatar} left ({User})", avatar, avatar.User); IBotConfiguration configuration = _configurationService.BotConfiguration; if (!configuration.AnnounceAvatarEvents || avatar.IsBot && !configuration.AnnounceBots) return; - _avatarLeft.OnNext(avatar); + if (RemoveCachedAvatar(avatar)) + _avatarLeft.OnNext(avatar); + } + + private bool AddCachedAvatar(VirtualParadiseAvatar avatar) + { + if (avatar is null) throw new ArgumentNullException(nameof(avatar)); + + bool result = !_cachedAvatars.Values.Any(a => a.User.Id == avatar.User.Id && a.Name == avatar.Name); + _cachedAvatars[avatar.Session] = avatar; + return result; + } + + private bool RemoveCachedAvatar(VirtualParadiseAvatar avatar) + { + if (avatar is null) throw new ArgumentNullException(nameof(avatar)); + + _cachedAvatars.TryRemove(avatar.Session, out _); + return !_cachedAvatars.Values.Any(a => a.User.Id == avatar.User.Id && a.Name == avatar.Name); } } diff --git a/VPLink/Services/BotService.cs b/VPLink/Services/BotService.cs new file mode 100644 index 0000000..aea7cf7 --- /dev/null +++ b/VPLink/Services/BotService.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using VpSharp; + +namespace VPLink.Services; + +internal sealed class BotService : BackgroundService +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + public BotService(ILogger logger) + { + _logger = logger; + var attribute = typeof(BotService).Assembly.GetCustomAttribute(); + Version = attribute?.InformationalVersion ?? "Unknown"; + + attribute = typeof(DiscordSocketClient).Assembly.GetCustomAttribute(); + DiscordNetVersion = attribute?.InformationalVersion ?? "Unknown"; + + attribute = typeof(VirtualParadiseClient).Assembly.GetCustomAttribute(); + VpSharpVersion = attribute?.InformationalVersion ?? "Unknown"; + } + + /// + /// Gets the Discord.Net version. + /// + /// The Discord.Net version. + public string DiscordNetVersion { get; } + + /// + /// Gets the date and time at which the bot was started. + /// + /// The start timestamp. + public DateTimeOffset StartedAt { get; private set; } + + /// + /// Gets the bot version. + /// + /// The bot version. + public string Version { get; } + + /// + /// Gets the VP# version. + /// + /// The VP# version. + public string VpSharpVersion { get; } + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + StartedAt = DateTimeOffset.UtcNow; + _logger.LogInformation("VPLink v{Version} is starting...", Version); + _logger.LogInformation("Discord.Net v{Version}", DiscordNetVersion); + _logger.LogInformation("VP# v{Version}", VpSharpVersion); + return Task.CompletedTask; + } +} diff --git a/VPLink/Services/DiscordMessageService.cs b/VPLink/Services/DiscordMessageService.cs index d50bd8e..8611e36 100644 --- a/VPLink/Services/DiscordMessageService.cs +++ b/VPLink/Services/DiscordMessageService.cs @@ -2,26 +2,24 @@ 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.Common; using VPLink.Common.Configuration; using VPLink.Common.Data; +using VPLink.Common.Extensions; using VPLink.Common.Services; using VpSharp.Entities; -using IUser = Discord.IUser; namespace VPLink.Services; /// -internal sealed partial class DiscordMessageService : BackgroundService, IDiscordMessageService +internal sealed 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; @@ -54,10 +52,7 @@ internal sealed partial class DiscordMessageService : BackgroundService, IDiscor var embed = new EmbedBuilder(); embed.WithColor(0x00FF00); - embed.WithTitle("📥 Avatar Joined"); - embed.WithDescription(avatar.Name); - embed.WithTimestamp(DateTimeOffset.UtcNow); - embed.WithFooter($"Session {avatar.Session}"); + embed.WithDescription($"📥 **Avatar Joined**: {avatar.Name} (User #{avatar.User.Id})"); return channel.SendMessageAsync(embed: embed.Build()); } @@ -70,10 +65,7 @@ internal sealed partial class DiscordMessageService : BackgroundService, IDiscor var embed = new EmbedBuilder(); embed.WithColor(0xFF0000); - embed.WithTitle("📤 Avatar Left"); - embed.WithDescription(avatar.Name); - embed.WithTimestamp(DateTimeOffset.UtcNow); - embed.WithFooter($"Session {avatar.Session}"); + embed.WithDescription($"📤 **Avatar Left**: {avatar.Name} (User #{avatar.User.Id})"); return channel.SendMessageAsync(embed: embed.Build()); } @@ -94,54 +86,187 @@ internal sealed partial class DiscordMessageService : BackgroundService, IDiscor private Task OnDiscordMessageReceived(SocketMessage arg) { - if (arg is not IUserMessage message) + if (!ValidateMessage(arg, out IUserMessage? message, out IGuildUser? author)) return Task.CompletedTask; - IUser author = message.Author; - if (author.Id == _discordClient.CurrentUser.Id) - return Task.CompletedTask; + string displayName = author.GetDisplayName(); + var builder = new PlainTextMessageBuilder(); - if (author.IsBot && !_configurationService.BotConfiguration.RelayBotMessages) - return Task.CompletedTask; + IGuild guild = author.Guild; + SanitizeContent(guild, message.Content, ref builder); + var content = builder.ToString(); - if (message.Channel.Id != _configurationService.DiscordConfiguration.ChannelId) - 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); + MessageReference reference = arg.Reference; + if (reference?.MessageId.IsSpecified == true) + { + string? replyContent = GetReplyContent(arg, reference, out IUserMessage? fetchedMessage); + if (replyContent is not null) + { + IUser replyAuthor = fetchedMessage!.Author; + string name = fetchedMessage.Author.GetDisplayName(); + + _logger.LogInformation("Replying to {Author}: {Content}", replyAuthor, replyContent); + builder.Clear(); + SanitizeContent(guild, replyContent, ref builder); + replyContent = builder.ToString(); + + messages.Add(new RelayedMessage(null!, $"↩️ Replying to {name}:", true)); + messages.Add(new RelayedMessage(null!, replyContent, true)); + } + } + + if (arg.Interaction is { Type: InteractionType.ApplicationCommand } interaction) + { + string name = interaction.User.GetDisplayName(); + string commandName = interaction.Name; + messages.Add(new RelayedMessage(null, $"⌨️ {name} used /{commandName}", true)); + } + + AddMessage(messages, displayName, content); + + IReadOnlyCollection attachments = message.Attachments; + foreach (IAttachment attachment in attachments) + { + messages.Add(new RelayedMessage(displayName, attachment.Url, false)); + } + + messages.ForEach(_messageReceived.OnNext); + builder.Dispose(); + return Task.CompletedTask; + } + + private static void AddMessage(ICollection messages, string displayName, string content) + { + Span buffer = stackalloc byte[255]; // VP message length limit + 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))); + messages.Add(new RelayedMessage(displayName, Utf8Encoding.GetString(buffer), false)); offset += length; } + } - messages.ForEach(_messageReceived.OnNext); - return Task.CompletedTask; + private string? GetReplyContent(SocketMessage message, MessageReference reference, out IUserMessage? fetchedMessage) + { + fetchedMessage = null; + IGuild authorGuild = ((IGuildUser)message.Author).Guild; + IGuild guild = authorGuild; + + Optional referenceGuildId = reference.GuildId; + Optional referenceMessageId = reference.MessageId; + + if (!referenceMessageId.IsSpecified) + { + return null; + } + + if (referenceGuildId.IsSpecified) + { + guild = _discordClient.GetGuild(referenceGuildId.Value) ?? authorGuild; + } + + ulong referenceChannelId = reference.ChannelId; + + if (!referenceMessageId.IsSpecified) + { + return null; + } + + if (guild.GetChannelAsync(referenceChannelId).GetAwaiter().GetResult() is not ITextChannel channel) + { + return null; + } + + IMessage? referencedMessage = channel.GetMessageAsync(referenceMessageId.Value).GetAwaiter().GetResult(); + if (referencedMessage is null) + { + return null; + } + + fetchedMessage = referencedMessage as IUserMessage; + string? content = referencedMessage.Content; + return string.IsNullOrWhiteSpace(content) ? null : content; + } + + private static void SanitizeContent(IGuild guild, ReadOnlySpan content, ref PlainTextMessageBuilder builder) + { + Utf8ValueStringBuilder wordBuffer = ZString.CreateUtf8StringBuilder(); + + for (var index = 0; index < content.Length; index++) + { + char current = content[index]; + if (char.IsWhiteSpace(current)) + { + AddWord(guild, ref builder, ref wordBuffer, current); + wordBuffer.Clear(); + } + else + { + wordBuffer.Append(current); + } + } + + if (wordBuffer.Length > 0) + { + AddWord(guild, ref builder, ref wordBuffer, '\0'); + } + + wordBuffer.Dispose(); + } + + private static void AddWord(IGuild guild, + ref PlainTextMessageBuilder builder, + ref Utf8ValueStringBuilder wordBuffer, + char whitespaceTrivia) + { + using Utf8ValueStringBuilder buffer = ZString.CreateUtf8StringBuilder(); + + ReadOnlySpan bytes = wordBuffer.AsSpan(); + int charCount = Utf8Encoding.GetCharCount(bytes); + Span chars = stackalloc char[charCount]; + Utf8Encoding.GetChars(bytes, chars); + + Span temp = stackalloc char[255]; + + var isEscaped = false; + for (var index = 0; index < chars.Length; index++) + { + char current = chars[index]; + switch (current) + { + case '\\' when isEscaped: + buffer.Append('\\'); + break; + + case '\\': + isEscaped = !isEscaped; + break; + + case '<': + index++; + int tagLength = ConsumeToEndOfTag(chars, ref index, temp); + char whitespace = index < chars.Length - 1 && char.IsWhiteSpace(chars[index]) ? chars[index] : '\0'; + MentionUtility.ParseTag(guild, temp[..tagLength], ref builder, whitespace); + break; + + default: + buffer.Append(current); + break; + } + } + + bytes = buffer.AsSpan(); + charCount = Utf8Encoding.GetCharCount(bytes); + chars = stackalloc char[charCount]; + Utf8Encoding.GetChars(bytes, chars); + builder.AddWord(chars, whitespaceTrivia); } private bool TryGetRelayChannel([NotNullWhen(true)] out ITextChannel? channel) @@ -160,9 +285,85 @@ internal sealed partial class DiscordMessageService : BackgroundService, IDiscor return false; } - [GeneratedRegex(@"\\(\*|_|`|~|\\)", RegexOptions.Compiled)] - private static partial Regex GetUnescapeRegex(); + private bool ValidateMessage(SocketMessage socketMessage, + [NotNullWhen(true)] out IUserMessage? message, + [NotNullWhen(true)] out IGuildUser? author) + { + message = socketMessage as IUserMessage; + if (message is null) + { + author = null; + return false; + } - [GeneratedRegex(@"(\*|_|`|~|\\)", RegexOptions.Compiled)] - private static partial Regex GetEscapeRegex(); + author = message.Author as IGuildUser; + if (author is null) + { + message = null; + return false; + } + + if (author.Id == _discordClient.CurrentUser.Id) + { + author = null; + message = null; + return false; + } + + if (author.IsBot && !_configurationService.BotConfiguration.RelayBotMessages) + { + author = null; + message = null; + return false; + } + + if (message.Channel.Id != _configurationService.DiscordConfiguration.ChannelId) + { + author = null; + message = null; + return false; + } + + if (string.IsNullOrWhiteSpace(message.Content) && message.Attachments.Count == 0) + { + author = null; + message = null; + return false; + } + + return true; + } + + private static int ConsumeToEndOfTag(ReadOnlySpan word, ref int index, Span element) + { + using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder(); + var isEscaped = false; + + int startIndex = index; + for (; index < word.Length; index++) + { + switch (word[index]) + { + case '\\' when isEscaped: + builder.Append('\\'); + isEscaped = false; + break; + + case '\\': + isEscaped = true; + break; + + case '>' when !isEscaped: + Utf8Encoding.GetChars(builder.AsSpan(), element); + return index + 1 - startIndex; + + default: + builder.Append(word[index]); + break; + } + } + + Utf8Encoding.GetChars(builder.AsSpan(), element); + return index + 1 - startIndex; + } } diff --git a/VPLink/Services/DiscordService.cs b/VPLink/Services/DiscordService.cs index 530bf02..1ba757d 100644 --- a/VPLink/Services/DiscordService.cs +++ b/VPLink/Services/DiscordService.cs @@ -44,6 +44,7 @@ internal sealed class DiscordService : BackgroundService _logger.LogInformation("Establishing relay"); _logger.LogInformation("Adding command modules"); + await _interactionService.AddModuleAsync(_serviceProvider).ConfigureAwait(false); await _interactionService.AddModuleAsync(_serviceProvider).ConfigureAwait(false); _discordClient.Ready += OnReady; diff --git a/VPLink/Services/VirtualParadiseMessageService.cs b/VPLink/Services/VirtualParadiseMessageService.cs index be7c784..48f0117 100644 --- a/VPLink/Services/VirtualParadiseMessageService.cs +++ b/VPLink/Services/VirtualParadiseMessageService.cs @@ -1,6 +1,6 @@ -using System.Drawing; using System.Reactive.Linq; using System.Reactive.Subjects; +using Discord; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using VPLink.Common.Configuration; @@ -8,7 +8,9 @@ using VPLink.Common.Data; using VPLink.Common.Services; using VpSharp; using VpSharp.Entities; +using Color = System.Drawing.Color; using FontStyle = VpSharp.FontStyle; +using MessageType = VpSharp.MessageType; namespace VPLink.Services; @@ -43,9 +45,11 @@ internal sealed class VirtualParadiseMessageService : BackgroundService, IVirtua { IChatConfiguration configuration = _configurationService.VirtualParadiseConfiguration.Chat; - Color color = Color.FromArgb((int)configuration.Color); - FontStyle style = configuration.Style; - return _virtualParadiseClient.SendMessageAsync(message.Author, message.Content, style, color); + Color color = Color.FromArgb((int)(message.IsReply ? configuration.ReplyColor : configuration.Color)); + FontStyle style = message.IsReply ? configuration.ReplyStyle : configuration.Style; + + string content = Format.StripMarkDown(message.Content); + return _virtualParadiseClient.SendMessageAsync(message.Author, content, style, color); } /// @@ -64,7 +68,7 @@ internal sealed class VirtualParadiseMessageService : BackgroundService, IVirtua _logger.LogInformation("Message by {Author}: {Content}", message.Author, message.Content); - var relayedMessage = new RelayedMessage(message.Author.Name, message.Content); + var relayedMessage = new RelayedMessage(message.Author.Name, message.Content, false); _messageReceived.OnNext(relayedMessage); } } diff --git a/VPLink/VPLink.csproj b/VPLink/VPLink.csproj index 7b45bd8..4d33764 100644 --- a/VPLink/VPLink.csproj +++ b/VPLink/VPLink.csproj @@ -9,7 +9,7 @@ Oliver Booth https://github.com/oliverbooth/VpBridge git - 1.2.1 + 1.3.0 @@ -41,7 +41,6 @@ - @@ -56,7 +55,7 @@ - +