From 71aec0d419d25a7454878230087eb794bf4b16ab Mon Sep 17 00:00:00 2001 From: spiral Date: Tue, 2 Nov 2021 06:08:17 -0400 Subject: [PATCH 01/18] feat(webhooks): init, add service/models, add JSON to patch objects --- PluralKit.Core/Database/Migrations/20.sql | 7 + .../Repository/ModelRepository.Group.cs | 6 + .../Repository/ModelRepository.Switch.cs | 3 + .../Database/Repository/ModelRepository.cs | 4 +- .../Database/Utils/DatabaseMigrator.cs | 2 +- PluralKit.Core/Dispatch/DispatchModels.cs | 109 +++++++++ PluralKit.Core/Dispatch/DispatchService.cs | 209 ++++++++++++++++++ PluralKit.Core/Models/PKSystem.cs | 6 + PluralKit.Core/Models/Patch/GroupPatch.cs | 46 ++++ .../Models/Patch/MemberGuildPatch.cs | 13 ++ PluralKit.Core/Models/Patch/MemberPatch.cs | 72 ++++++ .../Models/Patch/SystemGuildPatch.cs | 22 ++ PluralKit.Core/Models/Patch/SystemPatch.cs | 52 +++++ PluralKit.Core/Modules/DataStoreModule.cs | 2 + 14 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 PluralKit.Core/Database/Migrations/20.sql create mode 100644 PluralKit.Core/Dispatch/DispatchModels.cs create mode 100644 PluralKit.Core/Dispatch/DispatchService.cs diff --git a/PluralKit.Core/Database/Migrations/20.sql b/PluralKit.Core/Database/Migrations/20.sql new file mode 100644 index 00000000..ea3c02e2 --- /dev/null +++ b/PluralKit.Core/Database/Migrations/20.sql @@ -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; \ No newline at end of file diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Group.cs b/PluralKit.Core/Database/Repository/ModelRepository.Group.cs index ddf1f26d..ba7fe956 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Group.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Group.cs @@ -8,6 +8,12 @@ namespace PluralKit.Core { public partial class ModelRepository { + public Task GetGroup(GroupId id) + { + var query = new Query("groups").Where("id", id); + return _db.QueryFirst(query); + } + public Task GetGroupByName(SystemId system, string name) { var query = new Query("groups").Where("system", system).WhereRaw("lower(name) = lower(?)", name.ToLower()); diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Switch.cs b/PluralKit.Core/Database/Repository/ModelRepository.Switch.cs index 0a8a5533..b34d5909 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Switch.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Switch.cs @@ -100,6 +100,9 @@ namespace PluralKit.Core return _db.QueryStream(query); } + public Task GetSwitch(SwitchId id) + => _db.QueryFirst(new Query("switches").Where("id", id)); + public Task GetSwitchByUuid(Guid uuid) { var query = new Query("switches").Where("uuid", uuid); diff --git a/PluralKit.Core/Database/Repository/ModelRepository.cs b/PluralKit.Core/Database/Repository/ModelRepository.cs index a3a35574..51bbdae3 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.cs @@ -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(); _db = db; + _dispatch = dispatch; } } } \ No newline at end of file diff --git a/PluralKit.Core/Database/Utils/DatabaseMigrator.cs b/PluralKit.Core/Database/Utils/DatabaseMigrator.cs index 40740b43..a76c329d 100644 --- a/PluralKit.Core/Database/Utils/DatabaseMigrator.cs +++ b/PluralKit.Core/Database/Utils/DatabaseMigrator.cs @@ -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) diff --git a/PluralKit.Core/Dispatch/DispatchModels.cs b/PluralKit.Core/Dispatch/DispatchModels.cs new file mode 100644 index 00000000..60190f13 --- /dev/null +++ b/PluralKit.Core/Dispatch/DispatchModels.cs @@ -0,0 +1,109 @@ +using System; +using System.Net; +using System.Net.Http; +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, + } + + 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); + if (data.EntityId != null) + o.Add("id", data.EntityId); + if (data.GuildId != null) + o.Add("guild_id", data.GuildId); + if (data.EventData != null) + 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 ValidateUri(string uri) + { + IPHostEntry host = null; + + try + { + host = await Dns.GetHostEntryAsync(uri); + } + catch (Exception) { } + + if (host == null || host.AddressList.Length == 0) + return false; + +#pragma warning disable CS0618 + + foreach (var address in host.AddressList) + { + 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; + } + + return true; + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Dispatch/DispatchService.cs b/PluralKit.Core/Dispatch/DispatchService.cs new file mode 100644 index 00000000..73ed288f --- /dev/null +++ b/PluralKit.Core/Dispatch/DispatchService.cs @@ -0,0 +1,209 @@ +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; + } + + private async Task DoPostRequest(SystemId system, string webhookUrl, HttpContent content) + { + 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) + { + _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(); + 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(); + 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(); + 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 dict, DispatchEvent evt) + { + var repo = _provider.Resolve(); + 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(); + 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(); + 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.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(); + 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.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(); + 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.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(SystemId systemId, Guid uuid, DispatchEvent evt) + { + var repo = _provider.Resolve(); + 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()); + } + } +} \ No newline at end of file diff --git a/PluralKit.Core/Models/PKSystem.cs b/PluralKit.Core/Models/PKSystem.cs index 3512669b..e743c9fc 100644 --- a/PluralKit.Core/Models/PKSystem.cs +++ b/PluralKit.Core/Models/PKSystem.cs @@ -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()); diff --git a/PluralKit.Core/Models/Patch/GroupPatch.cs b/PluralKit.Core/Models/Patch/GroupPatch.cs index 293b3d66..507dcd4d 100644 --- a/PluralKit.Core/Models/Patch/GroupPatch.cs +++ b/PluralKit.Core/Models/Patch/GroupPatch.cs @@ -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; + } } } \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/MemberGuildPatch.cs b/PluralKit.Core/Models/Patch/MemberGuildPatch.cs index 7a51df25..9b463128 100644 --- a/PluralKit.Core/Models/Patch/MemberGuildPatch.cs +++ b/PluralKit.Core/Models/Patch/MemberGuildPatch.cs @@ -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; + } } } \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/MemberPatch.cs b/PluralKit.Core/Models/Patch/MemberPatch.cs index edf47289..fe21266f 100644 --- a/PluralKit.Core/Models/Patch/MemberPatch.cs +++ b/PluralKit.Core/Models/Patch/MemberPatch.cs @@ -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; + } } } \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/SystemGuildPatch.cs b/PluralKit.Core/Models/Patch/SystemGuildPatch.cs index 3410fc6b..366ac033 100644 --- a/PluralKit.Core/Models/Patch/SystemGuildPatch.cs +++ b/PluralKit.Core/Models/Patch/SystemGuildPatch.cs @@ -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; + } } } \ No newline at end of file diff --git a/PluralKit.Core/Models/Patch/SystemPatch.cs b/PluralKit.Core/Models/Patch/SystemPatch.cs index 50802a1b..40eff765 100644 --- a/PluralKit.Core/Models/Patch/SystemPatch.cs +++ b/PluralKit.Core/Models/Patch/SystemPatch.cs @@ -126,5 +126,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; + } } } \ No newline at end of file diff --git a/PluralKit.Core/Modules/DataStoreModule.cs b/PluralKit.Core/Modules/DataStoreModule.cs index 1daf5150..7f8b37a4 100644 --- a/PluralKit.Core/Modules/DataStoreModule.cs +++ b/PluralKit.Core/Modules/DataStoreModule.cs @@ -14,6 +14,8 @@ namespace PluralKit.Core builder.RegisterType().As().SingleInstance(); builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance(); + builder.Populate(new ServiceCollection().AddMemoryCache()); } } From c1f05eecf890ea6d3b772536059796a04baac450 Mon Sep 17 00:00:00 2001 From: spiral Date: Wed, 3 Nov 2021 01:36:03 -0400 Subject: [PATCH 02/18] feat: rename Commands/Token to Commands/Api --- PluralKit.Bot/Commands/{Token.cs => Api.cs} | 4 ++-- PluralKit.Bot/Commands/CommandTree.cs | 4 ++-- PluralKit.Bot/Modules.cs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename PluralKit.Bot/Commands/{Token.cs => Api.cs} (97%) diff --git a/PluralKit.Bot/Commands/Token.cs b/PluralKit.Bot/Commands/Api.cs similarity index 97% rename from PluralKit.Bot/Commands/Token.cs rename to PluralKit.Bot/Commands/Api.cs index 8b4c8dfd..c34ec9e3 100644 --- a/PluralKit.Bot/Commands/Token.cs +++ b/PluralKit.Bot/Commands/Api.cs @@ -9,11 +9,11 @@ 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) + public Api(IDatabase db, ModelRepository repo) { _db = db; _repo = repo; diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index cc9a1916..e484be8e 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -152,9 +152,9 @@ namespace PluralKit.Bot return ctx.Execute(Unlink, m => m.UnlinkAccount(ctx)); if (ctx.Match("token")) if (ctx.Match("refresh", "renew", "invalidate", "reroll", "regen")) - return ctx.Execute(TokenRefresh, m => m.RefreshToken(ctx)); + return ctx.Execute(TokenRefresh, m => m.RefreshToken(ctx)); else - return ctx.Execute(TokenGet, m => m.GetToken(ctx)); + return ctx.Execute(TokenGet, m => m.GetToken(ctx)); if (ctx.Match("import")) return ctx.Execute(Import, m => m.Import(ctx)); if (ctx.Match("export")) diff --git a/PluralKit.Bot/Modules.cs b/PluralKit.Bot/Modules.cs index 7c54ef4a..5cfd95ec 100644 --- a/PluralKit.Bot/Modules.cs +++ b/PluralKit.Bot/Modules.cs @@ -68,6 +68,7 @@ namespace PluralKit.Bot // Commands builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); + builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); @@ -89,7 +90,6 @@ namespace PluralKit.Bot builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); builder.RegisterType().AsSelf(); - builder.RegisterType().AsSelf(); // Bot core builder.RegisterType().AsSelf().SingleInstance(); From a81ffc3399053f9dbf122c4df1d41ec091936c57 Mon Sep 17 00:00:00 2001 From: spiral Date: Wed, 3 Nov 2021 02:01:35 -0400 Subject: [PATCH 03/18] feat(webhooks): add basic commands --- .../CommandSystem/ContextChecksExt.cs | 6 ++ PluralKit.Bot/Commands/Api.cs | 55 ++++++++++++++++++- PluralKit.Bot/Commands/CommandTree.cs | 2 + PluralKit.Core/Models/Patch/SystemPatch.cs | 4 ++ 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs index 25355837..8be4d24f 100644 --- a/PluralKit.Bot/CommandSystem/ContextChecksExt.cs +++ b/PluralKit.Bot/CommandSystem/ContextChecksExt.cs @@ -16,6 +16,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; diff --git a/PluralKit.Bot/Commands/Api.cs b/PluralKit.Bot/Commands/Api.cs index c34ec9e3..ac276976 100644 --- a/PluralKit.Bot/Commands/Api.cs +++ b/PluralKit.Bot/Commands/Api.cs @@ -11,12 +11,12 @@ namespace PluralKit.Bot { public class Api { - private readonly IDatabase _db; private readonly ModelRepository _repo; - public Api(IDatabase db, ModelRepository repo) + private readonly DispatchService _dispatch; + public Api(ModelRepository repo, DispatchService dispatch) { - _db = db; _repo = repo; + _dispatch = dispatch; } public async Task GetToken(Context ctx) @@ -91,5 +91,54 @@ 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.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 `!"); + 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, + Token = null, + }); + + await ctx.Reply($"{Emojis.Success} System webhook URL removed."); + return; + } + + var newUrl = ctx.RemainderOrNull(); + if (!await DispatchExt.ValidateUri(newUrl)) + { + await ctx.Reply($"New URL '{newUrl}' is invalid. Are you sure this is a valid, publicly accessible URL?"); + return; + } + + var newToken = StringUtils.GenerateToken(); + + await _repo.UpdateSystem(ctx.System.Id, new() + { + WebhookUrl = newUrl, + Token = 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); + } } } \ No newline at end of file diff --git a/PluralKit.Bot/Commands/CommandTree.cs b/PluralKit.Bot/Commands/CommandTree.cs index e484be8e..e274858a 100644 --- a/PluralKit.Bot/Commands/CommandTree.cs +++ b/PluralKit.Bot/Commands/CommandTree.cs @@ -286,6 +286,8 @@ namespace PluralKit.Bot await ctx.Execute(SystemAvatar, m => m.Avatar(ctx)); else if (ctx.Match("delete", "remove", "destroy", "erase", "yeet")) await ctx.Execute(SystemDelete, m => m.Delete(ctx)); + else if (ctx.Match("webhook", "hook")) + await ctx.Execute(null, m => m.SystemWebhook(ctx)); else if (ctx.Match("timezone", "tz")) await ctx.Execute(SystemTimezone, m => m.SystemTimezone(ctx)); else if (ctx.Match("proxy")) diff --git a/PluralKit.Core/Models/Patch/SystemPatch.cs b/PluralKit.Core/Models/Patch/SystemPatch.cs index 40eff765..3e015ab7 100644 --- a/PluralKit.Core/Models/Patch/SystemPatch.cs +++ b/PluralKit.Core/Models/Patch/SystemPatch.cs @@ -20,6 +20,8 @@ namespace PluralKit.Core public Partial BannerImage { get; set; } public Partial Color { get; set; } public Partial Token { get; set; } + public Partial WebhookUrl { get; set; } + public Partial WebhookToken { get; set; } public Partial UiTz { get; set; } public Partial DescriptionPrivacy { get; set; } public Partial 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) From 00b7f76a5b02da3d04843c6efb6297a3fd304ae6 Mon Sep 17 00:00:00 2001 From: spiral Date: Wed, 3 Nov 2021 02:02:07 -0400 Subject: [PATCH 04/18] fix(webhooks): actually correctly parse urls for DNS resolution --- PluralKit.Core/Dispatch/DispatchModels.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/PluralKit.Core/Dispatch/DispatchModels.cs b/PluralKit.Core/Dispatch/DispatchModels.cs index 60190f13..543457d8 100644 --- a/PluralKit.Core/Dispatch/DispatchModels.cs +++ b/PluralKit.Core/Dispatch/DispatchModels.cs @@ -76,13 +76,15 @@ namespace PluralKit.Core return o; } - public static async Task ValidateUri(string uri) + public static async Task ValidateUri(string url) { + var uri = new Uri(url); + IPHostEntry host = null; try { - host = await Dns.GetHostEntryAsync(uri); + host = await Dns.GetHostEntryAsync(uri.DnsSafeHost); } catch (Exception) { } From e8beb245da35794b13c1f847f0fbbc278fa1dfb3 Mon Sep 17 00:00:00 2001 From: spiral Date: Fri, 19 Nov 2021 10:58:12 -0500 Subject: [PATCH 05/18] fix(webhooks): fix error when DNS entry has non-ipv4 addresses --- PluralKit.Bot/Commands/Api.cs | 9 +++------ PluralKit.Core/Dispatch/DispatchModels.cs | 18 +++++++++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/PluralKit.Bot/Commands/Api.cs b/PluralKit.Bot/Commands/Api.cs index ac276976..75ef3f5d 100644 --- a/PluralKit.Bot/Commands/Api.cs +++ b/PluralKit.Bot/Commands/Api.cs @@ -110,7 +110,7 @@ namespace PluralKit.Bot await _repo.UpdateSystem(ctx.System.Id, new() { WebhookUrl = null, - Token = null, + WebhookToken = null, }); await ctx.Reply($"{Emojis.Success} System webhook URL removed."); @@ -119,17 +119,14 @@ namespace PluralKit.Bot var newUrl = ctx.RemainderOrNull(); if (!await DispatchExt.ValidateUri(newUrl)) - { - await ctx.Reply($"New URL '{newUrl}' is invalid. Are you sure this is a valid, publicly accessible URL?"); - return; - } + throw new PKError($"The URL {newUrl.AsCode()} is invalid or I cannot access it. Are you sure this is a valid, publicly accessible URL?"); var newToken = StringUtils.GenerateToken(); await _repo.UpdateSystem(ctx.System.Id, new() { WebhookUrl = newUrl, - Token = newToken, + WebhookToken = newToken, }); await ctx.Reply($"{Emojis.Success} Successfully the new webhook URL for your system." diff --git a/PluralKit.Core/Dispatch/DispatchModels.cs b/PluralKit.Core/Dispatch/DispatchModels.cs index 543457d8..62ca8902 100644 --- a/PluralKit.Core/Dispatch/DispatchModels.cs +++ b/PluralKit.Core/Dispatch/DispatchModels.cs @@ -1,6 +1,8 @@ using System; +using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Sockets; using System.Threading.Tasks; using Newtonsoft.Json; @@ -78,22 +80,24 @@ namespace PluralKit.Core public static async Task ValidateUri(string url) { - var uri = new Uri(url); - IPHostEntry host = null; try { + var uri = new Uri(url); host = await Dns.GetHostEntryAsync(uri.DnsSafeHost); } - catch (Exception) { } + catch (Exception) + { + return false; + } if (host == null || host.AddressList.Length == 0) return false; #pragma warning disable CS0618 - foreach (var address in host.AddressList) + foreach (var address in host.AddressList.Where(address => address.AddressFamily is AddressFamily.InterNetwork)) { if ((address.Address & 0x7f000000) == 0x7f000000) // 127.0/8 return false; @@ -105,7 +109,11 @@ namespace PluralKit.Core return false; } - return true; + 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); } } } \ No newline at end of file From e48e39eeb10ff1d5b90824bafb1685171daaafdd Mon Sep 17 00:00:00 2001 From: spiral Date: Fri, 19 Nov 2021 11:13:21 -0500 Subject: [PATCH 06/18] fix(webhooks): add signing token to events that are missing it --- PluralKit.Core/Dispatch/DispatchService.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/PluralKit.Core/Dispatch/DispatchService.cs b/PluralKit.Core/Dispatch/DispatchService.cs index 73ed288f..613d99b9 100644 --- a/PluralKit.Core/Dispatch/DispatchService.cs +++ b/PluralKit.Core/Dispatch/DispatchService.cs @@ -138,6 +138,7 @@ namespace PluralKit.Core 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()); @@ -161,6 +162,7 @@ namespace PluralKit.Core 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); @@ -179,6 +181,7 @@ namespace PluralKit.Core 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; From 1822788e4ddf050c03a2eeb7a2141afaaf2b81f8 Mon Sep 17 00:00:00 2001 From: spiral Date: Fri, 19 Nov 2021 11:14:40 -0500 Subject: [PATCH 07/18] feat(webhooks): add message create event --- PluralKit.Bot/Proxy/ProxyService.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/PluralKit.Bot/Proxy/ProxyService.cs b/PluralKit.Bot/Proxy/ProxyService.cs index fa59578c..1c4410b7 100644 --- a/PluralKit.Bot/Proxy/ProxyService.cs +++ b/PluralKit.Bot/Proxy/ProxyService.cs @@ -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; @@ -295,6 +297,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 @@ -313,11 +317,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() ); } From f071485a82ac47631d411bff93fdb5c559730df7 Mon Sep 17 00:00:00 2001 From: spiral Date: Fri, 19 Nov 2021 11:23:23 -0500 Subject: [PATCH 08/18] fix(webhooks): always send event data, even if null --- PluralKit.Core/Dispatch/DispatchModels.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/PluralKit.Core/Dispatch/DispatchModels.cs b/PluralKit.Core/Dispatch/DispatchModels.cs index 62ca8902..26cd5df3 100644 --- a/PluralKit.Core/Dispatch/DispatchModels.cs +++ b/PluralKit.Core/Dispatch/DispatchModels.cs @@ -58,8 +58,7 @@ namespace PluralKit.Core o.Add("id", data.EntityId); if (data.GuildId != null) o.Add("guild_id", data.GuildId); - if (data.EventData != null) - o.Add("data", data.EventData); + o.Add("data", data.EventData); return new StringContent(JsonConvert.SerializeObject(o)); } From 7b9d2a4e5eac708c74c4d8124dfe81d9c63a6edc Mon Sep 17 00:00:00 2001 From: spiral Date: Fri, 19 Nov 2021 11:37:50 -0500 Subject: [PATCH 09/18] feat(webhooks): add all events except group member events --- .../Repository/ModelRepository.Account.cs | 1 + .../Repository/ModelRepository.Group.cs | 26 ++++++++-- .../Repository/ModelRepository.Guild.cs | 8 +-- .../Repository/ModelRepository.Member.cs | 32 +++++++++++- .../Repository/ModelRepository.Switch.cs | 51 ++++++++++++++++--- .../Repository/ModelRepository.System.cs | 29 +++++++++-- PluralKit.Core/Dispatch/DispatchService.cs | 17 +++++++ PluralKit.Core/Models/Patch/AccountPatch.cs | 12 +++++ 8 files changed, 156 insertions(+), 20 deletions(-) diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Account.cs b/PluralKit.Core/Database/Repository/ModelRepository.Account.cs index bba07815..2839df38 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Account.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Account.cs @@ -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 *"); } } diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Group.cs b/PluralKit.Core/Database/Repository/ModelRepository.Group.cs index ba7fe956..4a51968c 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Group.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Group.cs @@ -2,6 +2,8 @@ using System; using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + using SqlKata; namespace PluralKit.Core @@ -62,22 +64,38 @@ namespace PluralKit.Core name = name }); var group = await _db.QueryFirst(conn, query, extraSql: "returning *"); + _ = _dispatch.Dispatch(group.Id, new UpdateDispatchData() + { + Event = DispatchEvent.CREATE_GROUP, + EventData = JObject.FromObject(new { name = name }), + }); _logger.Information("Created group {GroupId} in system {SystemId}: {GroupName}", group.Id, system, name); return group; } - public Task UpdateGroup(GroupId id, GroupPatch patch, IPKConnection? conn = null) + public async Task 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(conn, query, extraSql: "returning *"); + var group = await _db.QueryFirst(conn, query, extraSql: "returning *"); + _ = _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); } } } \ No newline at end of file diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Guild.cs b/PluralKit.Core/Database/Repository/ModelRepository.Guild.cs index 851c83d2..df36dffc 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Guild.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Guild.cs @@ -39,14 +39,15 @@ namespace PluralKit.Core ); } - public Task UpdateSystemGuild(SystemId system, ulong guild, SystemGuildPatch patch) + public async Task 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(query, extraSql: "returning *"); + var settings = await _db.QueryFirst(query, extraSql: "returning *"); + _ = _dispatch.Dispatch(system, guild, patch); + return settings; } - public Task 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(query, extraSql: "returning *"); } } diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Member.cs b/PluralKit.Core/Database/Repository/ModelRepository.Member.cs index d2cf4e4f..dc32b7f8 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Member.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Member.cs @@ -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(query); } + public Task> GetMemberGuids(IEnumerable ids) + { + var query = new Query("members") + .Select("uuid") + .WhereIn("id", ids); + + return _db.Query(query); + } + public async Task CreateMember(SystemId systemId, string memberName, IPKConnection? conn = null) { var query = new Query("members").AsInsert(new @@ -57,6 +69,11 @@ namespace PluralKit.Core var member = await _db.QueryFirst(conn, query, "returning *"); _logger.Information("Created {MemberId} in {SystemId}: {MemberName}", member.Id, systemId, memberName); + _ = _dispatch.Dispatch(member.Id, new() + { + Event = DispatchEvent.CREATE_MEMBER, + EventData = JObject.FromObject(new { name = memberName }), + }); return member; } @@ -64,14 +81,25 @@ namespace PluralKit.Core { _logger.Information("Updated {MemberId}: {@MemberPatch}", id, patch); var query = patch.Apply(new Query("members").Where("id", id)); + _ = _dispatch.Dispatch(id, new() + { + Event = DispatchEvent.UPDATE_MEMBER, + EventData = patch.ToJson(), + }); return _db.QueryFirst(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); } } } \ No newline at end of file diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Switch.cs b/PluralKit.Core/Database/Repository/ModelRepository.Switch.cs index b34d5909..8282d8ad 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Switch.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Switch.cs @@ -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 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 GetSwitches(SystemId system) diff --git a/PluralKit.Core/Database/Repository/ModelRepository.System.cs b/PluralKit.Core/Database/Repository/ModelRepository.System.cs index 805892c9..5ba19fc3 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.System.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.System.cs @@ -78,17 +78,27 @@ namespace PluralKit.Core }); var system = await _db.QueryFirst(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 UpdateSystem(SystemId id, SystemPatch patch, IPKConnection? conn = null) + public async Task 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(conn, query, extraSql: "returning *"); + var res = await _db.QueryFirst(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) diff --git a/PluralKit.Core/Dispatch/DispatchService.cs b/PluralKit.Core/Dispatch/DispatchService.cs index 613d99b9..367f2052 100644 --- a/PluralKit.Core/Dispatch/DispatchService.cs +++ b/PluralKit.Core/Dispatch/DispatchService.cs @@ -191,6 +191,23 @@ namespace PluralKit.Core await DoPostRequest(system.Id, system.WebhookUrl, data.GetPayloadBody()); } + public async Task Dispatch(ulong accountId, AccountPatch patch) + { + var repo = _provider.Resolve(); + 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(); diff --git a/PluralKit.Core/Models/Patch/AccountPatch.cs b/PluralKit.Core/Models/Patch/AccountPatch.cs index 9f2480d0..a1dca69d 100644 --- a/PluralKit.Core/Models/Patch/AccountPatch.cs +++ b/PluralKit.Core/Models/Patch/AccountPatch.cs @@ -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; + } } } \ No newline at end of file From 75c35b7f8592650a2dd5d0a10ab9643c09cd65dc Mon Sep 17 00:00:00 2001 From: spiral Date: Fri, 19 Nov 2021 15:53:48 -0500 Subject: [PATCH 10/18] fix(webhooks): CheckSystem before trying to set a webhook URL --- PluralKit.Bot/Commands/Api.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/Api.cs b/PluralKit.Bot/Commands/Api.cs index 75ef3f5d..6c5a88a6 100644 --- a/PluralKit.Bot/Commands/Api.cs +++ b/PluralKit.Bot/Commands/Api.cs @@ -94,7 +94,7 @@ namespace PluralKit.Bot public async Task SystemWebhook(Context ctx) { - ctx.CheckDMContext(); + ctx.CheckSystem().CheckDMContext(); if (!ctx.HasNext(false)) { From 40dbf7dad6acf9dfdf53b3145332056d60bc8456 Mon Sep 17 00:00:00 2001 From: spiral Date: Fri, 19 Nov 2021 15:54:39 -0500 Subject: [PATCH 11/18] fix(webhooks): don't allow Discord webhook URLs --- PluralKit.Bot/Commands/Api.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/PluralKit.Bot/Commands/Api.cs b/PluralKit.Bot/Commands/Api.cs index 6c5a88a6..85d0634f 100644 --- a/PluralKit.Bot/Commands/Api.cs +++ b/PluralKit.Bot/Commands/Api.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using System.Threading.Tasks; using Myriad.Extensions; @@ -13,6 +14,7 @@ namespace PluralKit.Bot { private readonly 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) { _repo = repo; @@ -121,6 +123,9 @@ namespace PluralKit.Bot 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."); + var newToken = StringUtils.GenerateToken(); await _repo.UpdateSystem(ctx.System.Id, new() From 51c900a378bbb1e454210ea407d1fc23899c8c48 Mon Sep 17 00:00:00 2001 From: spiral Date: Mon, 22 Nov 2021 14:20:43 -0500 Subject: [PATCH 12/18] feat(webhooks): docs --- PluralKit.Core/Dispatch/DispatchModels.cs | 6 +-- docs/content/api/dispatch.md | 57 +++++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 docs/content/api/dispatch.md diff --git a/PluralKit.Core/Dispatch/DispatchModels.cs b/PluralKit.Core/Dispatch/DispatchModels.cs index 26cd5df3..b1342d46 100644 --- a/PluralKit.Core/Dispatch/DispatchModels.cs +++ b/PluralKit.Core/Dispatch/DispatchModels.cs @@ -54,10 +54,8 @@ namespace PluralKit.Core o.Add("type", data.Event.ToString()); o.Add("signing_token", data.SigningToken); o.Add("system_id", data.SystemId); - if (data.EntityId != null) - o.Add("id", data.EntityId); - if (data.GuildId != null) - o.Add("guild_id", data.GuildId); + o.Add("id", data.EntityId); + o.Add("guild_id", data.GuildId); o.Add("data", data.EventData); return new StringContent(JsonConvert.SerializeObject(o)); diff --git a/docs/content/api/dispatch.md b/docs/content/api/dispatch.md new file mode 100644 index 00000000..8509cd16 --- /dev/null +++ b/docs/content/api/dispatch.md @@ -0,0 +1,57 @@ +--- +title: Dispatch Webhooks +permalink: /api/dispatch +--- + +# Dispatch Webhooks + +todo: write text here + +## 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](#event-type-enum)| +|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) 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) 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| \ No newline at end of file From 269646a4552f000cb439a79481e487db7ca94626 Mon Sep 17 00:00:00 2001 From: spiral Date: Mon, 22 Nov 2021 17:05:13 -0500 Subject: [PATCH 13/18] fix(webhook): don't try escaping backslashes in a raw string --- PluralKit.Bot/Commands/Api.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PluralKit.Bot/Commands/Api.cs b/PluralKit.Bot/Commands/Api.cs index 85d0634f..ad2de2ff 100644 --- a/PluralKit.Bot/Commands/Api.cs +++ b/PluralKit.Bot/Commands/Api.cs @@ -14,7 +14,7 @@ namespace PluralKit.Bot { private readonly ModelRepository _repo; private readonly DispatchService _dispatch; - private static readonly Regex _webhookRegex = new(@"https://(?:\\w+.)?discord(?:app)?.com/api(?:/v.*)?/webhooks/(.*)"); + private static readonly Regex _webhookRegex = new("https://(?:\\w+.)?discord(?:app)?.com/api(?:/v.*)?/webhooks/(.*)"); public Api(ModelRepository repo, DispatchService dispatch) { _repo = repo; From a05c3cfeed04159d65d52819d51677a36a6954a9 Mon Sep 17 00:00:00 2001 From: spiral Date: Thu, 25 Nov 2021 12:48:49 -0500 Subject: [PATCH 14/18] fix(webhooks): docs links --- docs/content/api/dispatch.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/content/api/dispatch.md b/docs/content/api/dispatch.md index 8509cd16..386d3507 100644 --- a/docs/content/api/dispatch.md +++ b/docs/content/api/dispatch.md @@ -23,7 +23,7 @@ PluralKit will send invalid requests to your endpoint, with `PING` event type, o |key|type|description| |---|---|---| -|type|string|[event type](#event-type-enum)| +|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)| @@ -47,8 +47,8 @@ PluralKit will send invalid requests to your endpoint, with `PING` event type, o |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) 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) with only modified keys| +|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| From bc7e0df872518064a2aaa47cad914f8facb37c3e Mon Sep 17 00:00:00 2001 From: spiral Date: Thu, 25 Nov 2021 15:33:02 -0500 Subject: [PATCH 15/18] feat(webhooks): SUCCESSFUL_IMPORT event, better behaviour when creating entities --- PluralKit.API/Controllers/PKControllerBase.cs | 2 ++ .../Controllers/v2/GroupControllerV2.cs | 8 +++++++ .../Controllers/v2/MemberControllerV2.cs | 6 ++++++ PluralKit.Bot/Commands/Groups.cs | 12 ++++++++++- PluralKit.Bot/Commands/Member.cs | 21 +++++++++++++++++-- .../Repository/ModelRepository.Group.cs | 17 +++++++-------- .../Repository/ModelRepository.Member.cs | 17 +++++++-------- PluralKit.Core/Dispatch/DispatchModels.cs | 1 + PluralKit.Core/Services/DataFileService.cs | 6 ++++-- .../Utils/BulkImporter/BulkImporter.cs | 7 ++++++- docs/content/api/dispatch.md | 3 ++- 11 files changed, 73 insertions(+), 27 deletions(-) diff --git a/PluralKit.API/Controllers/PKControllerBase.cs b/PluralKit.API/Controllers/PKControllerBase.cs index d2540ab4..a9a3e8d8 100644 --- a/PluralKit.API/Controllers/PKControllerBase.cs +++ b/PluralKit.API/Controllers/PKControllerBase.cs @@ -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(); _db = svc.GetRequiredService(); _repo = svc.GetRequiredService(); + _dispatch = svc.GetRequiredService(); } protected Task ResolveSystem(string systemRef) diff --git a/PluralKit.API/Controllers/v2/GroupControllerV2.cs b/PluralKit.API/Controllers/v2/GroupControllerV2.cs index 63f4e6a4..5d177704 100644 --- a/PluralKit.API/Controllers/v2/GroupControllerV2.cs +++ b/PluralKit.API/Controllers/v2/GroupControllerV2.cs @@ -69,6 +69,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)); diff --git a/PluralKit.API/Controllers/v2/MemberControllerV2.cs b/PluralKit.API/Controllers/v2/MemberControllerV2.cs index c7b8c090..eaf81bc3 100644 --- a/PluralKit.API/Controllers/v2/MemberControllerV2.cs +++ b/PluralKit.API/Controllers/v2/MemberControllerV2.cs @@ -55,6 +55,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)); diff --git a/PluralKit.Bot/Commands/Groups.cs b/PluralKit.Bot/Commands/Groups.cs index f629c60f..949b85fc 100644 --- a/PluralKit.Bot/Commands/Groups.cs +++ b/PluralKit.Bot/Commands/Groups.cs @@ -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()}**")) diff --git a/PluralKit.Bot/Commands/Member.cs b/PluralKit.Bot/Commands/Member.cs index f46e034b..e600bcc6 100644 --- a/PluralKit.Bot/Commands/Member.cs +++ b/PluralKit.Bot/Commands/Member.cs @@ -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 diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Group.cs b/PluralKit.Core/Database/Repository/ModelRepository.Group.cs index 4a51968c..f3bc27a3 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Group.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Group.cs @@ -64,11 +64,6 @@ namespace PluralKit.Core name = name }); var group = await _db.QueryFirst(conn, query, extraSql: "returning *"); - _ = _dispatch.Dispatch(group.Id, new UpdateDispatchData() - { - Event = DispatchEvent.CREATE_GROUP, - EventData = JObject.FromObject(new { name = name }), - }); _logger.Information("Created group {GroupId} in system {SystemId}: {GroupName}", group.Id, system, name); return group; } @@ -78,11 +73,13 @@ namespace PluralKit.Core _logger.Information("Updated {GroupId}: {@GroupPatch}", id, patch); var query = patch.Apply(new Query("groups").Where("id", id)); var group = await _db.QueryFirst(conn, query, extraSql: "returning *"); - _ = _dispatch.Dispatch(id, new() - { - Event = DispatchEvent.UPDATE_GROUP, - EventData = patch.ToJson(), - }); + + if (conn == null) + _ = _dispatch.Dispatch(id, new() + { + Event = DispatchEvent.UPDATE_GROUP, + EventData = patch.ToJson(), + }); return group; } diff --git a/PluralKit.Core/Database/Repository/ModelRepository.Member.cs b/PluralKit.Core/Database/Repository/ModelRepository.Member.cs index dc32b7f8..889f550b 100644 --- a/PluralKit.Core/Database/Repository/ModelRepository.Member.cs +++ b/PluralKit.Core/Database/Repository/ModelRepository.Member.cs @@ -69,11 +69,6 @@ namespace PluralKit.Core var member = await _db.QueryFirst(conn, query, "returning *"); _logger.Information("Created {MemberId} in {SystemId}: {MemberName}", member.Id, systemId, memberName); - _ = _dispatch.Dispatch(member.Id, new() - { - Event = DispatchEvent.CREATE_MEMBER, - EventData = JObject.FromObject(new { name = memberName }), - }); return member; } @@ -81,11 +76,13 @@ namespace PluralKit.Core { _logger.Information("Updated {MemberId}: {@MemberPatch}", id, patch); var query = patch.Apply(new Query("members").Where("id", id)); - _ = _dispatch.Dispatch(id, new() - { - Event = DispatchEvent.UPDATE_MEMBER, - EventData = patch.ToJson(), - }); + + if (conn == null) + _ = _dispatch.Dispatch(id, new() + { + Event = DispatchEvent.UPDATE_MEMBER, + EventData = patch.ToJson(), + }); return _db.QueryFirst(conn, query, extraSql: "returning *"); } diff --git a/PluralKit.Core/Dispatch/DispatchModels.cs b/PluralKit.Core/Dispatch/DispatchModels.cs index b1342d46..ce6ed715 100644 --- a/PluralKit.Core/Dispatch/DispatchModels.cs +++ b/PluralKit.Core/Dispatch/DispatchModels.cs @@ -33,6 +33,7 @@ namespace PluralKit.Core UPDATE_SWITCH_MEMBERS, DELETE_SWITCH, DELETE_ALL_SWITCHES, + SUCCESSFUL_IMPORT, } public struct UpdateDispatchData diff --git a/PluralKit.Core/Services/DataFileService.cs b/PluralKit.Core/Services/DataFileService.cs index 7e96f8be..235b10cc 100644 --- a/PluralKit.Core/Services/DataFileService.cs +++ b/PluralKit.Core/Services/DataFileService.cs @@ -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 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); } } diff --git a/PluralKit.Core/Utils/BulkImporter/BulkImporter.cs b/PluralKit.Core/Utils/BulkImporter/BulkImporter.cs index c7bae342..be609196 100644 --- a/PluralKit.Core/Utils/BulkImporter/BulkImporter.cs +++ b/PluralKit.Core/Utils/BulkImporter/BulkImporter.cs @@ -34,7 +34,7 @@ namespace PluralKit.Core private ImportResultNew _result = new(); internal static async Task PerformImport(IPKConnection conn, IPKTransaction tx, ModelRepository repo, ILogger logger, - ulong userId, PKSystem? system, JObject importFile, Func confirmFunc) + DispatchService dispatch, ulong userId, PKSystem? system, JObject importFile, Func 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) { diff --git a/docs/content/api/dispatch.md b/docs/content/api/dispatch.md index 386d3507..71a336b9 100644 --- a/docs/content/api/dispatch.md +++ b/docs/content/api/dispatch.md @@ -54,4 +54,5 @@ PluralKit will send invalid requests to your endpoint, with `PING` event type, o |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| \ No newline at end of file +|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| From 0a244eb9b585d6883dff5503bab342870412892a Mon Sep 17 00:00:00 2001 From: spiral Date: Thu, 25 Nov 2021 15:42:25 -0500 Subject: [PATCH 16/18] feat(webhooks): add documentation for pk;s webhook command --- docs/content/command-list.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/content/command-list.md b/docs/content/command-list.md index 43cc999b..35cb0be7 100644 --- a/docs/content/command-list.md +++ b/docs/content/command-list.md @@ -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. From ece17f7470bcefe9a1c2162ab1a2a6a2857b46d3 Mon Sep 17 00:00:00 2001 From: spiral Date: Thu, 25 Nov 2021 16:45:00 -0500 Subject: [PATCH 17/18] feat(webhooks): verify that url is accessible before saving it --- PluralKit.Bot/Commands/Api.cs | 10 ++++++++++ PluralKit.Core/Dispatch/DispatchService.cs | 7 +++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/PluralKit.Bot/Commands/Api.cs b/PluralKit.Bot/Commands/Api.cs index ad2de2ff..0b594d1c 100644 --- a/PluralKit.Bot/Commands/Api.cs +++ b/PluralKit.Bot/Commands/Api.cs @@ -1,3 +1,4 @@ +using System; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -126,6 +127,15 @@ namespace PluralKit.Bot 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() diff --git a/PluralKit.Core/Dispatch/DispatchService.cs b/PluralKit.Core/Dispatch/DispatchService.cs index 367f2052..c911101b 100644 --- a/PluralKit.Core/Dispatch/DispatchService.cs +++ b/PluralKit.Core/Dispatch/DispatchService.cs @@ -21,7 +21,7 @@ namespace PluralKit.Core _provider = provider; } - private async Task DoPostRequest(SystemId system, string webhookUrl, HttpContent content) + public async Task DoPostRequest(SystemId system, string webhookUrl, HttpContent content, bool isVerify = false) { if (!await DispatchExt.ValidateUri(webhookUrl)) { @@ -35,7 +35,10 @@ namespace PluralKit.Core } catch (HttpRequestException e) { - _logger.Error("Could not dispatch webhook request!", e); + if (isVerify) + throw; + else + _logger.Error("Could not dispatch webhook request!", e); } } From ec8ecb1e969f14cb6623005dd5c58277679475cf Mon Sep 17 00:00:00 2001 From: spiral Date: Thu, 25 Nov 2021 17:13:15 -0500 Subject: [PATCH 18/18] feat(webhooks): add blurb about dispatch webhooks, add to nav --- docs/content/.vuepress/config.js | 2 +- docs/content/api/dispatch.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/content/.vuepress/config.js b/docs/content/.vuepress/config.js index 8764b640..44a60ccf 100644 --- a/docs/content/.vuepress/config.js +++ b/docs/content/.vuepress/config.js @@ -66,7 +66,7 @@ module.exports = { "/api/endpoints", "/api/models", "/api/errors", - // "/api/integrations", + "/api/dispatch", "/api/legacy" ] }, diff --git a/docs/content/api/dispatch.md b/docs/content/api/dispatch.md index 71a336b9..e3b0b72c 100644 --- a/docs/content/api/dispatch.md +++ b/docs/content/api/dispatch.md @@ -1,11 +1,13 @@ --- -title: Dispatch Webhooks +title: Dispatch permalink: /api/dispatch --- # Dispatch Webhooks -todo: write text here +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