Merge branch 'feat/webhooks' into main

This commit is contained in:
spiral 2021-11-25 17:15:42 -05:00
commit b8e2ebd470
No known key found for this signature in database
GPG Key ID: A6059F0CA0E1BD31
34 changed files with 920 additions and 39 deletions

View File

@ -22,12 +22,14 @@ namespace PluralKit.API
protected readonly ApiConfig _config;
protected readonly IDatabase _db;
protected readonly ModelRepository _repo;
protected readonly DispatchService _dispatch;
public PKControllerBase(IServiceProvider svc)
{
_config = svc.GetRequiredService<ApiConfig>();
_db = svc.GetRequiredService<IDatabase>();
_repo = svc.GetRequiredService<ModelRepository>();
_dispatch = svc.GetRequiredService<DispatchService>();
}
protected Task<PKSystem?> ResolveSystem(string systemRef)

View File

@ -78,6 +78,14 @@ namespace PluralKit.API
var newGroup = await _repo.CreateGroup(system.Id, patch.Name.Value, conn);
newGroup = await _repo.UpdateGroup(newGroup.Id, patch, conn);
_ = _dispatch.Dispatch(newGroup.Id, new UpdateDispatchData()
{
Event = DispatchEvent.CREATE_GROUP,
EventData = patch.ToJson(),
});
await tx.CommitAsync();
return Ok(newGroup.ToJson(LookupContext.ByOwner));

View File

@ -60,6 +60,12 @@ namespace PluralKit.API
var newMember = await _repo.CreateMember(system.Id, patch.Name.Value, conn);
newMember = await _repo.UpdateMember(newMember.Id, patch, conn);
_ = _dispatch.Dispatch(newMember.Id, new()
{
Event = DispatchEvent.CREATE_MEMBER,
EventData = patch.ToJson(),
});
await tx.CommitAsync();
return Ok(newMember.ToJson(LookupContext.ByOwner, v: APIVersion.V2));

View File

@ -18,6 +18,12 @@ namespace PluralKit.Bot
throw new PKError("This command can not be run in a DM.");
}
public static Context CheckDMContext(this Context ctx)
{
if (ctx.Channel.GuildId == null) return ctx;
throw new PKError("This command must be run in a DM.");
}
public static Context CheckSystemPrivacy(this Context ctx, PKSystem target, PrivacyLevel level)
{
if (level.CanAccess(ctx.LookupContextFor(target))) return ctx;

View File

@ -1,3 +1,5 @@
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Myriad.Extensions;
@ -9,14 +11,15 @@ using PluralKit.Core;
namespace PluralKit.Bot
{
public class Token
public class Api
{
private readonly IDatabase _db;
private readonly ModelRepository _repo;
public Token(IDatabase db, ModelRepository repo)
private readonly DispatchService _dispatch;
private static readonly Regex _webhookRegex = new("https://(?:\\w+.)?discord(?:app)?.com/api(?:/v.*)?/webhooks/(.*)");
public Api(ModelRepository repo, DispatchService dispatch)
{
_db = db;
_repo = repo;
_dispatch = dispatch;
}
public async Task GetToken(Context ctx)
@ -91,5 +94,63 @@ namespace PluralKit.Bot
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)
await ctx.Reply("Your system does not have a webhook URL set. Set one with `pk;system webhook <url>`!");
else
await ctx.Reply($"Your system's webhook URL is <{ctx.System.WebhookUrl}>.");
return;
}
if (await ctx.MatchClear("your system's webhook URL"))
{
await _repo.UpdateSystem(ctx.System.Id, new()
{
WebhookUrl = null,
WebhookToken = null,
});
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.");
try
{
await _dispatch.DoPostRequest(ctx.System.Id, newUrl, null, true);
}
catch (Exception e)
{
throw new PKError($"Could not verify that the new URL is working: {e.Message}");
}
var newToken = StringUtils.GenerateToken();
await _repo.UpdateSystem(ctx.System.Id, new()
{
WebhookUrl = newUrl,
WebhookToken = newToken,
});
await ctx.Reply($"{Emojis.Success} Successfully the new webhook URL for your system."
+ $"\n\n{Emojis.Warn} The following token is used to authenticate requests from PluralKit to you."
+ " If it leaks, you should clear and re-set the webhook URL to get a new token."
+ "\ntodo: add link to docs or something"
);
await ctx.Reply(newToken);
}
}
}

View File

@ -152,9 +152,9 @@ namespace PluralKit.Bot
return ctx.Execute<SystemLink>(Unlink, m => m.UnlinkAccount(ctx));
if (ctx.Match("token"))
if (ctx.Match("refresh", "renew", "invalidate", "reroll", "regen"))
return ctx.Execute<Token>(TokenRefresh, m => m.RefreshToken(ctx));
return ctx.Execute<Api>(TokenRefresh, m => m.RefreshToken(ctx));
else
return ctx.Execute<Token>(TokenGet, m => m.GetToken(ctx));
return ctx.Execute<Api>(TokenGet, m => m.GetToken(ctx));
if (ctx.Match("import"))
return ctx.Execute<ImportExport>(Import, m => m.Import(ctx));
if (ctx.Match("export"))
@ -286,6 +286,8 @@ namespace PluralKit.Bot
await ctx.Execute<SystemEdit>(SystemAvatar, m => m.Avatar(ctx));
else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet"))
await ctx.Execute<SystemEdit>(SystemDelete, m => m.Delete(ctx));
else if (ctx.Match("webhook", "hook"))
await ctx.Execute<Api>(null, m => m.SystemWebhook(ctx));
else if (ctx.Match("timezone", "tz"))
await ctx.Execute<SystemEdit>(SystemTimezone, m => m.SystemTimezone(ctx));
else if (ctx.Match("proxy"))

View File

