2020-12-20 11:38:26 +01:00
using System.Text ;
2020-12-21 03:16:48 +01:00
using System.Text.RegularExpressions ;
2020-06-11 23:20:46 +02:00
2020-06-14 22:19:12 +02:00
using App.Metrics ;
2020-12-22 13:15:26 +01:00
using Myriad.Cache ;
using Myriad.Extensions ;
using Myriad.Gateway ;
using Myriad.Rest ;
using Myriad.Rest.Exceptions ;
using Myriad.Rest.Types.Requests ;
using Myriad.Types ;
2020-06-11 23:20:46 +02:00
using PluralKit.Core ;
using Serilog ;
2021-11-26 21:10:56 -05:00
namespace PluralKit.Bot ;
public class ProxyService
2020-06-11 23:20:46 +02:00
{
2021-11-26 21:10:56 -05:00
private static readonly TimeSpan MessageDeletionDelay = TimeSpan . FromMilliseconds ( 1000 ) ;
private readonly IDiscordCache _cache ;
private readonly IDatabase _db ;
private readonly DispatchService _dispatch ;
private readonly LastMessageCacheService _lastMessage ;
private readonly LogChannelService _logChannel ;
private readonly ILogger _logger ;
private readonly ProxyMatcher _matcher ;
private readonly IMetrics _metrics ;
private readonly ModelRepository _repo ;
private readonly DiscordApiClient _rest ;
private readonly WebhookExecutorService _webhookExecutor ;
public ProxyService ( LogChannelService logChannel , ILogger logger , WebhookExecutorService webhookExecutor ,
DispatchService dispatch , IDatabase db , ProxyMatcher matcher , IMetrics metrics , ModelRepository repo ,
IDiscordCache cache , DiscordApiClient rest , LastMessageCacheService lastMessage )
2020-06-12 20:29:50 +02:00
{
2021-11-26 21:10:56 -05:00
_logChannel = logChannel ;
_webhookExecutor = webhookExecutor ;
_dispatch = dispatch ;
_db = db ;
_matcher = matcher ;
_metrics = metrics ;
_repo = repo ;
_cache = cache ;
_lastMessage = lastMessage ;
_rest = rest ;
_logger = logger . ForContext < ProxyService > ( ) ;
}
2020-06-11 23:20:46 +02:00
2021-11-26 21:10:56 -05:00
public async Task < bool > HandleIncomingMessage ( Shard shard , MessageCreateEvent message , MessageContext ctx ,
Guild guild , Channel channel , bool allowAutoproxy , PermissionSet botPermissions )
{
if ( ! ShouldProxy ( channel , message , ctx ) )
return false ;
2021-09-22 13:48:34 -04:00
2021-11-26 21:10:56 -05:00
var rootChannel = await _cache . GetRootChannel ( message . ChannelId ) ;
2021-07-15 12:41:19 +02:00
2021-11-26 21:10:56 -05:00
List < ProxyMember > members ;
// Fetch members and try to match to a specific member
using ( _metrics . Measure . Timer . Time ( BotMetrics . ProxyMembersQueryTime ) )
members = ( await _repo . GetProxyMembers ( message . Author . Id , message . GuildId ! . Value ) ) . ToList ( ) ;
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
if ( ! _matcher . TryMatch ( ctx , members , out var match , message . Content , message . Attachments . Length > 0 ,
2020-06-12 23:13:21 +02:00
allowAutoproxy ) ) return false ;
2020-06-12 20:29:50 +02:00
2021-11-26 21:10:56 -05:00
// this is hopefully temporary, so not putting it into a separate method
if ( message . Content ! = null & & message . Content . Length > 2000 )
throw new PKError ( "PluralKit cannot proxy messages over 2000 characters in length." ) ;
2021-06-14 11:31:14 -04:00
2021-11-26 21:10:56 -05:00
// Permission check after proxy match so we don't get spammed when not actually proxying
if ( ! await CheckBotPermissionsOrError ( botPermissions , rootChannel . Id ) )
return false ;
2020-11-26 00:04:40 -05:00
2021-11-26 21:10:56 -05:00
// this method throws, so no need to wrap it in an if statement
CheckProxyNameBoundsOrError ( match . Member . ProxyName ( ctx ) ) ;
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
// Check if the sender account can mention everyone/here + embed links
// we need to "mirror" these permissions when proxying to prevent exploits
var senderPermissions = PermissionExtensions . PermissionsFor ( guild , rootChannel , message ) ;
var allowEveryone = senderPermissions . HasFlag ( PermissionSet . MentionEveryone ) ;
var allowEmbeds = senderPermissions . HasFlag ( PermissionSet . EmbedLinks ) ;
2020-06-12 20:29:50 +02:00
2021-11-26 21:10:56 -05:00
// Everything's in order, we can execute the proxy!
await ExecuteProxy ( shard , message , ctx , match , allowEveryone , allowEmbeds ) ;
return true ;
}
2020-06-12 23:13:21 +02:00
2021-11-26 21:10:56 -05:00
public bool ShouldProxy ( Channel channel , Message msg , MessageContext ctx )
{
// Make sure author has a system
if ( ctx . SystemId = = null )
throw new ProxyChecksFailedException ( Errors . NoSystemError . Message ) ;
// Make sure channel is a guild text channel and this is a normal message
if ( ! DiscordUtils . IsValidGuildChannel ( channel ) )
throw new ProxyChecksFailedException ( "This channel is not a text channel." ) ;
if ( msg . Type ! = Message . MessageType . Default & & msg . Type ! = Message . MessageType . Reply )
throw new ProxyChecksFailedException ( "This message is not a normal message." ) ;
// Make sure author is a normal user
if ( msg . Author . System = = true | | msg . Author . Bot | | msg . WebhookId ! = null )
throw new ProxyChecksFailedException ( "This message was not sent by a normal user." ) ;
// Make sure proxying is enabled here
if ( ctx . InBlacklist )
throw new ProxyChecksFailedException (
"Proxying was disabled in this channel by a server administrator (via the proxy blacklist)." ) ;
// Make sure the system has proxying enabled in the server
if ( ! ctx . ProxyEnabled )
throw new ProxyChecksFailedException (
"Your system has proxying disabled in this server. Type `pk;proxy on` to enable it." ) ;
// Make sure we have either an attachment or message content
var isMessageBlank = msg . Content = = null | | msg . Content . Trim ( ) . Length = = 0 ;
if ( isMessageBlank & & msg . Attachments . Length = = 0 )
throw new ProxyChecksFailedException ( "Message cannot be blank." ) ;
// All good!
return true ;
}
2020-06-12 23:13:21 +02:00
2021-11-26 21:10:56 -05:00
private async Task ExecuteProxy ( Shard shard , Message trigger , MessageContext ctx ,
ProxyMatch match , bool allowEveryone , bool allowEmbeds )
{
// Create reply embed
var embeds = new List < Embed > ( ) ;
if ( trigger . Type = = Message . MessageType . Reply & & trigger . MessageReference ? . ChannelId = = trigger . ChannelId )
2020-06-11 23:20:46 +02:00
{
2021-11-26 21:10:56 -05:00
var repliedTo = trigger . ReferencedMessage . Value ;
if ( repliedTo ! = null )
2020-12-20 11:38:26 +01:00
{
2021-11-26 21:10:56 -05:00
var ( nickname , avatar ) = await FetchReferencedMessageAuthorInfo ( trigger , repliedTo ) ;
var embed = CreateReplyEmbed ( match , trigger , repliedTo , nickname , avatar ) ;
if ( embed ! = null )
embeds . Add ( embed ) ;
2020-12-20 11:38:26 +01:00
}
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
// TODO: have a clean error for when message can't be fetched instead of just being silent
}
2020-08-29 13:46:27 +02:00
2021-11-26 21:10:56 -05:00
// Send the webhook
var content = match . ProxyContent ;
if ( ! allowEmbeds ) content = content . BreakLinkEmbeds ( ) ;
2021-07-15 12:41:19 +02:00
2021-11-26 21:10:56 -05:00
var messageChannel = await _cache . GetChannel ( trigger . ChannelId ) ;
var rootChannel = await _cache . GetRootChannel ( trigger . ChannelId ) ;
var threadId = messageChannel . IsThread ( ) ? messageChannel . Id : ( ulong? ) null ;
var guild = await _cache . GetGuild ( trigger . GuildId . Value ) ;
2020-11-15 14:34:49 +01:00
2021-11-26 21:10:56 -05:00
var proxyMessage = await _webhookExecutor . ExecuteWebhook ( new ProxyRequest
2020-12-20 11:38:26 +01:00
{
2021-11-26 21:10:56 -05:00
GuildId = trigger . GuildId ! . Value ,
ChannelId = rootChannel . Id ,
ThreadId = threadId ,
Name = await FixSameName ( messageChannel . Id , ctx , match . Member ) ,
AvatarUrl = AvatarUtils . TryRewriteCdnUrl ( match . Member . ProxyAvatar ( ctx ) ) ,
Content = content ,
Attachments = trigger . Attachments ,
FileSizeLimit = guild . FileSizeLimit ( ) ,
Embeds = embeds . ToArray ( ) ,
AllowEveryone = allowEveryone
} ) ;
await HandleProxyExecutedActions ( shard , ctx , trigger , proxyMessage , match ) ;
}
2021-01-31 16:02:34 +01:00
2021-11-26 21:10:56 -05:00
private async Task < ( string? , string? ) > FetchReferencedMessageAuthorInfo ( Message trigger , Message referenced )
{
if ( referenced . WebhookId ! = null )
return ( null , null ) ;
2020-12-20 16:58:52 +01:00
2021-11-26 21:10:56 -05:00
try
{
var member = await _rest . GetGuildMember ( trigger . GuildId ! . Value , referenced . Author . Id ) ;
return ( member ? . Nick , member ? . Avatar ) ;
}
catch ( ForbiddenException )
2020-12-20 16:58:52 +01:00
{
2021-11-26 21:10:56 -05:00
_logger . Warning (
"Failed to fetch member {UserId} in guild {GuildId} when getting reply nickname, falling back to username" ,
referenced . Author . Id , trigger . GuildId ! . Value ) ;
return ( null , null ) ;
}
}
private Embed CreateReplyEmbed ( ProxyMatch match , Message trigger , Message repliedTo , string? nickname ,
string? avatar )
{
// repliedTo doesn't have a GuildId field :/
var jumpLink = $"https://discord.com/channels/{trigger.GuildId}/{repliedTo.ChannelId}/{repliedTo.Id}" ;
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
var content = new StringBuilder ( ) ;
2020-12-20 11:38:26 +01:00
2021-11-26 21:10:56 -05:00
var hasContent = ! string . IsNullOrWhiteSpace ( repliedTo . Content ) ;
if ( hasContent )
{
var msg = repliedTo . Content ;
if ( msg . Length > 100 )
2020-12-20 16:58:52 +01:00
{
2021-11-26 21:10:56 -05:00
msg = repliedTo . Content . Substring ( 0 , 100 ) ;
var endsWithOpenMention = Regex . IsMatch ( msg , @"<[at]?[@#:][!&]?(\w+:)?(\d+)?(:[tTdDfFR])?$" ) ;
if ( endsWithOpenMention )
2020-12-21 03:16:48 +01:00
{
2021-11-26 21:10:56 -05:00
var mentionTail = repliedTo . Content . Substring ( 100 ) . Split ( ">" ) [ 0 ] ;
if ( repliedTo . Content . Contains ( msg + mentionTail + ">" ) )
msg + = mentionTail + ">" ;
2020-12-21 03:16:48 +01:00
}
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
var endsWithUrl = Regex . IsMatch ( msg ,
@"(http|https)(:\/\/)?(www\.)?([-a-zA-Z0-9@:%._\+~#=]{1,256})?\.?([a-zA-Z0-9()]{1,6})?\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$" ) ;
if ( endsWithUrl )
{
var urlTail = repliedTo . Content . Substring ( 100 ) . Split ( " " ) [ 0 ] ;
msg + = urlTail + " " ;
}
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
var spoilersInOriginalString = Regex . Matches ( repliedTo . Content , @"\|\|" ) . Count ;
var spoilersInTruncatedString = Regex . Matches ( msg , @"\|\|" ) . Count ;
if ( spoilersInTruncatedString % 2 = = 1 & & spoilersInOriginalString % 2 = = 0 )
msg + = "||" ;
if ( msg ! = repliedTo . Content )
msg + = "…" ;
}
2020-12-22 13:15:26 +01:00
2021-11-26 21:10:56 -05:00
content . Append ( $"**[Reply to:]({jumpLink})** " ) ;
content . Append ( msg ) ;
if ( repliedTo . Attachments . Length > 0 )
content . Append ( $" {Emojis.Paperclip}" ) ;
2020-12-20 11:38:26 +01:00
}
2021-11-26 21:10:56 -05:00
else
{
content . Append ( $"*[(click to see attachment)]({jumpLink})*" ) ;
}
var username = nickname ? ? repliedTo . Author . Username ;
var avatarUrl = avatar ! = null
? $"https://cdn.discordapp.com/guilds/{trigger.GuildId}/users/{repliedTo.Author.Id}/avatars/{avatar}.png"
: $"https://cdn.discordapp.com/avatars/{repliedTo.Author.Id}/{repliedTo.Author.Avatar}.png" ;
2020-12-20 11:38:26 +01:00
2021-11-26 21:10:56 -05:00
return new Embed
2021-08-03 13:44:22 -04:00
{
2021-11-26 21:10:56 -05:00
// unicodes: [three-per-em space] [left arrow emoji] [force emoji presentation]
Author = new Embed . EmbedAuthor ( $"{username}\u2004\u21a9\ufe0f" , IconUrl : avatarUrl ) ,
Description = content . ToString ( ) ,
Color = match . Member . Color ? . ToDiscordColor ( )
} ;
}
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
private async Task < string > FixSameName ( ulong channelId , MessageContext ctx , ProxyMember member )
{
var proxyName = member . ProxyName ( ctx ) ;
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
var lastMessage = _lastMessage . GetLastMessage ( channelId ) ? . Previous ;
if ( lastMessage = = null )
// cache is out of date or channel is empty.
return proxyName ;
2021-08-03 13:44:22 -04:00
2021-11-26 21:10:56 -05:00
var pkMessage = await _db . Execute ( conn = > _repo . GetMessage ( conn , lastMessage . Id ) ) ;
2021-08-03 13:44:22 -04:00
2021-11-26 21:10:56 -05:00
if ( lastMessage . AuthorUsername = = proxyName )
{
// last message wasn't proxied by us, but somehow has the same name
// it's probably from a different webhook (Tupperbox?) but let's fix it anyway!
if ( pkMessage = = null )
2021-08-03 13:44:22 -04:00
return FixSameNameInner ( proxyName ) ;
2021-11-26 21:10:56 -05:00
// last message was proxied by a different member
if ( pkMessage . Member . Id ! = member . Id )
return FixSameNameInner ( proxyName ) ;
2021-08-03 13:44:22 -04:00
}
2021-11-26 21:10:56 -05:00
// if we fixed the name last message and it's the same member proxying, we want to fix it again
if ( lastMessage . AuthorUsername = = FixSameNameInner ( proxyName ) & & pkMessage ? . Member . Id = = member . Id )
return FixSameNameInner ( proxyName ) ;
2021-08-03 13:44:22 -04:00
2021-11-26 21:10:56 -05:00
// No issues found, current proxy name is fine.
return proxyName ;
}
2021-08-04 00:41:51 -04:00
2021-11-26 21:10:56 -05:00
private string FixSameNameInner ( string name )
= > $"{name}\u17b5" ;
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
private async Task HandleProxyExecutedActions ( Shard shard , MessageContext ctx ,
Message triggerMessage , Message proxyMessage , ProxyMatch match )
{
var sentMessage = new PKMessage
{
Channel = triggerMessage . ChannelId ,
Guild = triggerMessage . GuildId ,
Member = match . Member . Id ,
Mid = proxyMessage . Id ,
OriginalMid = triggerMessage . Id ,
Sender = triggerMessage . Author . Id
} ;
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
Task SaveMessageInDatabase ( )
= > _repo . AddMessage ( sentMessage ) ;
2021-11-19 11:14:40 -05:00
2021-11-26 21:10:56 -05:00
Task LogMessageToChannel ( ) = >
_logChannel . LogMessage ( ctx , sentMessage , triggerMessage , proxyMessage ) . AsTask ( ) ;
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
Task DispatchWebhook ( ) = > _dispatch . Dispatch ( ctx . SystemId . Value , sentMessage ) ;
2020-11-15 14:34:49 +01:00
2021-11-26 21:10:56 -05:00
async Task DeleteProxyTriggerMessage ( )
2020-11-15 14:34:49 +01:00
{
2021-11-26 21:10:56 -05:00
// Wait a second or so before deleting the original message
await Task . Delay ( MessageDeletionDelay ) ;
2020-11-15 14:34:49 +01:00
try
{
2021-11-26 21:10:56 -05:00
await _rest . DeleteMessage ( triggerMessage . ChannelId , triggerMessage . Id ) ;
2020-11-15 14:34:49 +01:00
}
2021-11-26 21:10:56 -05:00
catch ( NotFoundException )
2020-06-11 23:20:46 +02:00
{
2021-11-26 21:10:56 -05:00
_logger . Debug (
"Trigger message {TriggerMessageId} was already deleted when we attempted to; deleting proxy message {ProxyMessageId} also" ,
triggerMessage . Id , proxyMessage . Id ) ;
await HandleTriggerAlreadyDeleted ( proxyMessage ) ;
// Swallow the exception, we don't need it
2020-06-11 23:20:46 +02:00
}
2021-11-26 21:10:56 -05:00
}
2020-06-11 23:20:46 +02:00
2021-11-26 21:10:56 -05:00
// Run post-proxy actions (simultaneously; order doesn't matter)
await Task . WhenAll (
DeleteProxyTriggerMessage ( ) ,
SaveMessageInDatabase ( ) ,
LogMessageToChannel ( ) ,
DispatchWebhook ( )
) ;
}
private async Task HandleTriggerAlreadyDeleted ( Message proxyMessage )
{
// If a trigger message is deleted before we get to delete it, we can assume a mod bot or similar got to it
// In this case we should also delete the now-proxied message.
// This is going to hit the message delete event handler also, so that'll do the cleanup for us
2020-06-11 23:20:46 +02:00
2021-11-26 21:10:56 -05:00
try
{
await _rest . DeleteMessage ( proxyMessage . ChannelId , proxyMessage . Id ) ;
2020-06-11 23:20:46 +02:00
}
2021-11-26 21:10:56 -05:00
catch ( NotFoundException ) { }
catch ( UnauthorizedException ) { }
}
2020-06-12 23:13:21 +02:00
2021-11-26 21:10:56 -05:00
private async Task < bool > CheckBotPermissionsOrError ( PermissionSet permissions , ulong responseChannel )
{
// If we can't send messages at all, just bail immediately.
// 2020-04-22: Manage Messages does *not* override a lack of Send Messages.
if ( ! permissions . HasFlag ( PermissionSet . SendMessages ) )
return false ;
if ( ! permissions . HasFlag ( PermissionSet . ManageWebhooks ) )
2020-06-12 20:29:50 +02:00
{
2021-11-26 21:10:56 -05:00
// todo: PKError-ify these
await _rest . CreateMessage ( responseChannel , new MessageRequest
{
Content = $"{Emojis.Error} PluralKit does not have the *Manage Webhooks* permission in this channel, and thus cannot proxy messages."
+ " Please contact a server administrator to remedy this."
} ) ;
return false ;
2020-06-12 20:29:50 +02:00
}
2021-11-26 21:10:56 -05:00
if ( ! permissions . HasFlag ( PermissionSet . ManageMessages ) )
2021-08-03 21:06:14 -04:00
{
2021-11-26 21:10:56 -05:00
await _rest . CreateMessage ( responseChannel , new MessageRequest
{
Content = $"{Emojis.Error} PluralKit does not have the *Manage Messages* permission in this channel, and thus cannot delete the original trigger message."
+ " Please contact a server administrator to remedy this."
} ) ;
return false ;
2021-08-03 21:06:14 -04:00
}
2021-11-26 21:10:56 -05:00
return true ;
}
private void CheckProxyNameBoundsOrError ( string proxyName )
{
if ( proxyName . Length > Limits . MaxProxyNameLength ) throw Errors . ProxyNameTooLong ( proxyName ) ;
}
public class ProxyChecksFailedException : Exception
{
public ProxyChecksFailedException ( string message ) : base ( message ) { }
2020-06-11 23:20:46 +02:00
}
2021-08-27 11:03:47 -04:00
}