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-06-11 21:20:46 +00:00
using System.Threading.Tasks ;
2020-06-14 20:19:12 +00:00
using App.Metrics ;
2020-06-11 21:20:46 +00:00
using DSharpPlus ;
using DSharpPlus.Entities ;
using DSharpPlus.Exceptions ;
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-06-12 18:29:50 +00:00
2020-08-29 11:46:27 +00:00
public ProxyService ( LogChannelService logChannel , ILogger logger ,
WebhookExecutorService webhookExecutor , IDatabase db , ProxyMatcher matcher , IMetrics metrics , ModelRepository repo )
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-06-11 21:20:46 +00:00
_logger = logger . ForContext < ProxyService > ( ) ;
}
2020-11-15 14:07:20 +00:00
public async Task < bool > HandleIncomingMessage ( DiscordClient shard , DiscordMessage message , MessageContext ctx , bool allowAutoproxy )
2020-06-11 21:20:46 +00:00
{
2020-06-12 21:13:21 +00:00
if ( ! ShouldProxy ( 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-08-29 11:46:27 +00:00
members = ( await _repo . GetProxyMembers ( conn , message . Author . Id , message . Channel . GuildId ) ) . ToList ( ) ;
2020-06-14 20:19:12 +00:00
2020-06-12 21:13:21 +00:00
if ( ! _matcher . TryMatch ( ctx , members , out var match , message . Content , message . Attachments . Count > 0 ,
allowAutoproxy ) ) return false ;
2020-06-12 18:29:50 +00:00
2020-06-12 21:13:21 +00:00
// Permission check after proxy match so we don't get spammed when not actually proxying
if ( ! await CheckBotPermissionsOrError ( message . Channel ) ) return false ;
2020-06-12 21:30:10 +00:00
if ( ! CheckProxyNameBoundsOrError ( match . Member . ProxyName ( ctx ) ) ) return false ;
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
var senderPermissions = message . Channel . PermissionsInSync ( message . Author ) ;
var allowEveryone = ( senderPermissions & Permissions . MentionEveryone ) ! = 0 ;
var allowEmbeds = ( senderPermissions & Permissions . EmbedLinks ) ! = 0 ;
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
private bool ShouldProxy ( DiscordMessage msg , MessageContext ctx )
{
// 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
2020-11-25 22:13:31 +00:00
if ( ( msg . Channel . Type ! = ChannelType . Text & & msg . Channel . Type ! = ChannelType . News ) | | msg . MessageType ! = MessageType . Default ) return false ;
2020-06-12 21:13:21 +00:00
// Make sure author is a normal user
if ( msg . Author . IsSystem = = true | | msg . Author . IsBot | | msg . WebhookMessage ) return false ;
// 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 ;
if ( isMessageBlank & & msg . Attachments . Count = = 0 ) return false ;
// All good!
return true ;
}
2020-11-15 14:07:20 +00:00
private async Task ExecuteProxy ( DiscordClient shard , IPKConnection conn , DiscordMessage 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-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-11-26 05:01:19 +00:00
var proxyMessage = await _webhookExecutor . ExecuteWebhook ( trigger . Channel , FixSingleCharacterName ( match . Member . ProxyName ( ctx ) ) ,
2020-06-12 21:30:10 +00:00
match . Member . ProxyAvatar ( ctx ) ,
2020-07-10 14:35:52 +00:00
content , trigger . Attachments , allowEveryone ) ;
2020-08-29 11:46:27 +00:00
2020-11-15 14:07:20 +00:00
await HandleProxyExecutedActions ( shard , conn , ctx , trigger , proxyMessage , match ) ;
2020-11-15 13:34:49 +00:00
}
2020-11-15 14:07:20 +00:00
private async Task HandleProxyExecutedActions ( DiscordClient shard , IPKConnection conn , MessageContext ctx ,
2020-11-15 13:34:49 +00:00
DiscordMessage triggerMessage , DiscordMessage proxyMessage ,
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 ,
Guild = triggerMessage . Channel . 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-11-15 14:07:20 +00:00
Task LogMessageToChannel ( ) = > _logChannel . LogMessage ( shard , 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-11-15 13:34:49 +00:00
await triggerMessage . DeleteAsync ( ) ;
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
private async Task HandleTriggerAlreadyDeleted ( DiscordMessage 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
try
{
await proxyMessage . DeleteAsync ( ) ;
}
catch ( NotFoundException ) { }
catch ( UnauthorizedException ) { }
}
2020-06-12 18:29:50 +00:00
private async Task < bool > CheckBotPermissionsOrError ( DiscordChannel channel )
2020-06-11 21:20:46 +00:00
{
var permissions = channel . BotPermissions ( ) ;
// 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 & Permissions . SendMessages ) = = 0 ) return false ;
if ( ( permissions & Permissions . ManageWebhooks ) = = 0 )
{
// todo: PKError-ify these
2020-06-20 15:33:10 +00:00
await channel . SendMessageFixedAsync (
2020-06-11 21:20:46 +00:00
$"{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 ;
}
if ( ( permissions & Permissions . ManageMessages ) = = 0 )
{
2020-06-20 15:33:10 +00:00
await channel . SendMessageFixedAsync (
2020-06-11 21:20:46 +00:00
$"{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 ;
}
return true ;
}
2020-06-12 21:13:21 +00:00
2020-11-26 05:01:19 +00:00
private string FixSingleCharacterName ( string proxyName )
{
if ( proxyName . Length = = 1 ) return proxyName + = "\u17b5" ;
else return proxyName ;
}
2020-06-12 21:30:10 +00:00
private bool CheckProxyNameBoundsOrError ( string proxyName )
2020-06-12 18:29:50 +00:00
{
2020-11-26 05:01:19 +00:00
// if (proxyName.Length < 2) throw Errors.ProxyNameTooShort(proxyName);
2020-06-12 18:29:50 +00:00
if ( proxyName . Length > Limits . MaxProxyNameLength ) throw Errors . ProxyNameTooLong ( proxyName ) ;
2020-06-12 21:13:21 +00:00
2020-06-12 18:29:50 +00:00
// TODO: this never returns false as it throws instead, should this happen?
return true ;
}
2020-06-11 21:20:46 +00:00
}
2020-06-12 18:29:50 +00:00
}