@ -10,6 +10,8 @@ using Dapper;
using Humanizer;
using Newtonsoft.Json.Linq;
using NodaTime;
using Myriad.Builders;
@ -24,13 +26,15 @@ namespace PluralKit.Bot
private readonly ModelRepository _repo;
private readonly EmbedService _embeds;
private readonly HttpClient _client;
private readonly DispatchService _dispatch;
public Groups(IDatabase db, ModelRepository repo, EmbedService embeds, HttpClient client)
public Groups(IDatabase db, ModelRepository repo, EmbedService embeds, HttpClient client, DispatchService dispatch)
{
_db = db;
_repo = repo;
_embeds = embeds;
_client = client;
_dispatch = dispatch;
}
public async Task CreateGroup(Context ctx)
@ -59,6 +63,12 @@ namespace PluralKit.Bot
var newGroup = await _repo.CreateGroup(ctx.System.Id, groupName);
_ = _dispatch.Dispatch(newGroup.Id, new UpdateDispatchData()
{
Event = DispatchEvent.CREATE_GROUP,
EventData = JObject.FromObject(new { name = groupName }),
});
var eb = new EmbedBuilder()
.Description($"Your new group, **{groupName}**, has been created, with the group ID **`{newGroup.Hid}`**.\nBelow are a couple of useful commands:")
.Field(new("View the group card", $"> pk;group **{newGroup.Reference()}**"))

View File

@ -21,13 +21,15 @@ namespace PluralKit.Bot
private readonly ModelRepository _repo;
private readonly EmbedService _embeds;
private readonly HttpClient _client;
private readonly DispatchService _dispatch;
public Member(EmbedService embeds, IDatabase db, ModelRepository repo, HttpClient client)
public Member(EmbedService embeds, IDatabase db, ModelRepository repo, HttpClient client, DispatchService dispatch)
{
_embeds = embeds;
_db = db;
_repo = repo;
_client = client;
_dispatch = dispatch;
}
public async Task NewMember(Context ctx)
@ -62,12 +64,20 @@ namespace PluralKit.Bot
// Try to match an image attached to the message
var avatarArg = ctx.Message.Attachments.FirstOrDefault();
Exception imageMatchError = null;
bool sentDispatch = false;
if (avatarArg != null)
{
try
{
await AvatarUtils.VerifyAvatarOrThrow(_client, avatarArg.Url);
await _repo.UpdateMember(member.Id, new MemberPatch { AvatarUrl = avatarArg.Url });
await _db.Execute(conn => _repo.UpdateMember(member.Id, new MemberPatch { AvatarUrl = avatarArg.Url }, conn));
_ = _dispatch.Dispatch(member.Id, new()
{
Event = DispatchEvent.CREATE_MEMBER,
EventData = JObject.FromObject(new { name = memberName, avatar_url = avatarArg.Url }),
});
sentDispatch = true;
}
catch (Exception e)
{
@ -75,6 +85,13 @@ namespace PluralKit.Bot
}
}
if (!sentDispatch)
_ = _dispatch.Dispatch(member.Id, new()
{
Event = DispatchEvent.CREATE_MEMBER,
EventData = JObject.FromObject(new { name = memberName }),
});
// Send confirmation and space hint
await ctx.Reply($"{Emojis.Success} Member \"{memberName}\" (`{member.Hid}`) registered! Check out the getting started page for how to get a member up and running: https://pluralkit.me/start#create-a-member");
// todo: move this to ModelRepository

View File

@ -73,6 +73,7 @@ namespace PluralKit.Bot
// Commands
builder.RegisterType<CommandTree>().AsSelf();
builder.RegisterType<Admin>().AsSelf();
builder.RegisterType<Api>().AsSelf();
builder.RegisterType<Autoproxy>().AsSelf();
builder.RegisterType<Checks>().AsSelf();
builder.RegisterType<Fun>().AsSelf();
@ -94,7 +95,6 @@ namespace PluralKit.Bot
builder.RegisterType<SystemFront>().AsSelf();
builder.RegisterType<SystemLink>().AsSelf();
builder.RegisterType<SystemList>().AsSelf();
builder.RegisterType<Token>().AsSelf();
// Bot core
builder.RegisterType<Bot>().AsSelf().SingleInstance();

View File

@ -30,17 +30,19 @@ namespace PluralKit.Bot
private readonly ModelRepository _repo;
private readonly ILogger _logger;
private readonly WebhookExecutorService _webhookExecutor;
private readonly DispatchService _dispatch;
private readonly ProxyMatcher _matcher;
private readonly IMetrics _metrics;
private readonly IDiscordCache _cache;
private readonly LastMessageCacheService _lastMessage;
private readonly DiscordApiClient _rest;
public ProxyService(LogChannelService logChannel, ILogger logger, WebhookExecutorService webhookExecutor, IDatabase db,
public ProxyService(LogChannelService logChannel, ILogger logger, WebhookExecutorService webhookExecutor, DispatchService dispatch, IDatabase db,
ProxyMatcher matcher, IMetrics metrics, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest, LastMessageCacheService lastMessage)
{
_logChannel = logChannel;
_webhookExecutor = webhookExecutor;
_dispatch = dispatch;
_db = db;
_matcher = matcher;
_metrics = metrics;
@ -297,6 +299,8 @@ namespace PluralKit.Bot
Task LogMessageToChannel() => _logChannel.LogMessage(ctx, sentMessage, triggerMessage, proxyMessage).AsTask();
Task DispatchWebhook() => _dispatch.Dispatch(ctx.SystemId.Value, sentMessage);
async Task DeleteProxyTriggerMessage()
{
// Wait a second or so before deleting the original message
@ -315,11 +319,11 @@ namespace PluralKit.Bot
}
// 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(
DeleteProxyTriggerMessage(),
SaveMessageInDatabase(),
LogMessageToChannel()
LogMessageToChannel(),
DispatchWebhook()
);
}

View File

@ -0,0 +1,7 @@
-- schema version 20: insert date
-- add outgoing webhook to systems
alter table systems add column webhook_url text;
alter table systems add column webhook_token text;
update info set schema_version = 20;

View File

@ -10,6 +10,7 @@ namespace PluralKit.Core
{
_logger.Information("Updated account {accountId}: {@AccountPatch}", id, patch);
var query = patch.Apply(new Query("accounts").Where("uid", id));
_ = _dispatch.Dispatch(id, patch);
await _db.ExecuteQuery(query, extraSql: "returning *");
}
}

View File

