Merge branch 'feat/webhooks' into main
This commit is contained in:
commit
b8e2ebd470
@ -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)
|
||||
|
@ -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));
|
||||
|
@ -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));
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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"))
|
||||
|
@ -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()}**"))
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
|
@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
|
7
PluralKit.Core/Database/Migrations/20.sql
Normal file
7
PluralKit.Core/Database/Migrations/20.sql
Normal 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;
|
@ -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 *");
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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 *");
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
await _db.ExecuteQuery(query);
|
||||
_ = _dispatch.Dispatch(id, new()
|
||||
{
|
||||
Event = DispatchEvent.UPDATE_SWITCH,
|
||||
EventData = JObject.FromObject(new
|
||||
{
|
||||
timestamp = time.FormatExport(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public Task DeleteSwitch(SwitchId id)
|
||||
public async Task DeleteSwitch(SwitchId id)
|
||||
{
|
||||
_logger.Information("Deleted {Switch}", id);
|
||||
var existingSwitch = await GetSwitch(id);
|
||||
|
||||
var query = new Query("switches").AsDelete().Where("id", id);
|
||||
return _db.ExecuteQuery(query);
|
||||
await _db.ExecuteQuery(query);
|
||||
_logger.Information("Deleted {Switch}", id);
|
||||
_ = _dispatch.Dispatch(existingSwitch.System, existingSwitch.Uuid, DispatchEvent.DELETE_SWITCH);
|
||||
}
|
||||
|
||||
public Task DeleteAllSwitches(SystemId system)
|
||||
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);
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
117
PluralKit.Core/Dispatch/DispatchModels.cs
Normal file
117
PluralKit.Core/Dispatch/DispatchModels.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
232
PluralKit.Core/Dispatch/DispatchService.cs
Normal file
232
PluralKit.Core/Dispatch/DispatchService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
@ -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());
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
{
|
||||
|
@ -66,7 +66,7 @@ module.exports = {
|
||||
"/api/endpoints",
|
||||
"/api/models",
|
||||
"/api/errors",
|
||||
// "/api/integrations",
|
||||
"/api/dispatch",
|
||||
"/api/legacy"
|
||||
]
|
||||
},
|
||||
|
60
docs/content/api/dispatch.md
Normal file
60
docs/content/api/dispatch.md
Normal 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|
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user