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