forked from EllieBotDevs/elliebot
629 lines
No EOL
22 KiB
C#
629 lines
No EOL
22 KiB
C#
#nullable disable
|
|
using LinqToDB;
|
|
using LinqToDB.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using EllieBot.Common.ModuleBehaviors;
|
|
using EllieBot.Common.TypeReaders.Models;
|
|
using EllieBot.Modules.Permissions.Services;
|
|
using EllieBot.Db.Models;
|
|
using Newtonsoft.Json;
|
|
|
|
namespace EllieBot.Modules.Administration.Services;
|
|
|
|
public class UserPunishService : IEService, IReadyExecutor
|
|
{
|
|
private readonly MuteService _mute;
|
|
private readonly DbService _db;
|
|
private readonly BlacklistService _blacklistService;
|
|
private readonly BotConfigService _bcs;
|
|
private readonly DiscordSocketClient _client;
|
|
private readonly IReplacementService _repSvc;
|
|
|
|
public event Func<Warning, Task> OnUserWarned = static delegate { return Task.CompletedTask; };
|
|
|
|
public UserPunishService(
|
|
MuteService mute,
|
|
DbService db,
|
|
BlacklistService blacklistService,
|
|
BotConfigService bcs,
|
|
DiscordSocketClient client,
|
|
IReplacementService repSvc)
|
|
{
|
|
_mute = mute;
|
|
_db = db;
|
|
_blacklistService = blacklistService;
|
|
_bcs = bcs;
|
|
_client = client;
|
|
_repSvc = repSvc;
|
|
}
|
|
|
|
public async Task OnReadyAsync()
|
|
{
|
|
if (_client.ShardId != 0)
|
|
return;
|
|
|
|
using var expiryTimer = new PeriodicTimer(TimeSpan.FromHours(12));
|
|
do
|
|
{
|
|
try
|
|
{
|
|
await CheckAllWarnExpiresAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error(ex, "Unexpected error while checking for warn expiries: {ErrorMessage}", ex.Message);
|
|
}
|
|
} while (await expiryTimer.WaitForNextTickAsync());
|
|
}
|
|
|
|
public async Task<WarningPunishment> Warn(
|
|
IGuild guild,
|
|
ulong userId,
|
|
IUser mod,
|
|
long weight,
|
|
string reason)
|
|
{
|
|
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(weight);
|
|
|
|
var modName = mod.ToString();
|
|
|
|
if (string.IsNullOrWhiteSpace(reason))
|
|
reason = "-";
|
|
|
|
var guildId = guild.Id;
|
|
|
|
var warn = new Warning
|
|
{
|
|
UserId = userId,
|
|
GuildId = guildId,
|
|
Forgiven = false,
|
|
Reason = reason,
|
|
Moderator = modName,
|
|
Weight = weight
|
|
};
|
|
|
|
long previousCount;
|
|
List<WarningPunishment> ps;
|
|
await using (var uow = _db.GetDbContext())
|
|
{
|
|
ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments;
|
|
|
|
previousCount = uow.Set<Warning>()
|
|
.ForId(guildId, userId)
|
|
.Where(w => !w.Forgiven && w.UserId == userId)
|
|
.Sum(x => x.Weight);
|
|
|
|
uow.Set<Warning>().Add(warn);
|
|
|
|
await uow.SaveChangesAsync();
|
|
}
|
|
|
|
_ = OnUserWarned(warn);
|
|
|
|
var totalCount = previousCount + weight;
|
|
|
|
var p = ps.Where(x => x.Count > previousCount && x.Count <= totalCount)
|
|
.MaxBy(x => x.Count);
|
|
|
|
if (p is not null)
|
|
{
|
|
var user = await guild.GetUserAsync(userId);
|
|
if (user is null)
|
|
return null;
|
|
|
|
await ApplyPunishment(guild, user, mod, p.Punishment, p.Time, p.RoleId, "Warned too many times.");
|
|
return p;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public async Task ApplyPunishment(
|
|
IGuild guild,
|
|
IGuildUser user,
|
|
IUser mod,
|
|
PunishmentAction p,
|
|
int minutes,
|
|
ulong? roleId,
|
|
string reason)
|
|
{
|
|
if (!await CheckPermission(guild, p))
|
|
return;
|
|
|
|
int banPrune;
|
|
switch (p)
|
|
{
|
|
case PunishmentAction.Mute:
|
|
if (minutes == 0)
|
|
await _mute.MuteUser(user, mod, reason: reason);
|
|
else
|
|
await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), reason: reason);
|
|
break;
|
|
case PunishmentAction.VoiceMute:
|
|
if (minutes == 0)
|
|
await _mute.MuteUser(user, mod, MuteType.Voice, reason);
|
|
else
|
|
await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), MuteType.Voice, reason);
|
|
break;
|
|
case PunishmentAction.ChatMute:
|
|
if (minutes == 0)
|
|
await _mute.MuteUser(user, mod, MuteType.Chat, reason);
|
|
else
|
|
await _mute.TimedMute(user, mod, TimeSpan.FromMinutes(minutes), MuteType.Chat, reason);
|
|
break;
|
|
case PunishmentAction.Kick:
|
|
await user.KickAsync(reason);
|
|
break;
|
|
case PunishmentAction.Ban:
|
|
banPrune = await GetBanPruneAsync(user.GuildId) ?? 7;
|
|
if (minutes == 0)
|
|
await guild.AddBanAsync(user, reason: reason, pruneDays: banPrune);
|
|
else
|
|
await _mute.TimedBan(user.Guild, user.Id, TimeSpan.FromMinutes(minutes), reason, banPrune);
|
|
break;
|
|
case PunishmentAction.Softban:
|
|
banPrune = await GetBanPruneAsync(user.GuildId) ?? 7;
|
|
await guild.AddBanAsync(user, banPrune, $"Softban | {reason}");
|
|
try
|
|
{
|
|
await guild.RemoveBanAsync(user);
|
|
}
|
|
catch
|
|
{
|
|
await guild.RemoveBanAsync(user);
|
|
}
|
|
|
|
break;
|
|
case PunishmentAction.RemoveRoles:
|
|
await user.RemoveRolesAsync(user.GetRoles().Where(x => !x.IsManaged && x != x.Guild.EveryoneRole));
|
|
break;
|
|
case PunishmentAction.AddRole:
|
|
if (roleId is null)
|
|
return;
|
|
var role = guild.GetRole(roleId.Value);
|
|
if (role is not null)
|
|
{
|
|
if (minutes == 0)
|
|
await user.AddRoleAsync(role);
|
|
else
|
|
await _mute.TimedRole(user, TimeSpan.FromMinutes(minutes), reason, role);
|
|
}
|
|
else
|
|
{
|
|
Log.Warning("Can't find role {RoleId} on server {GuildId} to apply punishment",
|
|
roleId.Value,
|
|
guild.Id);
|
|
}
|
|
|
|
break;
|
|
case PunishmentAction.Warn:
|
|
await Warn(guild, user.Id, mod, 1, reason);
|
|
break;
|
|
case PunishmentAction.TimeOut:
|
|
await user.SetTimeOutAsync(TimeSpan.FromMinutes(minutes));
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Used to prevent the bot from hitting 403's when it needs to
|
|
/// apply punishments with insufficient permissions
|
|
/// </summary>
|
|
/// <param name="guild">Guild the punishment is applied in</param>
|
|
/// <param name="punish">Punishment to apply</param>
|
|
/// <returns>Whether the bot has sufficient permissions</returns>
|
|
private async Task<bool> CheckPermission(IGuild guild, PunishmentAction punish)
|
|
{
|
|
var botUser = await guild.GetCurrentUserAsync();
|
|
switch (punish)
|
|
{
|
|
case PunishmentAction.Mute:
|
|
return botUser.GuildPermissions.MuteMembers && botUser.GuildPermissions.ManageRoles;
|
|
case PunishmentAction.Kick:
|
|
return botUser.GuildPermissions.KickMembers;
|
|
case PunishmentAction.Ban:
|
|
return botUser.GuildPermissions.BanMembers;
|
|
case PunishmentAction.Softban:
|
|
return botUser.GuildPermissions.BanMembers; // ban + unban
|
|
case PunishmentAction.RemoveRoles:
|
|
return botUser.GuildPermissions.ManageRoles;
|
|
case PunishmentAction.ChatMute:
|
|
return botUser.GuildPermissions.ManageRoles; // adds ellie-mute role
|
|
case PunishmentAction.VoiceMute:
|
|
return botUser.GuildPermissions.MuteMembers;
|
|
case PunishmentAction.AddRole:
|
|
return botUser.GuildPermissions.ManageRoles;
|
|
case PunishmentAction.TimeOut:
|
|
return botUser.GuildPermissions.ModerateMembers;
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
public async Task CheckAllWarnExpiresAsync()
|
|
{
|
|
await using var uow = _db.GetDbContext();
|
|
|
|
var toClear = await uow.GetTable<Warning>()
|
|
.Where(x => uow.GetTable<GuildConfig>()
|
|
.Count(y => y.GuildId == x.GuildId
|
|
&& y.WarnExpireHours > 0
|
|
&& y.WarnExpireAction == WarnExpireAction.Clear)
|
|
> 0
|
|
&& x.Forgiven == false
|
|
&& x.DateAdded
|
|
< DateTime.UtcNow.AddHours(-uow.GetTable<GuildConfig>()
|
|
.Where(y => x.GuildId == y.GuildId)
|
|
.Select(y => y.WarnExpireHours)
|
|
.First()))
|
|
.Select(x => x.Id)
|
|
.ToListAsyncLinqToDB();
|
|
|
|
var cleared = await uow.GetTable<Warning>()
|
|
.Where(x => toClear.Contains(x.Id))
|
|
.UpdateAsync(_ => new()
|
|
{
|
|
Forgiven = true,
|
|
ForgivenBy = "expiry"
|
|
});
|
|
|
|
var toDelete = await uow.GetTable<Warning>()
|
|
.Where(x => uow.GetTable<GuildConfig>()
|
|
.Count(y => y.GuildId == x.GuildId
|
|
&& y.WarnExpireHours > 0
|
|
&& y.WarnExpireAction == WarnExpireAction.Delete)
|
|
> 0
|
|
&& x.DateAdded
|
|
< DateTime.UtcNow.AddHours(-uow.GetTable<GuildConfig>()
|
|
.Where(y => x.GuildId == y.GuildId)
|
|
.Select(y => y.WarnExpireHours)
|
|
.First()))
|
|
.Select(x => x.Id)
|
|
.ToListAsyncLinqToDB();
|
|
|
|
var deleted = await uow.GetTable<Warning>()
|
|
.Where(x => toDelete.Contains(x.Id))
|
|
.DeleteAsync();
|
|
|
|
if (cleared > 0 || deleted > 0)
|
|
{
|
|
Log.Information("Cleared {ClearedWarnings} warnings and deleted {DeletedWarnings} warnings due to expiry",
|
|
cleared,
|
|
toDelete.Count);
|
|
}
|
|
}
|
|
|
|
public async Task CheckWarnExpiresAsync(ulong guildId)
|
|
{
|
|
await using var uow = _db.GetDbContext();
|
|
var config = uow.GuildConfigsForId(guildId, inc => inc);
|
|
|
|
if (config.WarnExpireHours == 0)
|
|
return;
|
|
|
|
if (config.WarnExpireAction == WarnExpireAction.Clear)
|
|
{
|
|
await uow.Set<Warning>()
|
|
.Where(x => x.GuildId == guildId
|
|
&& x.Forgiven == false
|
|
&& x.DateAdded < DateTime.UtcNow.AddHours(-config.WarnExpireHours))
|
|
.UpdateAsync(_ => new()
|
|
{
|
|
Forgiven = true,
|
|
ForgivenBy = "expiry"
|
|
});
|
|
}
|
|
else if (config.WarnExpireAction == WarnExpireAction.Delete)
|
|
{
|
|
await uow.Set<Warning>()
|
|
.Where(x => x.GuildId == guildId
|
|
&& x.DateAdded < DateTime.UtcNow.AddHours(-config.WarnExpireHours))
|
|
.DeleteAsync();
|
|
}
|
|
|
|
await uow.SaveChangesAsync();
|
|
}
|
|
|
|
public Task<int> GetWarnExpire(ulong guildId)
|
|
{
|
|
using var uow = _db.GetDbContext();
|
|
var config = uow.GuildConfigsForId(guildId, set => set);
|
|
return Task.FromResult(config.WarnExpireHours / 24);
|
|
}
|
|
|
|
public async Task WarnExpireAsync(ulong guildId, int days, bool delete)
|
|
{
|
|
await using (var uow = _db.GetDbContext())
|
|
{
|
|
var config = uow.GuildConfigsForId(guildId, inc => inc);
|
|
|
|
config.WarnExpireHours = days * 24;
|
|
config.WarnExpireAction = delete ? WarnExpireAction.Delete : WarnExpireAction.Clear;
|
|
await uow.SaveChangesAsync();
|
|
|
|
// no need to check for warn expires
|
|
if (config.WarnExpireHours == 0)
|
|
return;
|
|
}
|
|
|
|
await CheckWarnExpiresAsync(guildId);
|
|
}
|
|
|
|
public IGrouping<ulong, Warning>[] WarnlogAll(ulong gid)
|
|
{
|
|
using var uow = _db.GetDbContext();
|
|
return uow.Set<Warning>().GetForGuild(gid).GroupBy(x => x.UserId).ToArray();
|
|
}
|
|
|
|
public Warning[] UserWarnings(ulong gid, ulong userId)
|
|
{
|
|
using var uow = _db.GetDbContext();
|
|
return uow.Set<Warning>().ForId(gid, userId);
|
|
}
|
|
|
|
public async Task<bool> WarnClearAsync(
|
|
ulong guildId,
|
|
ulong userId,
|
|
int index,
|
|
string moderator)
|
|
{
|
|
var toReturn = true;
|
|
await using var uow = _db.GetDbContext();
|
|
if (index == 0)
|
|
await uow.Set<Warning>().ForgiveAll(guildId, userId, moderator);
|
|
else
|
|
toReturn = uow.Set<Warning>().Forgive(guildId, userId, moderator, index - 1);
|
|
await uow.SaveChangesAsync();
|
|
return toReturn;
|
|
}
|
|
|
|
public bool WarnPunish(
|
|
ulong guildId,
|
|
int number,
|
|
PunishmentAction punish,
|
|
StoopidTime time,
|
|
IRole role = null)
|
|
{
|
|
// these 3 don't make sense with time
|
|
if (punish is PunishmentAction.Softban or PunishmentAction.Kick or PunishmentAction.RemoveRoles
|
|
&& time is not null)
|
|
return false;
|
|
if (number <= 0 || (time is not null && time.Time > TimeSpan.FromDays(49)))
|
|
return false;
|
|
|
|
if (punish is PunishmentAction.AddRole && role is null)
|
|
return false;
|
|
|
|
if (punish is PunishmentAction.TimeOut && time is null)
|
|
return false;
|
|
|
|
using var uow = _db.GetDbContext();
|
|
var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments;
|
|
var toDelete = ps.Where(x => x.Count == number);
|
|
|
|
uow.RemoveRange(toDelete);
|
|
|
|
ps.Add(new()
|
|
{
|
|
Count = number,
|
|
Punishment = punish,
|
|
Time = (int?)time?.Time.TotalMinutes ?? 0,
|
|
RoleId = punish == PunishmentAction.AddRole ? role!.Id : default(ulong?)
|
|
});
|
|
uow.SaveChanges();
|
|
return true;
|
|
}
|
|
|
|
public bool WarnPunishRemove(ulong guildId, int number)
|
|
{
|
|
if (number <= 0)
|
|
return false;
|
|
|
|
using var uow = _db.GetDbContext();
|
|
var ps = uow.GuildConfigsForId(guildId, set => set.Include(x => x.WarnPunishments)).WarnPunishments;
|
|
var p = ps.FirstOrDefault(x => x.Count == number);
|
|
|
|
if (p is not null)
|
|
{
|
|
uow.Remove(p);
|
|
uow.SaveChanges();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public WarningPunishment[] WarnPunishList(ulong guildId)
|
|
{
|
|
using var uow = _db.GetDbContext();
|
|
return uow.GuildConfigsForId(guildId, gc => gc.Include(x => x.WarnPunishments))
|
|
.WarnPunishments.OrderBy(x => x.Count)
|
|
.ToArray();
|
|
}
|
|
|
|
public (IReadOnlyCollection<(string Original, ulong? Id, string Reason)> Bans, int Missing) MassKill(
|
|
SocketGuild guild,
|
|
string people)
|
|
{
|
|
var gusers = guild.Users;
|
|
//get user objects and reasons
|
|
var bans = people.Split("\n")
|
|
.Select(x =>
|
|
{
|
|
var split = x.Trim().Split(" ");
|
|
|
|
var reason = string.Join(" ", split.Skip(1));
|
|
|
|
if (ulong.TryParse(split[0], out var id))
|
|
return (Original: split[0], Id: id, Reason: reason);
|
|
|
|
return (Original: split[0],
|
|
gusers.FirstOrDefault(u => u.ToString().ToLowerInvariant() == x)?.Id,
|
|
Reason: reason);
|
|
})
|
|
.ToArray();
|
|
|
|
//if user is null, means that person couldn't be found
|
|
var missing = bans.Count(x => !x.Id.HasValue);
|
|
|
|
//get only data for found users
|
|
var found = bans.Where(x => x.Id.HasValue).Select(x => x.Id.Value).ToList();
|
|
|
|
_ = _blacklistService.BlacklistUsers(found);
|
|
|
|
return (bans, missing);
|
|
}
|
|
|
|
public string GetBanTemplate(ulong guildId)
|
|
{
|
|
using var uow = _db.GetDbContext();
|
|
var template = uow.Set<BanTemplate>().AsQueryable().FirstOrDefault(x => x.GuildId == guildId);
|
|
return template?.Text;
|
|
}
|
|
|
|
public void SetBanTemplate(ulong guildId, string text)
|
|
{
|
|
using var uow = _db.GetDbContext();
|
|
var template = uow.Set<BanTemplate>().AsQueryable().FirstOrDefault(x => x.GuildId == guildId);
|
|
|
|
if (text is null)
|
|
{
|
|
if (template is null)
|
|
return;
|
|
|
|
uow.Remove(template);
|
|
}
|
|
else if (template is null)
|
|
{
|
|
uow.Set<BanTemplate>()
|
|
.Add(new()
|
|
{
|
|
GuildId = guildId,
|
|
Text = text
|
|
});
|
|
}
|
|
else
|
|
template.Text = text;
|
|
|
|
uow.SaveChanges();
|
|
}
|
|
|
|
public async Task SetBanPruneAsync(ulong guildId, int? pruneDays)
|
|
{
|
|
await using var ctx = _db.GetDbContext();
|
|
await ctx.Set<BanTemplate>()
|
|
.ToLinqToDBTable()
|
|
.InsertOrUpdateAsync(() => new()
|
|
{
|
|
GuildId = guildId,
|
|
Text = null,
|
|
DateAdded = DateTime.UtcNow,
|
|
PruneDays = pruneDays
|
|
},
|
|
old => new()
|
|
{
|
|
PruneDays = pruneDays
|
|
},
|
|
() => new()
|
|
{
|
|
GuildId = guildId
|
|
});
|
|
}
|
|
|
|
public async Task<int?> GetBanPruneAsync(ulong guildId)
|
|
{
|
|
await using var ctx = _db.GetDbContext();
|
|
return await ctx.Set<BanTemplate>()
|
|
.Where(x => x.GuildId == guildId)
|
|
.Select(x => x.PruneDays)
|
|
.FirstOrDefaultAsyncLinqToDB();
|
|
}
|
|
|
|
public Task<SmartText> GetBanUserDmEmbed(
|
|
ICommandContext context,
|
|
IGuildUser target,
|
|
string defaultMessage,
|
|
string banReason,
|
|
TimeSpan? duration)
|
|
=> GetBanUserDmEmbed((DiscordSocketClient)context.Client,
|
|
(SocketGuild)context.Guild,
|
|
(IGuildUser)context.User,
|
|
target,
|
|
defaultMessage,
|
|
banReason,
|
|
duration);
|
|
|
|
public async Task<SmartText> GetBanUserDmEmbed(
|
|
DiscordSocketClient client,
|
|
SocketGuild guild,
|
|
IGuildUser moderator,
|
|
IGuildUser target,
|
|
string defaultMessage,
|
|
string banReason,
|
|
TimeSpan? duration)
|
|
{
|
|
var template = GetBanTemplate(guild.Id);
|
|
|
|
banReason = string.IsNullOrWhiteSpace(banReason) ? "-" : banReason;
|
|
|
|
var repCtx = new ReplacementContext(client, guild)
|
|
.WithOverride("%ban.mod%", () => moderator.ToString())
|
|
.WithOverride("%ban.mod.fullname%", () => moderator.ToString())
|
|
.WithOverride("%ban.mod.name%", () => moderator.Username)
|
|
.WithOverride("%ban.mod.discrim%", () => moderator.Discriminator)
|
|
.WithOverride("%ban.user%", () => target.ToString())
|
|
.WithOverride("%ban.user.fullname%", () => target.ToString())
|
|
.WithOverride("%ban.user.name%", () => target.Username)
|
|
.WithOverride("%ban.user.discrim%", () => target.Discriminator)
|
|
.WithOverride("%reason%", () => banReason)
|
|
.WithOverride("%ban.reason%", () => banReason)
|
|
.WithOverride("%ban.duration%",
|
|
() => duration?.ToString(@"d\.hh\:mm") ?? "perma");
|
|
|
|
|
|
// if template isn't set, use the old message style
|
|
if (string.IsNullOrWhiteSpace(template))
|
|
{
|
|
template = JsonConvert.SerializeObject(new
|
|
{
|
|
color = _bcs.Data.Color.Error.PackedValue >> 8,
|
|
description = defaultMessage
|
|
});
|
|
}
|
|
// if template is set to "-" do not dm the user
|
|
else if (template == "-")
|
|
return default;
|
|
// if template is an embed, send that embed with replacements
|
|
// otherwise, treat template as a regular string with replacements
|
|
else if (SmartText.CreateFrom(template) is not { IsEmbed: true } or { IsEmbedArray: true })
|
|
{
|
|
template = JsonConvert.SerializeObject(new
|
|
{
|
|
color = _bcs.Data.Color.Error.PackedValue >> 8,
|
|
description = template
|
|
});
|
|
}
|
|
|
|
var output = SmartText.CreateFrom(template);
|
|
return await _repSvc.ReplaceAsync(output, repCtx);
|
|
}
|
|
|
|
public async Task<Warning> WarnDelete(ulong userId, int index)
|
|
{
|
|
await using var uow = _db.GetDbContext();
|
|
|
|
var warn = await uow.GetTable<Warning>()
|
|
.Where(x => x.UserId == userId)
|
|
.OrderByDescending(x => x.DateAdded)
|
|
.Skip(index)
|
|
.FirstOrDefaultAsyncLinqToDB();
|
|
|
|
if (warn is not null)
|
|
{
|
|
await uow.GetTable<Warning>()
|
|
.Where(x => x.Id == warn.Id)
|
|
.DeleteAsync();
|
|
}
|
|
|
|
return warn;
|
|
}
|
|
} |