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