mirror of https://github.com/oliverbooth/VPLink
Merge branch 'release/1.3.0' into main
This commit is contained in:
commit
f4b5ff34e2
|
@ -18,4 +18,16 @@ public interface IChatConfiguration
|
|||
/// </summary>
|
||||
/// <value>The font style.</value>
|
||||
FontStyle Style { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the color of a reply message.
|
||||
/// </summary>
|
||||
/// <value>The reply message color.</value>
|
||||
uint ReplyColor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the font style of a reply message.
|
||||
/// </summary>
|
||||
/// <value>The reply font style.</value>
|
||||
FontStyle ReplyStyle { get; set; }
|
||||
}
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the builder.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_builder.Clear();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_builder.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string ToString()
|
||||
{
|
||||
return _builder.ToString().Trim();
|
||||
}
|
||||
}
|
|
@ -10,12 +10,20 @@ public readonly struct RelayedMessage
|
|||
/// </summary>
|
||||
/// <param name="author">The author.</param>
|
||||
/// <param name="content">The content.</param>
|
||||
public RelayedMessage(string author, string content)
|
||||
/// <param name="isReply">A value indicating whether this message is a reply.</param>
|
||||
public RelayedMessage(string? author, string content, bool isReply)
|
||||
{
|
||||
Author = author;
|
||||
Content = content;
|
||||
IsReply = isReply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user that sent the message.
|
||||
/// </summary>
|
||||
/// <value>The user that sent the message.</value>
|
||||
public string? Author { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the message content.
|
||||
/// </summary>
|
||||
|
@ -23,8 +31,8 @@ public readonly struct RelayedMessage
|
|||
public string Content { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user that sent the message.
|
||||
/// Gets a value indicating whether this message is a reply.
|
||||
/// </summary>
|
||||
/// <value>The user that sent the message.</value>
|
||||
public string Author { get; }
|
||||
/// <value><see langword="true" /> if this message is a reply; otherwise, <see langword="false" />.</value>
|
||||
public bool IsReply { get; }
|
||||
}
|
||||
|
|
|
@ -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,27 @@
|
|||
using Discord;
|
||||
|
||||
namespace VPLink.Common.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for the <see cref="IUser" /> interface.
|
||||
/// </summary>
|
||||
public static class UserExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the display name of the user.
|
||||
/// </summary>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>The display name.</returns>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="user" /> is <c>null</c>.</exception>
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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<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;
|
||||
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<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>
|
||||
|
||||
<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"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
using System.Text;
|
||||
using Discord;
|
||||
using Discord.Interactions;
|
||||
using Discord.WebSocket;
|
||||
using VPLink.Services;
|
||||
|
||||
namespace VPLink.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a class which implements the <c>info</c> command.
|
||||
/// </summary>
|
||||
internal sealed class InfoCommand : InteractionModuleBase<SocketInteractionContext>
|
||||
{
|
||||
private readonly BotService _botService;
|
||||
private readonly DiscordSocketClient _discordClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InfoCommand" /> class.
|
||||
/// </summary>
|
||||
/// <param name="botService">The bot service.</param>
|
||||
/// <param name="discordClient"></param>
|
||||
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", $"<t:{_botService.StartedAt.ToUnixTimeSeconds()}:R>", 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);
|
||||
}
|
||||
}
|
|
@ -41,12 +41,12 @@ internal sealed class WhoCommand : InteractionModuleBase<SocketInteractionContex
|
|||
{
|
||||
if (avatar.IsBot)
|
||||
{
|
||||
botsBuilder.AppendLine($"* {avatar.Name} ({avatar.Session})");
|
||||
botsBuilder.AppendLine($"* {avatar.Name} ({avatar.User.Id})");
|
||||
botCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
userBuilder.AppendLine($"* {avatar.Name} ({avatar.Session})");
|
||||
userBuilder.AppendLine($"* {avatar.Name} ({avatar.User.Id})");
|
||||
userCount++;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,4 +11,10 @@ internal sealed class ChatConfiguration : IChatConfiguration
|
|||
|
||||
/// <inheritdoc />
|
||||
public FontStyle Style { get; set; } = FontStyle.Regular;
|
||||
|
||||
/// <inheritdoc />
|
||||
public uint ReplyColor { get; set; } = 0x808080;
|
||||
|
||||
/// <inheritdoc />
|
||||
public FontStyle ReplyStyle { get; set; } = FontStyle.Italic;
|
||||
}
|
||||
|
|
|
@ -25,6 +25,8 @@ builder.Configuration.AddTomlFile("data/config.toml", true, true);
|
|||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddSerilog();
|
||||
|
||||
builder.Services.AddHostedSingleton<BotService>();
|
||||
|
||||
builder.Services.AddSingleton<VirtualParadiseClient>();
|
||||
builder.Services.AddSingleton<IConfigurationService, ConfigurationService>();
|
||||
|
||||
|
|
|
@ -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<VirtualParadiseAvatar> _avatarJoined = new();
|
||||
private readonly Subject<VirtualParadiseAvatar> _avatarLeft = new();
|
||||
private readonly ConcurrentDictionary<int, VirtualParadiseAvatar> _cachedAvatars = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AvatarService" /> 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;
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<BotService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BotService" /> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public BotService(ILogger<BotService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
var attribute = typeof(BotService).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
Version = attribute?.InformationalVersion ?? "Unknown";
|
||||
|
||||
attribute = typeof(DiscordSocketClient).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
DiscordNetVersion = attribute?.InformationalVersion ?? "Unknown";
|
||||
|
||||
attribute = typeof(VirtualParadiseClient).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>();
|
||||
VpSharpVersion = attribute?.InformationalVersion ?? "Unknown";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Discord.Net version.
|
||||
/// </summary>
|
||||
/// <value>The Discord.Net version.</value>
|
||||
public string DiscordNetVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the date and time at which the bot was started.
|
||||
/// </summary>
|
||||
/// <value>The start timestamp.</value>
|
||||
public DateTimeOffset StartedAt { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bot version.
|
||||
/// </summary>
|
||||
/// <value>The bot version.</value>
|
||||
public string Version { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the VP# version.
|
||||
/// </summary>
|
||||
/// <value>The VP# version.</value>
|
||||
public string VpSharpVersion { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
/// <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 Regex UnescapeRegex = GetUnescapeRegex();
|
||||
private static readonly Regex EscapeRegex = GetEscapeRegex();
|
||||
|
||||
private readonly ILogger<DiscordMessageService> _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<IAttachment> 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<byte> buffer = stackalloc byte[255]; // VP message length limit
|
||||
var messages = new List<RelayedMessage>();
|
||||
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<IAttachment> 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<RelayedMessage> messages, string displayName, string content)
|
||||
{
|
||||
Span<byte> 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<ulong> referenceGuildId = reference.GuildId;
|
||||
Optional<ulong> 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<char> 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<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)
|
||||
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,6 +44,7 @@ internal sealed class DiscordService : BackgroundService
|
|||
_logger.LogInformation("Establishing relay");
|
||||
|
||||
_logger.LogInformation("Adding command modules");
|
||||
await _interactionService.AddModuleAsync<InfoCommand>(_serviceProvider).ConfigureAwait(false);
|
||||
await _interactionService.AddModuleAsync<WhoCommand>(_serviceProvider).ConfigureAwait(false);
|
||||
|
||||
_discordClient.Ready += OnReady;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
<Authors>Oliver Booth</Authors>
|
||||
<RepositoryUrl>https://github.com/oliverbooth/VpBridge</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<VersionPrefix>1.2.1</VersionPrefix>
|
||||
<VersionPrefix>1.3.0</VersionPrefix>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true'">
|
||||
|
@ -41,7 +41,6 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Discord.Net" Version="3.12.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1"/>
|
||||
<PackageReference Include="Serilog" Version="3.0.1"/>
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="7.0.0"/>
|
||||
|
@ -56,7 +55,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VPLink.Common\VPLink.Common.csproj" />
|
||||
<ProjectReference Include="..\VPLink.Common\VPLink.Common.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
Loading…
Reference in New Issue