374 lines
13 KiB
C#
374 lines
13 KiB
C#
// Copyright (C) 2021-present sam (starshines.gay)
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published
|
|
// by the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
using Catalogger.Backend.Cache;
|
|
using Catalogger.Backend.Cache.InMemoryCache;
|
|
using Catalogger.Backend.Database.Repositories;
|
|
using Catalogger.Backend.Extensions;
|
|
using Catalogger.Backend.Services;
|
|
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.Rest.Core;
|
|
using Remora.Results;
|
|
|
|
namespace Catalogger.Backend.Bot.Responders.Members;
|
|
|
|
public class GuildMemberUpdateResponder(
|
|
ILogger logger,
|
|
GuildRepository guildRepository,
|
|
UserCache userCache,
|
|
RoleCache roleCache,
|
|
IMemberCache memberCache,
|
|
WebhookExecutorService webhookExecutor,
|
|
AuditLogCache auditLogCache
|
|
) : IResponder<IGuildMemberUpdate>
|
|
{
|
|
private readonly ILogger _logger = logger.ForContext<GuildMemberUpdateResponder>();
|
|
|
|
public async Task<Result> RespondAsync(
|
|
IGuildMemberUpdate newMember,
|
|
CancellationToken ct = default
|
|
)
|
|
{
|
|
try
|
|
{
|
|
var oldMember = await memberCache.TryGetAsync(newMember.GuildID, newMember.User.ID);
|
|
if (oldMember == null)
|
|
{
|
|
_logger.Information(
|
|
"Received member update event for {MemberId} in {GuildId} but they weren't cached, ignoring",
|
|
newMember.User.ID,
|
|
newMember.GuildID
|
|
);
|
|
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);
|
|
}
|
|
|
|
if (
|
|
newMember.Roles.Except(oldMember.Roles).Any()
|
|
|| oldMember.Roles.Except(newMember.Roles).Any()
|
|
)
|
|
{
|
|
return await HandleRoleUpdateAsync(newMember, oldMember.Roles, ct);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
await memberCache.UpdateAsync(newMember);
|
|
|
|
userCache.UpdateUser(newMember.User);
|
|
}
|
|
|
|
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 guildRepository.GetAsync(newMember.GuildID);
|
|
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()}
|
|
"""
|
|
);
|
|
|
|
await memberCache.UpdateMemberNameAsync(
|
|
newMember.GuildID,
|
|
newMember.User.ID,
|
|
oldUser.Tag(),
|
|
newMember.User.Tag()
|
|
);
|
|
}
|
|
|
|
var guildConfig = await guildRepository.GetAsync(newMember.GuildID);
|
|
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.TryFormatUserAsync(actionData.ModeratorId);
|
|
embed.AddField("Responsible moderator", moderator);
|
|
embed.AddField("Reason", actionData.Reason ?? "No reason given");
|
|
}
|
|
else
|
|
{
|
|
embed.AddField("Responsible moderator", "*(unknown)*");
|
|
embed.AddField("Reason", "*(unknown)*");
|
|
}
|
|
|
|
var guildConfig = await guildRepository.GetAsync(member.GuildID);
|
|
webhookExecutor.QueueLog(
|
|
guildConfig,
|
|
LogChannelType.GuildMemberTimeout,
|
|
embed.Build().GetOrThrow()
|
|
);
|
|
return Result.Success;
|
|
}
|
|
|
|
private async Task<Result> HandleRoleUpdateAsync(
|
|
IGuildMemberUpdate member,
|
|
IReadOnlyList<Snowflake> oldRoles,
|
|
CancellationToken ct = default
|
|
)
|
|
{
|
|
var guildConfig = await guildRepository.GetAsync(member.GuildID);
|
|
var guildRoles = roleCache.GuildRoles(member.GuildID).ToList();
|
|
|
|
var keyRoleUpdate = new EmbedBuilder()
|
|
.WithAuthor(member.User.Tag(), null, member.User.AvatarUrl())
|
|
.WithTitle("Key roles added or removed")
|
|
.WithDescription($"<@{member.User.ID}>")
|
|
.WithColour(DiscordUtils.Purple)
|
|
.WithFooter($"User ID: {member.User.ID}")
|
|
.WithCurrentTimestamp();
|
|
|
|
var roleUpdate = new EmbedBuilder()
|
|
.WithAuthor(member.User.Tag(), null, member.User.AvatarUrl())
|
|
.WithTitle("Roles added or removed")
|
|
.WithDescription($"<@{member.User.ID}>")
|
|
.WithColour(DiscordUtils.Purple)
|
|
.WithFooter($"User ID: {member.User.ID}")
|
|
.WithCurrentTimestamp();
|
|
|
|
var addedRoles = member.Roles.Except(oldRoles).Select(s => s.Value).ToList();
|
|
var removedRoles = oldRoles.Except(member.Roles).Select(s => s.Value).ToList();
|
|
|
|
if (addedRoles.Count != 0)
|
|
{
|
|
roleUpdate.AddField("Added", string.Join(", ", addedRoles.Select(id => $"<@&{id}>")));
|
|
|
|
// Add all added key roles to the log
|
|
if (!addedRoles.Except(guildConfig.KeyRoles).Any())
|
|
{
|
|
var value = string.Join(
|
|
"\n",
|
|
addedRoles
|
|
.Where(guildConfig.KeyRoles.Contains)
|
|
.Select(id =>
|
|
{
|
|
var role = guildRoles.FirstOrDefault(r => r.ID.Value == id);
|
|
return role != null ? $"{role.Name} <@&{role.ID}>" : $"<@&{id}>";
|
|
})
|
|
);
|
|
|
|
keyRoleUpdate.AddField("Added", value);
|
|
}
|
|
}
|
|
|
|
if (removedRoles.Count != 0)
|
|
{
|
|
roleUpdate.AddField(
|
|
"Removed",
|
|
string.Join(", ", removedRoles.Select(id => $"<@&{id}>"))
|
|
);
|
|
|
|
// Add all removed key roles to the log
|
|
if (!removedRoles.Except(guildConfig.KeyRoles).Any())
|
|
{
|
|
var value = string.Join(
|
|
"\n",
|
|
removedRoles
|
|
.Where(guildConfig.KeyRoles.Contains)
|
|
.Select(id =>
|
|
{
|
|
var role = guildRoles.FirstOrDefault(r => r.ID.Value == id);
|
|
return role != null ? $"{role.Name} <@&{role.ID}>" : $"<@&{id}>";
|
|
})
|
|
);
|
|
|
|
keyRoleUpdate.AddField("Added", value);
|
|
}
|
|
}
|
|
|
|
// If there are any fields in the role update embed, we should send it
|
|
if (roleUpdate.Fields.Count != 0)
|
|
{
|
|
webhookExecutor.QueueLog(
|
|
guildConfig,
|
|
LogChannelType.GuildMemberUpdate,
|
|
roleUpdate.Build().GetOrThrow()
|
|
);
|
|
}
|
|
|
|
// Do the same for the key role update embed, but we also need to fetch the moderator that updated them
|
|
if (keyRoleUpdate.Fields.Count != 0)
|
|
{
|
|
if (
|
|
auditLogCache.TryGetMemberUpdate(member.GuildID, member.User.ID, out var actionData)
|
|
)
|
|
{
|
|
var moderator = await userCache.TryFormatUserAsync(actionData.ModeratorId);
|
|
keyRoleUpdate.AddField("Responsible moderator", moderator);
|
|
}
|
|
else
|
|
{
|
|
keyRoleUpdate.AddField("Responsible moderator", "*(unknown)*");
|
|
}
|
|
|
|
// Finally, send the embed
|
|
webhookExecutor.QueueLog(
|
|
guildConfig,
|
|
LogChannelType.GuildKeyRoleUpdate,
|
|
keyRoleUpdate.Build().GetOrThrow()
|
|
);
|
|
}
|
|
|
|
return Result.Success;
|
|
}
|
|
}
|