diff --git a/VPLink.Common/Data/PlainTextMessageBuilder.cs b/VPLink.Common/Data/PlainTextMessageBuilder.cs new file mode 100644 index 0000000..7c039a5 --- /dev/null +++ b/VPLink.Common/Data/PlainTextMessageBuilder.cs @@ -0,0 +1,99 @@ +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; + } + } + + /// + public void Dispose() + { + _builder.Dispose(); + } + + /// + public override string ToString() + { + return _builder.ToString().Trim(); + } +} 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/MentionUtility.cs b/VPLink.Common/MentionUtility.cs new file mode 100644 index 0000000..ebe9963 --- /dev/null +++ b/VPLink.Common/MentionUtility.cs @@ -0,0 +1,115 @@ +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; + + for (var index = 2; index < contents.Length; index++) + { + char current = contents[index]; + switch (current) + { + case '\\': + isEscaped = !isEscaped; + break; + + case ':' when !isEscaped && index + 1 < contents.Length: + formatSpecifier = contents[index + 1]; + 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/Services/DiscordMessageService.cs b/VPLink/Services/DiscordMessageService.cs index 0bc31bd..46b5eea 100644 --- a/VPLink/Services/DiscordMessageService.cs +++ b/VPLink/Services/DiscordMessageService.cs @@ -7,6 +7,7 @@ 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.Services; @@ -15,7 +16,7 @@ using VpSharp.Entities; 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); @@ -88,22 +89,12 @@ internal sealed partial class DiscordMessageService : BackgroundService, IDiscor return Task.CompletedTask; string displayName = author.Nickname ?? author.GlobalName ?? author.Username; - string content = message.Content; + var builder = new PlainTextMessageBuilder(); + Utf8ValueStringBuilder wordBuffer = ZString.CreateUtf8StringBuilder(); - 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); - } + SanitizeContent(author.Guild, message.Content, ref builder, ref wordBuffer); + var content = builder.ToString(); - // += 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 @@ -119,10 +110,91 @@ internal sealed partial class DiscordMessageService : BackgroundService, IDiscor offset += length; } + IReadOnlyCollection attachments = message.Attachments; + foreach (IAttachment attachment in attachments) + { + messages.Add(new RelayedMessage(displayName, attachment.Url)); + } + messages.ForEach(_messageReceived.OnNext); + builder.Dispose(); + wordBuffer.Dispose(); return Task.CompletedTask; } + private static void SanitizeContent(IGuild guild, + ReadOnlySpan content, + ref PlainTextMessageBuilder builder, + ref Utf8ValueStringBuilder wordBuffer) + { + 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'); + } + } + + 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) { IDiscordConfiguration configuration = _configurationService.DiscordConfiguration; @@ -187,4 +259,37 @@ internal sealed partial class DiscordMessageService : BackgroundService, IDiscor 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/VPLink.csproj b/VPLink/VPLink.csproj index e3a7140..4d33764 100644 --- a/VPLink/VPLink.csproj +++ b/VPLink/VPLink.csproj @@ -41,7 +41,6 @@ - @@ -56,7 +55,7 @@ - +