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 ApiConfig _config;
protected readonly IDatabase _db; protected readonly IDatabase _db;
protected readonly ModelRepository _repo; protected readonly ModelRepository _repo;
protected readonly DispatchService _dispatch;
public PKControllerBase(IServiceProvider svc) public PKControllerBase(IServiceProvider svc)
{ {
_config = svc.GetRequiredService<ApiConfig>(); _config = svc.GetRequiredService<ApiConfig>();
_db = svc.GetRequiredService<IDatabase>(); _db = svc.GetRequiredService<IDatabase>();
_repo = svc.GetRequiredService<ModelRepository>(); _repo = svc.GetRequiredService<ModelRepository>();
_dispatch = svc.GetRequiredService<DispatchService>();
} }
protected Task<PKSystem?> ResolveSystem(string systemRef) 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); var newGroup = await _repo.CreateGroup(system.Id, patch.Name.Value, conn);
newGroup = await _repo.UpdateGroup(newGroup.Id, patch, 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(); await tx.CommitAsync();
return Ok(newGroup.ToJson(LookupContext.ByOwner)); 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); var newMember = await _repo.CreateMember(system.Id, patch.Name.Value, conn);
newMember = await _repo.UpdateMember(newMember.Id, patch, conn); newMember = await _repo.UpdateMember(newMember.Id, patch, conn);
_ = _dispatch.Dispatch(newMember.Id, new()
{
Event = DispatchEvent.CREATE_MEMBER,
EventData = patch.ToJson(),
});
await tx.CommitAsync(); await tx.CommitAsync();
return Ok(newMember.ToJson(LookupContext.ByOwner, v: APIVersion.V2)); 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."); 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) public static Context CheckSystemPrivacy(this Context ctx, PKSystem target, PrivacyLevel level)
{ {
if (level.CanAccess(ctx.LookupContextFor(target))) return ctx; 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 System.Threading.Tasks;
using Myriad.Extensions; using Myriad.Extensions;
@ -9,14 +11,15 @@ using PluralKit.Core;
namespace PluralKit.Bot namespace PluralKit.Bot
{ {
public class Token public class Api
{ {
private readonly IDatabase _db;
private readonly ModelRepository _repo; 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; _repo = repo;
_dispatch = dispatch;
} }
public async Task GetToken(Context ctx) 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?"); 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)); return ctx.Execute<SystemLink>(Unlink, m => m.UnlinkAccount(ctx));
if (ctx.Match("token")) if (ctx.Match("token"))
if (ctx.Match("refresh", "renew", "invalidate", "reroll", "regen")) 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 else
return ctx.Execute<Token>(TokenGet, m => m.GetToken(ctx)); return ctx.Execute<Api>(TokenGet, m => m.GetToken(ctx));
if (ctx.Match("import")) if (ctx.Match("import"))
return ctx.Execute<ImportExport>(Import, m => m.Import(ctx)); return ctx.Execute<ImportExport>(Import, m => m.Import(ctx));
if (ctx.Match("export")) if (ctx.Match("export"))
@ -286,6 +286,8 @@ namespace PluralKit.Bot
await ctx.Execute<SystemEdit>(SystemAvatar, m => m.Avatar(ctx)); await ctx.Execute<SystemEdit>(SystemAvatar, m => m.Avatar(ctx));
else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet")) else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet"))
await ctx.Execute<SystemEdit>(SystemDelete, m => m.Delete(ctx)); 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")) else if (ctx.Match("timezone", "tz"))
await ctx.Execute<SystemEdit>(SystemTimezone, m => m.SystemTimezone(ctx)); await ctx.Execute<SystemEdit>(SystemTimezone, m => m.SystemTimezone(ctx));
else if (ctx.Match("proxy")) else if (ctx.Match("proxy"))

View File

@ -10,6 +10,8 @@ using Dapper;
using Humanizer; using Humanizer;
using Newtonsoft.Json.Linq;
using NodaTime; using NodaTime;
using Myriad.Builders; using Myriad.Builders;
@ -24,13 +26,15 @@ namespace PluralKit.Bot
private readonly ModelRepository _repo; private readonly ModelRepository _repo;
private readonly EmbedService _embeds; private readonly EmbedService _embeds;
private readonly HttpClient _client; 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; _db = db;
_repo = repo; _repo = repo;
_embeds = embeds; _embeds = embeds;
_client = client; _client = client;
_dispatch = dispatch;
} }
public async Task CreateGroup(Context ctx) public async Task CreateGroup(Context ctx)
@ -59,6 +63,12 @@ namespace PluralKit.Bot
var newGroup = await _repo.CreateGroup(ctx.System.Id, groupName); 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() 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:") .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()}**")) .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 ModelRepository _repo;
private readonly EmbedService _embeds; private readonly EmbedService _embeds;
private readonly HttpClient _client; 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; _embeds = embeds;
_db = db; _db = db;
_repo = repo; _repo = repo;
_client = client; _client = client;
_dispatch = dispatch;
} }
public async Task NewMember(Context ctx) public async Task NewMember(Context ctx)
@ -62,12 +64,20 @@ namespace PluralKit.Bot
// Try to match an image attached to the message // Try to match an image attached to the message
var avatarArg = ctx.Message.Attachments.FirstOrDefault(); var avatarArg = ctx.Message.Attachments.FirstOrDefault();
Exception imageMatchError = null; Exception imageMatchError = null;
bool sentDispatch = false;
if (avatarArg != null) if (avatarArg != null)
{ {
try try
{ {
await AvatarUtils.VerifyAvatarOrThrow(_client, avatarArg.Url); 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) 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 // 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"); 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 // todo: move this to ModelRepository

View File

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

View File

@ -30,17 +30,19 @@ namespace PluralKit.Bot
private readonly ModelRepository _repo; private readonly ModelRepository _repo;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly WebhookExecutorService _webhookExecutor; private readonly WebhookExecutorService _webhookExecutor;
private readonly DispatchService _dispatch;
private readonly ProxyMatcher _matcher; private readonly ProxyMatcher _matcher;
private readonly IMetrics _metrics; private readonly IMetrics _metrics;
private readonly IDiscordCache _cache; private readonly IDiscordCache _cache;
private readonly LastMessageCacheService _lastMessage; private readonly LastMessageCacheService _lastMessage;
private readonly DiscordApiClient _rest; 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) ProxyMatcher matcher, IMetrics metrics, ModelRepository repo, IDiscordCache cache, DiscordApiClient rest, LastMessageCacheService lastMessage)
{ {
_logChannel = logChannel; _logChannel = logChannel;
_webhookExecutor = webhookExecutor; _webhookExecutor = webhookExecutor;
_dispatch = dispatch;
_db = db; _db = db;
_matcher = matcher; _matcher = matcher;
_metrics = metrics; _metrics = metrics;
@ -297,6 +299,8 @@ namespace PluralKit.Bot
Task LogMessageToChannel() => _logChannel.LogMessage(ctx, sentMessage, triggerMessage, proxyMessage).AsTask(); Task LogMessageToChannel() => _logChannel.LogMessage(ctx, sentMessage, triggerMessage, proxyMessage).AsTask();
Task DispatchWebhook() => _dispatch.Dispatch(ctx.SystemId.Value, sentMessage);
async Task DeleteProxyTriggerMessage() async Task DeleteProxyTriggerMessage()
{ {
// Wait a second or so before deleting the original message // 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) // 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( await Task.WhenAll(
DeleteProxyTriggerMessage(), DeleteProxyTriggerMessage(),
SaveMessageInDatabase(), 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); _logger.Information("Updated account {accountId}: {@AccountPatch}", id, patch);
var query = patch.Apply(new Query("accounts").Where("uid", id)); var query = patch.Apply(new Query("accounts").Where("uid", id));
_ = _dispatch.Dispatch(id, patch);
await _db.ExecuteQuery(query, extraSql: "returning *"); await _db.ExecuteQuery(query, extraSql: "returning *");
} }
} }