@ -2,12 +2,20 @@
using System;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SqlKata;
namespace PluralKit.Core
{
public partial class ModelRepository
{
public Task<PKGroup?> GetGroup(GroupId id)
{
var query = new Query("groups").Where("id", id);
return _db.QueryFirst<PKGroup?>(query);
}
public Task<PKGroup?> GetGroupByName(SystemId system, string name)
{
var query = new Query("groups").Where("system", system).WhereRaw("lower(name) = lower(?)", name.ToLower());
@ -60,18 +68,31 @@ namespace PluralKit.Core
return group;
}
public Task<PKGroup> UpdateGroup(GroupId id, GroupPatch patch, IPKConnection? conn = null)
public async Task<PKGroup> UpdateGroup(GroupId id, GroupPatch patch, IPKConnection? conn = null)
{
_logger.Information("Updated {GroupId}: {@GroupPatch}", id, patch);
var query = patch.Apply(new Query("groups").Where("id", id));
return _db.QueryFirst<PKGroup>(conn, query, extraSql: "returning *");
var group = await _db.QueryFirst<PKGroup>(conn, query, extraSql: "returning *");
if (conn == null)
_ = _dispatch.Dispatch(id, new()
{
Event = DispatchEvent.UPDATE_GROUP,
EventData = patch.ToJson(),
});
return group;
}
public Task DeleteGroup(GroupId group)
public async Task DeleteGroup(GroupId group)
{
var oldGroup = await GetGroup(group);
_logger.Information("Deleted {GroupId}", group);
var query = new Query("groups").AsDelete().Where("id", group);
return _db.ExecuteQuery(query);
await _db.ExecuteQuery(query);
if (oldGroup != null)
_ = _dispatch.Dispatch(oldGroup.System, oldGroup.Uuid, DispatchEvent.DELETE_GROUP);
}
}
}

View File

@ -39,14 +39,15 @@ namespace PluralKit.Core
);
}
public Task<SystemGuildSettings> UpdateSystemGuild(SystemId system, ulong guild, SystemGuildPatch patch)
public async Task<SystemGuildSettings> UpdateSystemGuild(SystemId system, ulong guild, SystemGuildPatch patch)
{
_logger.Information("Updated {SystemId} in guild {GuildId}: {@SystemGuildPatch}", system, guild, patch);
var query = patch.Apply(new Query("system_guild").Where("system", system).Where("guild", guild));
return _db.QueryFirst<SystemGuildSettings>(query, extraSql: "returning *");
var settings = await _db.QueryFirst<SystemGuildSettings>(query, extraSql: "returning *");
_ = _dispatch.Dispatch(system, guild, patch);
return settings;
}
public Task<MemberGuildSettings> GetMemberGuild(ulong guild, MemberId member, bool defaultInsert = true)
{
if (!defaultInsert)
@ -69,6 +70,7 @@ namespace PluralKit.Core
{
_logger.Information("Updated {MemberId} in guild {GuildId}: {@MemberGuildPatch}", member, guild, patch);
var query = patch.Apply(new Query("member_guild").Where("member", member).Where("guild", guild));
_ = _dispatch.Dispatch(member, guild, patch);
return _db.QueryFirst<MemberGuildSettings>(query, extraSql: "returning *");
}
}

View File

@ -1,7 +1,10 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SqlKata;
namespace PluralKit.Core
@ -46,6 +49,15 @@ namespace PluralKit.Core
return _db.QueryFirst<PKMember?>(query);
}
public Task<IEnumerable<Guid>> GetMemberGuids(IEnumerable<MemberId> ids)
{
var query = new Query("members")
.Select("uuid")
.WhereIn("id", ids);
return _db.Query<Guid>(query);
}
public async Task<PKMember> CreateMember(SystemId systemId, string memberName, IPKConnection? conn = null)
{
var query = new Query("members").AsInsert(new
@ -64,14 +76,27 @@ namespace PluralKit.Core
{
_logger.Information("Updated {MemberId}: {@MemberPatch}", id, patch);
var query = patch.Apply(new Query("members").Where("id", id));
if (conn == null)
_ = _dispatch.Dispatch(id, new()
{
Event = DispatchEvent.UPDATE_MEMBER,
EventData = patch.ToJson(),
});
return _db.QueryFirst<PKMember>(conn, query, extraSql: "returning *");
}
public Task DeleteMember(MemberId id)
public async Task DeleteMember(MemberId id)
{
var oldMember = await GetMember(id);
_logger.Information("Deleted {MemberId}", id);
var query = new Query("members").AsDelete().Where("id", id);
return _db.ExecuteQuery(query);
await _db.ExecuteQuery(query);
// shh, compiler
if (oldMember != null)
_ = _dispatch.Dispatch(oldMember.System, oldMember.Uuid, DispatchEvent.DELETE_MEMBER);
}
}
}

View File

