2025-08-17 02:47:01 -07:00
using System.Text ;
2021-11-19 15:54:39 -05:00
using System.Text.RegularExpressions ;
2020-02-12 15:16:19 +01:00
2025-08-17 02:47:01 -07:00
using Myriad.Builders ;
2020-12-25 13:19:35 +01:00
using Myriad.Extensions ;
2020-12-25 12:56:46 +01:00
using Myriad.Rest.Exceptions ;
2025-08-17 02:47:01 -07:00
using Myriad.Rest.Types ;
2020-12-25 13:19:35 +01:00
using Myriad.Rest.Types.Requests ;
using Myriad.Types ;
2019-10-05 07:41:00 +02:00
2025-08-17 02:47:01 -07:00
using NodaTime ;
using SqlKata ;
2020-02-12 15:16:19 +01:00
using PluralKit.Core ;
2019-06-20 21:15:57 +02:00
2021-11-26 21:10:56 -05:00
namespace PluralKit.Bot ;
public class Api
2019-06-20 21:15:57 +02:00
{
2025-08-17 02:47:01 -07:00
private record PaginatedApiKey ( Guid Id , string Name , string [ ] Scopes , string? AppName , Instant Created ) ;
2021-11-26 21:10:56 -05:00
private static readonly Regex _webhookRegex =
new ( "https://(?:\\w+.)?discord(?:app)?.com/api(?:/v.*)?/webhooks/(.*)" ) ;
2021-11-27 11:09:08 -05:00
private readonly BotConfig _botConfig ;
2021-11-26 21:10:56 -05:00
private readonly DispatchService _dispatch ;
2025-08-17 02:47:01 -07:00
private readonly InteractionDispatchService _interactions ;
2022-01-22 02:47:47 -05:00
private readonly PrivateChannelService _dmCache ;
2025-08-17 02:47:01 -07:00
private readonly ApiKeyService _apiKey ;
2021-11-26 21:10:56 -05:00
2025-08-17 02:47:01 -07:00
public Api ( BotConfig botConfig , DispatchService dispatch , InteractionDispatchService interactions , PrivateChannelService dmCache , ApiKeyService apiKey )
2019-06-20 21:15:57 +02:00
{
2021-11-27 11:09:08 -05:00
_botConfig = botConfig ;
2021-11-26 21:10:56 -05:00
_dispatch = dispatch ;
2025-08-17 02:47:01 -07:00
_interactions = interactions ;
2022-01-22 02:47:47 -05:00
_dmCache = dmCache ;
2025-08-17 02:47:01 -07:00
_apiKey = apiKey ;
2021-11-26 21:10:56 -05:00
}
2019-10-05 07:41:00 +02:00
2021-11-26 21:10:56 -05:00
public async Task GetToken ( Context ctx )
{
ctx . CheckSystem ( ) ;
2020-08-26 00:17:05 +02:00
2021-11-26 21:10:56 -05:00
// Get or make a token
2022-01-22 03:05:01 -05:00
var token = ctx . System . Token ? ? await MakeAndSetNewToken ( ctx , ctx . System ) ;
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
try
{
// DM the user a security disclaimer, and then the token in a separate message (for easy copying on mobile)
2022-01-22 02:47:47 -05:00
var dm = await _dmCache . GetOrCreateDmChannel ( ctx . Author . Id ) ;
await ctx . Rest . CreateMessage ( dm ,
2021-11-26 21:10:56 -05:00
new MessageRequest
2020-12-25 13:19:35 +01:00
{
2021-11-26 21:10:56 -05:00
Content = $"{Emojis.Warn} Please note that this grants access to modify (and delete!) all your system data, so keep it safe and secure."
2024-12-31 08:09:18 -07:00
+ $" If it leaks or you need a new one, you can invalidate this one with `{ctx.DefaultPrefix}token refresh`.\n\nYour token is below:"
2020-12-25 13:19:35 +01:00
} ) ;
2022-01-22 02:47:47 -05:00
await ctx . Rest . CreateMessage ( dm , new MessageRequest { Content = token } ) ;
2021-11-26 21:10:56 -05:00
2021-11-27 11:09:08 -05:00
if ( _botConfig . IsBetaBot )
2022-01-22 02:47:47 -05:00
await ctx . Rest . CreateMessage ( dm , new MessageRequest
2021-11-27 11:09:08 -05:00
{
Content = $"{Emojis.Note} The beta bot's API base URL is currently <{_botConfig.BetaBotAPIUrl}>."
+ " You need to use this URL instead of the base URL listed on the documentation website."
} ) ;
2021-11-26 21:10:56 -05:00
// If we're not already in a DM, reply with a reminder to check
if ( ctx . Channel . Type ! = Channel . ChannelType . Dm )
await ctx . Reply ( $"{Emojis.Success} Check your DMs!" ) ;
2019-06-20 21:15:57 +02:00
}
2021-11-26 21:10:56 -05:00
catch ( ForbiddenException )
{
// Can't check for permission errors beforehand, so have to handle here :/
if ( ctx . Channel . Type ! = Channel . ChannelType . Dm )
await ctx . Reply ( $"{Emojis.Error} Could not send token in DMs. Are your DMs closed?" ) ;
}
}
2022-01-22 03:05:01 -05:00
private async Task < string > MakeAndSetNewToken ( Context ctx , PKSystem system )
2021-11-26 21:10:56 -05:00
{
2022-01-22 03:05:01 -05:00
system = await ctx . Repository . UpdateSystem ( system . Id , new SystemPatch { Token = StringUtils . GenerateToken ( ) } ) ;
2021-11-26 21:10:56 -05:00
return system . Token ;
}
public async Task RefreshToken ( Context ctx )
{
ctx . CheckSystem ( ) ;
2019-06-20 21:15:57 +02:00
2021-11-26 21:10:56 -05:00
if ( ctx . System . Token = = null )
2019-06-20 21:15:57 +02:00
{
2021-11-26 21:10:56 -05:00
// If we don't have a token, call the other method instead
// This does pretty much the same thing, except words the messages more appropriately for that :)
await GetToken ( ctx ) ;
return ;
2019-06-20 21:15:57 +02:00
}
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
try
2019-06-20 21:15:57 +02:00
{
2021-11-26 21:10:56 -05:00
// DM the user an invalidation disclaimer, and then the token in a separate message (for easy copying on mobile)
2022-01-22 02:47:47 -05:00
var dm = await _dmCache . GetOrCreateDmChannel ( ctx . Author . Id ) ;
await ctx . Rest . CreateMessage ( dm ,
2021-11-26 21:10:56 -05:00
new MessageRequest
2020-12-25 13:19:35 +01:00
{
Content = $"{Emojis.Warn} Your previous API token has been invalidated. You will need to change it anywhere it's currently used.\n\nYour token is below:"
} ) ;
2021-08-27 11:03:47 -04:00
2021-11-26 21:10:56 -05:00
// Make the new token after sending the first DM; this ensures if we can't DM, we also don't end up
// breaking their existing token as a side effect :)
2022-01-22 03:05:01 -05:00
var token = await MakeAndSetNewToken ( ctx , ctx . System ) ;
2022-01-22 02:47:47 -05:00
await ctx . Rest . CreateMessage ( dm , new MessageRequest { Content = token } ) ;
2021-11-26 21:10:56 -05:00
2021-11-27 11:09:08 -05:00
if ( _botConfig . IsBetaBot )
2022-01-22 02:47:47 -05:00
await ctx . Rest . CreateMessage ( dm , new MessageRequest
2021-11-27 11:09:08 -05:00
{
Content = $"{Emojis.Note} The beta bot's API base URL is currently <{_botConfig.BetaBotAPIUrl}>."
+ " You need to use this URL instead of the base URL listed on the documentation website."
} ) ;
2021-11-26 21:10:56 -05:00
// If we're not already in a DM, reply with a reminder to check
if ( ctx . Channel . Type ! = Channel . ChannelType . Dm )
await ctx . Reply ( $"{Emojis.Success} Check your DMs!" ) ;
}
catch ( ForbiddenException )
{
// Can't check for permission errors beforehand, so have to handle here :/
if ( ctx . Channel . Type ! = Channel . ChannelType . Dm )
await ctx . Reply ( $"{Emojis.Error} Could not send token in DMs. Are your DMs closed?" ) ;
}
}
public async Task SystemWebhook ( Context ctx )
{
ctx . CheckSystem ( ) . CheckDMContext ( ) ;
if ( ! ctx . HasNext ( false ) )
{
if ( ctx . System . WebhookUrl = = null )
2024-12-31 08:09:18 -07:00
await ctx . Reply ( $"Your system does not have a webhook URL set. Set one with `{ctx.DefaultPrefix}system webhook <url>`!" ) ;
2021-11-26 21:10:56 -05:00
else
await ctx . Reply ( $"Your system's webhook URL is <{ctx.System.WebhookUrl}>." ) ;
return ;
2019-06-20 21:15:57 +02:00
}
2021-11-03 02:01:35 -04:00
2022-12-01 07:16:36 +00:00
if ( ctx . MatchClear ( ) & & await ctx . ConfirmClear ( "your system's webhook URL" ) )
2021-11-03 02:01:35 -04:00
{
2022-01-22 03:05:01 -05:00
await ctx . Repository . UpdateSystem ( ctx . System . Id , new SystemPatch { WebhookUrl = null , WebhookToken = null } ) ;
2021-11-26 21:10:56 -05:00
await ctx . Reply ( $"{Emojis.Success} System webhook URL removed." ) ;
return ;
}
var newUrl = ctx . RemainderOrNull ( ) ;
if ( ! await DispatchExt . ValidateUri ( newUrl ) )
throw new PKError ( $"The URL {newUrl.AsCode()} is invalid or I cannot access it. Are you sure this is a valid, publicly accessible URL?" ) ;
if ( _webhookRegex . IsMatch ( newUrl ) )
throw new PKError ( "PluralKit does not currently support setting a Discord webhook URL as your system's webhook URL." ) ;
2021-11-03 02:01:35 -04:00
2024-08-22 07:10:35 +09:00
var newToken = StringUtils . GenerateToken ( ) ;
await ctx . Reply ( $"{Emojis.Warn} The following token is used to authenticate requests from PluralKit to you."
+ " If it is exposed publicly, you **must** clear and re-set the webhook URL to get a new token."
+ "\n\n**Please review the security requirements at <https://pluralkit.me/api/dispatch#security> before continuing.**"
+ "\n\nWhen the server is correctly validating the token, click or reply 'yes' to continue."
) ;
2024-08-23 04:24:25 +09:00
if ( ! await ctx . PromptYesNo ( newToken , "Continue" , matchFlag : false ) )
throw Errors . GenericCancelled ( ) ;
2024-08-22 07:10:35 +09:00
var status = await _dispatch . TestUrl ( ctx . System . Uuid , newUrl , newToken ) ;
if ( status ! = "OK" )
2021-11-26 21:10:56 -05:00
{
2024-08-22 07:10:35 +09:00
var message = status switch
{
"BadData" = > "the webhook url is invalid" ,
"NoIPs" = > "could not find any valid IP addresses for the provided domain" ,
"InvalidIP" = > "could not find any valid IP addresses for the provided domain" ,
"FetchFailed" = > "unable to reach server" ,
"TestFailed" = > "server failed to validate the signing token" ,
_ = > $"an unknown error occurred ({status})"
} ;
throw new PKError ( $"Failed to validate the webhook url: {message}" ) ;
2021-11-03 02:01:35 -04:00
}
2021-11-26 21:10:56 -05:00
2022-01-22 03:05:01 -05:00
await ctx . Repository . UpdateSystem ( ctx . System . Id , new SystemPatch { WebhookUrl = newUrl , WebhookToken = newToken } ) ;
2021-11-26 21:10:56 -05:00
2024-08-22 07:10:35 +09:00
await ctx . Reply ( $"{Emojis.Success} Successfully the new webhook URL for your system." ) ;
2019-06-20 21:15:57 +02:00
}
2025-08-17 02:47:01 -07:00
public async Task ApiKeyCreate ( Context ctx )
{
if ( ! ctx . HasNext ( ) )
throw new PKSyntaxError ( $"An API key name must be provided." ) ;
var rawScopes = ctx . MatchFlag ( "scopes" , "scope" ) ;
var keyName = ctx . PopArgument ( ) ;
List < string > keyScopes = new ( ) ;
if ( ! ctx . HasNext ( ) )
throw new PKSyntaxError ( $"A list of API key scopes must be provided." ) ;
var scopestr = ctx . RemainderOrNull ( ) ! . NormalizeLineEndSpacing ( ) . Trim ( ) ;
if ( rawScopes )
keyScopes = scopestr . Split ( " " ) . Distinct ( ) . ToList ( ) ;
else
keyScopes . Add ( scopestr switch
{
"full" = > "write:all" ,
"read private" = > "read:all" ,
"read public" = > "readpublic:all" ,
"identify" = > "identify" ,
_ = > throw new PKError (
$"Couldn't find a scope preset named {scopestr}." ) ,
} ) ;
string? check = null ! ;
try
{
check = await _apiKey . CreateUserApiKey ( ctx . System . Id , keyName , keyScopes . ToArray ( ) , check : true ) ;
if ( check ! = null )
throw new PKError ( "API key validation failed: unknown error" ) ;
}
catch ( Exception ex )
{
if ( ex . Message . StartsWith ( "API key" ) )
throw new PKError ( ex . Message ) ;
throw ;
}
async Task cb ( InteractionContext ictx )
{
if ( ictx . User . Id ! = ctx . Author . Id )
{
await ictx . Ignore ( ) ;
return ;
}
var newKey = await _apiKey . CreateUserApiKey ( ctx . System . Id , keyName , keyScopes . ToArray ( ) ) ;
await ictx . Reply ( $"Your new API key is below. You will only be shown this once, so please save it!\n\n||`{newKey}`||" ) ;
await ctx . Rest . EditMessage ( ictx . ChannelId , ictx . MessageId ! . Value , new MessageEditRequest
{
Components = new MessageComponent [ ] { } ,
} ) ;
}
var content =
$"Ready to create a new API key named **{keyName}**, "
+ $"with these scopes: {(String.Join(" , ", keyScopes.Select(x => x.AsCode())))}\n"
+ "To create this API key, press the button below." ;
await ctx . Rest . CreateMessage ( ctx . Channel . Id , new MessageRequest
{
Content = content ,
AllowedMentions = new ( ) { Parse = new AllowedMentions . ParseType [ ] { } , RepliedUser = false } ,
Components = new [ ] {
new MessageComponent
{
Type = ComponentType . ActionRow ,
Components = new [ ]
{
new MessageComponent
{
Type = ComponentType . Button ,
Style = ButtonStyle . Primary ,
Label = "Create API key" ,
CustomId = _interactions . Register ( cb ) ,
} ,
}
}
} ,
} ) ;
}
public async Task ApiKeyList ( Context ctx )
{
var keys = await ctx . Repository . GetSystemApiKeys ( ctx . System . Id )
. Select ( k = > new PaginatedApiKey ( k . Id , k . Name , k . Scopes , null , k . Created ) )
. ToListAsync ( ) ;
await ctx . Paginate < PaginatedApiKey > (
keys . ToAsyncEnumerable ( ) ,
keys . Count ,
10 ,
"Current API keys for your system" ,
ctx . System . Color ,
( eb , l ) = >
{
var description = new StringBuilder ( ) ;
foreach ( var item in l )
{
description . Append ( $"**{item.Name}** (`{item.Id}`)" ) ;
description . AppendLine ( ) ;
description . Append ( "- Scopes: " ) ;
description . Append ( String . Join ( ", " , item . Scopes . Select ( sc = > $"`{sc}`" ) ) ) ;
description . AppendLine ( ) ;
description . Append ( "- Created: " ) ;
description . Append ( item . Created . FormatZoned ( ctx . Zone ) ) ;
description . AppendLine ( ) ;
description . AppendLine ( ) ;
}
eb . Description ( description . ToString ( ) ) ;
return Task . CompletedTask ;
}
) ;
}
public async Task ApiKeyRename ( Context ctx , PKApiKey key )
{
if ( ! ctx . HasNext ( ) )
throw new PKError ( "You must provide a new name for this API key." ) ;
var name = ctx . RemainderOrNull ( false ) . NormalizeLineEndSpacing ( ) ;
await ctx . Repository . UpdateApiKey ( key . Id , new ApiKeyPatch { Name = name } ) ;
await ctx . Reply ( $"{Emojis.Success} API key renamed." ) ;
}
public async Task ApiKeyDelete ( Context ctx , PKApiKey key )
{
if ( ! await ctx . PromptYesNo ( $"Really delete API key **{key.Name}** `{key.Id}`?" , "Delete" , matchFlag : false ) )
{
await ctx . Reply ( $"{Emojis.Error} Deletion cancelled." ) ;
return ;
}
await ctx . Repository . DeleteApiKey ( key . Id ) ;
await ctx . Reply ( $"{Emojis.Success} Successfully deleted API key." ) ;
}
public async Task ApiKeyDeleteAll ( Context ctx )
{
if ( ! await ctx . PromptYesNo ( $"Really delete *all manually-created* API keys for your system?" , "Delete" , matchFlag : false ) )
{
await ctx . Reply ( $"{Emojis.Error} Deletion cancelled." ) ;
return ;
}
await ctx . BusyIndicator ( async ( ) = >
{
var query = new Query ( "api_keys" )
. AsDelete ( )
. WhereRaw ( "[kind]::text not in ( 'dashboard', 'external_app' )" )
. Where ( "system" , ctx . System . Id ) ;
await ctx . Database . ExecuteQuery ( query ) ;
} ) ;
await ctx . Reply ( $"{Emojis.Success} Successfully deleted all manually-created API keys." ) ;
}
2019-06-20 21:15:57 +02:00
}