View File

@ -2,12 +2,20 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SqlKata; using SqlKata;
namespace PluralKit.Core namespace PluralKit.Core
{ {
public partial class ModelRepository 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) public Task<PKGroup?> GetGroupByName(SystemId system, string name)
{ {
var query = new Query("groups").Where("system", system).WhereRaw("lower(name) = lower(?)", name.ToLower()); var query = new Query("groups").Where("system", system).WhereRaw("lower(name) = lower(?)", name.ToLower());
@ -60,18 +68,31 @@ namespace PluralKit.Core
return group; 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); _logger.Information("Updated {GroupId}: {@GroupPatch}", id, patch);
var query = patch.Apply(new Query("groups").Where("id", id)); 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); _logger.Information("Deleted {GroupId}", group);
var query = new Query("groups").AsDelete().Where("id", 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); _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)); 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) public Task<MemberGuildSettings> GetMemberGuild(ulong guild, MemberId member, bool defaultInsert = true)
{ {
if (!defaultInsert) if (!defaultInsert)
@ -69,6 +70,7 @@ namespace PluralKit.Core
{ {
_logger.Information("Updated {MemberId} in guild {GuildId}: {@MemberGuildPatch}", member, guild, patch); _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)); 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 *"); return _db.QueryFirst<MemberGuildSettings>(query, extraSql: "returning *");
} }
} }