@ -5,6 +5,8 @@ using System.Threading.Tasks;
using Dapper;
using Newtonsoft.Json.Linq;
using NodaTime;
using NpgsqlTypes;
@ -42,8 +44,19 @@ namespace PluralKit.Core
await tx.CommitAsync();
_logger.Information("Created {SwitchId} in {SystemId}: {Members}", sw.Id, system, members);
_ = _dispatch.Dispatch(sw.Id, new()
{
Event = DispatchEvent.CREATE_SWITCH,
EventData = JObject.FromObject(new
{
id = sw.Uuid.ToString(),
timestamp = sw.Timestamp.FormatExport(),
members = await GetMemberGuids(members),
}),
});
return sw;
}
public async Task EditSwitch(IPKConnection conn, SwitchId switchId, IReadOnlyCollection<MemberId> members)
{
// Use a transaction here since we're doing multiple executed commands in one
@ -69,28 +82,52 @@ namespace PluralKit.Core
// Finally we commit the tx, since the using block will otherwise rollback it
await tx.CommitAsync();
_ = _dispatch.Dispatch(switchId, new()
{
Event = DispatchEvent.UPDATE_SWITCH_MEMBERS,
EventData = JObject.FromObject(new
{
members = await GetMemberGuids(members),
}),
});
_logger.Information("Updated {SwitchId} members: {Members}", switchId, members);
}
public Task MoveSwitch(SwitchId id, Instant time)
public async Task MoveSwitch(SwitchId id, Instant time)
{
_logger.Information("Updated {SwitchId} timestamp: {SwitchTimestamp}", id, time);
var query = new Query("switches").AsUpdate(new { timestamp = time }).Where("id", id);
return _db.ExecuteQuery(query);
}
public Task DeleteSwitch(SwitchId id)
await _db.ExecuteQuery(query);
_ = _dispatch.Dispatch(id, new()
{
_logger.Information("Deleted {Switch}", id);
var query = new Query("switches").AsDelete().Where("id", id);
return _db.ExecuteQuery(query);
Event = DispatchEvent.UPDATE_SWITCH,
EventData = JObject.FromObject(new
{
timestamp = time.FormatExport(),
}),
});
}
public Task DeleteAllSwitches(SystemId system)
public async Task DeleteSwitch(SwitchId id)
{
var existingSwitch = await GetSwitch(id);
var query = new Query("switches").AsDelete().Where("id", id);
await _db.ExecuteQuery(query);
_logger.Information("Deleted {Switch}", id);
_ = _dispatch.Dispatch(existingSwitch.System, existingSwitch.Uuid, DispatchEvent.DELETE_SWITCH);
}
public async Task DeleteAllSwitches(SystemId system)
{
_logger.Information("Deleted all switches in {SystemId}", system);
var query = new Query("switches").AsDelete().Where("system", system);
return _db.ExecuteQuery(query);
await _db.ExecuteQuery(query);
_ = _dispatch.Dispatch(system, new UpdateDispatchData()
{
Event = DispatchEvent.DELETE_ALL_SWITCHES
});
}
public IAsyncEnumerable<PKSwitch> GetSwitches(SystemId system)
@ -100,6 +137,9 @@ namespace PluralKit.Core
return _db.QueryStream<PKSwitch>(query);
}
public Task<PKSwitch?> GetSwitch(SwitchId id)
=> _db.QueryFirst<PKSwitch?>(new Query("switches").Where("id", id));
public Task<PKSwitch> GetSwitchByUuid(Guid uuid)
{
var query = new Query("switches").Where("uuid", uuid);

View File

@ -78,17 +78,27 @@ namespace PluralKit.Core
});
var system = await _db.QueryFirst<PKSystem>(conn, query, extraSql: "returning *");
_logger.Information("Created {SystemId}", system.Id);
// no dispatch call here - system was just created, we don't have a webhook URL
return system;
}
public Task<PKSystem> UpdateSystem(SystemId id, SystemPatch patch, IPKConnection? conn = null)
public async Task<PKSystem> UpdateSystem(SystemId id, SystemPatch patch, IPKConnection? conn = null)
{
_logger.Information("Updated {SystemId}: {@SystemPatch}", id, patch);
var query = patch.Apply(new Query("systems").Where("id", id));
return _db.QueryFirst<PKSystem>(conn, query, extraSql: "returning *");
var res = await _db.QueryFirst<PKSystem>(conn, query, extraSql: "returning *");
_ = _dispatch.Dispatch(id, new UpdateDispatchData()
{
Event = DispatchEvent.UPDATE_SYSTEM,
EventData = patch.ToJson(),
});
return res;
}
public Task AddAccount(SystemId system, ulong accountId, IPKConnection? conn = null)
public async Task AddAccount(SystemId system, ulong accountId, IPKConnection? conn = null)
{
// We have "on conflict do nothing" since linking an account when it's already linked to the same system is idempotent
// This is used in import/export, although the pk;link command checks for this case beforehand
@ -100,7 +110,13 @@ namespace PluralKit.Core
});
_logger.Information("Linked account {UserId} to {SystemId}", accountId, system);
return _db.ExecuteQuery(conn, query, extraSql: "on conflict do nothing");
await _db.ExecuteQuery(conn, query, extraSql: "on conflict do nothing");
_ = _dispatch.Dispatch(system, new UpdateDispatchData()
{
Event = DispatchEvent.LINK_ACCOUNT,
EntityId = accountId.ToString(),
});
}
public async Task RemoveAccount(SystemId system, ulong accountId)
@ -108,6 +124,11 @@ namespace PluralKit.Core
var query = new Query("accounts").AsDelete().Where("uid", accountId).Where("system", system);
await _db.ExecuteQuery(query);
_logger.Information("Unlinked account {UserId} from {SystemId}", accountId, system);
_ = _dispatch.Dispatch(system, new UpdateDispatchData()
{
Event = DispatchEvent.UNLINK_ACCOUNT,
EntityId = accountId.ToString(),
});
}
public Task DeleteSystem(SystemId id)

View File

@ -6,10 +6,12 @@ namespace PluralKit.Core
{
private readonly ILogger _logger;
private readonly IDatabase _db;
public ModelRepository(ILogger logger, IDatabase db)
private readonly DispatchService _dispatch;
public ModelRepository(ILogger logger, IDatabase db, DispatchService dispatch)
{
_logger = logger.ForContext<ModelRepository>();
_db = db;
_dispatch = dispatch;
}
}
}

View File

