feat: member avatar/name logging, timeout logging

This commit is contained in:
sam 2024-10-11 20:38:53 +02:00
parent c906a4d6b6
commit d445b5ba44
10 changed files with 350 additions and 17 deletions

View file

@ -144,6 +144,11 @@ public class ChannelCommands(
PrettyChannelString(guildConfig.Channels.GuildMemberAvatarUpdate), PrettyChannelString(guildConfig.Channels.GuildMemberAvatarUpdate),
true true
), ),
new EmbedField(
"Timeouts",
PrettyChannelString(guildConfig.Channels.GuildMemberTimeout),
true
),
new EmbedField( new EmbedField(
"Kicks", "Kicks",
PrettyChannelString(guildConfig.Channels.GuildMemberKick), PrettyChannelString(guildConfig.Channels.GuildMemberKick),
@ -306,12 +311,24 @@ public class ChannelCommands(
), ),
new ButtonComponent( new ButtonComponent(
ButtonComponentStyle.Primary, ButtonComponentStyle.Primary,
Label: "Members avatar changes", Label: "Member avatar changes",
CustomID: CustomIDHelpers.CreateButtonIDWithState( CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels", "config-channels",
nameof(LogChannelType.GuildMemberAvatarUpdate) nameof(LogChannelType.GuildMemberAvatarUpdate)
) )
), ),
new ButtonComponent(
ButtonComponentStyle.Primary,
Label: "Timeouts",
CustomID: CustomIDHelpers.CreateButtonIDWithState(
"config-channels",
nameof(LogChannelType.GuildMemberTimeout)
)
),
]
),
new ActionRowComponent(
[
new ButtonComponent( new ButtonComponent(
ButtonComponentStyle.Primary, ButtonComponentStyle.Primary,
Label: "Kicks", Label: "Kicks",
@ -320,10 +337,6 @@ public class ChannelCommands(
nameof(LogChannelType.GuildMemberKick) nameof(LogChannelType.GuildMemberKick)
) )
), ),
]
),
new ActionRowComponent(
[
new ButtonComponent( new ButtonComponent(
ButtonComponentStyle.Primary, ButtonComponentStyle.Primary,
Label: "Bans", Label: "Bans",
@ -356,6 +369,10 @@ public class ChannelCommands(
nameof(LogChannelType.InviteDelete) nameof(LogChannelType.InviteDelete)
) )
), ),
]
),
new ActionRowComponent(
[
new ButtonComponent( new ButtonComponent(
ButtonComponentStyle.Primary, ButtonComponentStyle.Primary,
Label: "Edited messages", Label: "Edited messages",
@ -364,10 +381,6 @@ public class ChannelCommands(
nameof(LogChannelType.MessageUpdate) nameof(LogChannelType.MessageUpdate)
) )
), ),
]
),
new ActionRowComponent(
[
new ButtonComponent( new ButtonComponent(
ButtonComponentStyle.Primary, ButtonComponentStyle.Primary,
Label: "Deleted messages", Label: "Deleted messages",
@ -420,10 +433,11 @@ public class ChannelCommands(
LogChannelType.ChannelUpdate => "Edited channels", LogChannelType.ChannelUpdate => "Edited channels",
LogChannelType.ChannelDelete => "Deleted channels", LogChannelType.ChannelDelete => "Deleted channels",
LogChannelType.GuildMemberAdd => "Members joining", LogChannelType.GuildMemberAdd => "Members joining",
LogChannelType.GuildMemberUpdate => "Members leaving", LogChannelType.GuildMemberUpdate => "Member role changes",
LogChannelType.GuildKeyRoleUpdate => "Key role changes", LogChannelType.GuildKeyRoleUpdate => "Key role changes",
LogChannelType.GuildMemberNickUpdate => "Member name changes", LogChannelType.GuildMemberNickUpdate => "Member name changes",
LogChannelType.GuildMemberAvatarUpdate => "Member avatar changes", LogChannelType.GuildMemberAvatarUpdate => "Member avatar changes",
LogChannelType.GuildMemberTimeout => "Timeouts",
LogChannelType.GuildMemberRemove => "Members leaving", LogChannelType.GuildMemberRemove => "Members leaving",
LogChannelType.GuildMemberKick => "Kicks", LogChannelType.GuildMemberKick => "Kicks",
LogChannelType.GuildBanAdd => "Bans", LogChannelType.GuildBanAdd => "Bans",

View file

@ -120,6 +120,9 @@ public class ChannelCommandsComponents(
case LogChannelType.GuildMemberRemove: case LogChannelType.GuildMemberRemove:
guildConfig.Channels.GuildMemberRemove = 0; guildConfig.Channels.GuildMemberRemove = 0;
break; break;
case LogChannelType.GuildMemberTimeout:
guildConfig.Channels.GuildMemberTimeout = 0;
break;
case LogChannelType.GuildMemberKick: case LogChannelType.GuildMemberKick:
guildConfig.Channels.GuildMemberKick = 0; guildConfig.Channels.GuildMemberKick = 0;
break; break;
@ -305,6 +308,9 @@ public class ChannelCommandsComponents(
case LogChannelType.GuildMemberRemove: case LogChannelType.GuildMemberRemove:
guildConfig.Channels.GuildMemberRemove = channelId; guildConfig.Channels.GuildMemberRemove = channelId;
break; break;
case LogChannelType.GuildMemberTimeout:
guildConfig.Channels.GuildMemberTimeout = channelId;
break;
case LogChannelType.GuildMemberKick: case LogChannelType.GuildMemberKick:
guildConfig.Channels.GuildMemberKick = channelId; guildConfig.Channels.GuildMemberKick = channelId;
break; break;

View file

@ -1,5 +1,7 @@
using Catalogger.Backend.Cache.InMemoryCache; using Catalogger.Backend.Cache.InMemoryCache;
using Remora.Discord.API;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Results; using Remora.Results;
@ -12,9 +14,36 @@ public class AuditLogResponder(AuditLogCache auditLogCache, ILogger logger)
public Task<Result> RespondAsync(IGuildAuditLogEntryCreate evt, CancellationToken ct = default) public Task<Result> RespondAsync(IGuildAuditLogEntryCreate evt, CancellationToken ct = default)
{ {
_logger.Debug("type: {ActionType}", evt.ActionType); if (evt.TargetID == null || evt.UserID == null)
_logger.Debug("{Id}, {Reason}", evt.ID, evt.Reason); return Task.FromResult(Result.Success);
throw new NotImplementedException(); switch (evt.ActionType)
{
case AuditLogEvent.MemberKick:
auditLogCache.SetKick(evt.GuildID, evt.TargetID, evt.UserID.Value, evt.Reason);
break;
case AuditLogEvent.MemberBanAdd:
auditLogCache.SetBan(evt.GuildID, evt.TargetID, evt.UserID.Value, evt.Reason);
break;
case AuditLogEvent.MemberBanRemove:
auditLogCache.SetUnban(evt.GuildID, evt.TargetID, evt.UserID.Value, evt.Reason);
break;
case AuditLogEvent.MemberUpdate:
auditLogCache.SetMemberUpdate(
evt.GuildID,
evt.TargetID,
evt.UserID.Value,
evt.Reason
);
break;
default:
_logger.Debug(
"Received audit log event {Id} that we don't care about, ignoring",
evt.ID
);
break;
}
return Task.FromResult(Result.Success);
} }
} }

View file

@ -36,6 +36,7 @@ public class GuildMemberAddResponder(
await memberCache.SetAsync(member.GuildID, member); await memberCache.SetAsync(member.GuildID, member);
var user = member.User.GetOrThrow(); var user = member.User.GetOrThrow();
userCache.UpdateUser(user);
var builder = new EmbedBuilder() var builder = new EmbedBuilder()
.WithTitle("Member joined") .WithTitle("Member joined")

View file

@ -1,12 +1,25 @@
using Catalogger.Backend.Cache; using Catalogger.Backend.Cache;
using Catalogger.Backend.Cache.InMemoryCache;
using Catalogger.Backend.Database;
using Catalogger.Backend.Database.Queries;
using Catalogger.Backend.Extensions;
using Catalogger.Backend.Services;
using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.Extensions.Embeds;
using Remora.Discord.Gateway.Responders; using Remora.Discord.Gateway.Responders;
using Remora.Results; using Remora.Results;
namespace Catalogger.Backend.Bot.Responders.MemberUpdate; namespace Catalogger.Backend.Bot.Responders.MemberUpdate;
public class GuildMemberUpdateResponder(ILogger logger, IMemberCache memberCache) public class GuildMemberUpdateResponder(
: IResponder<IGuildMemberUpdate> ILogger logger,
DatabaseContext db,
UserCache userCache,
IMemberCache memberCache,
WebhookExecutorService webhookExecutor,
AuditLogCache auditLogCache
) : IResponder<IGuildMemberUpdate>
{ {
private readonly ILogger _logger = logger.ForContext<GuildMemberUpdateResponder>(); private readonly ILogger _logger = logger.ForContext<GuildMemberUpdateResponder>();
@ -27,12 +40,198 @@ public class GuildMemberUpdateResponder(ILogger logger, IMemberCache memberCache
); );
return Result.Success; return Result.Success;
} }
var oldUser = oldMember.User.GetOrThrow();
if (
!Equals(oldMember.Avatar.OrDefault(), newMember.Avatar.OrDefault())
|| !Equals(oldUser.Avatar, newMember.User.Avatar)
)
{
return await HandleAvatarUpdateAsync(newMember, oldMember, ct);
}
if (
newMember.Nickname.OrDefault() != oldMember.Nickname.OrDefault()
|| newMember.User.Tag() != oldUser.Tag()
|| newMember.User.GlobalName.OrDefault() != oldUser.GlobalName.OrDefault()
)
{
return await HandleNameUpdateAsync(newMember, oldMember, ct);
}
if (
newMember.CommunicationDisabledUntil.OrDefault()
!= oldMember.CommunicationDisabledUntil.OrDefault()
)
{
return await HandleTimeoutAsync(newMember, ct);
}
} }
finally finally
{ {
await memberCache.UpdateAsync(newMember); await memberCache.UpdateAsync(newMember);
userCache.UpdateUser(newMember.User);
} }
return Result.Success; return Result.Success;
} }
private async Task<Result> HandleAvatarUpdateAsync(
IGuildMemberUpdate newMember,
IGuildMember oldMember,
CancellationToken ct = default
)
{
IEmbed embed;
if (!Equals(oldMember.Avatar.OrDefault(), newMember.Avatar.OrDefault()))
{
var builder = new EmbedBuilder()
.WithAuthor(newMember.User.Tag(), null, newMember.User.AvatarUrl())
.WithColour(DiscordUtils.Green)
.WithFooter($"User ID: {newMember.User.ID}")
.WithCurrentTimestamp();
if (newMember.Avatar.IsDefined())
{
builder = builder
.WithTitle("Changed server avatar")
.WithThumbnailUrl(newMember.AvatarUrl(1024)!);
}
else
{
builder = builder.WithTitle("Removed server avatar");
}
embed = builder.Build().GetOrThrow();
}
else
{
embed = new EmbedBuilder()
.WithAuthor(newMember.User.Tag(), null, newMember.User.AvatarUrl())
.WithTitle("Changed avatar")
.WithThumbnailUrl(newMember.User.AvatarUrl(1024))
.WithColour(DiscordUtils.Green)
.WithFooter($"User ID: {newMember.User.ID}")
.WithCurrentTimestamp()
.Build()
.GetOrThrow();
}
var guildConfig = await db.GetGuildAsync(newMember.GuildID, ct);
webhookExecutor.QueueLog(guildConfig, LogChannelType.GuildMemberAvatarUpdate, embed);
return Result.Success;
}
private async Task<Result> HandleNameUpdateAsync(
IGuildMemberUpdate newMember,
IGuildMember oldMember,
CancellationToken ct = default
)
{
var oldUser = oldMember.User.GetOrThrow();
var builder = new EmbedBuilder()
.WithAuthor(newMember.User.Tag(), null, newMember.User.AvatarUrl())
.WithColour(DiscordUtils.Green)
.WithFooter($"User ID: {newMember.User.ID}")
.WithCurrentTimestamp();
if (newMember.Nickname.OrDefault() != oldMember.Nickname.OrDefault())
{
builder.AddField(
"Changed nickname",
$"""
**Before:** {oldMember.Nickname.OrDefault("*(none)*")}
**After:** {newMember.Nickname.OrDefault("*(none)*")}
"""
);
}
if (newMember.User.GlobalName.OrDefault() != oldUser.GlobalName.OrDefault())
{
builder.AddField(
"Changed display name",
$"""
**Before:** {oldUser.GlobalName.OrDefault("*(none)*")}
**After:** {newMember.User.GlobalName.OrDefault("*(none)*")}
"""
);
}
if (newMember.User.Tag() != oldUser.Tag())
{
builder.AddField(
"Changed username",
$"""
**Before:** {oldUser.Tag()}
**After:** {newMember.User.Tag()}
"""
);
}
var guildConfig = await db.GetGuildAsync(newMember.GuildID, ct);
webhookExecutor.QueueLog(
guildConfig,
LogChannelType.GuildMemberNickUpdate,
builder.Build().GetOrThrow()
);
return Result.Success;
}
private async Task<Result> HandleTimeoutAsync(
IGuildMemberUpdate member,
CancellationToken ct = default
)
{
// Delay 2 seconds to make sure the timeout audit log got cached
await Task.Delay(2000, ct);
var timeoutUntil = member.CommunicationDisabledUntil.OrDefault();
var embed = new EmbedBuilder()
.WithAuthor(member.User.Tag(), null, member.User.AvatarUrl())
.WithTitle(
timeoutUntil != null ? "Member timed out" : "Member removed from timeout early"
)
.WithDescription($"<@{member.User.ID}>")
.WithColour(DiscordUtils.Red)
.WithFooter($"User ID: {member.User.ID}")
.WithCurrentTimestamp();
if (timeoutUntil != null)
{
embed.AddField(
"Until",
$"<t:{timeoutUntil.Value.ToUnixTimeSeconds()}>\nin {timeoutUntil.Value.AddSeconds(5).Prettify()}"
);
}
if (auditLogCache.TryGetMemberUpdate(member.GuildID, member.User.ID, out var actionData))
{
var moderator = await userCache.GetUserAsync(actionData.ModeratorId);
embed.AddField(
"Responsible moderator",
moderator == null
? $"*(unknown user {actionData.ModeratorId}) <@{actionData.ModeratorId}>*"
: $"{moderator.Tag()} <@{moderator.ID}>"
);
embed.AddField("Reason", actionData.Reason ?? "No reason given");
}
else
{
embed.AddField("Responsible moderator", "*(unknown)*");
embed.AddField("Reason", "*(unknown)*");
}
var guildConfig = await db.GetGuildAsync(member.GuildID, ct);
webhookExecutor.QueueLog(
guildConfig,
LogChannelType.GuildMemberTimeout,
embed.Build().GetOrThrow()
);
return Result.Success;
}
} }