View File

@ -1,7 +1,10 @@
#nullable enable #nullable enable
using System; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SqlKata; using SqlKata;
namespace PluralKit.Core namespace PluralKit.Core
@ -46,6 +49,15 @@ namespace PluralKit.Core
return _db.QueryFirst<PKMember?>(query); 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) public async Task<PKMember> CreateMember(SystemId systemId, string memberName, IPKConnection? conn = null)
{ {
var query = new Query("members").AsInsert(new var query = new Query("members").AsInsert(new
@ -64,14 +76,27 @@ namespace PluralKit.Core
{ {
_logger.Information("Updated {MemberId}: {@MemberPatch}", id, patch); _logger.Information("Updated {MemberId}: {@MemberPatch}", id, patch);
var query = patch.Apply(new Query("members").Where("id", id)); 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 *"); 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); _logger.Information("Deleted {MemberId}", id);
var query = new Query("members").AsDelete().Where("id", 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 Dapper;
using Newtonsoft.Json.Linq;
using NodaTime; using NodaTime;
using NpgsqlTypes; using NpgsqlTypes;
@ -42,8 +44,19 @@ namespace PluralKit.Core
await tx.CommitAsync(); await tx.CommitAsync();
_logger.Information("Created {SwitchId} in {SystemId}: {Members}", sw.Id, system, members); _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; return sw;
} }
public async Task EditSwitch(IPKConnection conn, SwitchId switchId, IReadOnlyCollection<MemberId> members) public async Task EditSwitch(IPKConnection conn, SwitchId switchId, IReadOnlyCollection<MemberId> members)
{ {
// Use a transaction here since we're doing multiple executed commands in one // 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 // Finally we commit the tx, since the using block will otherwise rollback it
await tx.CommitAsync(); 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); _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); _logger.Information("Updated {SwitchId} timestamp: {SwitchTimestamp}", id, time);
var query = new Query("switches").AsUpdate(new { timestamp = time }).Where("id", id); var query = new Query("switches").AsUpdate(new { timestamp = time }).Where("id", id);
return _db.ExecuteQuery(query); await _db.ExecuteQuery(query);
} _ = _dispatch.Dispatch(id, new()
public Task DeleteSwitch(SwitchId id)
{ {
_logger.Information("Deleted {Switch}", id); Event = DispatchEvent.UPDATE_SWITCH,
var query = new Query("switches").AsDelete().Where("id", id); EventData = JObject.FromObject(new
return _db.ExecuteQuery(query); {
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); _logger.Information("Deleted all switches in {SystemId}", system);
var query = new Query("switches").AsDelete().Where("system", 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) public IAsyncEnumerable<PKSwitch> GetSwitches(SystemId system)
@ -100,6 +137,9 @@ namespace PluralKit.Core
return _db.QueryStream<PKSwitch>(query); 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) public Task<PKSwitch> GetSwitchByUuid(Guid uuid)
{ {
var query = new Query("switches").Where("uuid", 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 *"); var system = await _db.QueryFirst<PKSystem>(conn, query, extraSql: "returning *");
_logger.Information("Created {SystemId}", system.Id); _logger.Information("Created {SystemId}", system.Id);
// no dispatch call here - system was just created, we don't have a webhook URL
return system; 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); _logger.Information("Updated {SystemId}: {@SystemPatch}", id, patch);
var query = patch.Apply(new Query("systems").Where("id", id)); 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 // 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 // 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); _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) 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); var query = new Query("accounts").AsDelete().Where("uid", accountId).Where("system", system);
await _db.ExecuteQuery(query); await _db.ExecuteQuery(query);
_logger.Information("Unlinked account {UserId} from {SystemId}", accountId, system); _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) public Task DeleteSystem(SystemId id)

View File

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

View File

@ -12,7 +12,7 @@ namespace PluralKit.Core
internal class DatabaseMigrator internal class DatabaseMigrator
{ {
private const string RootPath = "PluralKit.Core.Database"; // "resource path" root for SQL files 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; private readonly ILogger _logger;
public DatabaseMigrator(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 BannerImage { get; }
public string Color { get; } public string Color { get; }
public string Token { get; } public string Token { get; }
public string WebhookUrl { get; }
public string WebhookToken { get; }
public Instant Created { get; } public Instant Created { get; }
public string UiTz { get; set; } public string UiTz { get; set; }
public bool PingsEnabled { get; } public bool PingsEnabled { get; }
@ -100,6 +102,10 @@ namespace PluralKit.Core
if (ctx == LookupContext.ByOwner) 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(); var p = new JObject();
p.Add("description_privacy", system.DescriptionPrivacy.ToJsonString()); p.Add("description_privacy", system.DescriptionPrivacy.ToJsonString());

View File

@ -1,5 +1,7 @@
using SqlKata; using SqlKata;
using Newtonsoft.Json.Linq;
namespace PluralKit.Core namespace PluralKit.Core
{ {
public class AccountPatch: PatchObject public class AccountPatch: PatchObject
@ -9,5 +11,15 @@ namespace PluralKit.Core
public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper public override Query Apply(Query q) => q.ApplyPatch(wrapper => wrapper
.With("allow_autoproxy", AllowAutoproxy) .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; 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; 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; 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; 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?> BannerImage { get; set; }
public Partial<string?> Color { get; set; } public Partial<string?> Color { get; set; }
public Partial<string?> Token { 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<string> UiTz { get; set; }
public Partial<PrivacyLevel> DescriptionPrivacy { get; set; } public Partial<PrivacyLevel> DescriptionPrivacy { get; set; }
public Partial<PrivacyLevel> MemberListPrivacy { get; set; } public Partial<PrivacyLevel> MemberListPrivacy { get; set; }
@ -40,6 +42,8 @@ namespace PluralKit.Core
.With("banner_image", BannerImage) .With("banner_image", BannerImage)
.With("color", Color) .With("color", Color)
.With("token", Token) .With("token", Token)
.With("webhook_url", WebhookUrl)
.With("webhook_token", WebhookToken)
.With("ui_tz", UiTz) .With("ui_tz", UiTz)
.With("description_privacy", DescriptionPrivacy) .With("description_privacy", DescriptionPrivacy)
.With("member_list_privacy", MemberListPrivacy) .With("member_list_privacy", MemberListPrivacy)
@ -126,5 +130,57 @@ namespace PluralKit.Core
return patch; 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<Database>().As<IDatabase>().SingleInstance();
builder.RegisterType<ModelRepository>().AsSelf().SingleInstance(); builder.RegisterType<ModelRepository>().AsSelf().SingleInstance();
builder.RegisterType<DispatchService>().AsSelf().SingleInstance();
builder.Populate(new ServiceCollection().AddMemoryCache()); builder.Populate(new ServiceCollection().AddMemoryCache());
} }
} }

View File

@ -18,12 +18,14 @@ namespace PluralKit.Core
private readonly IDatabase _db; private readonly IDatabase _db;
private readonly ModelRepository _repo; private readonly ModelRepository _repo;
private readonly ILogger _logger; 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; _db = db;
_repo = repo; _repo = repo;
_logger = logger; _logger = logger;
_dispatch = dispatch;
} }
public async Task<JObject> ExportSystem(PKSystem system) public async Task<JObject> ExportSystem(PKSystem system)
@ -72,7 +74,7 @@ namespace PluralKit.Core
await using var conn = await _db.Obtain(); await using var conn = await _db.Obtain();
await using var tx = await conn.BeginTransactionAsync(); 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(); private ImportResultNew _result = new();
internal static async Task<ImportResultNew> PerformImport(IPKConnection conn, IPKTransaction tx, ModelRepository repo, ILogger logger, 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() await using var importer = new BulkImporter()
{ {
@ -82,6 +82,11 @@ namespace PluralKit.Core
throw new ImportException("File type is unknown."); throw new ImportException("File type is unknown.");
importer._result.Success = true; importer._result.Success = true;
await tx.CommitAsync(); await tx.CommitAsync();
_ = dispatch.Dispatch(system.Id, new UpdateDispatchData()
{
Event = DispatchEvent.SUCCESSFUL_IMPORT
});
} }
catch (ImportException e) catch (ImportException e)
{ {

View File

@ -66,7 +66,7 @@ module.exports = {
"/api/endpoints", "/api/endpoints",
"/api/models", "/api/models",
"/api/errors", "/api/errors",
// "/api/integrations", "/api/dispatch",
"/api/legacy" "/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)* *(for using the [PluralKit API](./api-documentation.md), useful for developers)*
- `pk;token` - DMs you a token for using the PluralKit API. - `pk;token` - DMs you a token for using the PluralKit API.
- `pk;token refresh` - Refreshes your API token and invalidates the old one. - `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 ## Help
- `pk;help` - Displays a basic help message describing how to use the bot. - `pk;help` - Displays a basic help message describing how to use the bot.