@ -12,7 +12,7 @@ namespace PluralKit.Core
internal class DatabaseMigrator
{
private const string RootPath = "PluralKit.Core.Database"; // "resource path" root for SQL files
private const int TargetSchemaVersion = 19;
private const int TargetSchemaVersion = 20;
private readonly ILogger _logger;
public DatabaseMigrator(ILogger logger)

View File

@ -0,0 +1,117 @@
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NodaTime;
namespace PluralKit.Core
{
public enum DispatchEvent
{
PING,
UPDATE_SYSTEM,
CREATE_MEMBER,
UPDATE_MEMBER,
DELETE_MEMBER,
CREATE_GROUP,
UPDATE_GROUP,
UPDATE_GROUP_MEMBERS,
DELETE_GROUP,
LINK_ACCOUNT,
UNLINK_ACCOUNT,
UPDATE_SYSTEM_GUILD,
UPDATE_MEMBER_GUILD,
CREATE_MESSAGE,
CREATE_SWITCH,
UPDATE_SWITCH,
UPDATE_SWITCH_MEMBERS,
DELETE_SWITCH,
DELETE_ALL_SWITCHES,
SUCCESSFUL_IMPORT,
}
public struct UpdateDispatchData
{
public DispatchEvent Event;
public string SystemId;
public string? EntityId;
public ulong? GuildId;
public string SigningToken;
public JObject? EventData;
}
public static class DispatchExt
{
public static StringContent GetPayloadBody(this UpdateDispatchData data)
{
var o = new JObject();
o.Add("type", data.Event.ToString());
o.Add("signing_token", data.SigningToken);
o.Add("system_id", data.SystemId);
o.Add("id", data.EntityId);
o.Add("guild_id", data.GuildId);
o.Add("data", data.EventData);
return new StringContent(JsonConvert.SerializeObject(o));
}
public static JObject ToDispatchJson(this PKMessage msg, string memberRef)
{
var o = new JObject();
o.Add("timestamp", Instant.FromUnixTimeMilliseconds((long)(msg.Mid >> 22) + 1420070400000).FormatExport());
o.Add("id", msg.Mid.ToString());
o.Add("original", msg.OriginalMid.ToString());
o.Add("sender", msg.Sender.ToString());
o.Add("channel", msg.Channel.ToString());
o.Add("member", memberRef);
return o;
}
public static async Task<bool> ValidateUri(string url)
{
IPHostEntry host = null;
try
{
var uri = new Uri(url);
host = await Dns.GetHostEntryAsync(uri.DnsSafeHost);
}
catch (Exception)
{
return false;
}
if (host == null || host.AddressList.Length == 0)
return false;
#pragma warning disable CS0618
foreach (var address in host.AddressList.Where(address => address.AddressFamily is AddressFamily.InterNetwork))
{
if ((address.Address & 0x7f000000) == 0x7f000000) // 127.0/8
return false;
if ((address.Address & 0x0a000000) == 0x0a000000) // 10.0/8
return false;
if ((address.Address & 0xa9fe0000) == 0xa9fe0000) // 169.254/16
return false;
if ((address.Address & 0xac100000) == 0xac100000) // 172.16/12
return false;
}
if (host.AddressList.Any(address => address.IsIPv6LinkLocal))
return false;
// we only support IPv4 in prod :(
return host.AddressList.Any(address => address.AddressFamily is AddressFamily.InterNetwork);
}
}
}

View File

@ -0,0 +1,232 @@
using System;
using System.Linq;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Autofac;
using Serilog;
namespace PluralKit.Core
{
public class DispatchService
{
private readonly ILogger _logger;
private readonly ILifetimeScope _provider;
private readonly HttpClient _client = new();
public DispatchService(ILogger logger, ILifetimeScope provider, CoreConfig cfg)
{
_logger = logger;
_provider = provider;
}
public async Task DoPostRequest(SystemId system, string webhookUrl, HttpContent content, bool isVerify = false)
{
if (!await DispatchExt.ValidateUri(webhookUrl))
{
_logger.Warning("Failed to dispatch webhook for system {SystemId}: URL is invalid or points to a private address", system);
return;
}
try
{
await _client.PostAsync(webhookUrl, content);
}
catch (HttpRequestException e)
{
if (isVerify)
throw;
else
_logger.Error("Could not dispatch webhook request!", e);
}
}
public async Task Dispatch(SystemId systemId, UpdateDispatchData data)
{
if (data.EventData != null && data.EventData.Count == 0)
return;
var repo = _provider.Resolve<ModelRepository>();
var system = await repo.GetSystem(systemId);
if (system.WebhookUrl == null)
return;
data.SigningToken = system.WebhookToken;
data.SystemId = system.Uuid.ToString();
_logger.Debug("Dispatching webhook for system {SystemId}", systemId);
await DoPostRequest(system.Id, system.WebhookUrl, data.GetPayloadBody());
}
public async Task Dispatch(MemberId memberId, UpdateDispatchData data)
{
if (data.EventData != null && data.EventData.Count == 0)
return;
var repo = _provider.Resolve<ModelRepository>();
var member = await repo.GetMember(memberId);
var system = await repo.GetSystem(member.System);
if (system.WebhookUrl == null)
return;
data.SigningToken = system.WebhookToken;
data.SystemId = system.Uuid.ToString();
data.EntityId = member.Uuid.ToString();
_logger.Debug("Dispatching webhook for member {MemberId} (system {SystemId})", memberId, system.Id);
await DoPostRequest(system.Id, system.WebhookUrl, data.GetPayloadBody());
}
public async Task Dispatch(GroupId groupId, UpdateDispatchData data)
{
if (data.EventData != null && data.EventData.Count == 0)
return;
var repo = _provider.Resolve<ModelRepository>();
var group = await repo.GetGroup(groupId);
var system = await repo.GetSystem(group.System);
if (system.WebhookUrl == null)
return;
data.SigningToken = system.WebhookToken;
data.SystemId = system.Uuid.ToString();
data.EntityId = group.Uuid.ToString();
_logger.Debug("Dispatching webhook for group {GroupId} (system {SystemId})", groupId, system.Id);
await DoPostRequest(system.Id, system.WebhookUrl, data.GetPayloadBody());
}
public async Task Dispatch(Dictionary<GroupId, MemberId> dict, DispatchEvent evt)
{
var repo = _provider.Resolve<ModelRepository>();
var g = await repo.GetGroup(dict.Keys.FirstOrDefault());
var system = await repo.GetSystem(g.System);
if (system.WebhookUrl == null)
return;
var data = new UpdateDispatchData();
data.Event = DispatchEvent.UPDATE_GROUP_MEMBERS;
data.SystemId = system.Uuid.ToString();
_logger.Debug("Dispatching webhook for group member update (system {SystemId})", system.Id);
await DoPostRequest(system.Id, system.WebhookUrl, data.GetPayloadBody());
}
public async Task Dispatch(SwitchId swId, UpdateDispatchData data)
{
var repo = _provider.Resolve<ModelRepository>();
var sw = await repo.GetSwitch(swId);
var system = await repo.GetSystem(sw.System);
if (system.WebhookUrl == null)
return;
data.SigningToken = system.WebhookToken;
data.SystemId = system.Uuid.ToString();
data.EntityId = sw.Uuid.ToString();
_logger.Debug("Dispatching webhook for switch {SwitchId} (system {SystemId})", sw.Id, system.Id);
await DoPostRequest(system.Id, system.WebhookUrl, data.GetPayloadBody());
}
public async Task Dispatch(SystemId systemId, PKMessage newMessage)
{
var repo = _provider.Resolve<ModelRepository>();
var system = await repo.GetSystem(systemId);
if (system.WebhookUrl == null)
return;
var member = await repo.GetMember(newMessage.Member);
var data = new UpdateDispatchData();
data.Event = DispatchEvent.CREATE_MESSAGE;
data.SigningToken = system.WebhookToken;
data.SystemId = system.Uuid.ToString();
data.EventData = newMessage.ToDispatchJson(member.Uuid.ToString());
_logger.Debug("Dispatching webhook for message create (system {SystemId})", system.Id);
await DoPostRequest(system.Id, system.WebhookUrl, data.GetPayloadBody());
}
public async Task Dispatch(SystemId systemId, ulong guild_id, SystemGuildPatch patch)
{
var repo = _provider.Resolve<ModelRepository>();
var system = await repo.GetSystem(systemId);
if (system.WebhookUrl == null)
return;
string memberRef = null;
if (patch.AutoproxyMember.Value != null)
{
var member = await repo.GetMember(patch.AutoproxyMember.Value.Value);
memberRef = member.Uuid.ToString();
}
var data = new UpdateDispatchData();
data.Event = DispatchEvent.UPDATE_SYSTEM_GUILD;
data.SigningToken = system.WebhookToken;
data.SystemId = system.Uuid.ToString();
data.GuildId = guild_id;
data.EventData = patch.ToJson(memberRef);
_logger.Debug("Dispatching webhook for system {SystemId} in guild {GuildId}", system.Id, guild_id);
await DoPostRequest(system.Id, system.WebhookUrl, data.GetPayloadBody());
}
public async Task Dispatch(MemberId memberId, ulong guild_id, MemberGuildPatch patch)
{
var repo = _provider.Resolve<ModelRepository>();
var member = await repo.GetMember(memberId);
var system = await repo.GetSystem(member.System);
if (system.WebhookUrl == null)
return;
var data = new UpdateDispatchData();
data.Event = DispatchEvent.UPDATE_MEMBER_GUILD;
data.SigningToken = system.WebhookToken;
data.SystemId = system.Uuid.ToString();
data.EntityId = member.Uuid.ToString();
data.GuildId = guild_id;
data.EventData = patch.ToJson();
_logger.Debug("Dispatching webhook for member {MemberId} (system {SystemId}) in guild {GuildId}", member.Id, system.Id, guild_id);
await DoPostRequest(system.Id, system.WebhookUrl, data.GetPayloadBody());
}
public async Task Dispatch(ulong accountId, AccountPatch patch)
{
var repo = _provider.Resolve<ModelRepository>();
var system = await repo.GetSystemByAccount(accountId);
if (system.WebhookUrl == null)
return;
var data = new UpdateDispatchData();
data.Event = DispatchEvent.UPDATE_MEMBER_GUILD;
data.SigningToken = system.WebhookToken;
data.EntityId = accountId.ToString();
data.EventData = patch.ToJson();
_logger.Debug("Dispatching webhook for account {AccountId} (system {SystemId})", accountId, system.Id);
await DoPostRequest(system.Id, system.WebhookUrl, data.GetPayloadBody());
}
public async Task Dispatch(SystemId systemId, Guid uuid, DispatchEvent evt)
{
var repo = _provider.Resolve<ModelRepository>();
var system = await repo.GetSystem(systemId);
if (system.WebhookUrl == null)
return;
var data = new UpdateDispatchData();
data.Event = evt;
data.SigningToken = system.WebhookToken;
data.SystemId = system.Uuid.ToString();
data.EntityId = uuid.ToString();
_logger.Debug("Dispatching webhook for entity delete (system {SystemId})", system.Id);
await DoPostRequest(system.Id, system.WebhookUrl, data.GetPayloadBody());
}
}
}

View File

@ -46,6 +46,8 @@ namespace PluralKit.Core
public string BannerImage { get; }
public string Color { get; }
public string Token { get; }
public string WebhookUrl { get; }
public string WebhookToken { get; }
public Instant Created { get; }
public string UiTz { get; set; }
public bool PingsEnabled { get; }
@ -100,6 +102,10 @@ namespace PluralKit.Core
if (ctx == LookupContext.ByOwner)
{
// todo: should this be moved to a different JSON model?
o.Add("webhook_url", system.WebhookUrl);
o.Add("webhook_token", system.WebhookToken);
var p = new JObject();
p.Add("description_privacy", system.DescriptionPrivacy.ToJsonString());

View File

@ -1,5 +1,7 @@
using SqlKata;
using Newtonsoft.Json.Linq;
namespace PluralKit.Core
{
public class AccountPatch: PatchObject
@ -9,5 +11,15 @@ namespace PluralKit.Core
public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper
.With("allow_autoproxy", AllowAutoproxy)
);
public JObject ToJson()
{
var o = new JObject();
if (AllowAutoproxy.IsPresent)
o.Add("allow_autoproxy", AllowAutoproxy.Value);
return o;
}
}
}