View file

@ -10,11 +10,22 @@ public class AuditLogCache
(Snowflake GuildId, Snowflake TargetId), (Snowflake GuildId, Snowflake TargetId),
ActionData ActionData
> _kicks = new(); > _kicks = new();
private readonly ConcurrentDictionary< private readonly ConcurrentDictionary<
(Snowflake GuildId, Snowflake TargetId), (Snowflake GuildId, Snowflake TargetId),
ActionData ActionData
> _bans = new(); > _bans = new();
private readonly ConcurrentDictionary<
(Snowflake GuildId, Snowflake TargetId),
ActionData
> _unbans = new();
private readonly ConcurrentDictionary<
(Snowflake GuildId, Snowflake TargetId),
ActionData
> _memberUpdates = new();
public void SetKick( public void SetKick(
Snowflake guildId, Snowflake guildId,
string targetId, string targetId,
@ -47,5 +58,40 @@ public class AuditLogCache
public bool TryGetBan(Snowflake guildId, Snowflake targetId, out ActionData data) => public bool TryGetBan(Snowflake guildId, Snowflake targetId, out ActionData data) =>
_bans.TryGetValue((guildId, targetId), out data); _bans.TryGetValue((guildId, targetId), out data);
public void SetUnban(
Snowflake guildId,
string targetId,
Snowflake moderatorId,
Optional<string> reason
)
{
if (!DiscordSnowflake.TryParse(targetId, out var targetUser))
throw new CataloggerError("Target ID was not a valid snowflake");
_unbans[(guildId, targetUser.Value)] = new ActionData(moderatorId, reason.OrDefault());
}
public bool TryGetUnban(Snowflake guildId, Snowflake targetId, out ActionData data) =>
_unbans.TryGetValue((guildId, targetId), out data);
public void SetMemberUpdate(
Snowflake guildId,
string targetId,
Snowflake moderatorId,
Optional<string> reason
)
{
if (!DiscordSnowflake.TryParse(targetId, out var targetUser))
throw new CataloggerError("Target ID was not a valid snowflake");
_memberUpdates[(guildId, targetUser.Value)] = new ActionData(
moderatorId,
reason.OrDefault()
);
}
public bool TryGetMemberUpdate(Snowflake guildId, Snowflake targetId, out ActionData data) =>
_memberUpdates.TryGetValue((guildId, targetId), out data);
public record struct ActionData(Snowflake ModeratorId, string? Reason); public record struct ActionData(Snowflake ModeratorId, string? Reason);
} }

