From 04b8049cc78c4ff0682c354929b14c29e068d955 Mon Sep 17 00:00:00 2001 From: FiniteReality Date: Tue, 20 Jun 2017 16:13:52 +0100 Subject: [PATCH] Initial audit logs implementation Frankly, I have no idea if any of this is in the correct place, or follows the "norm" for internal stuff. Feedback would be nice for this implementation, as I believe this implementation uses the least amount of partial objects (I couldn't really avoid them in RestAuditLogEntry, unless I wanted to write entry types for each type of entry) and is also fairly simple. --- src/Discord.Net.Core/DiscordConfig.cs | 1 + .../Entities/AuditLogs/ActionType.cs | 50 +++++++++++++++++++ .../Entities/AuditLogs/IAuditLogChange.cs | 16 ++++++ .../Entities/AuditLogs/IAuditLogEntry.cs | 44 ++++++++++++++++ .../Entities/AuditLogs/IAuditLogOptions.cs | 16 ++++++ .../Entities/Guilds/IGuild.cs | 3 ++ src/Discord.Net.Rest/API/Common/AuditLog.cs | 17 +++++++ .../API/Common/AuditLogChange.cs | 17 +++++++ .../API/Common/AuditLogEntry.cs | 26 ++++++++++ .../API/Common/AuditLogOptions.cs | 14 ++++++ .../API/Rest/GetAuditLogsParams.cs | 8 +++ src/Discord.Net.Rest/DiscordRestApiClient.cs | 15 ++++++ .../Entities/AuditLogs/AuditLogHelper.cs | 37 ++++++++++++++ .../Changes/MemberRoleAuditLogChange.cs | 19 +++++++ .../Options/MessageDeleteAuditLogOptions.cs | 23 +++++++++ .../Entities/AuditLogs/RestAuditLogEntry.cs | 47 +++++++++++++++++ .../Entities/Guilds/GuildHelper.cs | 29 +++++++++++ .../Entities/Guilds/RestGuild.cs | 12 +++++ .../Entities/Guilds/SocketGuild.cs | 12 +++++ 19 files changed, 406 insertions(+) create mode 100644 src/Discord.Net.Core/Entities/AuditLogs/ActionType.cs create mode 100644 src/Discord.Net.Core/Entities/AuditLogs/IAuditLogChange.cs create mode 100644 src/Discord.Net.Core/Entities/AuditLogs/IAuditLogEntry.cs create mode 100644 src/Discord.Net.Core/Entities/AuditLogs/IAuditLogOptions.cs create mode 100644 src/Discord.Net.Rest/API/Common/AuditLog.cs create mode 100644 src/Discord.Net.Rest/API/Common/AuditLogChange.cs create mode 100644 src/Discord.Net.Rest/API/Common/AuditLogEntry.cs create mode 100644 src/Discord.Net.Rest/API/Common/AuditLogOptions.cs create mode 100644 src/Discord.Net.Rest/API/Rest/GetAuditLogsParams.cs create mode 100644 src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs create mode 100644 src/Discord.Net.Rest/Entities/AuditLogs/Changes/MemberRoleAuditLogChange.cs create mode 100644 src/Discord.Net.Rest/Entities/AuditLogs/Options/MessageDeleteAuditLogOptions.cs create mode 100644 src/Discord.Net.Rest/Entities/AuditLogs/RestAuditLogEntry.cs diff --git a/src/Discord.Net.Core/DiscordConfig.cs b/src/Discord.Net.Core/DiscordConfig.cs index fd2fe92e8..31f187ffb 100644 --- a/src/Discord.Net.Core/DiscordConfig.cs +++ b/src/Discord.Net.Core/DiscordConfig.cs @@ -20,6 +20,7 @@ namespace Discord public const int MaxMessagesPerBatch = 100; public const int MaxUsersPerBatch = 1000; public const int MaxGuildsPerBatch = 100; + public const int MaxAuditLogEntriesPerBatch = 100; /// Gets or sets how a request should act in the case of an error, by default. public RetryMode DefaultRetryMode { get; set; } = RetryMode.AlwaysRetry; diff --git a/src/Discord.Net.Core/Entities/AuditLogs/ActionType.cs b/src/Discord.Net.Core/Entities/AuditLogs/ActionType.cs new file mode 100644 index 000000000..e5a4ff30a --- /dev/null +++ b/src/Discord.Net.Core/Entities/AuditLogs/ActionType.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// The action type within a + /// + public enum ActionType + { + GuildUpdated = 1, + + ChannelCreated = 10, + ChannelUpdated = 11, + ChannelDeleted = 12, + + OverwriteCreated = 13, + OverwriteUpdated = 14, + OverwriteDeleted = 15, + + Kick = 20, + Prune = 21, + Ban = 22, + Unban = 23, + + MemberUpdated = 24, + MemberRoleUpdated = 25, + + RoleCreated = 30, + RoleUpdated = 31, + RoleDeleted = 32, + + InviteCreated = 40, + InviteUpdated = 41, + InviteDeleted = 42, + + WebhookCreated = 50, + WebhookUpdated = 51, + WebhookDeleted = 52, + + EmojiCreated = 60, + EmojiUpdated = 61, + EmojiDeleted = 62, + + MessageDeleted = 72 + } +} diff --git a/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogChange.cs b/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogChange.cs new file mode 100644 index 000000000..0c36b53b2 --- /dev/null +++ b/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogChange.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents changes which may occur within a + /// + public interface IAuditLogChange + { + + } +} diff --git a/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogEntry.cs b/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogEntry.cs new file mode 100644 index 000000000..0569f5fff --- /dev/null +++ b/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogEntry.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents an entry in an audit log + /// + public interface IAuditLogEntry : IEntity + { + /// + /// The action which occured to create this entry + /// + ActionType Action { get; } + + /// + /// The changes which occured within this entry. May be empty if no changes occured. + /// + IReadOnlyCollection Changes { get; } + + /// + /// Any options which apply to this entry. If no options were provided, this may be . + /// + IAuditLogOptions Options { get; } + + /// + /// The id which the target applies to + /// + ulong TargetId { get; } + + /// + /// The user responsible for causing the changes + /// + IUser User { get; } + + /// + /// The reason behind the change. May be if no reason was provided. + /// + string Reason { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogOptions.cs b/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogOptions.cs new file mode 100644 index 000000000..1ca12255e --- /dev/null +++ b/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogOptions.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents options which may be applied to an + /// + public interface IAuditLogOptions + { + + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs index 506cbd3e4..4c7ade43b 100644 --- a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs +++ b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs @@ -114,5 +114,8 @@ namespace Discord Task DownloadUsersAsync(); /// Removes all users from this guild if they have not logged on in a provided number of days or, if simulate is true, returns the number of users that would be removed. Task PruneUsersAsync(int days = 30, bool simulate = false, RequestOptions options = null); + + Task> GetAuditLogAsync(int limit = DiscordConfig.MaxAuditLogEntriesPerBatch, + CacheMode cacheMode = CacheMode.AllowDownload, RequestOptions options = null); } } \ No newline at end of file diff --git a/src/Discord.Net.Rest/API/Common/AuditLog.cs b/src/Discord.Net.Rest/API/Common/AuditLog.cs new file mode 100644 index 000000000..f2147cb0e --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AuditLog.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class AuditLog + { + //TODO: figure out how this works + //[JsonProperty("webhooks")] + //public object Webhooks { get; set; } + + [JsonProperty("users")] + public User[] Users { get; set; } + + [JsonProperty("audit_log_entries")] + public AuditLogEntry[] Entries { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/AuditLogChange.cs b/src/Discord.Net.Rest/API/Common/AuditLogChange.cs new file mode 100644 index 000000000..44e585021 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AuditLogChange.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Discord.API +{ + internal class AuditLogChange + { + [JsonProperty("key")] + public string ChangedProperty { get; set; } + + [JsonProperty("new_value")] + public JToken NewValue { get; set; } + + [JsonProperty("old_value")] + public JToken OldValue { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/AuditLogEntry.cs b/src/Discord.Net.Rest/API/Common/AuditLogEntry.cs new file mode 100644 index 000000000..ecdb12650 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AuditLogEntry.cs @@ -0,0 +1,26 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class AuditLogEntry + { + [JsonProperty("target_id")] + public ulong TargetId { get; set; } + [JsonProperty("user_id")] + public ulong UserId { get; set; } + + [JsonProperty("changes")] + public AuditLogChange[] Changes { get; set; } + [JsonProperty("options")] + public AuditLogOptions Options { get; set; } + + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("action_type")] + public ActionType Action { get; set; } + + [JsonProperty("reason")] + public string Reason { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/AuditLogOptions.cs b/src/Discord.Net.Rest/API/Common/AuditLogOptions.cs new file mode 100644 index 000000000..bbda95405 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AuditLogOptions.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + // TODO: Complete this with all possible values for options + internal class AuditLogOptions + { + [JsonProperty("count")] + public int Count { get; set; } + + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/GetAuditLogsParams.cs b/src/Discord.Net.Rest/API/Rest/GetAuditLogsParams.cs new file mode 100644 index 000000000..fc3e0d9f3 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/GetAuditLogsParams.cs @@ -0,0 +1,8 @@ +namespace Discord.API.Rest +{ + class GetAuditLogsParams + { + public Optional Limit { get; set; } + public Optional AfterEntryId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/DiscordRestApiClient.cs b/src/Discord.Net.Rest/DiscordRestApiClient.cs index a632e5d42..c7b1b23c5 100644 --- a/src/Discord.Net.Rest/DiscordRestApiClient.cs +++ b/src/Discord.Net.Rest/DiscordRestApiClient.cs @@ -1059,6 +1059,21 @@ namespace Discord.API return await SendJsonAsync>("PATCH", () => $"guilds/{guildId}/roles", args, ids, options: options).ConfigureAwait(false); } + //Audit logs + public async Task GetAuditLogsAsync(ulong guildId, GetAuditLogsParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + options = RequestOptions.CreateOrClone(options); + + int limit = args.Limit.GetValueOrDefault(int.MaxValue); + ulong afterEntryId = args.AfterEntryId.GetValueOrDefault(0); + + var ids = new BucketIds(guildId: guildId); + Expression> endpoint = () => $"guilds/{guildId}/audit-logs?limit={limit}&after={afterEntryId}"; + return await SendAsync("GET", endpoint, ids, options: options).ConfigureAwait(false); + } + //Users public async Task GetUserAsync(ulong userId, RequestOptions options = null) { diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs b/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs new file mode 100644 index 000000000..7e349e110 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using EntryModel = Discord.API.AuditLogEntry; +using ChangeModel = Discord.API.AuditLogChange; +using OptionModel = Discord.API.AuditLogOptions; + +namespace Discord.Rest +{ + internal static class AuditLogHelper + { + public static IAuditLogChange CreateChange(BaseDiscordClient discord, EntryModel entryModel, ChangeModel model) + { + switch (entryModel.Action) + { + case ActionType.MemberRoleUpdated: + return new MemberRoleAuditLogChange(discord, model); + default: + throw new NotImplementedException($"{nameof(AuditLogHelper)} does not implement the {entryModel.Action} audit log action."); + } + } + + public static IAuditLogOptions CreateOptions(BaseDiscordClient discord, EntryModel entryModel, OptionModel model) + { + switch (entryModel.Action) + { + case ActionType.MessageDeleted: + return new MessageDeleteAuditLogOptions(discord, model); + default: + throw new NotImplementedException($"{nameof(AuditLogHelper)} does not implement the {entryModel.Action} audit log action."); + } + } + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/Changes/MemberRoleAuditLogChange.cs b/src/Discord.Net.Rest/Entities/AuditLogs/Changes/MemberRoleAuditLogChange.cs new file mode 100644 index 000000000..c5f167d6f --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/Changes/MemberRoleAuditLogChange.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +using Model = Discord.API.AuditLogChange; + +namespace Discord.Rest +{ + public class MemberRoleAuditLogChange : IAuditLogChange + { + internal MemberRoleAuditLogChange(BaseDiscordClient discord, Model model) + { + RoleAdded = model.ChangedProperty == "$add"; + RoleId = model.NewValue.Value("id"); + } + + public bool RoleAdded { get; set; } + //TODO: convert to IRole + public ulong RoleId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/Options/MessageDeleteAuditLogOptions.cs b/src/Discord.Net.Rest/Entities/AuditLogs/Options/MessageDeleteAuditLogOptions.cs new file mode 100644 index 000000000..3d53ed790 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/Options/MessageDeleteAuditLogOptions.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Model = Discord.API.AuditLogOptions; + +namespace Discord.Rest +{ + public class MessageDeleteAuditLogOptions : IAuditLogOptions + { + internal MessageDeleteAuditLogOptions(BaseDiscordClient discord, Model model) + { + MessageCount = model.Count; + SourceChannelId = model.ChannelId; + } + + //TODO: turn this into an IChannel + public ulong SourceChannelId { get; } + public int MessageCount { get; } + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/RestAuditLogEntry.cs b/src/Discord.Net.Rest/Entities/AuditLogs/RestAuditLogEntry.cs new file mode 100644 index 000000000..6efe4e88e --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/RestAuditLogEntry.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +using FullModel = Discord.API.AuditLog; +using Model = Discord.API.AuditLogEntry; + +namespace Discord.Rest +{ + public class RestAuditLogEntry : RestEntity, IAuditLogEntry + { + internal RestAuditLogEntry(BaseDiscordClient discord, Model model, API.User user) + : base(discord, model.Id) + { + Action = model.Action; + if (model.Changes != null) + Changes = model.Changes + .Select(x => AuditLogHelper.CreateChange(discord, model, x)) + .ToReadOnlyCollection(() => model.Changes.Length); + else + Changes = ImmutableArray.Create(); + + if (model.Options != null) + Options = AuditLogHelper.CreateOptions(discord, model, model.Options); + + TargetId = model.TargetId; + User = RestUser.Create(discord, user); + + Reason = model.Reason; + } + + internal static RestAuditLogEntry Create(BaseDiscordClient discord, FullModel fullLog, Model model) + { + var user = fullLog.Users.FirstOrDefault(x => x.Id == model.UserId); + + return new RestAuditLogEntry(discord, model, user); + } + + public ActionType Action { get; } + public IReadOnlyCollection Changes { get; } + public IAuditLogOptions Options { get; } + public ulong TargetId { get; } + public IUser User { get; } + public string Reason { get; } + } +} diff --git a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs index 98303cea6..e568bf402 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs @@ -247,5 +247,34 @@ namespace Discord.Rest model = await client.ApiClient.BeginGuildPruneAsync(guild.Id, args, options).ConfigureAwait(false); return model.Pruned; } + + public static IAsyncEnumerable> GetAuditLogsAsync(IGuild guild, BaseDiscordClient client, + ulong? from, int? limit, RequestOptions options) + { + return new PagedAsyncEnumerable( + DiscordConfig.MaxAuditLogEntriesPerBatch, + async (info, ct) => + { + var args = new GetAuditLogsParams + { + Limit = info.PageSize + }; + if (info.Position != null) + args.AfterEntryId = info.Position.Value; + var model = await client.ApiClient.GetAuditLogsAsync(guild.Id, args, options); + return model.Entries.Select((x) => RestAuditLogEntry.Create(client, model, x)).ToImmutableArray(); + }, + nextPage: (info, lastPage) => + { + if (lastPage.Count != DiscordConfig.MaxAuditLogEntriesPerBatch) + return false; + info.Position = lastPage.Max(x => x.Id); + return true; + }, + start: from, + count: limit + ); + + } } } diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs index 8b5598ffe..23f6353bf 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs @@ -239,6 +239,10 @@ namespace Discord.Rest public Task PruneUsersAsync(int days = 30, bool simulate = false, RequestOptions options = null) => GuildHelper.PruneUsersAsync(this, Discord, days, simulate, options); + //Audit logs + public IAsyncEnumerable> GetAuditLogsAsync(int limit, RequestOptions options = null) + => GuildHelper.GetAuditLogsAsync(this, Discord, null, limit, options); + public override string ToString() => Name; private string DebuggerDisplay => $"{Name} ({Id})"; @@ -361,5 +365,13 @@ namespace Discord.Rest return ImmutableArray.Create(); } Task IGuild.DownloadUsersAsync() { throw new NotSupportedException(); } + + async Task> IGuild.GetAuditLogAsync(int limit, CacheMode cacheMode, RequestOptions options) + { + if (cacheMode == CacheMode.AllowDownload) + return (await GetAuditLogsAsync(limit, options).Flatten().ConfigureAwait(false)).ToImmutableArray(); + else + return ImmutableArray.Create(); + } } } diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs index 5358605c8..72beac217 100644 --- a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs +++ b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs @@ -423,6 +423,10 @@ namespace Discord.WebSocket _downloaderPromise.TrySetResultAsync(true); } + //Audit logs + public IAsyncEnumerable> GetAuditLogsAsync(int limit, RequestOptions options = null) + => GuildHelper.GetAuditLogsAsync(this, Discord, null, limit, options); + //Voice States internal async Task AddOrUpdateVoiceStateAsync(ClientState state, VoiceStateModel model) { @@ -659,5 +663,13 @@ namespace Discord.WebSocket Task IGuild.GetOwnerAsync(CacheMode mode, RequestOptions options) => Task.FromResult(Owner); Task IGuild.DownloadUsersAsync() { throw new NotSupportedException(); } + + async Task> IGuild.GetAuditLogAsync(int limit, CacheMode cacheMode, RequestOptions options) + { + if (cacheMode == CacheMode.AllowDownload) + return (await GetAuditLogsAsync(limit, options).Flatten().ConfigureAwait(false)).ToImmutableArray(); + else + return ImmutableArray.Create(); + } } }