View File

@ -91,5 +91,51 @@ namespace PluralKit.Core
return patch;
}
public JObject ToJson()
{
var o = new JObject();
if (Name.IsPresent)
o.Add("name", Name.Value);
if (Hid.IsPresent)
o.Add("id", Hid.Value);
if (DisplayName.IsPresent)
o.Add("display_name", DisplayName.Value);
if (Description.IsPresent)
o.Add("description", Description.Value);
if (Icon.IsPresent)
o.Add("icon", Icon.Value);
if (BannerImage.IsPresent)
o.Add("banner", BannerImage.Value);
if (Color.IsPresent)
o.Add("color", Color.Value);
if (
DescriptionPrivacy.IsPresent
|| IconPrivacy.IsPresent
|| ListPrivacy.IsPresent
|| Visibility.IsPresent
)
{
var p = new JObject();
if (DescriptionPrivacy.IsPresent)
p.Add("description_privacy", DescriptionPrivacy.Value.ToJsonString());
if (IconPrivacy.IsPresent)
p.Add("icon_privacy", IconPrivacy.Value.ToJsonString());
if (ListPrivacy.IsPresent)
p.Add("list_privacy", ListPrivacy.Value.ToJsonString());
if (Visibility.IsPresent)
p.Add("visibilithy", Visibility.Value.ToJsonString());
o.Add("privacy", p);
}
return o;
}
}
}

View File

@ -38,5 +38,18 @@ namespace PluralKit.Core
return patch;
}
public JObject ToJson()
{
var o = new JObject();
if (DisplayName.IsPresent)
o.Add("display_name", DisplayName.Value);
if (AvatarUrl.IsPresent)
o.Add("avatar_url", AvatarUrl.Value);
return o;
}
}
}

View File

