feat(bot): add support for Discord message context commands (#513)
This commit is contained in:
17
PluralKit.Bot/ApplicationCommandMeta/ApplicationCommand.cs
Normal file
17
PluralKit.Bot/ApplicationCommandMeta/ApplicationCommand.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using ApplicationCommandType = Myriad.Types.ApplicationCommand.ApplicationCommandType;
|
||||
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class ApplicationCommand
|
||||
{
|
||||
public ApplicationCommand(ApplicationCommandType type, string name, string? description = null)
|
||||
{
|
||||
Type = type;
|
||||
Name = name;
|
||||
Description = description;
|
||||
}
|
||||
|
||||
public ApplicationCommandType Type { get; }
|
||||
public string Name { get; }
|
||||
public string Description { get; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ApplicationCommandType = Myriad.Types.ApplicationCommand.ApplicationCommandType;
|
||||
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public partial class ApplicationCommandTree
|
||||
{
|
||||
public static ApplicationCommand ProxiedMessageQuery = new(ApplicationCommandType.Message, "\U00002753 Message info");
|
||||
public static ApplicationCommand ProxiedMessageDelete = new(ApplicationCommandType.Message, "\U0000274c Delete message");
|
||||
public static ApplicationCommand ProxiedMessagePing = new(ApplicationCommandType.Message, "\U0001f514 Ping author");
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ApplicationCommandType = Myriad.Types.ApplicationCommand.ApplicationCommandType;
|
||||
using InteractionType = Myriad.Types.Interaction.InteractionType;
|
||||
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public partial class ApplicationCommandTree
|
||||
{
|
||||
public Task TryHandleCommand(InteractionContext ctx)
|
||||
{
|
||||
if (ctx.Event.Data!.Name == ProxiedMessageQuery.Name)
|
||||
return ctx.Execute<ApplicationCommandProxiedMessage>(ProxiedMessageQuery, m => m.QueryMessage(ctx));
|
||||
else if (ctx.Event.Data!.Name == ProxiedMessageDelete.Name)
|
||||
return ctx.Execute<ApplicationCommandProxiedMessage>(ProxiedMessageDelete, m => m.DeleteMessage(ctx));
|
||||
else if (ctx.Event.Data!.Name == ProxiedMessagePing.Name)
|
||||
return ctx.Execute<ApplicationCommandProxiedMessage>(ProxiedMessageDelete, m => m.PingMessageAuthor(ctx));
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
152
PluralKit.Bot/ApplicationCommands/Message.cs
Normal file
152
PluralKit.Bot/ApplicationCommands/Message.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using Autofac;
|
||||
|
||||
using Myriad.Cache;
|
||||
using Myriad.Extensions;
|
||||
using Myriad.Rest;
|
||||
using Myriad.Rest.Types;
|
||||
using Myriad.Types;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class ApplicationCommandProxiedMessage
|
||||
{
|
||||
private readonly DiscordApiClient _rest;
|
||||
private readonly IDiscordCache _cache;
|
||||
private readonly EmbedService _embeds;
|
||||
private readonly ModelRepository _repo;
|
||||
|
||||
public ApplicationCommandProxiedMessage(DiscordApiClient rest, IDiscordCache cache, EmbedService embeds,
|
||||
ModelRepository repo)
|
||||
{
|
||||
_rest = rest;
|
||||
_cache = cache;
|
||||
_embeds = embeds;
|
||||
_repo = repo;
|
||||
}
|
||||
|
||||
public async Task QueryMessage(InteractionContext ctx)
|
||||
{
|
||||
var messageId = ctx.Event.Data!.TargetId!.Value;
|
||||
var msg = await ctx.Repository.GetFullMessage(messageId);
|
||||
if (msg == null)
|
||||
throw Errors.MessageNotFound(messageId);
|
||||
|
||||
var showContent = true;
|
||||
var channel = await _rest.GetChannelOrNull(msg.Message.Channel);
|
||||
if (channel == null)
|
||||
showContent = false;
|
||||
|
||||
var embeds = new List<Embed>();
|
||||
|
||||
var guild = await _cache.GetGuild(ctx.GuildId);
|
||||
if (msg.Member != null)
|
||||
embeds.Add(await _embeds.CreateMemberEmbed(
|
||||
msg.System,
|
||||
msg.Member,
|
||||
guild,
|
||||
LookupContext.ByNonOwner,
|
||||
DateTimeZone.Utc
|
||||
));
|
||||
|
||||
embeds.Add(await _embeds.CreateMessageInfoEmbed(msg, showContent));
|
||||
|
||||
await ctx.Reply(embeds: embeds.ToArray());
|
||||
}
|
||||
|
||||
public async Task DeleteMessage(InteractionContext ctx)
|
||||
{
|
||||
var messageId = ctx.Event.Data!.TargetId!.Value;
|
||||
|
||||
// check for command messages
|
||||
var (authorId, channelId) = await ctx.Services.Resolve<CommandMessageService>().GetCommandMessage(messageId);
|
||||
if (authorId != null)
|
||||
{
|
||||
if (authorId != ctx.User.Id)
|
||||
throw new PKError("You can only delete command messages queried by this account.");
|
||||
|
||||
var isDM = (await _repo.GetDmChannel(ctx.User!.Id)) == channelId;
|
||||
await DeleteMessageInner(ctx, channelId!.Value, messageId, isDM);
|
||||
return;
|
||||
}
|
||||
|
||||
// and do the same for proxied messages
|
||||
var message = await ctx.Repository.GetFullMessage(messageId);
|
||||
if (message != null)
|
||||
{
|
||||
if (message.System?.Id != ctx.System.Id && message.Message.Sender != ctx.User.Id)
|
||||
throw new PKError("You can only delete your own messages.");
|
||||
|
||||
await DeleteMessageInner(ctx, message.Message.Channel, message.Message.Mid, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise, we don't know about this message at all!
|
||||
throw Errors.MessageNotFound(messageId);
|
||||
}
|
||||
|
||||
internal async Task DeleteMessageInner(InteractionContext ctx, ulong channelId, ulong messageId, bool isDM = false)
|
||||
{
|
||||
if (!((await _cache.PermissionsIn(channelId)).HasFlag(PermissionSet.ManageMessages) || isDM))
|
||||
throw new PKError("PluralKit does not have the *Manage Messages* permission in this channel, and thus cannot delete the message."
|
||||
+ " Please contact a server administrator to remedy this.");
|
||||
|
||||
await ctx.Rest.DeleteMessage(channelId, messageId);
|
||||
await ctx.Reply($"{Emojis.Success} Message deleted.");
|
||||
}
|
||||
|
||||
public async Task PingMessageAuthor(InteractionContext ctx)
|
||||
{
|
||||
var messageId = ctx.Event.Data!.TargetId!.Value;
|
||||
var msg = await ctx.Repository.GetFullMessage(messageId);
|
||||
if (msg == null)
|
||||
throw Errors.MessageNotFound(messageId);
|
||||
|
||||
// Check if the "pinger" has permission to send messages in this channel
|
||||
// (if not, PK shouldn't send messages on their behalf)
|
||||
var member = await _rest.GetGuildMember(ctx.GuildId, ctx.User.Id);
|
||||
var requiredPerms = PermissionSet.ViewChannel | PermissionSet.SendMessages;
|
||||
if (member == null || !(await _cache.PermissionsFor(ctx.ChannelId, member)).HasFlag(requiredPerms))
|
||||
{
|
||||
throw new PKError("You do not have permission to send messages in this channel.");
|
||||
};
|
||||
|
||||
var config = await _repo.GetSystemConfig(msg.System.Id);
|
||||
|
||||
if (config.PingsEnabled)
|
||||
{
|
||||
// If the system has pings enabled, go ahead
|
||||
await ctx.Respond(InteractionResponse.ResponseType.ChannelMessageWithSource,
|
||||
new InteractionApplicationCommandCallbackData
|
||||
{
|
||||
Content = $"Psst, **{msg.Member.DisplayName()}** (<@{msg.Message.Sender}>), you have been pinged by <@{ctx.User.Id}>.",
|
||||
Components = new[]
|
||||
{
|
||||
new MessageComponent
|
||||
{
|
||||
Type = ComponentType.ActionRow,
|
||||
Components = new[]
|
||||
{
|
||||
new MessageComponent
|
||||
{
|
||||
Style = ButtonStyle.Link,
|
||||
Type = ComponentType.Button,
|
||||
Label = "Jump",
|
||||
Url = msg.Message.JumpLink(),
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
AllowedMentions = new AllowedMentions { Users = new[] { msg.Message.Sender } },
|
||||
Flags = new() { },
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await ctx.Reply($"{Emojis.Error} {msg.Member.DisplayName()}'s system has disabled command pings.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,7 +236,11 @@ public class Bot
|
||||
// Once we've sent it to Sentry, report it to the user (if we have permission to)
|
||||
var reportChannel = handler.ErrorChannelFor(evt, _config.ClientId);
|
||||
if (reportChannel == null)
|
||||
{
|
||||
if (evt is InteractionCreateEvent ice && ice.Type == Interaction.InteractionType.ApplicationCommand)
|
||||
await _errorMessageService.InteractionRespondWithErrorMessage(ice, sentryEvent.EventId.ToString());
|
||||
return;
|
||||
}
|
||||
|
||||
var botPerms = await _cache.PermissionsIn(reportChannel.Value);
|
||||
if (botPerms.HasFlag(PermissionSet.SendMessages | PermissionSet.EmbedLinks))
|
||||
|
||||
@@ -53,6 +53,23 @@ public static class BotMetrics
|
||||
Context = "Bot"
|
||||
};
|
||||
|
||||
public static MeterOptions ApplicationCommandsRun => new()
|
||||
{
|
||||
Name = "Application commands run",
|
||||
MeasurementUnit = Unit.Commands,
|
||||
RateUnit = TimeUnit.Seconds,
|
||||
Context = "Bot"
|
||||
};
|
||||
|
||||
public static TimerOptions ApplicationCommandTime => new()
|
||||
{
|
||||
Name = "Application command run time",
|
||||
MeasurementUnit = Unit.Commands,
|
||||
RateUnit = TimeUnit.Seconds,
|
||||
DurationUnit = TimeUnit.Seconds,
|
||||
Context = "Bot"
|
||||
};
|
||||
|
||||
public static MeterOptions WebhookCacheMisses => new()
|
||||
{
|
||||
Name = "Webhook cache misses",
|
||||
|
||||
@@ -18,6 +18,8 @@ using NodaTime;
|
||||
using App.Metrics;
|
||||
|
||||
using PluralKit.Core;
|
||||
using Myriad.Gateway;
|
||||
using Myriad.Utils;
|
||||
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
|
||||
@@ -4,36 +4,58 @@ using Serilog;
|
||||
|
||||
using Myriad.Gateway;
|
||||
using Myriad.Types;
|
||||
using System.Buffers;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class InteractionCreated: IEventHandler<InteractionCreateEvent>
|
||||
{
|
||||
private readonly InteractionDispatchService _interactionDispatch;
|
||||
private readonly ApplicationCommandTree _commandTree;
|
||||
private readonly ILifetimeScope _services;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
public InteractionCreated(InteractionDispatchService interactionDispatch, ILifetimeScope services, ILogger logger)
|
||||
public InteractionCreated(InteractionDispatchService interactionDispatch, ApplicationCommandTree commandTree,
|
||||
ILifetimeScope services, ILogger logger)
|
||||
{
|
||||
_interactionDispatch = interactionDispatch;
|
||||
_commandTree = commandTree;
|
||||
_services = services;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(int shardId, InteractionCreateEvent evt)
|
||||
{
|
||||
if (evt.Type == Interaction.InteractionType.MessageComponent)
|
||||
var system = await _services.Resolve<ModelRepository>().GetSystemByAccount(evt.Member?.User.Id ?? evt.User!.Id);
|
||||
var ctx = new InteractionContext(_services, evt, system);
|
||||
|
||||
switch (evt.Type)
|
||||
{
|
||||
_logger.Information("Discord debug: got interaction with ID {id} from custom ID {custom_id}", evt.Id, evt.Data?.CustomId);
|
||||
var customId = evt.Data?.CustomId;
|
||||
if (customId == null) return;
|
||||
case Interaction.InteractionType.MessageComponent:
|
||||
_logger.Information("Discord debug: got interaction with ID {id} from custom ID {custom_id}", evt.Id, evt.Data?.CustomId);
|
||||
var customId = evt.Data?.CustomId;
|
||||
if (customId == null) return;
|
||||
|
||||
var ctx = new InteractionContext(evt, _services);
|
||||
if (customId.Contains("help-menu"))
|
||||
await Help.ButtonClick(ctx);
|
||||
else
|
||||
await _interactionDispatch.Dispatch(customId, ctx);
|
||||
|
||||
if (customId.Contains("help-menu"))
|
||||
await Help.ButtonClick(ctx);
|
||||
else
|
||||
await _interactionDispatch.Dispatch(customId, ctx);
|
||||
}
|
||||
break;
|
||||
|
||||
case Interaction.InteractionType.ApplicationCommand:
|
||||
var res = _commandTree.TryHandleCommand(ctx);
|
||||
if (res != null)
|
||||
{
|
||||
await res;
|
||||
return;
|
||||
}
|
||||
|
||||
// got some unhandled command, log and ignore
|
||||
_logger.Warning(@"Unhandled ApplicationCommand interaction: {EventId} {CommandName}", evt.Id, evt.Data?.Name);
|
||||
break;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,10 @@ public class BotModule: Module
|
||||
builder.RegisterType<SystemLink>().AsSelf();
|
||||
builder.RegisterType<SystemList>().AsSelf();
|
||||
|
||||
// Application commands
|
||||
builder.RegisterType<ApplicationCommandTree>().AsSelf();
|
||||
builder.RegisterType<ApplicationCommandProxiedMessage>().AsSelf();
|
||||
|
||||
// Bot core
|
||||
builder.RegisterType<Bot>().AsSelf().SingleInstance();
|
||||
builder.RegisterType<MessageCreated>().As<IEventHandler<MessageCreateEvent>>();
|
||||
|
||||
@@ -6,6 +6,7 @@ using Myriad.Builders;
|
||||
using Myriad.Rest;
|
||||
using Myriad.Rest.Types.Requests;
|
||||
using Myriad.Types;
|
||||
using Myriad.Gateway;
|
||||
|
||||
using NodaTime;
|
||||
|
||||
@@ -37,6 +38,46 @@ public class ErrorMessageService
|
||||
// private readonly ConcurrentDictionary<ulong, Instant> _lastErrorInChannel = new ConcurrentDictionary<ulong, Instant>();
|
||||
private Instant lastErrorTime { get; set; }
|
||||
|
||||
public async Task InteractionRespondWithErrorMessage(InteractionCreateEvent evt, string errorId)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
if (!ShouldSendErrorMessage(null, now))
|
||||
{
|
||||
_logger.Warning("Rate limited sending error interaction response for id {InteractionId} with error code {ErrorId}",
|
||||
evt.Id, errorId);
|
||||
_metrics.Measure.Meter.Mark(BotMetrics.ErrorMessagesSent, "throttled");
|
||||
return;
|
||||
}
|
||||
|
||||
var embed = CreateErrorEmbed(errorId, now);
|
||||
|
||||
try
|
||||
{
|
||||
var interactionData = new InteractionApplicationCommandCallbackData
|
||||
{
|
||||
Content = $"> **Error code:** `{errorId}`",
|
||||
Embeds = new[] { embed },
|
||||
Flags = Message.MessageFlags.Ephemeral
|
||||
};
|
||||
|
||||
await _rest.CreateInteractionResponse(evt.Id, evt.Token,
|
||||
new InteractionResponse
|
||||
{
|
||||
Type = InteractionResponse.ResponseType.ChannelMessageWithSource,
|
||||
Data = interactionData,
|
||||
});
|
||||
|
||||
_logger.Information("Sent error message interaction response for id {InteractionId} with error code {ErrorId}", evt.Id, errorId);
|
||||
_metrics.Measure.Meter.Mark(BotMetrics.ErrorMessagesSent, "sent");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.Error(e, "Error sending error interaction response for id {InteractionId}", evt.Id);
|
||||
_metrics.Measure.Meter.Mark(BotMetrics.ErrorMessagesSent, "failed");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendErrorMessage(ulong channelId, string errorId)
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
@@ -48,21 +89,12 @@ public class ErrorMessageService
|
||||
return;
|
||||
}
|
||||
|
||||
var channelInfo = _botConfig.IsBetaBot
|
||||
? "**#beta-testing** on **[the support server *(click to join)*](https://discord.gg/THvbH59btW)**"
|
||||
: "**#bug-reports-and-errors** on **[the support server *(click to join)*](https://discord.gg/PczBt78)**";
|
||||
|
||||
var embed = new EmbedBuilder()
|
||||
.Color(0xE74C3C)
|
||||
.Title("Internal error occurred")
|
||||
.Description($"For support, please send the error code above in {channelInfo} with a description of what you were doing at the time.")
|
||||
.Footer(new Embed.EmbedFooter(errorId))
|
||||
.Timestamp(now.ToDateTimeOffset().ToString("O"));
|
||||
var embed = CreateErrorEmbed(errorId, now);
|
||||
|
||||
try
|
||||
{
|
||||
await _rest.CreateMessage(channelId,
|
||||
new MessageRequest { Content = $"> **Error code:** `{errorId}`", Embeds = new[] { embed.Build() } });
|
||||
new MessageRequest { Content = $"> **Error code:** `{errorId}`", Embeds = new[] { embed } });
|
||||
|
||||
_logger.Information("Sent error message to {ChannelId} with error code {ErrorId}", channelId, errorId);
|
||||
_metrics.Measure.Meter.Mark(BotMetrics.ErrorMessagesSent, "sent");
|
||||
@@ -75,7 +107,22 @@ public class ErrorMessageService
|
||||
}
|
||||
}
|
||||
|
||||
private bool ShouldSendErrorMessage(ulong channelId, Instant now)
|
||||
private Embed CreateErrorEmbed(string errorId, Instant now)
|
||||
{
|
||||
var channelInfo = _botConfig.IsBetaBot
|
||||
? "**#beta-testing** on **[the support server *(click to join)*](https://discord.gg/THvbH59btW)**"
|
||||
: "**#bug-reports-and-errors** on **[the support server *(click to join)*](https://discord.gg/PczBt78)**";
|
||||
|
||||
return new EmbedBuilder()
|
||||
.Color(0xE74C3C)
|
||||
.Title("Internal error occurred")
|
||||
.Description($"For support, please send the error code above in {channelInfo} with a description of what you were doing at the time.")
|
||||
.Footer(new Embed.EmbedFooter(errorId))
|
||||
.Timestamp(now.ToDateTimeOffset().ToString("O"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
private bool ShouldSendErrorMessage(ulong? channelId, Instant now)
|
||||
{
|
||||
// if (_lastErrorInChannel.TryGetValue(channelId, out var lastErrorTime))
|
||||
|
||||
|
||||
@@ -1,34 +1,77 @@
|
||||
using App.Metrics;
|
||||
|
||||
using Autofac;
|
||||
|
||||
using Myriad.Cache;
|
||||
using Myriad.Gateway;
|
||||
using Myriad.Rest;
|
||||
using Myriad.Types;
|
||||
|
||||
using PluralKit.Core;
|
||||
|
||||
namespace PluralKit.Bot;
|
||||
|
||||
public class InteractionContext
|
||||
{
|
||||
private readonly ILifetimeScope _services;
|
||||
private readonly ILifetimeScope _provider;
|
||||
private readonly IMetrics _metrics;
|
||||
|
||||
public InteractionContext(InteractionCreateEvent evt, ILifetimeScope services)
|
||||
public InteractionContext(ILifetimeScope provider, InteractionCreateEvent evt, PKSystem system)
|
||||
{
|
||||
Event = evt;
|
||||
_services = services;
|
||||
System = system;
|
||||
Cache = provider.Resolve<IDiscordCache>();
|
||||
Rest = provider.Resolve<DiscordApiClient>();
|
||||
Repository = provider.Resolve<ModelRepository>();
|
||||
_metrics = provider.Resolve<IMetrics>();
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
internal readonly IDiscordCache Cache;
|
||||
internal readonly DiscordApiClient Rest;
|
||||
internal readonly ModelRepository Repository;
|
||||
public readonly PKSystem System;
|
||||
|
||||
public InteractionCreateEvent Event { get; }
|
||||
|
||||
public ulong GuildId => Event.GuildId;
|
||||
public ulong ChannelId => Event.ChannelId;
|
||||
public ulong? MessageId => Event.Message?.Id;
|
||||
public GuildMember? Member => Event.Member;
|
||||
public User User => Event.Member?.User ?? Event.User;
|
||||
public string Token => Event.Token;
|
||||
public string? CustomId => Event.Data?.CustomId;
|
||||
public IComponentContext Services => _provider;
|
||||
|
||||
public async Task Reply(string content)
|
||||
public async Task Execute<T>(ApplicationCommand? command, Func<T, Task> handler)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (_metrics.Measure.Timer.Time(BotMetrics.ApplicationCommandTime, new MetricTags("Application command", command?.Name ?? "null")))
|
||||
await handler(_provider.Resolve<T>());
|
||||
|
||||
_metrics.Measure.Meter.Mark(BotMetrics.ApplicationCommandsRun);
|
||||
}
|
||||
catch (PKError e)
|
||||
{
|
||||
await Reply($"{Emojis.Error} {e.Message}");
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// Got a complaint the old error was a bit too patronizing. Hopefully this is better?
|
||||
await Reply($"{Emojis.Error} Operation timed out, sorry. Try again, perhaps?");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Reply(string content = null, Embed[]? embeds = null)
|
||||
{
|
||||
await Respond(InteractionResponse.ResponseType.ChannelMessageWithSource,
|
||||
new InteractionApplicationCommandCallbackData { Content = content, Flags = Message.MessageFlags.Ephemeral });
|
||||
new InteractionApplicationCommandCallbackData
|
||||
{
|
||||
Content = content,
|
||||
Embeds = embeds,
|
||||
Flags = Message.MessageFlags.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
public async Task Ignore()
|
||||
@@ -49,8 +92,7 @@ public class InteractionContext
|
||||
public async Task Respond(InteractionResponse.ResponseType type,
|
||||
InteractionApplicationCommandCallbackData? data)
|
||||
{
|
||||
var rest = _services.Resolve<DiscordApiClient>();
|
||||
await rest.CreateInteractionResponse(Event.Id, Event.Token,
|
||||
await Rest.CreateInteractionResponse(Event.Id, Event.Token,
|
||||
new InteractionResponse { Type = type, Data = data });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user