2020-06-11 21:20:46 +00:00
using System ;
2020-06-14 20:19:12 +00:00
using System.Collections.Generic ;
2020-06-12 18:29:50 +00:00
using System.Linq ;
2020-12-20 10:38:26 +00:00
using System.Text ;
2020-12-21 02:16:48 +00:00
using System.Text.RegularExpressions ;
2020-06-11 21:20:46 +00:00
using System.Threading.Tasks ;
2020-06-14 20:19:12 +00:00
using App.Metrics ;
2020-12-22 12:15:26 +00: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 21:20:46 +00:00
using PluralKit.Core ;
using Serilog ;
namespace PluralKit.Bot
{
2020-06-12 18:29:50 +00:00
public class ProxyService
{
2020-06-13 20:20:24 +00:00
private static readonly TimeSpan MessageDeletionDelay = TimeSpan . FromMilliseconds ( 1000 ) ;
2020-06-12 18:29:50 +00:00
2020-06-12 21:13:21 +00:00
private readonly LogChannelService _logChannel ;
2020-06-13 17:36:43 +00:00
private readonly IDatabase _db ;
2020-08-29 11:46:27 +00:00
private readonly ModelRepository _repo ;
2020-06-12 21:13:21 +00:00
private readonly ILogger _logger ;
private readonly WebhookExecutorService _webhookExecutor ;
2020-06-12 18:29:50 +00:00
private readonly ProxyMatcher _matcher ;
2020-06-14 20:19:12 +00:00
private readonly IMetrics _metrics ;
2020-12-22 12:15:26 +00:00
private readonly IDiscordCache _cache ;
private readonly DiscordApiClient _rest ;
2020-06-12 18:29:50 +00:00
2020-08-29 11:46:27 +00:00
public ProxyService ( LogChannelService logChannel , ILogger logger ,
2020-12-22 12:15:26 +00:00
WebhookExecutorService webhookExecutor , IDatabase db , ProxyMatcher matcher , IMetrics metrics , ModelRepository repo , IDiscordCache cache , DiscordApiClient rest )
2020-06-11 21:20:46 +00:00
{
_logChannel = logChannel ;
_webhookExecutor = webhookExecutor ;
2020-06-12 18:29:50 +00:00
_db = db ;
_matcher = matcher ;
2020-06-14 20:19:12 +00:00
_metrics = metrics ;
2020-08-29 11:46:27 +00:00
_repo = repo ;
2020-12-22 12:15:26 +00:00
_cache = cache ;
_rest = rest ;
2020-06-11 21:20:46 +00:00
_logger = logger . ForContext < ProxyService > ( ) ;
}
2020-12-22 12:15:26 +00:00
public async Task < bool > HandleIncomingMessage ( Shard shard , MessageCreateEvent message , MessageContext ctx , Guild guild , Channel channel , bool allowAutoproxy , PermissionSet botPermissions )
2020-06-11 21:20:46 +00:00
{
2020-12-22 12:15:26 +00:00
if ( ! ShouldProxy ( channel , message , ctx ) )
return false ;
2020-06-12 18:29:50 +00:00
// Fetch members and try to match to a specific member
2020-06-13 16:31:20 +00:00
await using var conn = await _db . Obtain ( ) ;
2020-06-14 20:19:12 +00:00
List < ProxyMember > members ;
using ( _metrics . Measure . Timer . Time ( BotMetrics . ProxyMembersQueryTime ) )
2020-12-22 12:15:26 +00:00
members = ( await _repo . GetProxyMembers ( conn , message . Author . Id , message . GuildId ! . Value ) ) . ToList ( ) ;
2020-06-14 20:19:12 +00:00
2020-12-22 12:15:26 +00:00
if ( ! _matcher . TryMatch ( ctx , members , out var match , message . Content , message . Attachments . Length > 0 ,
2020-06-12 21:13:21 +00:00
allowAutoproxy ) ) return false ;
2020-06-12 18:29:50 +00:00
2021-06-14 15:31:14 +00: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." ) ;
2020-06-12 21:13:21 +00:00
// Permission check after proxy match so we don't get spammed when not actually proxying
2020-12-22 12:15:26 +00:00
if ( ! await CheckBotPermissionsOrError ( botPermissions , message . ChannelId ) )
return false ;
2020-11-26 05:04:40 +00:00
// this method throws, so no need to wrap it in an if statement
CheckProxyNameBoundsOrError ( match . Member . ProxyName ( ctx ) ) ;
2020-06-28 18:26:14 +00:00
2020-07-10 14:35:52 +00:00
// Check if the sender account can mention everyone/here + embed links
// we need to "mirror" these permissions when proxying to prevent exploits
2020-12-22 12:15:26 +00:00
var senderPermissions = PermissionExtensions . PermissionsFor ( guild , channel , message ) ;
var allowEveryone = senderPermissions . HasFlag ( PermissionSet . MentionEveryone ) ;
var allowEmbeds = senderPermissions . HasFlag ( PermissionSet . EmbedLinks ) ;
2020-06-12 18:29:50 +00:00
// Everything's in order, we can execute the proxy!
2020-11-15 14:07:20 +00:00
await ExecuteProxy ( shard , conn , message , ctx , match , allowEveryone , allowEmbeds ) ;
2020-06-12 21:13:21 +00:00
return true ;
2020-06-11 21:20:46 +00:00
}
2020-06-12 21:13:21 +00:00
2020-12-22 12:15:26 +00:00
private bool ShouldProxy ( Channel channel , Message msg , MessageContext ctx )
2020-06-12 21:13:21 +00:00
{
// Make sure author has a system
if ( ctx . SystemId = = null ) return false ;
// Make sure channel is a guild text channel and this is a normal message
2021-01-31 15:02:34 +00:00
if ( channel . Type ! = Channel . ChannelType . GuildText & & channel . Type ! = Channel . ChannelType . GuildNews ) return false ;
if ( msg . Type ! = Message . MessageType . Default & & msg . Type ! = Message . MessageType . Reply ) return false ;
2020-06-12 21:13:21 +00:00
// Make sure author is a normal user
2020-12-22 12:15:26 +00:00
if ( msg . Author . System = = true | | msg . Author . Bot | | msg . WebhookId ! = null ) return false ;
2020-06-12 21:13:21 +00:00
// Make sure proxying is enabled here
if ( ! ctx . ProxyEnabled | | ctx . InBlacklist ) return false ;
// Make sure we have either an attachment or message content
var isMessageBlank = msg . Content = = null | | msg . Content . Trim ( ) . Length = = 0 ;
2020-12-22 12:15:26 +00:00
if ( isMessageBlank & & msg . Attachments . Length = = 0 ) return false ;
2020-06-12 21:13:21 +00:00
// All good!
return true ;
}
2020-12-22 12:15:26 +00:00
private async Task ExecuteProxy ( Shard shard , IPKConnection conn , Message trigger , MessageContext ctx ,
2020-07-10 14:35:52 +00:00
ProxyMatch match , bool allowEveryone , bool allowEmbeds )
2020-06-11 21:20:46 +00:00
{
2020-12-20 10:38:26 +00:00
// Create reply embed
2020-12-22 12:15:26 +00:00
var embeds = new List < Embed > ( ) ;
2021-01-31 15:02:34 +00:00
if ( trigger . Type = = Message . MessageType . Reply & & trigger . MessageReference ? . ChannelId = = trigger . ChannelId )
2020-12-20 10:38:26 +00:00
{
2021-01-31 15:02:34 +00:00
var repliedTo = trigger . ReferencedMessage . Value ;
2021-01-14 02:21:56 +00:00
if ( repliedTo ! = null )
{
2021-01-31 15:02:34 +00:00
var nickname = await FetchReferencedMessageAuthorNickname ( trigger , repliedTo ) ;
2021-05-01 18:20:00 +00:00
var embed = CreateReplyEmbed ( match , trigger , repliedTo , nickname ) ;
2021-01-14 02:21:56 +00:00
if ( embed ! = null )
embeds . Add ( embed ) ;
}
// TODO: have a clean error for when message can't be fetched instead of just being silent
2020-12-20 10:38:26 +00:00
}
2020-06-12 18:29:50 +00:00
// Send the webhook
2020-07-10 14:35:52 +00:00
var content = match . ProxyContent ;
if ( ! allowEmbeds ) content = content . BreakLinkEmbeds ( ) ;
2020-08-29 11:46:27 +00:00
2020-12-22 12:15:26 +00:00
var proxyMessage = await _webhookExecutor . ExecuteWebhook ( new ProxyRequest
{
GuildId = trigger . GuildId ! . Value ,
ChannelId = trigger . ChannelId ,
2021-01-31 15:02:34 +00:00
Name = match . Member . ProxyName ( ctx ) ,
2020-12-22 12:15:26 +00:00
AvatarUrl = match . Member . ProxyAvatar ( ctx ) ,
Content = content ,
Attachments = trigger . Attachments ,
Embeds = embeds . ToArray ( ) ,
AllowEveryone = allowEveryone ,
} ) ;
2020-11-15 14:07:20 +00:00
await HandleProxyExecutedActions ( shard , conn , ctx , trigger , proxyMessage , match ) ;
2020-11-15 13:34:49 +00:00
}
2021-01-31 15:02:34 +00:00
private async Task < string? > FetchReferencedMessageAuthorNickname ( Message trigger , Message referenced )
2020-12-20 10:38:26 +00:00
{
2021-01-31 15:02:34 +00:00
if ( referenced . WebhookId ! = null )
return null ;
2020-12-20 10:38:26 +00:00
try
{
2021-01-31 15:02:34 +00:00
var member = await _rest . GetGuildMember ( trigger . GuildId ! . Value , referenced . Author . Id ) ;
return member ? . Nick ;
2020-12-20 10:38:26 +00:00
}
2021-01-31 15:02:34 +00:00
catch ( ForbiddenException )
2020-12-20 10:38:26 +00:00
{
2021-01-31 15:02:34 +00: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 ;
2020-12-20 10:38:26 +00:00
}
2020-12-20 15:58:52 +00:00
}
2021-05-01 18:20:00 +00:00
private Embed CreateReplyEmbed ( ProxyMatch match , Message trigger , Message repliedTo , string? nickname )
2020-12-20 15:58:52 +00:00
{
2021-01-31 15:02:34 +00:00
// repliedTo doesn't have a GuildId field :/
var jumpLink = $"https://discord.com/channels/{trigger.GuildId}/{repliedTo.ChannelId}/{repliedTo.Id}" ;
2020-12-22 12:15:26 +00:00
2020-12-20 10:38:26 +00:00
var content = new StringBuilder ( ) ;
2021-01-31 15:02:34 +00:00
var hasContent = ! string . IsNullOrWhiteSpace ( repliedTo . Content ) ;
2020-12-20 15:58:52 +00:00
if ( hasContent )
{
2021-01-31 15:02:34 +00:00
var msg = repliedTo . Content ;
2020-12-21 02:16:48 +00:00
if ( msg . Length > 100 )
{
2021-01-31 15:02:34 +00:00
msg = repliedTo . Content . Substring ( 0 , 100 ) ;
2021-06-25 10:54:49 +00:00
var endsWithOpenMention = Regex . IsMatch ( msg , @"<[at]?[@#:][!&]?(\w+:)?(\d+)?(:[tTdDfFR])?$" ) ;
if ( endsWithOpenMention )
2021-06-25 10:34:44 +00:00
{
2021-06-25 10:54:49 +00:00
var mentionTail = repliedTo . Content . Substring ( 100 ) . Split ( ">" ) [ 0 ] ;
2021-06-25 11:15:25 +00:00
if ( repliedTo . Content . Contains ( msg + mentionTail + ">" ) )
2021-06-25 10:54:49 +00:00
msg + = mentionTail + ">" ;
2021-06-25 10:34:44 +00:00
}
2021-06-25 13:05:25 +00: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-06-25 10:34:44 +00:00
2021-01-31 15:02:34 +00:00
var spoilersInOriginalString = Regex . Matches ( repliedTo . Content , @"\|\|" ) . Count ;
2020-12-21 02:16:48 +00:00
var spoilersInTruncatedString = Regex . Matches ( msg , @"\|\|" ) . Count ;
if ( spoilersInTruncatedString % 2 = = 1 & & spoilersInOriginalString % 2 = = 0 )
msg + = "||" ;
2021-06-25 13:05:25 +00:00
if ( msg ! = repliedTo . Content )
msg + = "…" ;
2020-12-21 02:16:48 +00:00
}
2020-12-22 12:15:26 +00:00
content . Append ( $"**[Reply to:]({jumpLink})** " ) ;
2020-12-21 02:16:48 +00:00
content . Append ( msg ) ;
2021-01-31 15:02:34 +00:00
if ( repliedTo . Attachments . Length > 0 )
2020-12-20 15:58:52 +00:00
content . Append ( $" {Emojis.Paperclip}" ) ;
}
else
{
2020-12-22 12:15:26 +00:00
content . Append ( $"*[(click to see attachment)]({jumpLink})*" ) ;
2020-12-20 15:58:52 +00:00
}
2020-12-20 10:38:26 +00:00
2021-01-31 15:02:34 +00:00
var username = nickname ? ? repliedTo . Author . Username ;
var avatarUrl = $"https://cdn.discordapp.com/avatars/{repliedTo.Author.Id}/{repliedTo.Author.Avatar}.png" ;
2020-12-22 12:15:26 +00:00
return new Embed
{
2020-12-20 15:58:52 +00:00
// unicodes: [three-per-em space] [left arrow emoji] [force emoji presentation]
2020-12-22 12:15:26 +00:00
Author = new ( $"{username}\u2004\u21a9\ufe0f" , IconUrl : avatarUrl ) ,
2021-05-01 18:20:00 +00:00
Description = content . ToString ( ) ,
Color = match . Member . Color ? . ToDiscordColor ( ) ,
2020-12-22 12:15:26 +00:00
} ;
2020-12-20 10:38:26 +00:00
}
2020-12-22 12:15:26 +00:00
private async Task HandleProxyExecutedActions ( Shard shard , IPKConnection conn , MessageContext ctx ,
Message triggerMessage , Message proxyMessage ,
2020-11-15 13:34:49 +00:00
ProxyMatch match )
{
Task SaveMessageInDatabase ( ) = > _repo . AddMessage ( conn , new PKMessage
2020-08-29 11:46:27 +00:00
{
2020-11-15 13:34:49 +00:00
Channel = triggerMessage . ChannelId ,
2020-12-22 12:15:26 +00:00
Guild = triggerMessage . GuildId ,
2020-08-29 11:46:27 +00:00
Member = match . Member . Id ,
2020-11-15 13:34:49 +00:00
Mid = proxyMessage . Id ,
OriginalMid = triggerMessage . Id ,
Sender = triggerMessage . Author . Id
2020-08-29 11:46:27 +00:00
} ) ;
2020-06-28 18:26:14 +00:00
2020-12-22 12:15:26 +00:00
Task LogMessageToChannel ( ) = > _logChannel . LogMessage ( ctx , match , triggerMessage , proxyMessage . Id ) . AsTask ( ) ;
2020-11-15 13:34:49 +00:00
async Task DeleteProxyTriggerMessage ( )
2020-06-11 21:20:46 +00:00
{
2020-06-13 16:31:20 +00:00
// Wait a second or so before deleting the original message
await Task . Delay ( MessageDeletionDelay ) ;
try
{
2020-12-22 12:15:26 +00:00
await _rest . DeleteMessage ( triggerMessage . ChannelId , triggerMessage . Id ) ;
2020-06-13 16:31:20 +00:00
}
catch ( NotFoundException )
{
2020-11-15 13:34:49 +00: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-13 16:31:20 +00:00
}
2020-06-11 21:20:46 +00:00
}
2020-06-13 16:31:20 +00:00
// Run post-proxy actions (simultaneously; order doesn't matter)
// Note that only AddMessage is using our passed-in connection, careful not to pass it elsewhere and run into conflicts
await Task . WhenAll (
2020-11-15 13:34:49 +00:00
DeleteProxyTriggerMessage ( ) ,
SaveMessageInDatabase ( ) ,
LogMessageToChannel ( )
2020-06-13 16:31:20 +00:00
) ;
2020-06-11 21:20:46 +00:00
}
2020-11-15 13:34:49 +00:00
2020-12-22 12:15:26 +00:00
private async Task HandleTriggerAlreadyDeleted ( Message proxyMessage )
2020-11-15 13:34:49 +00:00
{
// 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
try
{
2020-12-22 12:15:26 +00:00
await _rest . DeleteMessage ( proxyMessage . ChannelId , proxyMessage . Id ) ;
2020-11-15 13:34:49 +00:00
}
catch ( NotFoundException ) { }
catch ( UnauthorizedException ) { }
}
2020-12-22 12:15:26 +00:00
private async Task < bool > CheckBotPermissionsOrError ( PermissionSet permissions , ulong responseChannel )
2020-06-11 21:20:46 +00:00
{
// If we can't send messages at all, just bail immediately.
// 2020-04-22: Manage Messages does *not* override a lack of Send Messages.
2020-12-22 12:15:26 +00:00
if ( ! permissions . HasFlag ( PermissionSet . SendMessages ) )
return false ;
2020-06-11 21:20:46 +00:00
2020-12-22 12:15:26 +00:00
if ( ! permissions . HasFlag ( PermissionSet . ManageWebhooks ) )
2020-06-11 21:20:46 +00:00
{
// todo: PKError-ify these
2020-12-22 12:15:26 +00:00
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."
} ) ;
2020-06-11 21:20:46 +00:00
return false ;
}
2020-12-22 12:15:26 +00:00
if ( ! permissions . HasFlag ( PermissionSet . ManageMessages ) )
2020-06-11 21:20:46 +00:00
{
2020-12-22 12:15:26 +00: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."
} ) ;
2020-06-11 21:20:46 +00:00
return false ;
}
return true ;
}
2020-06-12 21:13:21 +00:00
2020-11-26 05:04:40 +00:00
private void CheckProxyNameBoundsOrError ( string proxyName )
2020-06-12 18:29:50 +00:00
{
if ( proxyName . Length > Limits . MaxProxyNameLength ) throw Errors . ProxyNameTooLong ( proxyName ) ;
}
2020-06-11 21:20:46 +00:00
}
2021-05-01 18:32:37 +00:00
}