@ -192,5 +192,77 @@ namespace PluralKit.Core
return patch;
}
public JObject ToJson()
{
var o = new JObject();
if (Name.IsPresent)
o.Add("name", Name.Value);
if (Hid.IsPresent)
o.Add("id", Hid.Value);
if (DisplayName.IsPresent)
o.Add("display_name", DisplayName.Value);
if (AvatarUrl.IsPresent)
o.Add("avatar_url", AvatarUrl.Value);
if (BannerImage.IsPresent)
o.Add("banner", BannerImage.Value);
if (Color.IsPresent)
o.Add("color", Color.Value);
if (Birthday.IsPresent)
o.Add("birthday", Birthday.Value?.FormatExport());
if (Pronouns.IsPresent)
o.Add("pronouns", Pronouns.Value);
if (Description.IsPresent)
o.Add("description", Description.Value);
if (ProxyTags.IsPresent)
{
var tagArray = new JArray();
foreach (var tag in ProxyTags.Value)
tagArray.Add(new JObject { { "prefix", tag.Prefix }, { "suffix", tag.Suffix } });
o.Add("proxy_tags", tagArray);
}
if (KeepProxy.IsPresent)
o.Add("keep_proxy", KeepProxy.Value);
if (
Visibility.IsPresent
|| NamePrivacy.IsPresent
|| DescriptionPrivacy.IsPresent
|| PronounPrivacy.IsPresent
|| BirthdayPrivacy.IsPresent
|| AvatarPrivacy.IsPresent
|| MetadataPrivacy.IsPresent
)
{
var p = new JObject();
if (Visibility.IsPresent)
p.Add("visibility", Visibility.Value.ToJsonString());
if (NamePrivacy.IsPresent)
p.Add("name_privacy", NamePrivacy.Value.ToJsonString());
if (DescriptionPrivacy.IsPresent)
p.Add("description_privacy", DescriptionPrivacy.Value.ToJsonString());
if (PronounPrivacy.IsPresent)
p.Add("pronoun_privacy", PronounPrivacy.Value.ToJsonString());
if (BirthdayPrivacy.IsPresent)
p.Add("birthday_privacy", BirthdayPrivacy.Value.ToJsonString());
if (AvatarPrivacy.IsPresent)
p.Add("avatar_privacy", AvatarPrivacy.Value.ToJsonString());
if (MetadataPrivacy.IsPresent)
p.Add("metadata_privacy", MetadataPrivacy.Value.ToJsonString());
o.Add("privacy", p);
}
return o;
}
}
}

View File

@ -55,5 +55,27 @@ namespace PluralKit.Core
return patch;
}
public JObject ToJson(string memberRef)
{
var o = new JObject();
if (ProxyEnabled.IsPresent)
o.Add("proxying_enabled", ProxyEnabled.Value);
if (AutoproxyMode.IsPresent)
o.Add("autoproxy_mode", AutoproxyMode.Value.ToString().ToLower());
if (AutoproxyMember.IsPresent)
o.Add("autoproxy_member", memberRef);
if (Tag.IsPresent)
o.Add("tag", Tag.Value);
if (TagEnabled.IsPresent)
o.Add("tag_enabled", TagEnabled.Value);
return o;
}
}
}

View File

@ -20,6 +20,8 @@ namespace PluralKit.Core
public Partial<string?> BannerImage { get; set; }
public Partial<string?> Color { get; set; }
public Partial<string?> Token { get; set; }
public Partial<string?> WebhookUrl { get; set; }
public Partial<string?> WebhookToken { get; set; }
public Partial<string> UiTz { get; set; }
public Partial<PrivacyLevel> DescriptionPrivacy { get; set; }
public Partial<PrivacyLevel> MemberListPrivacy { get; set; }
@ -40,6 +42,8 @@ namespace PluralKit.Core
.With("banner_image", BannerImage)
.With("color", Color)
.With("token", Token)
.With("webhook_url", WebhookUrl)
.With("webhook_token", WebhookToken)
.With("ui_tz", UiTz)
.With("description_privacy", DescriptionPrivacy)
.With("member_list_privacy", MemberListPrivacy)
@ -126,5 +130,57 @@ namespace PluralKit.Core
return patch;
}
public JObject ToJson()
{
var o = new JObject();
if (Name.IsPresent)
o.Add("name", Name.Value);
if (Hid.IsPresent)
o.Add("id", Hid.Value);
if (Description.IsPresent)
o.Add("description", Description.Value);
if (Tag.IsPresent)
o.Add("tag", Tag.Value);
if (AvatarUrl.IsPresent)
o.Add("avatar_url", AvatarUrl.Value);
if (BannerImage.IsPresent)
o.Add("banner", BannerImage.Value);
if (Color.IsPresent)
o.Add("color", Color.Value);
if (UiTz.IsPresent)
o.Add("timezone", UiTz.Value);
if (
DescriptionPrivacy.IsPresent
|| MemberListPrivacy.IsPresent
|| GroupListPrivacy.IsPresent
|| FrontPrivacy.IsPresent
|| FrontHistoryPrivacy.IsPresent
)
{
var p = new JObject();
if (DescriptionPrivacy.IsPresent)
p.Add("description_privacy", DescriptionPrivacy.Value.ToJsonString());
if (MemberListPrivacy.IsPresent)
p.Add("member_list_privacy", MemberListPrivacy.Value.ToJsonString());
if (GroupListPrivacy.IsPresent)
p.Add("group_list_privacy", GroupListPrivacy.Value.ToJsonString());
if (FrontPrivacy.IsPresent)
p.Add("front_privacy", FrontPrivacy.Value.ToJsonString());
if (FrontHistoryPrivacy.IsPresent)
p.Add("front_history_privacy", FrontHistoryPrivacy.Value.ToJsonString());
o.Add("privacy", p);
}
return o;
}
}
}

View File

@ -14,6 +14,8 @@ namespace PluralKit.Core
builder.RegisterType<Database>().As<IDatabase>().SingleInstance();
builder.RegisterType<ModelRepository>().AsSelf().SingleInstance();
builder.RegisterType<DispatchService>().AsSelf().SingleInstance();
builder.Populate(new ServiceCollection().AddMemoryCache());
}
}

View File