View file

@ -54,6 +54,7 @@ public class Guild
public ulong GuildKeyRoleUpdate { get; set; } public ulong GuildKeyRoleUpdate { get; set; }
public ulong GuildMemberNickUpdate { get; set; } public ulong GuildMemberNickUpdate { get; set; }
public ulong GuildMemberAvatarUpdate { get; set; } public ulong GuildMemberAvatarUpdate { get; set; }
public ulong GuildMemberTimeout { get; set; }
public ulong GuildMemberRemove { get; set; } public ulong GuildMemberRemove { get; set; }
public ulong GuildMemberKick { get; set; } public ulong GuildMemberKick { get; set; }
public ulong GuildBanAdd { get; set; } public ulong GuildBanAdd { get; set; }

View file

@ -1,6 +1,7 @@
using System.Drawing; using System.Drawing;
using Humanizer; using Humanizer;
using OneOf; using OneOf;
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Objects;
using Remora.Discord.API.Abstractions.Rest; using Remora.Discord.API.Abstractions.Rest;
using Remora.Discord.API.Objects; using Remora.Discord.API.Objects;
@ -35,6 +36,40 @@ public static class DiscordExtensions
return $"https://cdn.discordapp.com/embed/avatars/{avatarIndex}.png?size={size}"; return $"https://cdn.discordapp.com/embed/avatars/{avatarIndex}.png?size={size}";
} }
public static string? AvatarUrl(this IGuildMemberUpdate member, int size = 256) =>
GuildAvatarUrl(
member.GuildID,
member.User.ID,
member.Avatar.OrDefault()?.Value,
isAnimated: member.Avatar.OrDefault()?.HasGif,
size
);
public static string? AvatarUrl(this IGuildMember member, Snowflake guildId, int size = 256) =>
GuildAvatarUrl(
guildId,
member.User.GetOrThrow().ID,
member.Avatar.OrDefault()?.Value,
isAnimated: member.Avatar.OrDefault()?.HasGif,
size
);
private static string? GuildAvatarUrl(
Snowflake guildId,
Snowflake userId,
string? hash,
bool? isAnimated,
int size = 256
)
{
if (hash == null)
return null;
var ext = isAnimated == true ? ".gif" : ".webp";
return $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{hash}{ext}?size={size}";
}
public static string? IconUrl(this IGuild guild, int size = 256) public static string? IconUrl(this IGuild guild, int size = 256)
{ {
if (guild.Icon == null) if (guild.Icon == null)

View file

@ -35,13 +35,13 @@ builder
.Configure<DiscordGatewayClientOptions>(g => .Configure<DiscordGatewayClientOptions>(g =>
g.Intents = g.Intents =
GatewayIntents.Guilds GatewayIntents.Guilds
| GatewayIntents.GuildBans | GatewayIntents.GuildBans // Actually GUILD_MODERATION
| GatewayIntents.GuildInvites | GatewayIntents.GuildInvites
| GatewayIntents.GuildMembers | GatewayIntents.GuildMembers
| GatewayIntents.GuildMessages | GatewayIntents.GuildMessages
| GatewayIntents.GuildWebhooks | GatewayIntents.GuildWebhooks
| GatewayIntents.MessageContents | GatewayIntents.MessageContents
| GatewayIntents.GuildEmojisAndStickers | GatewayIntents.GuildEmojisAndStickers // Actually GUILD_EXPRESSIONS
) )
.AddDiscordCaching() .AddDiscordCaching()
.AddDiscordCommands(enableSlash: true, useDefaultCommandResponder: false) .AddDiscordCommands(enableSlash: true, useDefaultCommandResponder: false)

View file

@ -274,6 +274,7 @@ public class WebhookExecutorService(
LogChannelType.GuildKeyRoleUpdate => guild.Channels.GuildKeyRoleUpdate, LogChannelType.GuildKeyRoleUpdate => guild.Channels.GuildKeyRoleUpdate,
LogChannelType.GuildMemberNickUpdate => guild.Channels.GuildMemberNickUpdate, LogChannelType.GuildMemberNickUpdate => guild.Channels.GuildMemberNickUpdate,
LogChannelType.GuildMemberAvatarUpdate => guild.Channels.GuildMemberAvatarUpdate, LogChannelType.GuildMemberAvatarUpdate => guild.Channels.GuildMemberAvatarUpdate,
LogChannelType.GuildMemberTimeout => guild.Channels.GuildMemberTimeout,
LogChannelType.GuildMemberRemove => guild.Channels.GuildMemberRemove, LogChannelType.GuildMemberRemove => guild.Channels.GuildMemberRemove,
LogChannelType.GuildMemberKick => guild.Channels.GuildMemberKick, LogChannelType.GuildMemberKick => guild.Channels.GuildMemberKick,
LogChannelType.GuildBanAdd => guild.Channels.GuildBanAdd, LogChannelType.GuildBanAdd => guild.Channels.GuildBanAdd,
@ -302,6 +303,7 @@ public enum LogChannelType
GuildKeyRoleUpdate, GuildKeyRoleUpdate,
GuildMemberNickUpdate, GuildMemberNickUpdate,
GuildMemberAvatarUpdate, GuildMemberAvatarUpdate,
GuildMemberTimeout,
GuildMemberRemove, GuildMemberRemove,
GuildMemberKick, GuildMemberKick,
GuildBanAdd, GuildBanAdd,