mirror of https://github.com/oliverbooth/VPLink
feat: add support for Discord's formatting
This commit is contained in:
parent
47d5323597
commit
02287d4995
|
@ -0,0 +1,99 @@
|
||||||
|
using Cysharp.Text;
|
||||||
|
using Humanizer;
|
||||||
|
|
||||||
|
namespace VPLink.Common.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a plain text message builder.
|
||||||
|
/// </summary>
|
||||||
|
public struct PlainTextMessageBuilder : IDisposable
|
||||||
|
{
|
||||||
|
private Utf8ValueStringBuilder _builder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="PlainTextMessageBuilder" /> struct.
|
||||||
|
/// </summary>
|
||||||
|
public PlainTextMessageBuilder()
|
||||||
|
{
|
||||||
|
_builder = ZString.CreateUtf8StringBuilder();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends the specified word.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="word">The word.</param>
|
||||||
|
/// <param name="whitespace">The trailing whitespace trivia.</param>
|
||||||
|
public void AddWord(ReadOnlySpan<char> word, char whitespace = ' ')
|
||||||
|
{
|
||||||
|
_builder.Append(word);
|
||||||
|
if (whitespace != '\0') _builder.Append(whitespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appends the specified word.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="timestamp">The timestamp.</param>
|
||||||
|
/// <param name="format">The format.</param>
|
||||||
|
/// <param name="whitespace">The trailing whitespace trivia.</param>
|
||||||
|
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($"<t:{timestamp.ToUnixTimeSeconds():D}:{format}>", whitespace);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_builder.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
return _builder.ToString().Trim();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'
|
||||||
|
}
|
|
@ -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<char> 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<char> 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<char> 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<char> 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<char> 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<byte> bytes = buffer.AsSpan();
|
||||||
|
int charCount = Encoding.UTF8.GetCharCount(bytes);
|
||||||
|
Span<char> chars = stackalloc char[charCount];
|
||||||
|
Encoding.UTF8.GetChars(bytes, chars);
|
||||||
|
|
||||||
|
DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(long.Parse(chars, CultureInfo.InvariantCulture));
|
||||||
|
builder.AddTimestamp(timestamp, (TimestampFormat)formatSpecifier, whitespaceTrivia);
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,8 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Discord.Net" Version="3.12.0"/>
|
||||||
|
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
|
||||||
<PackageReference Include="VpSharp" Version="0.1.0-nightly.43"/>
|
<PackageReference Include="VpSharp" Version="0.1.0-nightly.43"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ using Discord;
|
||||||
using Discord.WebSocket;
|
using Discord.WebSocket;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using VPLink.Common;
|
||||||
using VPLink.Common.Configuration;
|
using VPLink.Common.Configuration;
|
||||||
using VPLink.Common.Data;
|
using VPLink.Common.Data;
|
||||||
using VPLink.Common.Services;
|
using VPLink.Common.Services;
|
||||||
|
@ -15,7 +16,7 @@ using VpSharp.Entities;
|
||||||
namespace VPLink.Services;
|
namespace VPLink.Services;
|
||||||
|
|
||||||
/// <inheritdoc cref="IDiscordMessageService" />
|
/// <inheritdoc cref="IDiscordMessageService" />
|
||||||
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 Encoding Utf8Encoding = new UTF8Encoding(false, false);
|
||||||
|
|
||||||
|
@ -88,22 +89,12 @@ internal sealed partial class DiscordMessageService : BackgroundService, IDiscor
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
string displayName = author.Nickname ?? author.GlobalName ?? author.Username;
|
string displayName = author.Nickname ?? author.GlobalName ?? author.Username;
|
||||||
string content = message.Content;
|
var builder = new PlainTextMessageBuilder();
|
||||||
|
Utf8ValueStringBuilder wordBuffer = ZString.CreateUtf8StringBuilder();
|
||||||
|
|
||||||
IReadOnlyCollection<IAttachment> attachments = message.Attachments;
|
SanitizeContent(author.Guild, message.Content, ref builder, ref wordBuffer);
|
||||||
if (attachments.Count > 0)
|
var content = builder.ToString();
|
||||||
{
|
|
||||||
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);
|
_logger.LogInformation("Message by {Author}: {Content}", author, content);
|
||||||
|
|
||||||
Span<byte> buffer = stackalloc byte[255]; // VP message length limit
|
Span<byte> buffer = stackalloc byte[255]; // VP message length limit
|
||||||
|
@ -119,10 +110,91 @@ internal sealed partial class DiscordMessageService : BackgroundService, IDiscor
|
||||||
offset += length;
|
offset += length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IReadOnlyCollection<IAttachment> attachments = message.Attachments;
|
||||||
|
foreach (IAttachment attachment in attachments)
|
||||||
|
{
|
||||||
|
messages.Add(new RelayedMessage(displayName, attachment.Url));
|
||||||
|
}
|
||||||
|
|
||||||
messages.ForEach(_messageReceived.OnNext);
|
messages.ForEach(_messageReceived.OnNext);
|
||||||
|
builder.Dispose();
|
||||||
|
wordBuffer.Dispose();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void SanitizeContent(IGuild guild,
|
||||||
|
ReadOnlySpan<char> 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<byte> bytes = wordBuffer.AsSpan();
|
||||||
|
int charCount = Utf8Encoding.GetCharCount(bytes);
|
||||||
|
Span<char> chars = stackalloc char[charCount];
|
||||||
|
Utf8Encoding.GetChars(bytes, chars);
|
||||||
|
|
||||||
|
Span<char> 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)
|
private bool TryGetRelayChannel([NotNullWhen(true)] out ITextChannel? channel)
|
||||||
{
|
{
|
||||||
IDiscordConfiguration configuration = _configurationService.DiscordConfiguration;
|
IDiscordConfiguration configuration = _configurationService.DiscordConfiguration;
|
||||||
|
@ -187,4 +259,37 @@ internal sealed partial class DiscordMessageService : BackgroundService, IDiscor
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int ConsumeToEndOfTag(ReadOnlySpan<char> word, ref int index, Span<char> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,6 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Discord.Net" Version="3.12.0"/>
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1"/>
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1"/>
|
||||||
<PackageReference Include="Serilog" Version="3.0.1"/>
|
<PackageReference Include="Serilog" Version="3.0.1"/>
|
||||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="7.0.0"/>
|
<PackageReference Include="Serilog.Extensions.Hosting" Version="7.0.0"/>
|
||||||
|
@ -56,7 +55,7 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\VPLink.Common\VPLink.Common.csproj" />
|
<ProjectReference Include="..\VPLink.Common\VPLink.Common.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
Loading…
Reference in New Issue