@ -18,12 +18,14 @@ namespace PluralKit.Core
private readonly IDatabase _db;
private readonly ModelRepository _repo;
private readonly ILogger _logger;
private readonly DispatchService _dispatch;
public DataFileService(IDatabase db, ModelRepository repo, ILogger logger)
public DataFileService(IDatabase db, ModelRepository repo, ILogger logger, DispatchService dispatch)
{
_db = db;
_repo = repo;
_logger = logger;
_dispatch = dispatch;
}
public async Task<JObject> ExportSystem(PKSystem system)
@ -72,7 +74,7 @@ namespace PluralKit.Core
await using var conn = await _db.Obtain();
await using var tx = await conn.BeginTransactionAsync();
return await BulkImporter.PerformImport(conn, tx, _repo, _logger, userId, system, importFile, confirmFunc);
return await BulkImporter.PerformImport(conn, tx, _repo, _logger, _dispatch, userId, system, importFile, confirmFunc);
}
}

View File

@ -34,7 +34,7 @@ namespace PluralKit.Core
private ImportResultNew _result = new();
internal static async Task<ImportResultNew> PerformImport(IPKConnection conn, IPKTransaction tx, ModelRepository repo, ILogger logger,
ulong userId, PKSystem? system, JObject importFile, Func<string, Task> confirmFunc)
DispatchService dispatch, ulong userId, PKSystem? system, JObject importFile, Func<string, Task> confirmFunc)
{
await using var importer = new BulkImporter()
{
@ -82,6 +82,11 @@ namespace PluralKit.Core
throw new ImportException("File type is unknown.");
importer._result.Success = true;
await tx.CommitAsync();
_ = dispatch.Dispatch(system.Id, new UpdateDispatchData()
{
Event = DispatchEvent.SUCCESSFUL_IMPORT
});
}
catch (ImportException e)
{

View File

@ -66,7 +66,7 @@ module.exports = {
"/api/endpoints",
"/api/models",
"/api/errors",
// "/api/integrations",
"/api/dispatch",
"/api/legacy"
]
},

View File

@ -0,0 +1,60 @@
---
title: Dispatch
permalink: /api/dispatch
---
# Dispatch Webhooks
Dispatch webhooks are a way to get notified when you update your system information on PluralKit. It can be used for integrations where you want to perform some action when you run a bot command on Discord, but also don't want to (or can't) set up a Discord bot to listem to messages.
You will need a publicly-accessible webserver that can receive and process JSON-formatted POST requests.
## Security
::: warning
On the internet, security is important! Don't skip this section.
:::
To get dispatch events from PluralKit, you must set up a *public* HTTP endpoint. As such, anyone who knows the URL to the endpoint - **not only PluralKit** - can send POST requests and "pretend" to be PluralKit.
For this reason, when you register a webhook URL, PluralKit generates a secret token, and then includes it with every event sent to you in the `signing_token` key. If you receive an event with an invalid `signing_token`, you **must** stop processing the request and **respond with a 401 status code**.
PluralKit will send invalid requests to your endpoint, with `PING` event type, once in a while to confirm that you are correctly validating requests.
## Dispatch Event Model
|key|type|description|
|---|---|---|
|type|string|[event type](#dispatch-events)|
|signing_token|string|the [signing token](#security) for your webhook URL|
|system_id|string|the system ID associated with this event|
|id|string?|the ID of the entity referenced by the event (can be a system/member/group/switch/Discord user ID)|
|guild_id|snowflake?*|the ID of the guild where this event occurred|
|data|object?|event data|
\* only sent for guild settings update events. in message create events, the guild id is sent in the `data` object
## Dispatch Events
|name|description|content of `data` object|notes|
|---|---|---|---|
|PING|PluralKit is checking if your webhook URL is working.|null|Reply with a 200 status code if the `signing_token` is correct, or a 401 status code if it is invalid.|
|UPDATE_SYSTEM|your system was updated|[system object](/api/models#system-model) only containing modififed keys|
|CREATE_MEMBER|a new member was created|[member object](/api/models#member-model) only containing `name` key|new member ID can be found in the top-level `id` key`|
|UPDATE_MEMBER|a member was updated|[member object](/api/models#member-model) only containing modified keys|member ID can be found in the top-level `id` key`|
|DELETE_MEMBER|a member was deleted|null|old member ID can be found in the top-level `id` key`|
|CREATE_GROUP|a new group was created|[group object](/api/models#group-model) only containing `name` key|new group ID can be found in the top-level `id` key`|
|UPDATE_GROUP|a group was updated|[group object](/api/models#group-model) only containing modified keys|group ID can be found in the top-level `id` key`|
|UPDATE_GROUP_MEMBERS|the member list of a group was updated|unknown|This event is currently non-functional|
|DELETE_GROUP|a group was deleted|null|old group ID can be found in the top-level `id` key`|
|LINK_ACCOUNT|a new Discord account was linked to your system|null|new account ID can be found in the top-level `id` key|
|UNLINK_ACCOUNT|a Discord account was unlinked from your system|null|old account ID can be found in the top-level `id` key|
|UPDATE_SYSTEM_GUILD|your system settings in a specific server were updated|[system guild settings](/api/models#system-guild-settings-model) with only modified keys|
|UPDATE_MEMBER_GUILD|the settings for a member in a specific server were updated|[member guild settings](/api/models#member-guild-settings-model) with only modified keys|
|CREATE_MESSAGE|a message was sent|[message object](/api/models#message-model)|
|CREATE_SWITCH|a new switch was logged|[switch object](/api/models#switch-model)|
|UPDATE_SWITCH|a switch was updated|[switch object](/api/models#switch-model) with only modified keys|
|UPDATE_SWITCH_MEMBERS|the member list of a switch was updated|list of member IDs|
|DELETE_SWITCH|a switch was deleted|null|old switch ID can be found in top-level `id` key|
|DELETE_ALL_SWITCHES|your system's switches were bulk deleted|null|
|SUCCESSFUL_IMPORT|some information was successfully imported through the `pk;import` command to your system|null|

View File

@ -135,6 +135,7 @@ Some arguments indicate the use of specific Discord features. These include:
*(for using the [PluralKit API](./api-documentation.md), useful for developers)*
- `pk;token` - DMs you a token for using the PluralKit API.
- `pk;token refresh` - Refreshes your API token and invalidates the old one.
- `pk;s webhook [url]` - Shows or updates the [dispatch webhook](/api/dispatch) URL for your system.
## Help
- `pk;help` - Displays a basic help message describing how to use the bot.