2020-07-18 11:53:02 +00:00
using System ;
using System.Collections.Generic ;
2020-07-06 17:50:39 +00:00
using System.Linq ;
using System.Text ;
2020-08-08 12:56:34 +00:00
using System.Text.RegularExpressions ;
2020-07-06 17:50:39 +00:00
using System.Threading.Tasks ;
2020-06-29 21:51:12 +00:00
2020-07-18 11:30:54 +00:00
using Dapper ;
2020-06-29 21:51:12 +00:00
using DSharpPlus.Entities ;
using PluralKit.Core ;
namespace PluralKit.Bot
{
public class Groups
{
private readonly IDatabase _db ;
public Groups ( IDatabase db )
{
_db = db ;
}
public async Task CreateGroup ( Context ctx )
{
ctx . CheckSystem ( ) ;
var groupName = ctx . RemainderOrNull ( ) ? ? throw new PKSyntaxError ( "You must pass a group name." ) ;
if ( groupName . Length > Limits . MaxGroupNameLength )
2020-08-08 12:56:34 +00:00
throw new PKError ( $"Group name too long ({groupName.Length}/{Limits.MaxGroupNameLength} characters)." ) ;
2020-06-29 21:51:12 +00:00
await using var conn = await _db . Obtain ( ) ;
2020-07-18 11:30:54 +00:00
var existingGroupCount = await conn . QuerySingleAsync < int > ( "select count(*) from groups where system = @System" , ctx . System . Id ) ;
if ( existingGroupCount > = Limits . MaxGroupCount )
throw new PKError ( $"System has reached the maximum number of groups ({Limits.MaxGroupCount}). Please delete unused groups first in order to create new ones." ) ;
var newGroup = await conn . CreateGroup ( ctx . System . Id , groupName ) ;
2020-07-18 14:49:00 +00:00
var eb = new DiscordEmbedBuilder ( )
. WithDescription ( $"Your new group, **{groupName}**, has been created, with the group ID **`{newGroup.Hid}`**.\nBelow are a couple of useful commands:" )
2020-08-08 12:56:34 +00:00
. AddField ( "View the group card" , $"> pk;group **{GroupReference(newGroup)}**" )
. AddField ( "Add members to the group" , $"> pk;group **{GroupReference(newGroup)}** add **MemberName**\n> pk;group **{GroupReference(newGroup)}** add **Member1** **Member2** **Member3** (and so on...)" )
. AddField ( "Set the description" , $"> pk;group **{GroupReference(newGroup)}** description **This is my new group, and here is the description!**" )
. AddField ( "Set the group icon" , $"> pk;group **{GroupReference(newGroup)}** icon\n*(with an image attached)*" ) ;
2020-07-18 14:49:00 +00:00
await ctx . Reply ( $"{Emojis.Success} Group created!" , eb . Build ( ) ) ;
2020-06-29 21:51:12 +00:00
}
2020-07-06 17:50:39 +00:00
public async Task RenameGroup ( Context ctx , PKGroup target )
{
ctx . CheckOwnGroup ( target ) ;
var newName = ctx . RemainderOrNull ( ) ? ? throw new PKSyntaxError ( "You must pass a new group name." ) ;
if ( newName . Length > Limits . MaxGroupNameLength )
throw new PKError ( $"New group name too long ({newName.Length}/{Limits.MaxMemberNameLength} characters)." ) ;
await using var conn = await _db . Obtain ( ) ;
await conn . UpdateGroup ( target . Id , new GroupPatch { Name = newName } ) ;
await ctx . Reply ( $"{Emojis.Success} Group name changed from \" * * { target . Name } * * \ " to \"**{newName}**\"." ) ;
}
public async Task GroupDescription ( Context ctx , PKGroup target )
{
if ( ctx . MatchClear ( ) )
{
ctx . CheckOwnGroup ( target ) ;
var patch = new GroupPatch { Description = Partial < string > . Null ( ) } ;
await _db . Execute ( conn = > conn . UpdateGroup ( target . Id , patch ) ) ;
await ctx . Reply ( $"{Emojis.Success} Group description cleared." ) ;
}
else if ( ! ctx . HasNext ( ) )
{
if ( target . Description = = null )
if ( ctx . System ? . Id = = target . System )
2020-08-08 12:56:34 +00:00
await ctx . Reply ( $"This group does not have a description set. To set one, type `pk;group {GroupReference(target)} description <description>`." ) ;
2020-07-06 17:50:39 +00:00
else
await ctx . Reply ( "This group does not have a description set." ) ;
else if ( ctx . MatchFlag ( "r" , "raw" ) )
await ctx . Reply ( $"```\n{target.Description}\n```" ) ;
else
await ctx . Reply ( embed : new DiscordEmbedBuilder ( )
. WithTitle ( "Group description" )
. WithDescription ( target . Description )
2020-08-08 12:56:34 +00:00
. AddField ( "\u200B" , $"To print the description with formatting, type `pk;group {GroupReference(target)} description -raw`."
+ ( ctx . System ? . Id = = target . System ? $" To clear it, type `pk;group {GroupReference(target)} description -clear`." : "" ) )
2020-07-06 17:50:39 +00:00
. Build ( ) ) ;
}
else
{
ctx . CheckOwnGroup ( target ) ;
var description = ctx . RemainderOrNull ( ) . NormalizeLineEndSpacing ( ) ;
if ( description . IsLongerThan ( Limits . MaxDescriptionLength ) )
throw Errors . DescriptionTooLongError ( description . Length ) ;
var patch = new GroupPatch { Description = Partial < string > . Present ( description ) } ;
await _db . Execute ( conn = > conn . UpdateGroup ( target . Id , patch ) ) ;
await ctx . Reply ( $"{Emojis.Success} Group description changed." ) ;
}
}
2020-08-08 13:09:42 +00:00
public async Task GroupIcon ( Context ctx , PKGroup target )
{
async Task ClearIcon ( )
{
ctx . CheckOwnGroup ( target ) ;
await _db . Execute ( c = > c . UpdateGroup ( target . Id , new GroupPatch { Icon = null } ) ) ;
await ctx . Reply ( $"{Emojis.Success} Group icon cleared." ) ;
}
async Task SetIcon ( ParsedImage img )
{
ctx . CheckOwnGroup ( target ) ;
if ( img . Url . Length > Limits . MaxUriLength )
throw Errors . InvalidUrl ( img . Url ) ;
await AvatarUtils . VerifyAvatarOrThrow ( img . Url ) ;
await _db . Execute ( c = > c . UpdateGroup ( target . Id , new GroupPatch { Icon = img . Url } ) ) ;
var msg = img . Source switch
{
AvatarSource . User = > $"{Emojis.Success} Group icon changed to {img.SourceUser?.Username}'s avatar!\n{Emojis.Warn} If {img.SourceUser?.Username} changes their avatar, the group icon will need to be re-set." ,
AvatarSource . Url = > $"{Emojis.Success} Group icon changed to the image at the given URL." ,
AvatarSource . Attachment = > $"{Emojis.Success} Group icon changed to attached image.\n{Emojis.Warn} If you delete the message containing the attachment, the group icon will stop working." ,
_ = > throw new ArgumentOutOfRangeException ( )
} ;
// The attachment's already right there, no need to preview it.
var hasEmbed = img . Source ! = AvatarSource . Attachment ;
await ( hasEmbed
? ctx . Reply ( msg , embed : new DiscordEmbedBuilder ( ) . WithImageUrl ( img . Url ) . Build ( ) )
: ctx . Reply ( msg ) ) ;
}
async Task ShowIcon ( )
{
if ( ( target . Icon ? . Trim ( ) ? ? "" ) . Length > 0 )
{
var eb = new DiscordEmbedBuilder ( )
. WithTitle ( "Group icon" )
. WithImageUrl ( target . Icon ) ;
if ( target . System = = ctx . System ? . Id )
{
eb . WithDescription ( $"To clear, use `pk;group {GroupReference(target)} icon -clear`." ) ;
}
await ctx . Reply ( embed : eb . Build ( ) ) ;
}
else
throw new PKSyntaxError ( "This group does not have an icon set. Set one by attaching an image to this command, or by passing an image URL or @mention." ) ;
}
if ( ctx . MatchClear ( ) )
await ClearIcon ( ) ;
else if ( await ctx . MatchImage ( ) is { } img )
await SetIcon ( img ) ;
else
await ShowIcon ( ) ;
}
2020-07-06 17:50:39 +00:00
public async Task ListSystemGroups ( Context ctx , PKSystem system )
{
if ( system = = null )
{
ctx . CheckSystem ( ) ;
system = ctx . System ;
}
// TODO: integrate with the normal "search" system
await using var conn = await _db . Obtain ( ) ;
2020-07-18 11:53:02 +00:00
var pctx = LookupContext . ByNonOwner ;
if ( ctx . MatchFlag ( "a" , "all" ) & & system . Id = = ctx . System . Id )
pctx = LookupContext . ByOwner ;
var groups = ( await conn . QueryGroupsInSystem ( system . Id ) ) . Where ( g = > g . Visibility . CanAccess ( pctx ) ) . ToList ( ) ;
2020-07-06 17:50:39 +00:00
if ( groups . Count = = 0 )
{
if ( system . Id = = ctx . System ? . Id )
await ctx . Reply ( $"This system has no groups. To create one, use the command `pk;group new <name>`." ) ;
else
await ctx . Reply ( $"This system has no groups." ) ;
return ;
}
var title = system . Name ! = null ? $"Groups of {system.Name} (`{system.Hid}`)" : $"Groups of `{system.Hid}`" ;
await ctx . Paginate ( groups . ToAsyncEnumerable ( ) , groups . Count , 25 , title , Renderer ) ;
Task Renderer ( DiscordEmbedBuilder eb , IEnumerable < PKGroup > page )
{
var sb = new StringBuilder ( ) ;
foreach ( var g in page )
{
sb . Append ( $"[`{g.Hid}`] **{g.Name}**\n" ) ;
}
eb . WithDescription ( sb . ToString ( ) ) ;
eb . WithFooter ( $"{groups.Count} total" ) ;
return Task . CompletedTask ;
}
}
2020-06-29 21:51:12 +00:00
public async Task ShowGroupCard ( Context ctx , PKGroup target )
{
await using var conn = await _db . Obtain ( ) ;
var system = await GetGroupSystem ( ctx , target , conn ) ;
2020-07-18 11:53:02 +00:00
var pctx = ctx . LookupContextFor ( system ) ;
2020-07-18 11:19:53 +00:00
var memberCount = await conn . QueryGroupMemberCount ( target . Id , PrivacyLevel . Public ) ;
2020-06-29 21:51:12 +00:00
var nameField = target . Name ;
if ( system . Name ! = null )
nameField = $"{nameField} ({system.Name})" ;
var eb = new DiscordEmbedBuilder ( )
2020-07-18 11:53:02 +00:00
. WithAuthor ( nameField , iconUrl : DiscordUtils . WorkaroundForUrlBug ( target . IconFor ( pctx ) ) )
2020-06-29 21:51:12 +00:00
. WithFooter ( $"System ID: {system.Hid} | Group ID: {target.Hid} | Created on {target.Created.FormatZoned(system)}" ) ;
2020-07-18 11:19:53 +00:00
if ( memberCount = = 0 )
2020-08-08 12:56:34 +00:00
eb . AddField ( "Members (0)" , $"Add one with `pk;group {GroupReference(target)} add <member>`!" , true ) ;
2020-07-18 11:19:53 +00:00
else
2020-08-08 12:56:34 +00:00
eb . AddField ( $"Members ({memberCount})" , $"(see `pk;group {GroupReference(target)} list`)" , true ) ;
2020-07-18 11:19:53 +00:00
2020-07-18 11:53:02 +00:00
if ( target . DescriptionFor ( pctx ) is { } desc )
eb . AddField ( "Description" , desc ) ;
if ( target . IconFor ( pctx ) is { } icon )
eb . WithThumbnail ( icon ) ;
2020-07-07 22:56:06 +00:00
2020-06-29 21:51:12 +00:00
await ctx . Reply ( embed : eb . Build ( ) ) ;
}
2020-07-07 13:28:53 +00:00
public async Task AddRemoveMembers ( Context ctx , PKGroup target , AddRemoveOperation op )
{
ctx . CheckOwnGroup ( target ) ;
2020-07-07 17:34:44 +00:00
var members = await ParseMemberList ( ctx ) ;
2020-07-07 13:28:53 +00:00
await using var conn = await _db . Obtain ( ) ;
if ( op = = AddRemoveOperation . Add )
{
await conn . AddMembersToGroup ( target . Id , members . Select ( m = > m . Id ) ) ;
await ctx . Reply ( $"{Emojis.Success} Members added to group." ) ;
}
else if ( op = = AddRemoveOperation . Remove )
{
await conn . RemoveMembersFromGroup ( target . Id , members . Select ( m = > m . Id ) ) ;
await ctx . Reply ( $"{Emojis.Success} Members removed from group." ) ;
}
}
2020-07-07 17:34:23 +00:00
public async Task ListGroupMembers ( Context ctx , PKGroup target )
{
await using var conn = await _db . Obtain ( ) ;
var targetSystem = await GetGroupSystem ( ctx , target , conn ) ;
ctx . CheckSystemPrivacy ( targetSystem , targetSystem . MemberListPrivacy ) ;
var opts = ctx . ParseMemberListOptions ( ctx . LookupContextFor ( target . System ) ) ;
opts . GroupFilter = target . Id ;
var title = new StringBuilder ( $"Members of {target.Name} (`{target.Hid}`) in " ) ;
if ( targetSystem . Name ! = null )
title . Append ( $"{targetSystem.Name} (`{targetSystem.Hid}`)" ) ;
else
title . Append ( $"`{targetSystem.Hid}`" ) ;
if ( opts . Search ! = null )
title . Append ( $" matching **{opts.Search}**" ) ;
await ctx . RenderMemberList ( ctx . LookupContextFor ( target . System ) , _db , target . System , title . ToString ( ) , opts ) ;
}
2020-07-07 13:28:53 +00:00
public enum AddRemoveOperation
{
Add ,
Remove
}
2020-07-07 17:34:44 +00:00
private static async Task < List < PKMember > > ParseMemberList ( Context ctx )
{
// TODO: move this to a context extension and share with the switch command somewhere, after branch merge?
var members = new List < PKMember > ( ) ;
while ( ctx . HasNext ( ) )
{
var member = await ctx . MatchMember ( ) ;
if ( member = = null )
throw new PKSyntaxError ( ctx . CreateMemberNotFoundError ( ctx . PopArgument ( ) ) ) ; ;
if ( member . System ! = ctx . System . Id )
throw new PKError ( $"Member **{member.Name}** (`{member.Hid}`) is not in your own system, so you can't add it to a group." ) ;
members . Add ( member ) ;
}
if ( members . Count = = 0 )
throw new PKSyntaxError ( "You must pass one or more members." ) ;
return members ;
}
2020-07-18 11:53:02 +00:00
public async Task GroupPrivacy ( Context ctx , PKGroup target , PrivacyLevel ? newValueFromCommand )
{
ctx . CheckSystem ( ) . CheckOwnGroup ( target ) ;
// Display privacy settings
if ( ! ctx . HasNext ( ) & & newValueFromCommand = = null )
{
await ctx . Reply ( embed : new DiscordEmbedBuilder ( )
. WithTitle ( $"Current privacy settings for {target.Name}" )
. AddField ( "Description" , target . DescriptionPrivacy . Explanation ( ) )
. AddField ( "Icon" , target . IconPrivacy . Explanation ( ) )
. AddField ( "Visibility" , target . Visibility . Explanation ( ) )
2020-08-08 12:56:34 +00:00
. WithDescription ( $"To edit privacy settings, use the command:\n`pk;group **{GroupReference(target)}** privacy <subject> <level>`\n\n- `subject` is one of `description`, `icon`, `visibility`, or `all`\n- `level` is either `public` or `private`." )
2020-07-18 11:53:02 +00:00
. Build ( ) ) ;
return ;
}
async Task SetAll ( PrivacyLevel level )
{
await _db . Execute ( c = > c . UpdateGroup ( target . Id , new GroupPatch ( ) . WithAllPrivacy ( level ) ) ) ;
if ( level = = PrivacyLevel . Private )
2020-08-08 12:56:34 +00:00
await ctx . Reply ( $"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see nothing on the group card." ) ;
2020-07-18 11:53:02 +00:00
else
2020-08-08 12:56:34 +00:00
await ctx . Reply ( $"{Emojis.Success} All {target.Name}'s privacy settings have been set to **{level.LevelName()}**. Other accounts will now see everything on the group card." ) ;
2020-07-18 11:53:02 +00:00
}
async Task SetLevel ( GroupPrivacySubject subject , PrivacyLevel level )
{
await _db . Execute ( c = > c . UpdateGroup ( target . Id , new GroupPatch ( ) . WithPrivacy ( subject , level ) ) ) ;
var subjectName = subject switch
{
GroupPrivacySubject . Description = > "description privacy" ,
GroupPrivacySubject . Icon = > "icon privacy" ,
GroupPrivacySubject . Visibility = > "visibility" ,
_ = > throw new ArgumentOutOfRangeException ( $"Unknown privacy subject {subject}" )
} ;
var explanation = ( subject , level ) switch
{
( GroupPrivacySubject . Description , PrivacyLevel . Private ) = > "This group's description is now hidden from other systems." ,
( GroupPrivacySubject . Icon , PrivacyLevel . Private ) = > "This group's icon is now hidden from other systems." ,
( GroupPrivacySubject . Visibility , PrivacyLevel . Private ) = > "This group is now hidden from group lists and member cards." ,
( GroupPrivacySubject . Description , PrivacyLevel . Public ) = > "This group's description is no longer hidden from other systems." ,
( GroupPrivacySubject . Icon , PrivacyLevel . Public ) = > "This group's icon is no longer hidden from other systems." ,
( GroupPrivacySubject . Visibility , PrivacyLevel . Public ) = > "This group is no longer hidden from group lists and member cards." ,
_ = > throw new InvalidOperationException ( $"Invalid subject/level tuple ({subject}, {level})" )
} ;
await ctx . Reply ( $"{Emojis.Success} {target.Name}'s **{subjectName}** has been set to **{level.LevelName()}**. {explanation}" ) ;
}
if ( ctx . Match ( "all" ) | | newValueFromCommand ! = null )
await SetAll ( newValueFromCommand ? ? ctx . PopPrivacyLevel ( ) ) ;
else
await SetLevel ( ctx . PopGroupPrivacySubject ( ) , ctx . PopPrivacyLevel ( ) ) ;
}
2020-07-07 17:34:44 +00:00
2020-08-08 12:56:34 +00:00
public async Task DeleteGroup ( Context ctx , PKGroup target )
{
ctx . CheckOwnGroup ( target ) ;
await ctx . Reply ( $"{Emojis.Warn} Are you sure you want to delete this group? If so, reply to this message with the group's ID (`{target.Hid}`).\n**Note: this action is permanent.**" ) ;
if ( ! await ctx . ConfirmWithReply ( target . Hid ) )
throw new PKError ( $"Group deletion cancelled. Note that you must reply with your group ID (`{target.Hid}`) *verbatim*." ) ;
await _db . Execute ( conn = > conn . DeleteGroup ( target . Id ) ) ;
await ctx . Reply ( $"{Emojis.Success} Group deleted." ) ;
}
2020-06-29 21:51:12 +00:00
private static async Task < PKSystem > GetGroupSystem ( Context ctx , PKGroup target , IPKConnection conn )
{
var system = ctx . System ;
if ( system ? . Id = = target . System )
return system ;
return await conn . QuerySystem ( target . System ) ! ;
}
2020-08-08 12:56:34 +00:00
private static string GroupReference ( PKGroup group )
{
if ( Regex . IsMatch ( group . Name , "[A-Za-z0-9\\-_]+" ) )
return group . Name ;
return group . Hid ;
}
2020-06-29 21:51:12 +00:00
}
}