Added Administration module

This commit is contained in:
Toastie 2024-09-21 00:13:18 +12:00
parent 03ab232251
commit ff653b5c57
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
59 changed files with 11797 additions and 0 deletions

View file

@ -0,0 +1,499 @@
#nullable disable
using EllieBot.Common.TypeReaders.Models;
using EllieBot.Modules.Administration._common.results;
using EllieBot.Modules.Administration.Services;
namespace EllieBot.Modules.Administration;
public partial class Administration : EllieModule<AdministrationService>
{
public enum Channel
{
Channel,
Ch,
Chnl,
Chan
}
public enum List
{
List = 0,
Ls = 0
}
public enum Server
{
Server
}
public enum State
{
Enable,
Disable,
Inherit
}
private readonly SomethingOnlyChannelService _somethingOnly;
private readonly AutoPublishService _autoPubService;
public Administration(SomethingOnlyChannelService somethingOnly, AutoPublishService autoPubService)
{
_somethingOnly = somethingOnly;
_autoPubService = autoPubService;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[BotPerm(GuildPerm.ManageGuild)]
public async Task ImageOnlyChannel(StoopidTime time = null)
{
var newValue = await _somethingOnly.ToggleImageOnlyChannelAsync(ctx.Guild.Id, ctx.Channel.Id);
if (newValue)
await Response().Confirm(strs.imageonly_enable).SendAsync();
else
await Response().Pending(strs.imageonly_disable).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[BotPerm(GuildPerm.ManageGuild)]
public async Task LinkOnlyChannel(StoopidTime time = null)
{
var newValue = await _somethingOnly.ToggleLinkOnlyChannelAsync(ctx.Guild.Id, ctx.Channel.Id);
if (newValue)
await Response().Confirm(strs.linkonly_enable).SendAsync();
else
await Response().Pending(strs.linkonly_disable).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(ChannelPerm.ManageChannels)]
[BotPerm(ChannelPerm.ManageChannels)]
public async Task Slowmode(StoopidTime time = null)
{
var seconds = (int?)time?.Time.TotalSeconds ?? 0;
if (time is not null && (time.Time < TimeSpan.FromSeconds(0) || time.Time > TimeSpan.FromHours(6)))
return;
await ((ITextChannel)ctx.Channel).ModifyAsync(tcp =>
{
tcp.SlowModeInterval = seconds;
});
await ctx.OkAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[BotPerm(GuildPerm.ManageMessages)]
[Priority(2)]
public async Task Delmsgoncmd(List _)
{
var guild = (SocketGuild)ctx.Guild;
var (enabled, channels) = _service.GetDelMsgOnCmdData(ctx.Guild.Id);
var embed = _sender.CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.server_delmsgoncmd))
.WithDescription(enabled ? "✅" : "❌");
var str = string.Join("\n",
channels.Select(x =>
{
var ch = guild.GetChannel(x.ChannelId)?.ToString() ?? x.ChannelId.ToString();
var prefixSign = x.State ? "✅ " : "❌ ";
return prefixSign + ch;
}));
if (string.IsNullOrWhiteSpace(str))
str = "-";
embed.AddField(GetText(strs.channel_delmsgoncmd), str);
await Response().Embed(embed).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[BotPerm(GuildPerm.ManageMessages)]
[Priority(1)]
public async Task Delmsgoncmd(Server _ = Server.Server)
{
if (_service.ToggleDeleteMessageOnCommand(ctx.Guild.Id))
{
_service.DeleteMessagesOnCommand.Add(ctx.Guild.Id);
await Response().Confirm(strs.delmsg_on).SendAsync();
}
else
{
_service.DeleteMessagesOnCommand.TryRemove(ctx.Guild.Id);
await Response().Confirm(strs.delmsg_off).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[BotPerm(GuildPerm.ManageMessages)]
[Priority(0)]
public Task Delmsgoncmd(Channel _, State s, ITextChannel ch)
=> Delmsgoncmd(_, s, ch.Id);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[BotPerm(GuildPerm.ManageMessages)]
[Priority(1)]
public async Task Delmsgoncmd(Channel _, State s, ulong? chId = null)
{
var actualChId = chId ?? ctx.Channel.Id;
await _service.SetDelMsgOnCmdState(ctx.Guild.Id, actualChId, s);
if (s == State.Disable)
await Response().Confirm(strs.delmsg_channel_off).SendAsync();
else if (s == State.Enable)
await Response().Confirm(strs.delmsg_channel_on).SendAsync();
else
await Response().Confirm(strs.delmsg_channel_inherit).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.DeafenMembers)]
[BotPerm(GuildPerm.DeafenMembers)]
public async Task Deafen(params IGuildUser[] users)
{
await _service.DeafenUsers(true, users);
await Response().Confirm(strs.deafen).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.DeafenMembers)]
[BotPerm(GuildPerm.DeafenMembers)]
public async Task UnDeafen(params IGuildUser[] users)
{
await _service.DeafenUsers(false, users);
await Response().Confirm(strs.undeafen).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageChannels)]
[BotPerm(GuildPerm.ManageChannels)]
public async Task DelVoiChanl([Leftover] IVoiceChannel voiceChannel)
{
await voiceChannel.DeleteAsync();
await Response().Confirm(strs.delvoich(Format.Bold(voiceChannel.Name))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageChannels)]
[BotPerm(GuildPerm.ManageChannels)]
public async Task CreatVoiChanl([Leftover] string channelName)
{
var ch = await ctx.Guild.CreateVoiceChannelAsync(channelName);
await Response().Confirm(strs.createvoich(Format.Bold(ch.Name))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageChannels)]
[BotPerm(GuildPerm.ManageChannels)]
public async Task DelTxtChanl([Leftover] ITextChannel toDelete)
{
await toDelete.DeleteAsync(new RequestOptions()
{
AuditLogReason = $"Deleted by {ctx.User.Username}"
});
await Response().Confirm(strs.deltextchan(Format.Bold(toDelete.Name))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageChannels)]
[BotPerm(GuildPerm.ManageChannels)]
public async Task CreaTxtChanl([Leftover] string channelName)
{
var txtCh = await ctx.Guild.CreateTextChannelAsync(channelName);
await Response().Confirm(strs.createtextchan(Format.Bold(txtCh.Name))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageChannels)]
[BotPerm(GuildPerm.ManageChannels)]
public async Task SetTopic([Leftover] string topic = null)
{
var channel = (ITextChannel)ctx.Channel;
topic ??= "";
await channel.ModifyAsync(c => c.Topic = topic);
await Response().Confirm(strs.set_topic).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageChannels)]
[BotPerm(GuildPerm.ManageChannels)]
public async Task SetChanlName([Leftover] string name)
{
var channel = (ITextChannel)ctx.Channel;
await channel.ModifyAsync(c => c.Name = name);
await Response().Confirm(strs.set_channel_name).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageChannels)]
[BotPerm(GuildPerm.ManageChannels)]
public async Task AgeRestrictToggle()
{
var channel = (ITextChannel)ctx.Channel;
var isEnabled = channel.IsNsfw;
await channel.ModifyAsync(c => c.IsNsfw = !isEnabled);
if (isEnabled)
await Response().Confirm(strs.nsfw_set_false).SendAsync();
else
await Response().Confirm(strs.nsfw_set_true).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(ChannelPerm.ManageMessages)]
[Priority(0)]
public Task Edit(ulong messageId, [Leftover] string text)
=> Edit((ITextChannel)ctx.Channel, messageId, text);
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(1)]
public async Task Edit(ITextChannel channel, ulong messageId, [Leftover] string text)
{
var userPerms = ((SocketGuildUser)ctx.User).GetPermissions(channel);
var botPerms = ((SocketGuild)ctx.Guild).CurrentUser.GetPermissions(channel);
if (!userPerms.Has(ChannelPermission.ManageMessages))
{
await Response().Error(strs.insuf_perms_u).SendAsync();
return;
}
if (!botPerms.Has(ChannelPermission.ViewChannel))
{
await Response().Error(strs.insuf_perms_i).SendAsync();
return;
}
await _service.EditMessage(ctx, channel, messageId, text);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(ChannelPerm.ManageMessages)]
[BotPerm(ChannelPerm.ManageMessages)]
public Task Delete(ulong messageId, StoopidTime time = null)
=> Delete((ITextChannel)ctx.Channel, messageId, time);
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Delete(ITextChannel channel, ulong messageId, StoopidTime time = null)
=> await InternalMessageAction(channel, messageId, time, msg => msg.DeleteAsync());
private async Task InternalMessageAction(
ITextChannel channel,
ulong messageId,
StoopidTime time,
Func<IMessage, Task> func)
{
var userPerms = ((SocketGuildUser)ctx.User).GetPermissions(channel);
var botPerms = ((SocketGuild)ctx.Guild).CurrentUser.GetPermissions(channel);
if (!userPerms.Has(ChannelPermission.ManageMessages))
{
await Response().Error(strs.insuf_perms_u).SendAsync();
return;
}
if (!botPerms.Has(ChannelPermission.ManageMessages))
{
await Response().Error(strs.insuf_perms_i).SendAsync();
return;
}
var msg = await channel.GetMessageAsync(messageId);
if (msg is null)
{
await Response().Error(strs.msg_not_found).SendAsync();
return;
}
if (time is null)
await msg.DeleteAsync();
else if (time.Time <= TimeSpan.FromDays(7))
{
_ = Task.Run(async () =>
{
await Task.Delay(time.Time);
await msg.DeleteAsync();
});
}
else
{
await Response().Error(strs.time_too_long).SendAsync();
return;
}
await ctx.OkAsync();
}
[Cmd]
[BotPerm(ChannelPermission.CreatePublicThreads)]
[UserPerm(ChannelPermission.CreatePublicThreads)]
public async Task ThreadCreate([Leftover] string name)
{
if (ctx.Channel is not SocketTextChannel stc)
return;
await stc.CreateThreadAsync(name, message: ctx.Message.ReferencedMessage);
await ctx.OkAsync();
}
[Cmd]
[BotPerm(ChannelPermission.ManageThreads)]
[UserPerm(ChannelPermission.ManageThreads)]
public async Task ThreadDelete([Leftover] string name)
{
if (ctx.Channel is not SocketTextChannel stc)
return;
var t = stc.Threads.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.InvariantCultureIgnoreCase));
if (t is null)
{
await Response().Error(strs.not_found).SendAsync();
return;
}
await t.DeleteAsync();
await ctx.OkAsync();
}
[Cmd]
[UserPerm(ChannelPerm.ManageMessages)]
public async Task AutoPublish()
{
if (ctx.Channel.GetChannelType() != ChannelType.News)
{
await Response().Error(strs.req_announcement_channel).SendAsync();
return;
}
var newState = await _autoPubService.ToggleAutoPublish(ctx.Guild.Id, ctx.Channel.Id);
if (newState)
{
await Response().Confirm(strs.autopublish_enable).SendAsync();
}
else
{
await Response().Confirm(strs.autopublish_disable).SendAsync();
}
}
[Cmd]
[UserPerm(GuildPerm.ManageNicknames)]
[BotPerm(GuildPerm.ChangeNickname)]
[Priority(0)]
public async Task SetNick([Leftover] string newNick = null)
{
if (string.IsNullOrWhiteSpace(newNick))
return;
var curUser = await ctx.Guild.GetCurrentUserAsync();
await curUser.ModifyAsync(u => u.Nickname = newNick);
await Response().Confirm(strs.bot_nick(Format.Bold(newNick) ?? "-")).SendAsync();
}
[Cmd]
[BotPerm(GuildPerm.ManageNicknames)]
[UserPerm(GuildPerm.ManageNicknames)]
[Priority(1)]
public async Task SetNick(IGuildUser gu, [Leftover] string newNick = null)
{
var sg = (SocketGuild)ctx.Guild;
if (sg.OwnerId == gu.Id
|| gu.GetRoles().Max(r => r.Position) >= sg.CurrentUser.GetRoles().Max(r => r.Position))
{
await Response().Error(strs.insuf_perms_i).SendAsync();
return;
}
await gu.ModifyAsync(u => u.Nickname = newNick);
await Response()
.Confirm(strs.user_nick(Format.Bold(gu.ToString()), Format.Bold(newNick) ?? "-"))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPermission.ManageGuild)]
[BotPerm(GuildPermission.ManageGuild)]
public async Task SetServerBanner([Leftover] string img = null)
{
// Tier2 or higher is required to set a banner.
if (ctx.Guild.PremiumTier is PremiumTier.Tier1 or PremiumTier.None) return;
var result = await _service.SetServerBannerAsync(ctx.Guild, img);
switch (result)
{
case SetServerBannerResult.Success:
await Response().Confirm(strs.set_srvr_banner).SendAsync();
break;
case SetServerBannerResult.InvalidFileType:
await Response().Error(strs.srvr_banner_invalid).SendAsync();
break;
case SetServerBannerResult.Toolarge:
await Response().Error(strs.srvr_banner_too_large).SendAsync();
break;
case SetServerBannerResult.InvalidURL:
await Response().Error(strs.srvr_banner_invalid_url).SendAsync();
break;
default:
throw new ArgumentOutOfRangeException();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPermission.ManageGuild)]
[BotPerm(GuildPermission.ManageGuild)]
public async Task SetServerIcon([Leftover] string img = null)
{
var result = await _service.SetServerIconAsync(ctx.Guild, img);
switch (result)
{
case SetServerIconResult.Success:
await Response().Confirm(strs.set_srvr_icon).SendAsync();
break;
case SetServerIconResult.InvalidFileType:
await Response().Error(strs.srvr_banner_invalid).SendAsync();
break;
case SetServerIconResult.InvalidURL:
await Response().Error(strs.srvr_banner_invalid_url).SendAsync();
break;
default:
throw new ArgumentOutOfRangeException();
}
}
}

View file

@ -0,0 +1,205 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using EllieBot.Db.Models;
using EllieBot.Modules.Administration._common.results;
namespace EllieBot.Modules.Administration.Services;
public class AdministrationService : IEService
{
public ConcurrentHashSet<ulong> DeleteMessagesOnCommand { get; }
public ConcurrentDictionary<ulong, bool> DeleteMessagesOnCommandChannels { get; }
private readonly DbService _db;
private readonly IReplacementService _repSvc;
private readonly ILogCommandService _logService;
private readonly IHttpClientFactory _httpFactory;
public AdministrationService(
IBot bot,
CommandHandler cmdHandler,
DbService db,
IReplacementService repSvc,
ILogCommandService logService,
IHttpClientFactory factory)
{
_db = db;
_repSvc = repSvc;
_logService = logService;
_httpFactory = factory;
DeleteMessagesOnCommand = new(bot.AllGuildConfigs.Where(g => g.DeleteMessageOnCommand).Select(g => g.GuildId));
DeleteMessagesOnCommandChannels = new(bot.AllGuildConfigs.SelectMany(x => x.DelMsgOnCmdChannels)
.ToDictionary(x => x.ChannelId, x => x.State)
.ToConcurrent());
cmdHandler.CommandExecuted += DelMsgOnCmd_Handler;
}
public (bool DelMsgOnCmd, IEnumerable<DelMsgOnCmdChannel> channels) GetDelMsgOnCmdData(ulong guildId)
{
using var uow = _db.GetDbContext();
var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.DelMsgOnCmdChannels));
return (conf.DeleteMessageOnCommand, conf.DelMsgOnCmdChannels);
}
private Task DelMsgOnCmd_Handler(IUserMessage msg, CommandInfo cmd)
{
if (msg.Channel is not ITextChannel channel)
return Task.CompletedTask;
_ = Task.Run(async () =>
{
//wat ?!
if (DeleteMessagesOnCommandChannels.TryGetValue(channel.Id, out var state))
{
if (state && cmd.Name != "prune" && cmd.Name != "pick")
{
_logService.AddDeleteIgnore(msg.Id);
try { await msg.DeleteAsync(); }
catch { }
}
//if state is false, that means do not do it
}
else if (DeleteMessagesOnCommand.Contains(channel.Guild.Id) && cmd.Name != "prune" && cmd.Name != "pick")
{
_logService.AddDeleteIgnore(msg.Id);
try { await msg.DeleteAsync(); }
catch { }
}
});
return Task.CompletedTask;
}
public bool ToggleDeleteMessageOnCommand(ulong guildId)
{
bool enabled;
using var uow = _db.GetDbContext();
var conf = uow.GuildConfigsForId(guildId, set => set);
enabled = conf.DeleteMessageOnCommand = !conf.DeleteMessageOnCommand;
uow.SaveChanges();
return enabled;
}
public async Task SetDelMsgOnCmdState(ulong guildId, ulong chId, Administration.State newState)
{
await using (var uow = _db.GetDbContext())
{
var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.DelMsgOnCmdChannels));
var old = conf.DelMsgOnCmdChannels.FirstOrDefault(x => x.ChannelId == chId);
if (newState == Administration.State.Inherit)
{
if (old is not null)
{
conf.DelMsgOnCmdChannels.Remove(old);
uow.Remove(old);
}
}
else
{
if (old is null)
{
old = new()
{
ChannelId = chId
};
conf.DelMsgOnCmdChannels.Add(old);
}
old.State = newState == Administration.State.Enable;
DeleteMessagesOnCommandChannels[chId] = newState == Administration.State.Enable;
}
await uow.SaveChangesAsync();
}
if (newState == Administration.State.Disable)
{
}
else if (newState == Administration.State.Enable)
DeleteMessagesOnCommandChannels[chId] = true;
else
DeleteMessagesOnCommandChannels.TryRemove(chId, out _);
}
public async Task DeafenUsers(bool value, params IGuildUser[] users)
{
if (!users.Any())
return;
foreach (var u in users)
{
try
{
await u.ModifyAsync(usr => usr.Deaf = value);
}
catch
{
// ignored
}
}
}
public async Task EditMessage(
ICommandContext context,
ITextChannel chanl,
ulong messageId,
string input)
{
var msg = await chanl.GetMessageAsync(messageId);
if (msg is not IUserMessage umsg || msg.Author.Id != context.Client.CurrentUser.Id)
return;
var repCtx = new ReplacementContext(context);
var text = SmartText.CreateFrom(input);
text = await _repSvc.ReplaceAsync(text, repCtx);
await umsg.EditAsync(text);
}
public async Task<SetServerBannerResult> SetServerBannerAsync(IGuild guild, string img)
{
if (!IsValidUri(img)) return SetServerBannerResult.InvalidURL;
var uri = new Uri(img);
using var http = _httpFactory.CreateClient();
using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
if (!sr.IsImage()) return SetServerBannerResult.InvalidFileType;
if (sr.GetContentLength() > 8.Megabytes())
{
return SetServerBannerResult.Toolarge;
}
await using var imageStream = await sr.Content.ReadAsStreamAsync();
await guild.ModifyAsync(x => x.Banner = new Image(imageStream));
return SetServerBannerResult.Success;
}
public async Task<SetServerIconResult> SetServerIconAsync(IGuild guild, string img)
{
if (!IsValidUri(img)) return SetServerIconResult.InvalidURL;
var uri = new Uri(img);
using var http = _httpFactory.CreateClient();
using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
if (!sr.IsImage()) return SetServerIconResult.InvalidFileType;
await using var imageStream = await sr.Content.ReadAsStreamAsync();
await guild.ModifyAsync(x => x.Icon = new Image(imageStream));
return SetServerIconResult.Success;
}
private bool IsValidUri(string img) => !string.IsNullOrWhiteSpace(img) && Uri.IsWellFormedUriString(img, UriKind.Absolute);
}

View file

@ -0,0 +1,60 @@
#nullable disable
using EllieBot.Modules.Administration.Services;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class AutoAssignRoleCommands : EllieModule<AutoAssignRoleService>
{
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task AutoAssignRole([Leftover] IRole role)
{
var guser = (IGuildUser)ctx.User;
if (role.Id == ctx.Guild.EveryoneRole.Id)
return;
// the user can't aar the role which is higher or equal to his highest role
if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position)
{
await Response().Error(strs.hierarchy).SendAsync();
return;
}
var roles = await _service.ToggleAarAsync(ctx.Guild.Id, role.Id);
if (roles.Count == 0)
await Response().Confirm(strs.aar_disabled).SendAsync();
else if (roles.Contains(role.Id))
await AutoAssignRole();
else
await Response().Confirm(strs.aar_role_removed(Format.Bold(role.ToString()))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task AutoAssignRole()
{
if (!_service.TryGetRoles(ctx.Guild.Id, out var roles))
{
await Response().Confirm(strs.aar_none).SendAsync();
return;
}
var existing = roles.Select(rid => ctx.Guild.GetRole(rid)).Where(r => r is not null).ToList();
if (existing.Count != roles.Count)
await _service.SetAarRolesAsync(ctx.Guild.Id, existing.Select(x => x.Id));
await Response()
.Confirm(strs.aar_roles(
'\n' + existing.Select(x => Format.Bold(x.ToString())).Join(",\n")))
.SendAsync();
}
}
}

View file

@ -0,0 +1,158 @@
#nullable disable
using EllieBot.Db.Models;
using System.Net;
using System.Threading.Channels;
using LinqToDB;
using Microsoft.EntityFrameworkCore;
namespace EllieBot.Modules.Administration.Services;
public sealed class AutoAssignRoleService : IEService
{
private readonly DiscordSocketClient _client;
private readonly DbService _db;
//guildid/roleid
private readonly ConcurrentDictionary<ulong, IReadOnlyList<ulong>> _autoAssignableRoles;
private readonly Channel<SocketGuildUser> _assignQueue = Channel.CreateBounded<SocketGuildUser>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
});
public AutoAssignRoleService(DiscordSocketClient client, IBot bot, DbService db)
{
_client = client;
_db = db;
_autoAssignableRoles = bot.AllGuildConfigs.Where(x => !string.IsNullOrWhiteSpace(x.AutoAssignRoleIds))
.ToDictionary<GuildConfig, ulong, IReadOnlyList<ulong>>(k => k.GuildId,
v => v.GetAutoAssignableRoles())
.ToConcurrent();
_ = Task.Run(async () =>
{
while (true)
{
var user = await _assignQueue.Reader.ReadAsync();
if (!_autoAssignableRoles.TryGetValue(user.Guild.Id, out var savedRoleIds))
continue;
try
{
var roleIds = savedRoleIds.Select(roleId => user.Guild.GetRole(roleId))
.Where(x => x is not null)
.ToList();
if (roleIds.Any())
{
await user.AddRolesAsync(roleIds);
await Task.Delay(250);
}
else
{
Log.Warning(
"Disabled 'Auto assign role' feature on {GuildName} [{GuildId}] server the roles dont exist",
user.Guild.Name,
user.Guild.Id);
await DisableAarAsync(user.Guild.Id);
}
}
catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden)
{
Log.Warning(
"Disabled 'Auto assign role' feature on {GuildName} [{GuildId}] server because I don't have role management permissions",
user.Guild.Name,
user.Guild.Id);
await DisableAarAsync(user.Guild.Id);
}
catch (Exception ex)
{
Log.Warning(ex, "Error in aar. Probably one of the roles doesn't exist");
}
}
});
_client.UserJoined += OnClientOnUserJoined;
_client.RoleDeleted += OnClientRoleDeleted;
}
private async Task OnClientRoleDeleted(SocketRole role)
{
if (_autoAssignableRoles.TryGetValue(role.Guild.Id, out var roles) && roles.Contains(role.Id))
await ToggleAarAsync(role.Guild.Id, role.Id);
}
private async Task OnClientOnUserJoined(SocketGuildUser user)
{
if (_autoAssignableRoles.TryGetValue(user.Guild.Id, out _))
await _assignQueue.Writer.WriteAsync(user);
}
public async Task<IReadOnlyList<ulong>> ToggleAarAsync(ulong guildId, ulong roleId)
{
await using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, set => set);
var roles = gc.GetAutoAssignableRoles();
if (!roles.Remove(roleId) && roles.Count < 3)
roles.Add(roleId);
gc.SetAutoAssignableRoles(roles);
await uow.SaveChangesAsync();
if (roles.Count > 0)
_autoAssignableRoles[guildId] = roles;
else
_autoAssignableRoles.TryRemove(guildId, out _);
return roles;
}
public async Task DisableAarAsync(ulong guildId)
{
await using var uow = _db.GetDbContext();
await uow.Set<GuildConfig>().AsNoTracking()
.Where(x => x.GuildId == guildId)
.UpdateAsync(_ => new()
{
AutoAssignRoleIds = null
});
_autoAssignableRoles.TryRemove(guildId, out _);
await uow.SaveChangesAsync();
}
public async Task SetAarRolesAsync(ulong guildId, IEnumerable<ulong> newRoles)
{
await using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, set => set);
gc.SetAutoAssignableRoles(newRoles);
await uow.SaveChangesAsync();
}
public bool TryGetRoles(ulong guildId, out IReadOnlyList<ulong> roles)
=> _autoAssignableRoles.TryGetValue(guildId, out roles);
}
public static class GuildConfigExtensions
{
public static List<ulong> GetAutoAssignableRoles(this GuildConfig gc)
{
if (string.IsNullOrWhiteSpace(gc.AutoAssignRoleIds))
return new();
return gc.AutoAssignRoleIds.Split(',').Select(ulong.Parse).ToList();
}
public static void SetAutoAssignableRoles(this GuildConfig gc, IEnumerable<ulong> roles)
=> gc.AutoAssignRoleIds = roles.Join(',');
}

View file

@ -0,0 +1,89 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration.Services;
public class AutoPublishService : IExecNoCommand, IReadyExecutor, IEService
{
private readonly DbService _db;
private readonly DiscordSocketClient _client;
private readonly IBotCredsProvider _creds;
private ConcurrentDictionary<ulong, ulong> _enabled;
public AutoPublishService(DbService db, DiscordSocketClient client, IBotCredsProvider creds)
{
_db = db;
_client = client;
_creds = creds;
}
public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
{
if (guild is null)
return;
if (msg.Channel.GetChannelType() != ChannelType.News)
return;
if (!_enabled.TryGetValue(guild.Id, out var cid) || cid != msg.Channel.Id)
return;
await msg.CrosspostAsync(new RequestOptions()
{
RetryMode = RetryMode.AlwaysFail
});
}
// todo GUILDS
public async Task OnReadyAsync()
{
var creds = _creds.GetCreds();
await using var ctx = _db.GetDbContext();
var items = await ctx.GetTable<AutoPublishChannel>()
.Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId, creds.TotalShards, _client.ShardId))
.ToListAsyncLinqToDB();
_enabled = items
.ToDictionary(x => x.GuildId, x => x.ChannelId)
.ToConcurrent();
}
public async Task<bool> ToggleAutoPublish(ulong guildId, ulong channelId)
{
await using var ctx = _db.GetDbContext();
var deleted = await ctx.GetTable<AutoPublishChannel>()
.DeleteAsync(x => x.GuildId == guildId && x.ChannelId == channelId);
if (deleted != 0)
{
_enabled.TryRemove(guildId, out _);
return false;
}
await ctx.GetTable<AutoPublishChannel>()
.InsertOrUpdateAsync(() => new()
{
GuildId = guildId,
ChannelId = channelId,
DateAdded = DateTime.UtcNow,
},
old => new()
{
ChannelId = channelId,
DateAdded = DateTime.UtcNow,
},
() => new()
{
GuildId = guildId
});
_enabled[guildId] = channelId;
return true;
}
}

View file

@ -0,0 +1,74 @@
using EllieBot.Modules.Administration.DangerousCommands;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class CleanupCommands : CleanupModuleBase
{
private readonly ICleanupService _svc;
private readonly IBotCredsProvider _creds;
public CleanupCommands(ICleanupService svc, IBotCredsProvider creds)
{
_svc = svc;
_creds = creds;
}
[Cmd]
[OwnerOnly]
[RequireContext(ContextType.DM)]
public async Task CleanupGuildData()
{
var result = await _svc.DeleteMissingGuildDataAsync();
if (result is null)
{
await ctx.ErrorAsync();
return;
}
await Response()
.Confirm($"{result.GuildCount} guilds' data remain in the database.")
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task Keep()
{
var result = await _svc.KeepGuild(Context.Guild.Id);
await Response().Text("This guild's bot data will be saved.").SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task LeaveUnkeptServers(int startShardId, int shardMultiplier = 3000)
{
var keptGuildCount = await _svc.GetKeptGuildCount();
var response = await PromptUserConfirmAsync(new EmbedBuilder()
.WithDescription($"""
Do you want the bot to leave all unkept servers?
There are currently {keptGuildCount} kept servers.
**This is a highly destructive and irreversible action.**
"""));
if (!response)
return;
for (var shardId = startShardId; shardId < _creds.GetCreds().TotalShards; shardId++)
{
await _svc.StartLeavingUnkeptServers(shardId);
await Task.Delay(shardMultiplier * 1000);
}
await ctx.OkAsync();
}
}
}

View file

@ -0,0 +1,271 @@
using LinqToDB;
using LinqToDB.Data;
using LinqToDB.EntityFrameworkCore;
using LinqToDB.Mapping;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration.DangerousCommands;
public sealed class CleanupService : ICleanupService, IReadyExecutor, IEService
{
private TypedKey<KeepReport> _cleanupReportKey = new("cleanup:report");
private TypedKey<bool> _cleanupTriggerKey = new("cleanup:trigger");
private TypedKey<int> _keepTriggerKey = new("keep:trigger");
private readonly IPubSub _pubSub;
private readonly DiscordSocketClient _client;
private ConcurrentDictionary<int, ulong[]> guildIds = new();
private readonly IBotCredsProvider _creds;
private readonly DbService _db;
public CleanupService(
IPubSub pubSub,
DiscordSocketClient client,
IBotCredsProvider creds,
DbService db)
{
_pubSub = pubSub;
_client = client;
_creds = creds;
_db = db;
}
public async Task OnReadyAsync()
{
await _pubSub.Sub(_cleanupTriggerKey, OnCleanupTrigger);
await _pubSub.Sub(_keepTriggerKey, InternalTriggerKeep);
_client.JoinedGuild += ClientOnJoinedGuild;
if (_client.ShardId == 0)
await _pubSub.Sub(_cleanupReportKey, OnKeepReport);
}
private bool keepTriggered = false;
private async ValueTask InternalTriggerKeep(int shardId)
{
if (_client.ShardId != shardId)
return;
if (keepTriggered)
return;
keepTriggered = true;
try
{
var allGuildIds = _client.Guilds.Select(x => x.Id).ToArray();
HashSet<ulong> dontDelete;
await using (var db = _db.GetDbContext())
{
await using var ctx = db.CreateLinqToDBContext();
var table = ctx.CreateTable<KeptGuilds>(tableOptions: TableOptions.CheckExistence);
var dontDeleteList = await table
.Where(x => allGuildIds.Contains(x.GuildId))
.Select(x => x.GuildId)
.ToListAsyncLinqToDB();
dontDelete = dontDeleteList.ToHashSet();
}
Log.Information("Leaving {RemainingCount} guilds, 1 every second. {DontDeleteCount} will remain",
allGuildIds.Length - dontDelete.Count,
dontDelete.Count);
foreach (var guildId in allGuildIds)
{
if (dontDelete.Contains(guildId))
continue;
await Task.Delay(1016);
SocketGuild? guild = null;
try
{
guild = _client.GetGuild(guildId);
if (guild is null)
{
Log.Warning("Unable to find guild {GuildId}", guildId);
continue;
}
await guild.LeaveAsync();
}
catch (Exception ex)
{
Log.Warning("Unable to leave guild {GuildName} [{GuildId}]: {ErrorMessage}",
guild?.Name,
guildId,
ex.Message);
}
}
}
finally
{
keepTriggered = false;
}
}
public async Task<KeepResult?> DeleteMissingGuildDataAsync()
{
guildIds = new();
var totalShards = _creds.GetCreds().TotalShards;
await _pubSub.Pub(_cleanupTriggerKey, true);
var counter = 0;
while (guildIds.Keys.Count < totalShards)
{
await Task.Delay(1000);
counter++;
if (counter >= 5)
break;
}
if (guildIds.Keys.Count < totalShards)
return default;
var allIds = guildIds.SelectMany(x => x.Value)
.ToArray();
await using var ctx = _db.GetDbContext();
await using var linqCtx = ctx.CreateLinqToDBContext();
await using var tempTable = linqCtx.CreateTempTable<CleanupId>();
foreach (var chunk in allIds.Chunk(20000))
{
await tempTable.BulkCopyAsync(chunk.Select(x => new CleanupId()
{
GuildId = x
}));
}
// delete guild configs
await ctx.GetTable<GuildConfig>()
.Where(x => !tempTable.Select(x => x.GuildId)
.Contains(x.GuildId))
.DeleteAsync();
// delete guild xp
await ctx.GetTable<UserXpStats>()
.Where(x => !tempTable.Select(x => x.GuildId)
.Contains(x.GuildId))
.DeleteAsync();
// delete expressions
await ctx.GetTable<EllieExpression>()
.Where(x => x.GuildId != null
&& !tempTable.Select(x => x.GuildId)
.Contains(x.GuildId.Value))
.DeleteAsync();
// delete quotes
await ctx.GetTable<Quote>()
.Where(x => !tempTable.Select(x => x.GuildId)
.Contains(x.GuildId))
.DeleteAsync();
// delete planted currencies
await ctx.GetTable<PlantedCurrency>()
.Where(x => !tempTable.Select(x => x.GuildId)
.Contains(x.GuildId))
.DeleteAsync();
// delete image only channels
await ctx.GetTable<ImageOnlyChannel>()
.Where(x => !tempTable.Select(x => x.GuildId)
.Contains(x.GuildId))
.DeleteAsync();
// delete reaction roles
await ctx.GetTable<ReactionRoleV2>()
.Where(x => !tempTable.Select(x => x.GuildId)
.Contains(x.GuildId))
.DeleteAsync();
// delete ignored users
await ctx.GetTable<DiscordPermOverride>()
.Where(x => x.GuildId != null
&& !tempTable.Select(x => x.GuildId)
.Contains(x.GuildId.Value))
.DeleteAsync();
// delete perm overrides
await ctx.GetTable<DiscordPermOverride>()
.Where(x => x.GuildId != null
&& !tempTable.Select(x => x.GuildId)
.Contains(x.GuildId.Value))
.DeleteAsync();
// delete repeaters
await ctx.GetTable<Repeater>()
.Where(x => !tempTable.Select(x => x.GuildId)
.Contains(x.GuildId))
.DeleteAsync();
return new()
{
GuildCount = guildIds.Keys.Count,
};
}
public async Task<bool> KeepGuild(ulong guildId)
{
await using var db = _db.GetDbContext();
await using var ctx = db.CreateLinqToDBContext();
var table = ctx.CreateTable<KeptGuilds>(tableOptions: TableOptions.CheckExistence);
if (await table.AnyAsyncLinqToDB(x => x.GuildId == guildId))
return false;
await table.InsertAsync(() => new()
{
GuildId = guildId
});
return true;
}
public async Task<int> GetKeptGuildCount()
{
await using var db = _db.GetDbContext();
await using var ctx = db.CreateLinqToDBContext();
var table = ctx.CreateTable<KeptGuilds>(tableOptions: TableOptions.CheckExistence);
return await table.CountAsync();
}
public async Task StartLeavingUnkeptServers(int shardId)
=> await _pubSub.Pub(_keepTriggerKey, shardId);
private ValueTask OnKeepReport(KeepReport report)
{
guildIds[report.ShardId] = report.GuildIds;
return default;
}
private async Task ClientOnJoinedGuild(SocketGuild arg)
{
await KeepGuild(arg.Id);
}
private ValueTask OnCleanupTrigger(bool arg)
{
_pubSub.Pub(_cleanupReportKey,
new KeepReport()
{
ShardId = _client.ShardId,
GuildIds = _client.GetGuildIds(),
});
return default;
}
}
public class KeptGuilds
{
[PrimaryKey]
public ulong GuildId { get; set; }
}

View file

@ -0,0 +1,164 @@
#nullable disable
using System.Globalization;
using CsvHelper;
using CsvHelper.Configuration;
using EllieBot.Modules.Gambling;
using EllieBot.Modules.Administration.Services;
using EllieBot.Modules.Xp;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
[OwnerOnly]
[NoPublicBot]
public partial class DangerousCommands : CleanupModuleBase
{
private readonly DangerousCommandsService _ds;
private readonly IGamblingCleanupService _gcs;
private readonly IXpCleanupService _xcs;
public DangerousCommands(
DangerousCommandsService ds,
IGamblingCleanupService gcs,
IXpCleanupService xcs)
{
_ds = ds;
_gcs = gcs;
_xcs = xcs;
}
[Cmd]
[OwnerOnly]
public Task SqlSelect([Leftover] string sql)
{
var result = _ds.SelectSql(sql);
return Response()
.Paginated()
.Items(result.Results)
.PageSize(20)
.Page((items, _) =>
{
if (!items.Any())
return _sender.CreateEmbed().WithErrorColor().WithFooter(sql).WithDescription("-");
return _sender.CreateEmbed()
.WithOkColor()
.WithFooter(sql)
.WithTitle(string.Join(" ║ ", result.ColumnNames))
.WithDescription(string.Join('\n', items.Select(x => string.Join(" ║ ", x))));
})
.SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task SqlSelectCsv([Leftover] string sql)
{
var result = _ds.SelectSql(sql);
// create a file stream and write the data as csv
using var ms = new MemoryStream();
await using var sw = new StreamWriter(ms);
await using var csv = new CsvWriter(sw,
new CsvConfiguration(CultureInfo.InvariantCulture)
{
Delimiter = ","
});
foreach (var cn in result.ColumnNames)
{
csv.WriteField(cn);
}
await csv.NextRecordAsync();
foreach (var row in result.Results)
{
foreach (var field in row)
{
csv.WriteField(field);
}
await csv.NextRecordAsync();
}
await csv.FlushAsync();
ms.Position = 0;
// send the file
await ctx.Channel.SendFileAsync(ms, $"query_result_{DateTime.UtcNow.Ticks}.csv");
}
[Cmd]
[OwnerOnly]
public async Task SqlExec([Leftover] string sql)
{
try
{
var embed = _sender.CreateEmbed()
.WithTitle(GetText(strs.sql_confirm_exec))
.WithDescription(Format.Code(sql));
if (!await PromptUserConfirmAsync(embed))
return;
var res = await _ds.ExecuteSql(sql);
await Response().Confirm(res.ToString()).SendAsync();
}
catch (Exception ex)
{
await Response().Error(ex.ToString()).SendAsync();
}
}
[Cmd]
[OwnerOnly]
public async Task PurgeUser(ulong userId)
{
var embed = _sender.CreateEmbed()
.WithDescription(GetText(strs.purge_user_confirm(Format.Bold(userId.ToString()))));
if (!await PromptUserConfirmAsync(embed))
return;
await _ds.PurgeUserAsync(userId);
await ctx.OkAsync();
}
[Cmd]
[OwnerOnly]
public Task PurgeUser([Leftover] IUser user)
=> PurgeUser(user.Id);
[Cmd]
[OwnerOnly]
public Task DeleteXp()
=> ConfirmActionInternalAsync("Delete Xp", () => _xcs.DeleteXp());
[Cmd]
[OwnerOnly]
public Task DeleteWaifus()
=> ConfirmActionInternalAsync("Delete Waifus", () => _gcs.DeleteWaifus());
[Cmd]
[OwnerOnly]
public async Task DeleteWaifu(IUser user)
=> await DeleteWaifu(user.Id);
[Cmd]
[OwnerOnly]
public Task DeleteWaifu(ulong userId)
=> ConfirmActionInternalAsync($"Delete Waifu {userId}", () => _gcs.DeleteWaifu(userId));
[Cmd]
[OwnerOnly]
public Task DeleteCurrency()
=> ConfirmActionInternalAsync("Delete Currency", () => _gcs.DeleteCurrency());
}
}

View file

@ -0,0 +1,103 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration.Services;
public class DangerousCommandsService : IEService
{
private readonly DbService _db;
public DangerousCommandsService(DbService db)
=> _db = db;
public async Task<int> ExecuteSql(string sql)
{
int res;
await using var uow = _db.GetDbContext();
res = await uow.Database.ExecuteSqlRawAsync(sql);
return res;
}
public SelectResult SelectSql(string sql)
{
var result = new SelectResult
{
ColumnNames = new(),
Results = new()
};
using var uow = _db.GetDbContext();
var conn = uow.Database.GetDbConnection();
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
using var reader = cmd.ExecuteReader();
if (reader.HasRows)
{
for (var i = 0; i < reader.FieldCount; i++)
result.ColumnNames.Add(reader.GetName(i));
while (reader.Read())
{
var obj = new object[reader.FieldCount];
reader.GetValues(obj);
result.Results.Add(obj.Select(x => x.ToString()).ToArray());
}
}
return result;
}
public async Task PurgeUserAsync(ulong userId)
{
await using var uow = _db.GetDbContext();
// get waifu info
var wi = await uow.Set<WaifuInfo>().FirstOrDefaultAsyncEF(x => x.Waifu.UserId == userId);
// if it exists, delete waifu related things
if (wi is not null)
{
// remove updates which have new or old as this waifu
await uow.Set<WaifuUpdate>().DeleteAsync(wu => wu.New.UserId == userId || wu.Old.UserId == userId);
// delete all items this waifu owns
await uow.Set<WaifuItem>().DeleteAsync(x => x.WaifuInfoId == wi.Id);
// all waifus this waifu claims are released
await uow.Set<WaifuInfo>()
.AsQueryable()
.Where(x => x.Claimer.UserId == userId)
.UpdateAsync(x => new()
{
ClaimerId = null
});
// all affinities set to this waifu are reset
await uow.Set<WaifuInfo>()
.AsQueryable()
.Where(x => x.Affinity.UserId == userId)
.UpdateAsync(x => new()
{
AffinityId = null
});
}
// delete guild xp
await uow.Set<UserXpStats>().DeleteAsync(x => x.UserId == userId);
// delete currency transactions
await uow.Set<CurrencyTransaction>().DeleteAsync(x => x.UserId == userId);
// delete user, currency, and clubs go away with it
await uow.Set<DiscordUser>().DeleteAsync(u => u.UserId == userId);
}
public class SelectResult
{
public List<string> ColumnNames { get; set; }
public List<string[]> Results { get; set; }
}
}

View file

@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace EllieBot.Modules.Administration.DangerousCommands;
public sealed class CleanupId
{
[Key]
public ulong GuildId { get; set; }
}

View file

@ -0,0 +1,9 @@
namespace EllieBot.Modules.Administration.DangerousCommands;
public interface ICleanupService
{
Task<KeepResult?> DeleteMissingGuildDataAsync();
Task<bool> KeepGuild(ulong guildId);
Task<int> GetKeptGuildCount();
Task StartLeavingUnkeptServers(int shardId);
}

View file

@ -0,0 +1,7 @@
namespace EllieBot.Modules.Administration.DangerousCommands;
public sealed class KeepReport
{
public required int ShardId { get; init; }
public required ulong[] GuildIds { get; init; }
}

View file

@ -0,0 +1,6 @@
namespace EllieBot.Modules.Administration.DangerousCommands;
public sealed class KeepResult
{
public required int GuildCount { get; init; }
}

View file

@ -0,0 +1,36 @@
#nullable disable
using EllieBot.Modules.Administration.Services;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class GameVoiceChannelCommands : EllieModule<GameVoiceChannelService>
{
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[BotPerm(GuildPerm.MoveMembers)]
public async Task GameVoiceChannel()
{
var vch = ((IGuildUser)ctx.User).VoiceChannel;
if (vch is null)
{
await Response().Error(strs.not_in_voice).SendAsync();
return;
}
var id = _service.ToggleGameVoiceChannel(ctx.Guild.Id, vch.Id);
if (id is null)
await Response().Confirm(strs.gvc_disabled).SendAsync();
else
{
_service.GameVoiceChannels.Add(vch.Id);
await Response().Confirm(strs.gvc_enabled(Format.Bold(vch.Name))).SendAsync();
}
}
}
}

View file

@ -0,0 +1,125 @@
#nullable disable
namespace EllieBot.Modules.Administration.Services;
public class GameVoiceChannelService : IEService
{
public ConcurrentHashSet<ulong> GameVoiceChannels { get; }
private readonly DbService _db;
private readonly DiscordSocketClient _client;
public GameVoiceChannelService(DiscordSocketClient client, DbService db, IBot bot)
{
_db = db;
_client = client;
GameVoiceChannels = new(bot.AllGuildConfigs
.Where(gc => gc.GameVoiceChannel is not null)
.Select(gc => gc.GameVoiceChannel!.Value));
_client.UserVoiceStateUpdated += OnUserVoiceStateUpdated;
_client.PresenceUpdated += OnPresenceUpdate;
}
private Task OnPresenceUpdate(SocketUser socketUser, SocketPresence before, SocketPresence after)
{
_ = Task.Run(async () =>
{
try
{
if (socketUser is not SocketGuildUser newUser)
return;
// if the user is in the voice channel and that voice channel is gvc
if (newUser.VoiceChannel is not { } vc
|| !GameVoiceChannels.Contains(vc.Id))
return;
//if the activity has changed, and is a playi1ng activity
foreach (var activity in after.Activities)
{
if (activity is { Type: ActivityType.Playing })
//trigger gvc
{
if (await TriggerGvc(newUser, activity.Name))
return;
}
}
}
catch (Exception ex)
{
Log.Warning(ex, "Error running GuildMemberUpdated in gvc");
}
});
return Task.CompletedTask;
}
public ulong? ToggleGameVoiceChannel(ulong guildId, ulong vchId)
{
ulong? id;
using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, set => set);
if (gc.GameVoiceChannel == vchId)
{
GameVoiceChannels.TryRemove(vchId);
id = gc.GameVoiceChannel = null;
}
else
{
if (gc.GameVoiceChannel is not null)
GameVoiceChannels.TryRemove(gc.GameVoiceChannel.Value);
GameVoiceChannels.Add(vchId);
id = gc.GameVoiceChannel = vchId;
}
uow.SaveChanges();
return id;
}
private Task OnUserVoiceStateUpdated(SocketUser usr, SocketVoiceState oldState, SocketVoiceState newState)
{
_ = Task.Run(async () =>
{
try
{
if (usr is not SocketGuildUser gUser)
return;
if (newState.VoiceChannel is null)
return;
if (!GameVoiceChannels.Contains(newState.VoiceChannel.Id))
return;
foreach (var game in gUser.Activities.Select(x => x.Name))
{
if (await TriggerGvc(gUser, game))
return;
}
}
catch (Exception ex)
{
Log.Warning(ex, "Error running VoiceStateUpdate in gvc");
}
});
return Task.CompletedTask;
}
private async Task<bool> TriggerGvc(SocketGuildUser gUser, string game)
{
if (string.IsNullOrWhiteSpace(game))
return false;
game = game.TrimTo(50)!.ToLowerInvariant();
var vch = gUser.Guild.VoiceChannels.FirstOrDefault(x => x.Name.ToLowerInvariant() == game);
if (vch is null)
return false;
await Task.Delay(1000);
await gUser.ModifyAsync(gu => gu.Channel = vch);
return true;
}
}

View file

@ -0,0 +1,226 @@
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class GreetCommands : EllieModule<GreetService>
{
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
public Task Boost()
=> Toggle(GreetType.Boost);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
public Task BoostDel(int timer = 30)
=> SetDel(GreetType.Boost, timer);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
public Task BoostMsg([Leftover] string? text = null)
=> SetMsg(GreetType.Boost, text);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
public Task Greet()
=> Toggle(GreetType.Greet);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
public Task GreetDel(int timer = 30)
=> SetDel(GreetType.Greet, timer);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
public Task GreetMsg([Leftover] string? text = null)
=> SetMsg(GreetType.Greet, text);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
public Task GreetDm()
=> Toggle(GreetType.GreetDm);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
public Task GreetDmMsg([Leftover] string? text = null)
=> SetMsg(GreetType.GreetDm, text);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
public Task Bye()
=> Toggle(GreetType.Bye);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
public Task ByeDel(int timer = 30)
=> SetDel(GreetType.Bye, timer);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
public Task ByeMsg([Leftover] string? text = null)
=> SetMsg(GreetType.Bye, text);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
public Task GreetTest([Leftover] IGuildUser? user = null)
=> Test(GreetType.Greet, user);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
public Task GreetDmTest([Leftover] IGuildUser? user = null)
=> Test(GreetType.GreetDm, user);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
[Ratelimit(5)]
public Task ByeTest([Leftover] IGuildUser? user = null)
=> Test(GreetType.Bye, user);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageGuild)]
[Ratelimit(5)]
public Task BoostTest([Leftover] IGuildUser? user = null)
=> Test(GreetType.Boost, user);
public async Task Toggle(GreetType type)
{
var enabled = await _service.SetGreet(ctx.Guild.Id, ctx.Channel.Id, type);
if (enabled)
await Response()
.Confirm(
type switch
{
GreetType.Boost => strs.boost_on,
GreetType.Greet => strs.greet_on,
GreetType.Bye => strs.bye_on,
GreetType.GreetDm => strs.greetdm_on,
_ => strs.error
}
)
.SendAsync();
else
await Response()
.Pending(
type switch
{
GreetType.Boost => strs.boost_off,
GreetType.Greet => strs.greet_off,
GreetType.Bye => strs.bye_off,
GreetType.GreetDm => strs.greetdm_off,
_ => strs.error
}
)
.SendAsync();
}
public async Task SetDel(GreetType type, int timer)
{
if (timer is < 0 or > 600)
return;
await _service.SetDeleteTimer(ctx.Guild.Id, type, timer);
if (timer > 0)
await Response()
.Confirm(
type switch
{
GreetType.Boost => strs.boostdel_on(timer),
GreetType.Greet => strs.greetdel_on(timer),
GreetType.Bye => strs.byedel_on(timer),
_ => strs.error
}
)
.SendAsync();
else
await Response()
.Pending(
type switch
{
GreetType.Boost => strs.boostdel_off,
GreetType.Greet => strs.greetdel_off,
GreetType.Bye => strs.byedel_off,
_ => strs.error
})
.SendAsync();
}
public async Task SetMsg(GreetType type, string? text = null)
{
if (string.IsNullOrWhiteSpace(text))
{
var conf = await _service.GetGreetSettingsAsync(ctx.Guild.Id, type);
var msg = conf?.MessageText ?? GreetService.GetDefaultGreet(type);
await Response()
.Confirm(
type switch
{
GreetType.Boost => strs.boostmsg_cur(msg),
GreetType.Greet => strs.greetmsg_cur(msg),
GreetType.Bye => strs.byemsg_cur(msg),
GreetType.GreetDm => strs.greetdmmsg_cur(msg),
_ => strs.error
})
.SendAsync();
return;
}
var isEnabled = await _service.SetMessage(ctx.Guild.Id, type, text);
await Response()
.Confirm(type switch
{
GreetType.Boost => strs.boostmsg_new,
GreetType.Greet => strs.greetmsg_new,
GreetType.Bye => strs.byemsg_new,
GreetType.GreetDm => strs.greetdmmsg_new,
_ => strs.error
})
.SendAsync();
if (!isEnabled)
{
var cmdName = type switch
{
GreetType.Greet => "greet",
GreetType.Bye => "bye",
GreetType.Boost => "boost",
GreetType.GreetDm => "greetdm",
_ => "unknown_command"
};
await Response().Pending(strs.boostmsg_enable($"`{prefix}{cmdName}`")).SendAsync();
}
}
public async Task Test(GreetType type, IGuildUser? user = null)
{
user ??= (IGuildUser)ctx.User;
await _service.Test(ctx.Guild.Id, type, (ITextChannel)ctx.Channel, user);
var conf = await _service.GetGreetSettingsAsync(ctx.Guild.Id, type);
if (conf?.IsEnabled is not true)
await Response().Pending(strs.boostmsg_enable($"`{prefix}boost`")).SendAsync();
}
}
}

View file

@ -0,0 +1,480 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using LinqToDB.Tools;
using EllieBot.Common.ModuleBehaviors;
using System.Threading.Channels;
namespace EllieBot.Services;
public class GreetService : IEService, IReadyExecutor
{
private readonly DbService _db;
private ConcurrentDictionary<GreetType, ConcurrentHashSet<ulong>> _enabled = new();
private readonly DiscordSocketClient _client;
private readonly IReplacementService _repSvc;
private readonly IBotCache _cache;
private readonly IMessageSenderService _sender;
private readonly Channel<(GreetSettings, IUser, ITextChannel?)> _greetQueue =
Channel.CreateBounded<(GreetSettings, IUser, ITextChannel?)>(
new BoundedChannelOptions(60)
{
FullMode = BoundedChannelFullMode.DropOldest
});
public GreetService(
DiscordSocketClient client,
DbService db,
IMessageSenderService sender,
IReplacementService repSvc,
IBotCache cache
)
{
_db = db;
_client = client;
_repSvc = repSvc;
_cache = cache;
_sender = sender;
foreach (var type in Enum.GetValues<GreetType>())
{
_enabled[type] = new();
}
}
public async Task OnReadyAsync()
{
// cache all enabled guilds
await using (var uow = _db.GetDbContext())
{
var guilds = _client.Guilds.Select(x => x.Id).ToList();
var enabled = await uow.GetTable<GreetSettings>()
.Where(x => x.GuildId.In(guilds))
.Where(x => x.IsEnabled)
.Select(x => new
{
x.GuildId,
x.GreetType
})
.ToListAsync();
foreach (var e in enabled)
{
_enabled[e.GreetType].Add(e.GuildId);
}
}
_client.UserJoined += OnUserJoined;
_client.UserLeft += OnUserLeft;
_client.LeftGuild += OnClientLeftGuild;
_client.GuildMemberUpdated += ClientOnGuildMemberUpdated;
var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
while (await timer.WaitForNextTickAsync())
{
var (conf, user, ch) = await _greetQueue.Reader.ReadAsync();
await GreetUsers(conf, ch, user);
}
}
private Task ClientOnGuildMemberUpdated(Cacheable<SocketGuildUser, ulong> optOldUser, SocketGuildUser newUser)
{
// if user is a new booster
// or boosted again the same server
if ((optOldUser.Value is { PremiumSince: null } && newUser is { PremiumSince: not null })
|| (optOldUser.Value?.PremiumSince is { } oldDate
&& newUser.PremiumSince is { } newDate
&& newDate > oldDate))
{
_ = Task.Run(async () =>
{
var conf = await GetGreetSettingsAsync(newUser.Guild.Id, GreetType.Boost);
if (conf is null || !conf.IsEnabled)
return;
ITextChannel? channel = null;
if (conf.ChannelId is { } cid)
channel = newUser.Guild.GetTextChannel(cid);
if (channel is null)
return;
await GreetUsers(conf, channel, newUser);
});
}
return Task.CompletedTask;
}
private async Task OnClientLeftGuild(SocketGuild guild)
{
foreach (var gt in Enum.GetValues<GreetType>())
{
_enabled[gt].TryRemove(guild.Id);
}
await using var uow = _db.GetDbContext();
await uow.GetTable<GreetSettings>()
.Where(x => x.GuildId == guild.Id)
.DeleteAsync();
}
private Task OnUserLeft(SocketGuild guild, SocketUser user)
{
_ = Task.Run(async () =>
{
try
{
var conf = await GetGreetSettingsAsync(guild.Id, GreetType.Bye);
if (conf is null)
return;
var channel = guild.TextChannels.FirstOrDefault(c => c.Id == conf.ChannelId);
if (channel is null) //maybe warn the server owner that the channel is missing
{
await SetGreet(guild.Id, null, GreetType.Bye, false);
return;
}
await _greetQueue.Writer.WriteAsync((conf, user, channel));
}
catch
{
// ignored
}
});
return Task.CompletedTask;
}
private readonly TypedKey<GreetSettings?> _greetSettingsKey = new("greet_settings");
public async Task<GreetSettings?> GetGreetSettingsAsync(ulong gid, GreetType type)
=> await _cache.GetOrAddAsync<GreetSettings?>(_greetSettingsKey,
() => InternalGetGreetSettingsAsync(gid, type),
TimeSpan.FromSeconds(3));
private async Task<GreetSettings?> InternalGetGreetSettingsAsync(ulong gid, GreetType type)
{
await using var uow = _db.GetDbContext();
var res = await uow.GetTable<GreetSettings>()
.Where(x => x.GuildId == gid && x.GreetType == type)
.FirstOrDefaultAsync();
if (res is not null)
res.MessageText ??= GetDefaultGreet(type);
return res;
}
private async Task GreetUsers(GreetSettings conf, ITextChannel? channel, IUser user)
{
if (conf.GreetType == GreetType.GreetDm)
{
if (user is not IGuildUser gu)
return;
await GreetDmUserInternal(conf, gu);
return;
}
if (channel is null)
return;
var repCtx = new ReplacementContext(client: _client,
guild: channel.Guild,
channel: channel,
user: user);
var text = SmartText.CreateFrom(conf.MessageText);
text = await _repSvc.ReplaceAsync(text, repCtx);
try
{
var toDelete = await _sender.Response(channel).Text(text).Sanitize(false).SendAsync();
if (conf.AutoDeleteTimer > 0)
toDelete.DeleteAfter(conf.AutoDeleteTimer);
}
catch (HttpException ex) when (ex.DiscordCode is DiscordErrorCode.InsufficientPermissions
or DiscordErrorCode.MissingPermissions
or DiscordErrorCode.UnknownChannel)
{
Log.Warning(ex,
"Missing permissions to send a bye message, the greet message will be disabled on server: {GuildId}",
channel.GuildId);
await SetGreet(channel.GuildId, channel.Id, GreetType.Greet, false);
}
catch (Exception ex)
{
Log.Warning(ex, "Error embeding greet message");
}
}
private async Task<bool> GreetDmUser(GreetSettings conf, IGuildUser user)
{
var completionSource = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
await _greetQueue.Writer.WriteAsync((conf, user, null));
return await completionSource.Task;
}
private async Task<bool> GreetDmUserInternal(GreetSettings conf, IGuildUser user)
{
try
{
var repCtx = new ReplacementContext(client: _client, guild: user.Guild, user: user);
var smartText = SmartText.CreateFrom(conf.MessageText);
smartText = await _repSvc.ReplaceAsync(smartText, repCtx);
if (smartText is SmartPlainText pt)
{
smartText = new SmartEmbedText()
{
Description = pt.Text
};
}
if (smartText is SmartEmbedText set)
{
smartText = set with
{
Footer = CreateFooterSource(user)
};
}
else if (smartText is SmartEmbedTextArray seta)
{
// if the greet dm message is a text array
var ebElem = seta.Embeds.LastOrDefault();
if (ebElem is null)
{
// if there are no embeds, add an embed with the footer
smartText = seta with
{
Embeds =
[
new SmartEmbedArrayElementText()
{
Footer = CreateFooterSource(user)
}
]
};
}
else
{
// if the maximum amount of embeds is reached, edit the last embed
if (seta.Embeds.Length >= 10)
{
seta.Embeds[^1] = seta.Embeds[^1] with
{
Footer = CreateFooterSource(user)
};
}
else
{
// if there is less than 10 embeds, add an embed with footer only
seta.Embeds = seta.Embeds.Append(new SmartEmbedArrayElementText()
{
Footer = CreateFooterSource(user)
})
.ToArray();
}
}
}
await _sender.Response(user).Text(smartText).Sanitize(false).SendAsync();
}
catch
{
return false;
}
return true;
}
private static SmartTextEmbedFooter CreateFooterSource(IGuildUser user)
=> new()
{
Text = $"This message was sent from {user.Guild} server.",
IconUrl = user.Guild.IconUrl
};
private Task OnUserJoined(IGuildUser user)
{
_ = Task.Run(async () =>
{
try
{
var conf = await GetGreetSettingsAsync(user.GuildId, GreetType.Greet);
if (conf is not null && conf.IsEnabled && conf.ChannelId is { } channelId)
{
var channel = await user.Guild.GetTextChannelAsync(channelId);
if (channel is not null)
{
await _greetQueue.Writer.WriteAsync((conf, user, channel));
}
}
var confDm = await GetGreetSettingsAsync(user.GuildId, GreetType.GreetDm);
if (confDm?.IsEnabled ?? false)
await GreetDmUser(confDm, user);
}
catch
{
// ignored
}
});
return Task.CompletedTask;
}
public static string GetDefaultGreet(GreetType greetType)
=> greetType switch
{
GreetType.Boost => "%user.mention% has boosted the server!",
GreetType.Greet => "%user.mention% has joined the server!",
GreetType.Bye => "%user.name% has left the server!",
GreetType.GreetDm => "Welcome to the server %user.name%",
_ => "%user.name% did something new!"
};
public async Task<bool> SetGreet(
ulong guildId,
ulong? channelId,
GreetType greetType,
bool? value = null)
{
await using var uow = _db.GetDbContext();
var q = uow.GetTable<GreetSettings>();
if(value is null)
value = !_enabled[greetType].Contains(guildId);
if (value is { } v)
{
await q
.InsertOrUpdateAsync(() => new()
{
GuildId = guildId,
GreetType = greetType,
IsEnabled = v,
ChannelId = channelId,
},
(old) => new()
{
IsEnabled = v,
ChannelId = channelId,
},
() => new()
{
GuildId = guildId,
GreetType = greetType,
});
}
if (value is true)
{
_enabled[greetType].Add(guildId);
return true;
}
_enabled[greetType].TryRemove(guildId);
return false;
}
public async Task<bool> SetMessage(ulong guildId, GreetType greetType, string? message)
{
await using (var uow = _db.GetDbContext())
{
await uow.GetTable<GreetSettings>()
.InsertOrUpdateAsync(() => new()
{
GuildId = guildId,
GreetType = greetType,
MessageText = message
},
x => new()
{
MessageText = message
},
() => new()
{
GuildId = guildId,
GreetType = greetType
});
}
var conf = await GetGreetSettingsAsync(guildId, greetType);
return conf?.IsEnabled ?? false;
}
public async Task<bool> SetDeleteTimer(ulong guildId, GreetType greetType, int timer)
{
if (timer < 0 || timer > 3600)
throw new ArgumentOutOfRangeException(nameof(timer));
await using (var uow = _db.GetDbContext())
{
await uow.GetTable<GreetSettings>()
.InsertOrUpdateAsync(() => new()
{
GuildId = guildId,
GreetType = greetType,
AutoDeleteTimer = timer,
},
x => new()
{
AutoDeleteTimer = timer
},
() => new()
{
GuildId = guildId,
GreetType = greetType
});
}
var conf = await GetGreetSettingsAsync(guildId, greetType);
return conf?.IsEnabled ?? false;
}
public async Task<bool> Test(
ulong guildId,
GreetType type,
IMessageChannel channel,
IGuildUser user)
{
var conf = await GetGreetSettingsAsync(guildId, type);
if (conf is null)
return false;
await SendMessage(conf, channel, user);
return true;
}
public async Task<bool> SendMessage(GreetSettings conf, IMessageChannel channel, IGuildUser user)
{
if (conf.GreetType == GreetType.GreetDm)
{
await _greetQueue.Writer.WriteAsync((conf, user, channel as ITextChannel));
return await GreetDmUser(conf, user);
}
if (channel is not ITextChannel ch)
return false;
await GreetUsers(conf, ch, user);
return true;
}
}

View file

@ -0,0 +1,21 @@
namespace EllieBot.Services;
public enum GreetType
{
Greet,
GreetDm,
Bye,
Boost,
}
public class GreetSettings
{
public int Id { get; set; }
public ulong GuildId { get; set; }
public GreetType GreetType { get; set; }
public string? MessageText { get; set; }
public bool IsEnabled { get; set; }
public ulong? ChannelId { get; set; }
public int AutoDeleteTimer { get; set; }
}

View file

@ -0,0 +1,95 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using System.Threading.Channels;
namespace EllieBot.Modules.Administration.Honeypot;
public sealed class HoneyPotService : IHoneyPotService, IReadyExecutor, IExecNoCommand, IEService
{
private readonly DbService _db;
private readonly CommandHandler _handler;
private ConcurrentHashSet<ulong> _channels = new();
private Channel<SocketGuildUser> _punishments = Channel.CreateBounded<SocketGuildUser>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false,
});
public HoneyPotService(DbService db, CommandHandler handler)
{
_db = db;
_handler = handler;
}
public async Task<bool> ToggleHoneypotChannel(ulong guildId, ulong channelId)
{
await using var uow = _db.GetDbContext();
var deleted = await uow.HoneyPotChannels
.Where(x => x.GuildId == guildId)
.DeleteWithOutputAsync();
if (deleted.Length > 0)
{
_channels.TryRemove(deleted[0].ChannelId);
return false;
}
await uow.HoneyPotChannels
.ToLinqToDBTable()
.InsertAsync(() => new HoneypotChannel
{
GuildId = guildId,
ChannelId = channelId
});
_channels.Add(channelId);
return true;
}
public async Task OnReadyAsync()
{
await using var uow = _db.GetDbContext();
var channels = await uow.HoneyPotChannels
.Select(x => x.ChannelId)
.ToListAsyncLinqToDB();
_channels = new(channels);
while (await _punishments.Reader.WaitToReadAsync())
{
while (_punishments.Reader.TryRead(out var user))
{
try
{
Log.Information("Honeypot caught user {User} [{UserId}]", user, user.Id);
await user.BanAsync(pruneDays: 1);
await user.Guild.RemoveBanAsync(user.Id);
}
catch (Exception e)
{
Log.Warning(e, "Failed banning {User} due to {Error}", user, e.Message);
}
await Task.Delay(1000);
}
}
}
public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
{
if (_channels.Contains(msg.Channel.Id) && msg.Author is SocketGuildUser sgu)
{
if (!sgu.GuildPermissions.BanMembers)
await _punishments.Writer.WriteAsync(sgu);
}
}
}

View file

@ -0,0 +1,29 @@
using EllieBot.Modules.Administration.Honeypot;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class HoneypotCommands : EllieModule
{
private readonly IHoneyPotService _service;
public HoneypotCommands(IHoneyPotService service)
=> _service = service;
[Cmd]
[RequireContext(ContextType.Guild)]
[RequireUserPermission(GuildPermission.Administrator)]
[RequireBotPermission(GuildPermission.BanMembers)]
public async Task Honeypot()
{
var enabled = await _service.ToggleHoneypotChannel(ctx.Guild.Id, ctx.Channel.Id);
if (enabled)
await Response().Confirm(strs.honeypot_on).SendAsync();
else
await Response().Confirm(strs.honeypot_off).SendAsync();
}
}
}

View file

@ -0,0 +1,6 @@
namespace EllieBot.Modules.Administration.Honeypot;
public interface IHoneyPotService
{
public Task<bool> ToggleHoneypotChannel(ulong guildId, ulong channelId);
}

View file

@ -0,0 +1,235 @@
#nullable disable
using LinqToDB;
using Microsoft.Extensions.Caching.Memory;
using EllieBot.Common.ModuleBehaviors;
using System.Net;
using System.Threading.Channels;
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration.Services;
public sealed class SomethingOnlyChannelService : IExecOnMessage
{
public int Priority { get; } = 0;
private readonly IMemoryCache _ticketCache;
private readonly DiscordSocketClient _client;
private readonly DbService _db;
private readonly ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> _imageOnly;
private readonly ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> _linkOnly;
private readonly Channel<IUserMessage> _deleteQueue = Channel.CreateBounded<IUserMessage>(
new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false
});
public SomethingOnlyChannelService(IMemoryCache ticketCache, DiscordSocketClient client, DbService db)
{
_ticketCache = ticketCache;
_client = client;
_db = db;
using var uow = _db.GetDbContext();
_imageOnly = uow.Set<ImageOnlyChannel>()
.Where(x => x.Type == OnlyChannelType.Image)
.ToList()
.GroupBy(x => x.GuildId)
.ToDictionary(x => x.Key, x => new ConcurrentHashSet<ulong>(x.Select(y => y.ChannelId)))
.ToConcurrent();
_linkOnly = uow.Set<ImageOnlyChannel>()
.Where(x => x.Type == OnlyChannelType.Link)
.ToList()
.GroupBy(x => x.GuildId)
.ToDictionary(x => x.Key, x => new ConcurrentHashSet<ulong>(x.Select(y => y.ChannelId)))
.ToConcurrent();
_ = Task.Run(DeleteQueueRunner);
_client.ChannelDestroyed += ClientOnChannelDestroyed;
}
private async Task ClientOnChannelDestroyed(SocketChannel ch)
{
if (ch is not IGuildChannel gch)
return;
if (_imageOnly.TryGetValue(gch.GuildId, out var channels) && channels.TryRemove(ch.Id))
await ToggleImageOnlyChannelAsync(gch.GuildId, ch.Id, true);
}
private async Task DeleteQueueRunner()
{
while (true)
{
var toDelete = await _deleteQueue.Reader.ReadAsync();
try
{
await toDelete.DeleteAsync();
await Task.Delay(1000);
}
catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden)
{
// disable if bot can't delete messages in the channel
await ToggleImageOnlyChannelAsync(((ITextChannel)toDelete.Channel).GuildId, toDelete.Channel.Id, true);
}
}
}
public async Task<bool> ToggleImageOnlyChannelAsync(ulong guildId, ulong channelId, bool forceDisable = false)
{
var newState = false;
await using var uow = _db.GetDbContext();
if (forceDisable || (_imageOnly.TryGetValue(guildId, out var channels) && channels.TryRemove(channelId)))
{
await uow.Set<ImageOnlyChannel>().DeleteAsync(x => x.ChannelId == channelId && x.Type == OnlyChannelType.Image);
}
else
{
await uow.Set<ImageOnlyChannel>().DeleteAsync(x => x.ChannelId == channelId);
uow.Set<ImageOnlyChannel>().Add(new()
{
GuildId = guildId,
ChannelId = channelId,
Type = OnlyChannelType.Image
});
if (_linkOnly.TryGetValue(guildId, out var chs))
chs.TryRemove(channelId);
channels = _imageOnly.GetOrAdd(guildId, new ConcurrentHashSet<ulong>());
channels.Add(channelId);
newState = true;
}
await uow.SaveChangesAsync();
return newState;
}
public async Task<bool> ToggleLinkOnlyChannelAsync(ulong guildId, ulong channelId, bool forceDisable = false)
{
var newState = false;
await using var uow = _db.GetDbContext();
if (forceDisable || (_linkOnly.TryGetValue(guildId, out var channels) && channels.TryRemove(channelId)))
{
await uow.Set<ImageOnlyChannel>().DeleteAsync(x => x.ChannelId == channelId && x.Type == OnlyChannelType.Link);
}
else
{
await uow.Set<ImageOnlyChannel>().DeleteAsync(x => x.ChannelId == channelId);
uow.Set<ImageOnlyChannel>().Add(new()
{
GuildId = guildId,
ChannelId = channelId,
Type = OnlyChannelType.Link
});
if (_imageOnly.TryGetValue(guildId, out var chs))
chs.TryRemove(channelId);
channels = _linkOnly.GetOrAdd(guildId, new ConcurrentHashSet<ulong>());
channels.Add(channelId);
newState = true;
}
await uow.SaveChangesAsync();
return newState;
}
public async Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage msg)
{
if (msg.Channel is not ITextChannel tch)
return false;
if (_imageOnly.TryGetValue(tch.GuildId, out var chs) && chs.Contains(msg.Channel.Id))
return await HandleOnlyChannel(tch, msg, OnlyChannelType.Image);
if (_linkOnly.TryGetValue(tch.GuildId, out chs) && chs.Contains(msg.Channel.Id))
return await HandleOnlyChannel(tch, msg, OnlyChannelType.Link);
return false;
}
private async Task<bool> HandleOnlyChannel(ITextChannel tch, IUserMessage msg, OnlyChannelType type)
{
if (type == OnlyChannelType.Image)
{
if (msg.Attachments.Any(x => x is { Height: > 0, Width: > 0 }))
return false;
}
else
{
if (msg.Content.TryGetUrlPath(out _))
return false;
}
var user = await tch.Guild.GetUserAsync(msg.Author.Id)
?? await _client.Rest.GetGuildUserAsync(tch.GuildId, msg.Author.Id);
if (user is null)
return false;
// ignore owner and admin
if (user.Id == tch.Guild.OwnerId || user.GuildPermissions.Administrator)
{
Log.Information("{Type}-Only Channel: Ignoring owner or admin ({ChannelId})", type, msg.Channel.Id);
return false;
}
// ignore users higher in hierarchy
var botUser = await tch.Guild.GetCurrentUserAsync();
if (user.GetRoles().Max(x => x.Position) >= botUser.GetRoles().Max(x => x.Position))
return false;
if (!botUser.GetPermissions(tch).ManageChannel)
{
if(type == OnlyChannelType.Image)
await ToggleImageOnlyChannelAsync(tch.GuildId, tch.Id, true);
else
await ToggleImageOnlyChannelAsync(tch.GuildId, tch.Id, true);
return false;
}
var shouldLock = AddUserTicket(tch.GuildId, msg.Author.Id);
if (shouldLock)
{
await tch.AddPermissionOverwriteAsync(msg.Author, new(sendMessages: PermValue.Deny));
Log.Warning("{Type}-Only Channel: User {User} [{UserId}] has been banned from typing in the channel [{ChannelId}]",
type,
msg.Author,
msg.Author.Id,
msg.Channel.Id);
}
try
{
await _deleteQueue.Writer.WriteAsync(msg);
}
catch (Exception ex)
{
Log.Error(ex, "Error deleting message {MessageId} in image-only channel {ChannelId}", msg.Id, tch.Id);
}
return true;
}
private bool AddUserTicket(ulong guildId, ulong userId)
{
var old = _ticketCache.GetOrCreate($"{guildId}_{userId}",
entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1);
return 0;
});
_ticketCache.Set($"{guildId}_{userId}", ++old);
// if this is the third time that the user posts a
// non image in an image-only channel on this server
return old > 2;
}
}

View file

@ -0,0 +1,264 @@
#nullable disable
using System.Globalization;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class LocalizationCommands : EllieModule
{
private static readonly IReadOnlyDictionary<string, string> _supportedLocales = new Dictionary<string, string>
{
{ "ar", "العربية" },
{ "zh-TW", "繁體中文, 台灣" },
{ "zh-CN", "简体中文, 中华人民共和国" },
{ "nl-NL", "Nederlands, Nederland" },
{ "en-US", "English, United States" },
{ "fr-FR", "Français, France" },
{ "cs-CZ", "Čeština, Česká republika" },
{ "da-DK", "Dansk, Danmark" },
{ "de-DE", "Deutsch, Deutschland" },
{ "he-IL", "עברית, ישראל" },
{ "hu-HU", "Magyar, Magyarország" },
{ "id-ID", "Bahasa Indonesia, Indonesia" },
{ "it-IT", "Italiano, Italia" },
{ "ja-JP", "日本語, 日本" },
{ "ko-KR", "한국어, 대한민국" },
{ "nb-NO", "Norsk, Norge" },
{ "pl-PL", "Polski, Polska" },
{ "pt-BR", "Português Brasileiro, Brasil" },
{ "ro-RO", "Română, România" },
{ "ru-RU", "Русский, Россия" },
{ "sr-Cyrl-RS", "Српски, Србија" },
{ "es-ES", "Español, España" },
{ "sv-SE", "Svenska, Sverige" },
{ "tr-TR", "Türkçe, Türkiye" },
{ "ts-TS", "Tsundere, You Baka" },
{ "uk-UA", "Українська, Україна" }
};
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(0)]
public async Task LanguageSet()
=> await Response().Confirm(strs.lang_set_show(Format.Bold(Culture.ToString()),
Format.Bold(Culture.NativeName))).SendAsync();
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[Priority(1)]
public async Task LanguageSet(string name)
{
try
{
CultureInfo ci;
if (name.Trim().ToLowerInvariant() == "default")
{
_localization.RemoveGuildCulture(ctx.Guild);
ci = _localization.DefaultCultureInfo;
}
else
{
ci = new CultureInfo(name);
if (!_supportedLocales.ContainsKey(ci.Name))
{
await LanguagesList();
return;
}
_localization.SetGuildCulture(ctx.Guild, ci);
}
var nativeName = ci.NativeName;
if (ci.Name == "ts-TS")
nativeName = _supportedLocales[ci.Name];
await Response().Confirm(strs.lang_set(Format.Bold(ci.ToString()), Format.Bold(nativeName))).SendAsync();
}
catch (Exception)
{
await Response().Error(strs.lang_set_fail).SendAsync();
}
}
[Cmd]
public async Task LanguageSetDefault()
{
var cul = _localization.DefaultCultureInfo;
await Response().Error(strs.lang_set_bot_show(cul, cul.NativeName)).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task LanguageSetDefault(string name)
{
try
{
CultureInfo ci;
if (name.Trim().ToLowerInvariant() == "default")
{
_localization.ResetDefaultCulture();
ci = _localization.DefaultCultureInfo;
}
else
{
ci = new CultureInfo(name);
if (!_supportedLocales.ContainsKey(ci.Name))
{
await LanguagesList();
return;
}
_localization.SetDefaultCulture(ci);
}
await Response().Confirm(strs.lang_set_bot(Format.Bold(ci.ToString()),
Format.Bold(ci.NativeName))).SendAsync();
}
catch (Exception)
{
await Response().Error(strs.lang_set_fail).SendAsync();
}
}
[Cmd]
public async Task LanguagesList()
=> await Response().Embed(_sender.CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.lang_list))
.WithDescription(string.Join("\n",
_supportedLocales.Select(
x => $"{Format.Code(x.Key),-10} => {x.Value}")))).SendAsync();
}
}
/* list of language codes for reference.
* taken from https://github.com/dotnet/coreclr/blob/ee5862c6a257e60e263537d975ab6c513179d47f/src/mscorlib/src/System/Globalization/CultureData.cs#L192
{ "029", "en-029" },
{ "AE", "ar-AE" },
{ "AF", "prs-AF" },
{ "AL", "sq-AL" },
{ "AM", "hy-AM" },
{ "AR", "es-AR" },
{ "AT", "de-AT" },
{ "AU", "en-AU" },
{ "AZ", "az-Cyrl-AZ" },
{ "BA", "bs-Latn-BA" },
{ "BD", "bn-BD" },
{ "BE", "nl-BE" },
{ "BG", "bg-BG" },
{ "BH", "ar-BH" },
{ "BN", "ms-BN" },
{ "BO", "es-BO" },
{ "BR", "pt-BR" },
{ "BY", "be-BY" },
{ "BZ", "en-BZ" },
{ "CA", "en-CA" },
{ "CH", "it-CH" },
{ "CL", "es-CL" },
{ "CN", "zh-CN" },
{ "CO", "es-CO" },
{ "CR", "es-CR" },
{ "CS", "sr-Cyrl-CS" },
{ "CZ", "cs-CZ" },
{ "DE", "de-DE" },
{ "DK", "da-DK" },
{ "DO", "es-DO" },
{ "DZ", "ar-DZ" },
{ "EC", "es-EC" },
{ "EE", "et-EE" },
{ "EG", "ar-EG" },
{ "ES", "es-ES" },
{ "ET", "am-ET" },
{ "FI", "fi-FI" },
{ "FO", "fo-FO" },
{ "FR", "fr-FR" },
{ "GB", "en-GB" },
{ "GE", "ka-GE" },
{ "GL", "kl-GL" },
{ "GR", "el-GR" },
{ "GT", "es-GT" },
{ "HK", "zh-HK" },
{ "HN", "es-HN" },
{ "HR", "hr-HR" },
{ "HU", "hu-HU" },
{ "ID", "id-ID" },
{ "IE", "en-IE" },
{ "IL", "he-IL" },
{ "IN", "hi-IN" },
{ "IQ", "ar-IQ" },
{ "IR", "fa-IR" },
{ "IS", "is-IS" },
{ "IT", "it-IT" },
{ "IV", "" },
{ "JM", "en-JM" },
{ "JO", "ar-JO" },
{ "JP", "ja-JP" },
{ "KE", "sw-KE" },
{ "KG", "ky-KG" },
{ "KH", "km-KH" },
{ "KR", "ko-KR" },
{ "KW", "ar-KW" },
{ "KZ", "kk-KZ" },
{ "LA", "lo-LA" },
{ "LB", "ar-LB" },
{ "LI", "de-LI" },
{ "LK", "si-LK" },
{ "LT", "lt-LT" },
{ "LU", "lb-LU" },
{ "LV", "lv-LV" },
{ "LY", "ar-LY" },
{ "MA", "ar-MA" },
{ "MC", "fr-MC" },
{ "ME", "sr-Latn-ME" },
{ "MK", "mk-MK" },
{ "MN", "mn-MN" },
{ "MO", "zh-MO" },
{ "MT", "mt-MT" },
{ "MV", "dv-MV" },
{ "MX", "es-MX" },
{ "MY", "ms-MY" },
{ "NG", "ig-NG" },
{ "NI", "es-NI" },
{ "NL", "nl-NL" },
{ "NO", "nn-NO" },
{ "NP", "ne-NP" },
{ "NZ", "en-NZ" },
{ "OM", "ar-OM" },
{ "PA", "es-PA" },
{ "PE", "es-PE" },
{ "PH", "en-PH" },
{ "PK", "ur-PK" },
{ "PL", "pl-PL" },
{ "PR", "es-PR" },
{ "PT", "pt-PT" },
{ "PY", "es-PY" },
{ "QA", "ar-QA" },
{ "RO", "ro-RO" },
{ "RS", "sr-Latn-RS" },
{ "RU", "ru-RU" },
{ "RW", "rw-RW" },
{ "SA", "ar-SA" },
{ "SE", "sv-SE" },
{ "SG", "zh-SG" },
{ "SI", "sl-SI" },
{ "SK", "sk-SK" },
{ "SN", "wo-SN" },
{ "SV", "es-SV" },
{ "SY", "ar-SY" },
{ "TH", "th-TH" },
{ "TJ", "tg-Cyrl-TJ" },
{ "TM", "tk-TM" },
{ "TN", "ar-TN" },
{ "TR", "tr-TR" },
{ "TT", "en-TT" },
{ "TW", "zh-TW" },
{ "UA", "uk-UA" },
{ "US", "en-US" },
{ "UY", "es-UY" },
{ "UZ", "uz-Cyrl-UZ" },
{ "VE", "es-VE" },
{ "VN", "vi-VN" },
{ "YE", "ar-YE" },
{ "ZA", "af-ZA" },
{ "ZW", "en-ZW" }
*/

View file

@ -0,0 +1,231 @@
#nullable disable
using EllieBot.Common.TypeReaders.Models;
using EllieBot.Modules.Administration.Services;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class MuteCommands : EllieModule<MuteService>
{
private async Task<bool> VerifyMutePermissions(IGuildUser runnerUser, IGuildUser targetUser)
{
var runnerUserRoles = runnerUser.GetRoles();
var targetUserRoles = targetUser.GetRoles();
if (runnerUser.Id != ctx.Guild.OwnerId
&& runnerUserRoles.Max(x => x.Position) <= targetUserRoles.Max(x => x.Position))
{
await Response().Error(strs.mute_perms).SendAsync();
return false;
}
return true;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
public async Task MuteRole([Leftover] IRole role = null)
{
if (role is null)
{
var muteRole = await _service.GetMuteRole(ctx.Guild);
await Response().Confirm(strs.mute_role(Format.Code(muteRole.Name))).SendAsync();
return;
}
if (ctx.User.Id != ctx.Guild.OwnerId
&& role.Position >= ((SocketGuildUser)ctx.User).Roles.Max(x => x.Position))
{
await Response().Error(strs.insuf_perms_u).SendAsync();
return;
}
await _service.SetMuteRoleAsync(ctx.Guild.Id, role.Name);
await Response().Confirm(strs.mute_role_set).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)]
[Priority(0)]
public async Task Mute(IGuildUser target, [Leftover] string reason = "")
{
try
{
if (!await VerifyMutePermissions((IGuildUser)ctx.User, target))
return;
await _service.MuteUser(target, ctx.User, reason: reason);
await Response().Confirm(strs.user_muted(Format.Bold(target.ToString()))).SendAsync();
}
catch (Exception ex)
{
Log.Warning(ex, "Exception in the mute command");
await Response().Error(strs.mute_error).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)]
[Priority(1)]
public async Task Mute(StoopidTime time, IGuildUser user, [Leftover] string reason = "")
{
if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49))
return;
try
{
if (!await VerifyMutePermissions((IGuildUser)ctx.User, user))
return;
await _service.TimedMute(user, ctx.User, time.Time, reason: reason);
await Response().Confirm(strs.user_muted_time(Format.Bold(user.ToString()),
(int)time.Time.TotalMinutes)).SendAsync();
}
catch (Exception ex)
{
Log.Warning(ex, "Error in mute command");
await Response().Error(strs.mute_error).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles | GuildPerm.MuteMembers)]
public async Task Unmute(IGuildUser user, [Leftover] string reason = "")
{
try
{
await _service.UnmuteUser(user.GuildId, user.Id, ctx.User, reason: reason);
await Response().Confirm(strs.user_unmuted(Format.Bold(user.ToString()))).SendAsync();
}
catch
{
await Response().Error(strs.mute_error).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[Priority(0)]
public async Task ChatMute(IGuildUser user, [Leftover] string reason = "")
{
try
{
if (!await VerifyMutePermissions((IGuildUser)ctx.User, user))
return;
await _service.MuteUser(user, ctx.User, MuteType.Chat, reason);
await Response().Confirm(strs.user_chat_mute(Format.Bold(user.ToString()))).SendAsync();
}
catch (Exception ex)
{
Log.Warning(ex, "Exception in the chatmute command");
await Response().Error(strs.mute_error).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[Priority(1)]
public async Task ChatMute(StoopidTime time, IGuildUser user, [Leftover] string reason = "")
{
if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49))
return;
try
{
if (!await VerifyMutePermissions((IGuildUser)ctx.User, user))
return;
await _service.TimedMute(user, ctx.User, time.Time, MuteType.Chat, reason);
await Response().Confirm(strs.user_chat_mute_time(Format.Bold(user.ToString()),
(int)time.Time.TotalMinutes)).SendAsync();
}
catch (Exception ex)
{
Log.Warning(ex, "Error in chatmute command");
await Response().Error(strs.mute_error).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
public async Task ChatUnmute(IGuildUser user, [Leftover] string reason = "")
{
try
{
await _service.UnmuteUser(user.Guild.Id, user.Id, ctx.User, MuteType.Chat, reason);
await Response().Confirm(strs.user_chat_unmute(Format.Bold(user.ToString()))).SendAsync();
}
catch
{
await Response().Error(strs.mute_error).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.MuteMembers)]
[Priority(0)]
public async Task VoiceMute(IGuildUser user, [Leftover] string reason = "")
{
try
{
if (!await VerifyMutePermissions((IGuildUser)ctx.User, user))
return;
await _service.MuteUser(user, ctx.User, MuteType.Voice, reason);
await Response().Confirm(strs.user_voice_mute(Format.Bold(user.ToString()))).SendAsync();
}
catch
{
await Response().Error(strs.mute_error).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.MuteMembers)]
[Priority(1)]
public async Task VoiceMute(StoopidTime time, IGuildUser user, [Leftover] string reason = "")
{
if (time.Time < TimeSpan.FromMinutes(1) || time.Time > TimeSpan.FromDays(49))
return;
try
{
if (!await VerifyMutePermissions((IGuildUser)ctx.User, user))
return;
await _service.TimedMute(user, ctx.User, time.Time, MuteType.Voice, reason);
await Response().Confirm(strs.user_voice_mute_time(Format.Bold(user.ToString()),
(int)time.Time.TotalMinutes)).SendAsync();
}
catch
{
await Response().Error(strs.mute_error).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.MuteMembers)]
public async Task VoiceUnmute(IGuildUser user, [Leftover] string reason = "")
{
try
{
await _service.UnmuteUser(user.GuildId, user.Id, ctx.User, MuteType.Voice, reason);
await Response().Confirm(strs.user_voice_unmute(Format.Bold(user.ToString()))).SendAsync();
}
catch
{
await Response().Error(strs.mute_error).SendAsync();
}
}
}
}

View file

@ -0,0 +1,503 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration.Services;
public enum MuteType
{
Voice,
Chat,
All
}
public class MuteService : IEService
{
public enum TimerType { Mute, Ban, AddRole }
private static readonly OverwritePermissions _denyOverwrite = new(addReactions: PermValue.Deny,
sendMessages: PermValue.Deny,
sendMessagesInThreads: PermValue.Deny,
attachFiles: PermValue.Deny);
public event Action<IGuildUser, IUser, MuteType, string> UserMuted = delegate { };
public event Action<IGuildUser, IUser, MuteType, string> UserUnmuted = delegate { };
public ConcurrentDictionary<ulong, string> GuildMuteRoles { get; }
public ConcurrentDictionary<ulong, ConcurrentHashSet<ulong>> MutedUsers { get; }
public ConcurrentDictionary<ulong, ConcurrentDictionary<(ulong, TimerType), Timer>> UnTimers { get; } = new();
private readonly DiscordSocketClient _client;
private readonly DbService _db;
private readonly IMessageSenderService _sender;
public MuteService(DiscordSocketClient client, DbService db, IMessageSenderService sender)
{
_client = client;
_db = db;
_sender = sender;
using (var uow = db.GetDbContext())
{
var guildIds = client.Guilds.Select(x => x.Id).ToList();
var configs = uow.Set<GuildConfig>()
.AsNoTracking()
.AsSplitQuery()
.Include(x => x.MutedUsers)
.Include(x => x.UnbanTimer)
.Include(x => x.UnmuteTimers)
.Include(x => x.UnroleTimer)
.Where(x => guildIds.Contains(x.GuildId))
.ToList();
GuildMuteRoles = configs.Where(c => !string.IsNullOrWhiteSpace(c.MuteRoleName))
.ToDictionary(c => c.GuildId, c => c.MuteRoleName)
.ToConcurrent();
MutedUsers = new(configs.ToDictionary(k => k.GuildId,
v => new ConcurrentHashSet<ulong>(v.MutedUsers.Select(m => m.UserId))));
var max = TimeSpan.FromDays(49);
foreach (var conf in configs)
{
foreach (var x in conf.UnmuteTimers)
{
TimeSpan after;
if (x.UnmuteAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow)
after = TimeSpan.FromMinutes(2);
else
{
var unmute = x.UnmuteAt - DateTime.UtcNow;
after = unmute > max ? max : unmute;
}
StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.Mute);
}
foreach (var x in conf.UnbanTimer)
{
TimeSpan after;
if (x.UnbanAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow)
after = TimeSpan.FromMinutes(2);
else
{
var unban = x.UnbanAt - DateTime.UtcNow;
after = unban > max ? max : unban;
}
StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.Ban);
}
foreach (var x in conf.UnroleTimer)
{
TimeSpan after;
if (x.UnbanAt - TimeSpan.FromMinutes(2) <= DateTime.UtcNow)
after = TimeSpan.FromMinutes(2);
else
{
var unban = x.UnbanAt - DateTime.UtcNow;
after = unban > max ? max : unban;
}
StartUn_Timer(conf.GuildId, x.UserId, after, TimerType.AddRole, x.RoleId);
}
}
_client.UserJoined += Client_UserJoined;
}
UserMuted += OnUserMuted;
UserUnmuted += OnUserUnmuted;
}
private void OnUserMuted(
IGuildUser user,
IUser mod,
MuteType type,
string reason)
{
if (string.IsNullOrWhiteSpace(reason))
return;
_ = Task.Run(() => _sender.Response(user)
.Embed(_sender.CreateEmbed()
.WithDescription($"You've been muted in {user.Guild} server")
.AddField("Mute Type", type.ToString())
.AddField("Moderator", mod.ToString())
.AddField("Reason", reason))
.SendAsync());
}
private void OnUserUnmuted(
IGuildUser user,
IUser mod,
MuteType type,
string reason)
{
if (string.IsNullOrWhiteSpace(reason))
return;
_ = Task.Run(() => _sender.Response(user)
.Embed(_sender.CreateEmbed()
.WithDescription($"You've been unmuted in {user.Guild} server")
.AddField("Unmute Type", type.ToString())
.AddField("Moderator", mod.ToString())
.AddField("Reason", reason))
.SendAsync());
}
private Task Client_UserJoined(IGuildUser usr)
{
try
{
MutedUsers.TryGetValue(usr.Guild.Id, out var muted);
if (muted is null || !muted.Contains(usr.Id))
return Task.CompletedTask;
_ = Task.Run(() => MuteUser(usr, _client.CurrentUser, reason: "Sticky mute"));
}
catch (Exception ex)
{
Log.Warning(ex, "Error in MuteService UserJoined event");
}
return Task.CompletedTask;
}
public async Task SetMuteRoleAsync(ulong guildId, string name)
{
await using var uow = _db.GetDbContext();
var config = uow.GuildConfigsForId(guildId, set => set);
config.MuteRoleName = name;
GuildMuteRoles.AddOrUpdate(guildId, name, (_, _) => name);
await uow.SaveChangesAsync();
}
public async Task MuteUser(
IGuildUser usr,
IUser mod,
MuteType type = MuteType.All,
string reason = "")
{
if (type == MuteType.All)
{
try { await usr.ModifyAsync(x => x.Mute = true); }
catch { }
var muteRole = await GetMuteRole(usr.Guild);
if (!usr.RoleIds.Contains(muteRole.Id))
await usr.AddRoleAsync(muteRole);
StopTimer(usr.GuildId, usr.Id, TimerType.Mute);
await using (var uow = _db.GetDbContext())
{
var config = uow.GuildConfigsForId(usr.Guild.Id,
set => set.Include(gc => gc.MutedUsers).Include(gc => gc.UnmuteTimers));
config.MutedUsers.Add(new()
{
UserId = usr.Id
});
if (MutedUsers.TryGetValue(usr.Guild.Id, out var muted))
muted.Add(usr.Id);
config.UnmuteTimers.RemoveWhere(x => x.UserId == usr.Id);
await uow.SaveChangesAsync();
}
UserMuted(usr, mod, MuteType.All, reason);
}
else if (type == MuteType.Voice)
{
try
{
await usr.ModifyAsync(x => x.Mute = true);
UserMuted(usr, mod, MuteType.Voice, reason);
}
catch { }
}
else if (type == MuteType.Chat)
{
await usr.AddRoleAsync(await GetMuteRole(usr.Guild));
UserMuted(usr, mod, MuteType.Chat, reason);
}
}
public async Task UnmuteUser(
ulong guildId,
ulong usrId,
IUser mod,
MuteType type = MuteType.All,
string reason = "")
{
var usr = _client.GetGuild(guildId)?.GetUser(usrId);
if (type == MuteType.All)
{
StopTimer(guildId, usrId, TimerType.Mute);
await using (var uow = _db.GetDbContext())
{
var config = uow.GuildConfigsForId(guildId,
set => set.Include(gc => gc.MutedUsers).Include(gc => gc.UnmuteTimers));
var match = new MutedUserId
{
UserId = usrId
};
var toRemove = config.MutedUsers.FirstOrDefault(x => x.Equals(match));
if (toRemove is not null)
uow.Remove(toRemove);
if (MutedUsers.TryGetValue(guildId, out var muted))
muted.TryRemove(usrId);
config.UnmuteTimers.RemoveWhere(x => x.UserId == usrId);
await uow.SaveChangesAsync();
}
if (usr is not null)
{
try { await usr.ModifyAsync(x => x.Mute = false); }
catch { }
try { await usr.RemoveRoleAsync(await GetMuteRole(usr.Guild)); }
catch
{
/*ignore*/
}
UserUnmuted(usr, mod, MuteType.All, reason);
}
}
else if (type == MuteType.Voice)
{
if (usr is null)
return;
try
{
await usr.ModifyAsync(x => x.Mute = false);
UserUnmuted(usr, mod, MuteType.Voice, reason);
}
catch { }
}
else if (type == MuteType.Chat)
{
if (usr is null)
return;
await usr.RemoveRoleAsync(await GetMuteRole(usr.Guild));
UserUnmuted(usr, mod, MuteType.Chat, reason);
}
}
public async Task<IRole> GetMuteRole(IGuild guild)
{
ArgumentNullException.ThrowIfNull(guild);
const string defaultMuteRoleName = "nadeko-mute";
var muteRoleName = GuildMuteRoles.GetOrAdd(guild.Id, defaultMuteRoleName);
var muteRole = guild.Roles.FirstOrDefault(r => r.Name == muteRoleName);
if (muteRole is null)
//if it doesn't exist, create it
{
try { muteRole = await guild.CreateRoleAsync(muteRoleName, isMentionable: false); }
catch
{
//if creations fails, maybe the name is not correct, find default one, if doesn't work, create default one
muteRole = guild.Roles.FirstOrDefault(r => r.Name == muteRoleName)
?? await guild.CreateRoleAsync(defaultMuteRoleName, isMentionable: false);
}
}
foreach (var toOverwrite in await guild.GetTextChannelsAsync())
{
try
{
if (!toOverwrite.PermissionOverwrites.Any(x => x.TargetId == muteRole.Id
&& x.TargetType == PermissionTarget.Role))
{
await toOverwrite.AddPermissionOverwriteAsync(muteRole, _denyOverwrite);
await Task.Delay(200);
}
}
catch
{
// ignored
}
}
return muteRole;
}
public async Task TimedMute(
IGuildUser user,
IUser mod,
TimeSpan after,
MuteType muteType = MuteType.All,
string reason = "")
{
await MuteUser(user, mod, muteType, reason); // mute the user. This will also remove any previous unmute timers
await using (var uow = _db.GetDbContext())
{
var config = uow.GuildConfigsForId(user.GuildId, set => set.Include(x => x.UnmuteTimers));
config.UnmuteTimers.Add(new()
{
UserId = user.Id,
UnmuteAt = DateTime.UtcNow + after
}); // add teh unmute timer to the database
uow.SaveChanges();
}
StartUn_Timer(user.GuildId, user.Id, after, TimerType.Mute); // start the timer
}
public async Task TimedBan(
IGuild guild,
ulong userId,
TimeSpan after,
string reason,
int pruneDays)
{
await guild.AddBanAsync(userId, pruneDays, reason);
await using (var uow = _db.GetDbContext())
{
var config = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.UnbanTimer));
config.UnbanTimer.Add(new()
{
UserId = userId,
UnbanAt = DateTime.UtcNow + after
}); // add teh unmute timer to the database
await uow.SaveChangesAsync();
}
StartUn_Timer(guild.Id, userId, after, TimerType.Ban); // start the timer
}
public async Task TimedRole(
IGuildUser user,
TimeSpan after,
string reason,
IRole role)
{
await user.AddRoleAsync(role);
await using (var uow = _db.GetDbContext())
{
var config = uow.GuildConfigsForId(user.GuildId, set => set.Include(x => x.UnroleTimer));
config.UnroleTimer.Add(new()
{
UserId = user.Id,
UnbanAt = DateTime.UtcNow + after,
RoleId = role.Id
}); // add teh unmute timer to the database
uow.SaveChanges();
}
StartUn_Timer(user.GuildId, user.Id, after, TimerType.AddRole, role.Id); // start the timer
}
public void StartUn_Timer(
ulong guildId,
ulong userId,
TimeSpan after,
TimerType type,
ulong? roleId = null)
{
//load the unmute timers for this guild
var userUnTimers = UnTimers.GetOrAdd(guildId, new ConcurrentDictionary<(ulong, TimerType), Timer>());
//unmute timer to be added
var toAdd = new Timer(async _ =>
{
if (type == TimerType.Ban)
{
try
{
RemoveTimerFromDb(guildId, userId, type);
StopTimer(guildId, userId, type);
var guild = _client.GetGuild(guildId); // load the guild
if (guild is not null)
await guild.RemoveBanAsync(userId);
}
catch (Exception ex)
{
Log.Warning(ex, "Couldn't unban user {UserId} in guild {GuildId}", userId, guildId);
}
}
else if (type == TimerType.AddRole)
{
try
{
if (roleId is null)
return;
RemoveTimerFromDb(guildId, userId, type);
StopTimer(guildId, userId, type);
var guild = _client.GetGuild(guildId);
var user = guild?.GetUser(userId);
var role = guild?.GetRole(roleId.Value);
if (guild is not null && user is not null && user.Roles.Contains(role))
await user.RemoveRoleAsync(role);
}
catch (Exception ex)
{
Log.Warning(ex, "Couldn't remove role from user {UserId} in guild {GuildId}", userId, guildId);
}
}
else
{
try
{
// unmute the user, this will also remove the timer from the db
await UnmuteUser(guildId, userId, _client.CurrentUser, reason: "Timed mute expired");
}
catch (Exception ex)
{
RemoveTimerFromDb(guildId, userId, type); // if unmute errored, just remove unmute from db
Log.Warning(ex, "Couldn't unmute user {UserId} in guild {GuildId}", userId, guildId);
}
}
},
null,
after,
Timeout.InfiniteTimeSpan);
//add it, or stop the old one and add this one
userUnTimers.AddOrUpdate((userId, type),
_ => toAdd,
(_, old) =>
{
old.Change(Timeout.Infinite, Timeout.Infinite);
return toAdd;
});
}
public void StopTimer(ulong guildId, ulong userId, TimerType type)
{
if (!UnTimers.TryGetValue(guildId, out var userTimer))
return;
if (userTimer.TryRemove((userId, type), out var removed))
removed.Change(Timeout.Infinite, Timeout.Infinite);
}
private void RemoveTimerFromDb(ulong guildId, ulong userId, TimerType type)
{
using var uow = _db.GetDbContext();
object toDelete;
if (type == TimerType.Mute)
{
var config = uow.GuildConfigsForId(guildId, set => set.Include(x => x.UnmuteTimers));
toDelete = config.UnmuteTimers.FirstOrDefault(x => x.UserId == userId);
}
else
{
var config = uow.GuildConfigsForId(guildId, set => set.Include(x => x.UnbanTimer));
toDelete = config.UnbanTimer.FirstOrDefault(x => x.UserId == userId);
}
if (toDelete is not null)
uow.Remove(toDelete);
uow.SaveChanges();
}
}

View file

@ -0,0 +1,83 @@
#nullable disable
using EllieBot.Common.TypeReaders;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class DiscordPermOverrideCommands : EllieModule<DiscordPermOverrideService>
{
// override stats, it should require that the user has managessages guild permission
// .po 'stats' add user guild managemessages
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task DiscordPermOverride(CommandOrExprInfo cmd, params GuildPerm[] perms)
{
if (perms is null || perms.Length == 0)
{
await _service.RemoveOverride(ctx.Guild.Id, cmd.Name);
await Response().Confirm(strs.perm_override_reset).SendAsync();
return;
}
var aggregatePerms = perms.Aggregate((acc, seed) => seed | acc);
await _service.AddOverride(ctx.Guild.Id, cmd.Name, aggregatePerms);
await Response()
.Confirm(strs.perm_override(Format.Bold(aggregatePerms.ToString()),
Format.Code(cmd.Name)))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task DiscordPermOverrideReset()
{
var result = await PromptUserConfirmAsync(_sender.CreateEmbed()
.WithOkColor()
.WithDescription(GetText(strs.perm_override_all_confirm)));
if (!result)
return;
await _service.ClearAllOverrides(ctx.Guild.Id);
await Response().Confirm(strs.perm_override_all).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task DiscordPermOverrideList(int page = 1)
{
if (--page < 0)
return;
var allOverrides = await _service.GetAllOverrides(ctx.Guild.Id);
await Response()
.Paginated()
.Items(allOverrides)
.PageSize(9)
.CurrentPage(page)
.Page((items, _) =>
{
var eb = _sender.CreateEmbed().WithTitle(GetText(strs.perm_overrides)).WithOkColor();
if (items.Count == 0)
eb.WithDescription(GetText(strs.perm_override_page_none));
else
{
eb.WithDescription(items.Select(ov => $"{ov.Command} => {ov.Perm.ToString()}")
.Join("\n"));
}
return eb;
})
.SendAsync();
}
}
}

View file

@ -0,0 +1,68 @@
#nullable disable
using EllieBot.Modules.Administration.Services;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class PlayingRotateCommands : EllieModule<PlayingRotateService>
{
[Cmd]
[OwnerOnly]
public async Task RotatePlaying()
{
if (_service.ToggleRotatePlaying())
await Response().Confirm(strs.ropl_enabled).SendAsync();
else
await Response().Confirm(strs.ropl_disabled).SendAsync();
}
[Cmd]
[OwnerOnly]
public Task AddPlaying([Leftover] string status)
=> AddPlaying(ActivityType.CustomStatus, status);
[Cmd]
[OwnerOnly]
public async Task AddPlaying(ActivityType statusType, [Leftover] string status)
{
await _service.AddPlaying(statusType, status);
await Response().Confirm(strs.ropl_added).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task ListPlaying()
{
var statuses = _service.GetRotatingStatuses();
if (!statuses.Any())
await Response().Error(strs.ropl_not_set).SendAsync();
else
{
var i = 1;
await Response()
.Confirm(strs.ropl_list(string.Join("\n\t",
statuses.Select(rs => $"`{i++}.` *{rs.Type}* {rs.Status}"))))
.SendAsync();
}
}
[Cmd]
[OwnerOnly]
public async Task RemovePlaying(int index)
{
index -= 1;
var msg = await _service.RemovePlayingAsync(index);
if (msg is null)
return;
await Response().Confirm(strs.reprm(msg)).SendAsync();
}
}
}

View file

@ -0,0 +1,109 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration.Services;
public sealed class PlayingRotateService : IEService, IReadyExecutor
{
private readonly BotConfigService _bss;
private readonly SelfService _selfService;
private readonly IReplacementService _repService;
// private readonly Replacer _rep;
private readonly DbService _db;
private readonly DiscordSocketClient _client;
public PlayingRotateService(
DiscordSocketClient client,
DbService db,
BotConfigService bss,
IEnumerable<IPlaceholderProvider> phProviders,
SelfService selfService,
IReplacementService repService)
{
_db = db;
_bss = bss;
_selfService = selfService;
_repService = repService;
_client = client;
}
public async Task OnReadyAsync()
{
if (_client.ShardId != 0)
return;
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));
var index = 0;
while (await timer.WaitForNextTickAsync())
{
try
{
if (!_bss.Data.RotateStatuses)
continue;
IReadOnlyList<RotatingPlayingStatus> rotatingStatuses;
await using (var uow = _db.GetDbContext())
{
rotatingStatuses = uow.Set<RotatingPlayingStatus>().AsNoTracking().OrderBy(x => x.Id).ToList();
}
if (rotatingStatuses.Count == 0)
continue;
var playingStatus = index >= rotatingStatuses.Count
? rotatingStatuses[index = 0]
: rotatingStatuses[index++];
var statusText = await _repService.ReplaceAsync(playingStatus.Status, new (client: _client));
await _selfService.SetActivityAsync(statusText, (ActivityType)playingStatus.Type);
}
catch (Exception ex)
{
Log.Warning(ex, "Rotating playing status errored: {ErrorMessage}", ex.Message);
}
}
}
public async Task<string> RemovePlayingAsync(int index)
{
ArgumentOutOfRangeException.ThrowIfNegative(index);
await using var uow = _db.GetDbContext();
var toRemove = await uow.Set<RotatingPlayingStatus>().AsQueryable().AsNoTracking().Skip(index).FirstOrDefaultAsync();
if (toRemove is null)
return null;
uow.Remove(toRemove);
await uow.SaveChangesAsync();
return toRemove.Status;
}
public async Task AddPlaying(ActivityType activityType, string status)
{
await using var uow = _db.GetDbContext();
var toAdd = new RotatingPlayingStatus
{
Status = status,
Type = (EllieBot.Db.DbActivityType)activityType
};
uow.Add(toAdd);
await uow.SaveChangesAsync();
}
public bool ToggleRotatePlaying()
{
var enabled = false;
_bss.ModifyConfig(bs => { enabled = bs.RotateStatuses = !bs.RotateStatuses; });
return enabled;
}
public IReadOnlyList<RotatingPlayingStatus> GetRotatingStatuses()
{
using var uow = _db.GetDbContext();
return uow.Set<RotatingPlayingStatus>().AsNoTracking().ToList();
}
}

View file

@ -0,0 +1,57 @@
#nullable disable
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class PrefixCommands : EllieModule
{
public enum Set
{
Set
}
[Cmd]
[Priority(1)]
public async Task Prefix()
=> await Response().Confirm(strs.prefix_current(Format.Code(_cmdHandler.GetPrefix(ctx.Guild)))).SendAsync();
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[Priority(0)]
public Task Prefix(Set _, [Leftover] string newPrefix)
=> Prefix(newPrefix);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[Priority(0)]
public async Task Prefix([Leftover] string toSet)
{
if (string.IsNullOrWhiteSpace(prefix))
return;
var oldPrefix = prefix;
var newPrefix = _cmdHandler.SetPrefix(ctx.Guild, toSet);
await Response().Confirm(strs.prefix_new(Format.Code(oldPrefix), Format.Code(newPrefix))).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task DefPrefix([Leftover] string toSet = null)
{
if (string.IsNullOrWhiteSpace(toSet))
{
await Response().Confirm(strs.defprefix_current(_cmdHandler.GetPrefix())).SendAsync();
return;
}
var oldPrefix = _cmdHandler.GetPrefix();
var newPrefix = _cmdHandler.SetDefaultPrefix(toSet);
await Response().Confirm(strs.defprefix_new(Format.Code(oldPrefix), Format.Code(newPrefix))).SendAsync();
}
}
}

View file

@ -0,0 +1,292 @@
#nullable disable
using EllieBot.Common.TypeReaders.Models;
using EllieBot.Modules.Administration.Services;
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class ProtectionCommands : EllieModule<ProtectionService>
{
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task AntiAlt()
{
if (await _service.TryStopAntiAlt(ctx.Guild.Id))
{
await Response().Confirm(strs.prot_disable("Anti-Alt")).SendAsync();
return;
}
await Response().Confirm(strs.protection_not_running("Anti-Alt")).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task AntiAlt(
StoopidTime minAge,
PunishmentAction action,
[Leftover] StoopidTime punishTime = null)
{
var minAgeMinutes = (int)minAge.Time.TotalMinutes;
var punishTimeMinutes = (int?)punishTime?.Time.TotalMinutes ?? 0;
if (minAgeMinutes < 1 || punishTimeMinutes < 0)
return;
var minutes = (int?)punishTime?.Time.TotalMinutes ?? 0;
if (action is PunishmentAction.TimeOut && minutes < 1)
minutes = 1;
await _service.StartAntiAltAsync(ctx.Guild.Id,
minAgeMinutes,
action,
minutes);
await ctx.OkAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task AntiAlt(StoopidTime minAge, PunishmentAction action, [Leftover] IRole role)
{
var minAgeMinutes = (int)minAge.Time.TotalMinutes;
if (minAgeMinutes < 1)
return;
if (action == PunishmentAction.TimeOut)
return;
await _service.StartAntiAltAsync(ctx.Guild.Id, minAgeMinutes, action, roleId: role.Id);
await ctx.OkAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public Task AntiRaid()
{
if (_service.TryStopAntiRaid(ctx.Guild.Id))
return Response().Confirm(strs.prot_disable("Anti-Raid")).SendAsync();
return Response().Pending(strs.protection_not_running("Anti-Raid")).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[Priority(1)]
public Task AntiRaid(
int userThreshold,
int seconds,
PunishmentAction action,
[Leftover] StoopidTime punishTime)
=> InternalAntiRaid(userThreshold, seconds, action, punishTime);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[Priority(2)]
public Task AntiRaid(int userThreshold, int seconds, PunishmentAction action)
=> InternalAntiRaid(userThreshold, seconds, action);
private async Task InternalAntiRaid(
int userThreshold,
int seconds = 10,
PunishmentAction action = PunishmentAction.Mute,
StoopidTime punishTime = null)
{
if (action == PunishmentAction.AddRole)
{
await Response().Error(strs.punishment_unsupported(action)).SendAsync();
return;
}
if (userThreshold is < 2 or > 30)
{
await Response().Error(strs.raid_cnt(2, 30)).SendAsync();
return;
}
if (seconds is < 2 or > 300)
{
await Response().Error(strs.raid_time(2, 300)).SendAsync();
return;
}
if (punishTime is not null)
{
if (!_service.IsDurationAllowed(action))
await Response().Error(strs.prot_cant_use_time).SendAsync();
}
var time = (int?)punishTime?.Time.TotalMinutes ?? 0;
if (time is < 0 or > 60 * 24)
return;
if (action is PunishmentAction.TimeOut && time < 1)
return;
var stats = await _service.StartAntiRaidAsync(ctx.Guild.Id, userThreshold, seconds, action, time);
if (stats is null)
return;
await Response()
.Confirm(GetText(strs.prot_enable("Anti-Raid")),
$"{ctx.User.Mention} {GetAntiRaidString(stats)}")
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public Task AntiSpam()
{
if (_service.TryStopAntiSpam(ctx.Guild.Id))
return Response().Confirm(strs.prot_disable("Anti-Spam")).SendAsync();
return Response().Pending(strs.protection_not_running("Anti-Spam")).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[Priority(0)]
public Task AntiSpam(int messageCount, PunishmentAction action, [Leftover] IRole role)
{
if (action != PunishmentAction.AddRole)
return Task.CompletedTask;
return InternalAntiSpam(messageCount, action, null, role);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[Priority(1)]
public Task AntiSpam(int messageCount, PunishmentAction action, [Leftover] StoopidTime punishTime)
=> InternalAntiSpam(messageCount, action, punishTime);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[Priority(2)]
public Task AntiSpam(int messageCount, PunishmentAction action)
=> InternalAntiSpam(messageCount, action);
private async Task InternalAntiSpam(
int messageCount,
PunishmentAction action,
StoopidTime timeData = null,
IRole role = null)
{
if (messageCount is < 2 or > 10)
return;
if (timeData is not null)
{
if (!_service.IsDurationAllowed(action))
await Response().Error(strs.prot_cant_use_time).SendAsync();
}
var time = (int?)timeData?.Time.TotalMinutes ?? 0;
if (time is < 0 or > 60 * 24)
return;
if (action is PunishmentAction.TimeOut && time < 1)
return;
var stats = await _service.StartAntiSpamAsync(ctx.Guild.Id, messageCount, action, time, role?.Id);
await Response()
.Confirm(GetText(strs.prot_enable("Anti-Spam")),
$"{ctx.User.Mention} {GetAntiSpamString(stats)}")
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task AntispamIgnore()
{
var added = await _service.AntiSpamIgnoreAsync(ctx.Guild.Id, ctx.Channel.Id);
if (added is null)
{
await Response().Error(strs.protection_not_running("Anti-Spam")).SendAsync();
return;
}
if (added.Value)
await Response().Confirm(strs.spam_ignore("Anti-Spam")).SendAsync();
else
await Response().Confirm(strs.spam_not_ignore("Anti-Spam")).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task AntiList()
{
var (spam, raid, alt) = _service.GetAntiStats(ctx.Guild.Id);
if (spam is null && raid is null && alt is null)
{
await Response().Confirm(strs.prot_none).SendAsync();
return;
}
var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.prot_active));
if (spam is not null)
embed.AddField("Anti-Spam", GetAntiSpamString(spam).TrimTo(1024), true);
if (raid is not null)
embed.AddField("Anti-Raid", GetAntiRaidString(raid).TrimTo(1024), true);
if (alt is not null)
embed.AddField("Anti-Alt", GetAntiAltString(alt), true);
await Response().Embed(embed).SendAsync();
}
private string GetAntiAltString(AntiAltStats alt)
=> GetText(strs.anti_alt_status(Format.Bold(alt.MinAge.ToString(@"dd\d\ hh\h\ mm\m\ ")),
Format.Bold(alt.Action.ToString()),
Format.Bold(alt.Counter.ToString())));
private string GetAntiSpamString(AntiSpamStats stats)
{
var settings = stats.AntiSpamSettings;
var ignoredString = string.Join(", ", settings.IgnoredChannels.Select(c => $"<#{c.ChannelId}>"));
if (string.IsNullOrWhiteSpace(ignoredString))
ignoredString = "none";
var add = string.Empty;
if (settings.MuteTime > 0)
add = $" ({TimeSpan.FromMinutes(settings.MuteTime):hh\\hmm\\m})";
return GetText(strs.spam_stats(Format.Bold(settings.MessageThreshold.ToString()),
Format.Bold(settings.Action + add),
ignoredString));
}
private string GetAntiRaidString(AntiRaidStats stats)
{
var actionString = Format.Bold(stats.AntiRaidSettings.Action.ToString());
if (stats.AntiRaidSettings.PunishDuration > 0)
actionString += $" **({TimeSpan.FromMinutes(stats.AntiRaidSettings.PunishDuration):hh\\hmm\\m})**";
return GetText(strs.raid_stats(Format.Bold(stats.AntiRaidSettings.UserThreshold.ToString()),
Format.Bold(stats.AntiRaidSettings.Seconds.ToString()),
actionString));
}
}
}

View file

@ -0,0 +1,498 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using EllieBot.Db.Models;
using System.Threading.Channels;
namespace EllieBot.Modules.Administration.Services;
public class ProtectionService : IEService
{
public event Func<PunishmentAction, ProtectionType, IGuildUser[], Task> OnAntiProtectionTriggered = delegate
{
return Task.CompletedTask;
};
private readonly ConcurrentDictionary<ulong, AntiRaidStats> _antiRaidGuilds = new();
private readonly ConcurrentDictionary<ulong, AntiSpamStats> _antiSpamGuilds = new();
private readonly ConcurrentDictionary<ulong, AntiAltStats> _antiAltGuilds = new();
private readonly DiscordSocketClient _client;
private readonly MuteService _mute;
private readonly DbService _db;
private readonly UserPunishService _punishService;
private readonly Channel<PunishQueueItem> _punishUserQueue =
Channel.CreateUnbounded<PunishQueueItem>(new()
{
SingleReader = true,
SingleWriter = false
});
public ProtectionService(
DiscordSocketClient client,
IBot bot,
MuteService mute,
DbService db,
UserPunishService punishService)
{
_client = client;
_mute = mute;
_db = db;
_punishService = punishService;
var ids = client.GetGuildIds();
using (var uow = db.GetDbContext())
{
var configs = uow.Set<GuildConfig>()
.AsQueryable()
.Include(x => x.AntiRaidSetting)
.Include(x => x.AntiSpamSetting)
.ThenInclude(x => x.IgnoredChannels)
.Include(x => x.AntiAltSetting)
.Where(x => ids.Contains(x.GuildId))
.ToList();
foreach (var gc in configs)
Initialize(gc);
}
_client.MessageReceived += HandleAntiSpam;
_client.UserJoined += HandleUserJoined;
bot.JoinedGuild += _bot_JoinedGuild;
_client.LeftGuild += _client_LeftGuild;
_ = Task.Run(RunQueue);
}
private async Task RunQueue()
{
while (true)
{
var item = await _punishUserQueue.Reader.ReadAsync();
var muteTime = item.MuteTime;
var gu = item.User;
try
{
await _punishService.ApplyPunishment(gu.Guild,
gu,
_client.CurrentUser,
item.Action,
muteTime,
item.RoleId,
$"{item.Type} Protection");
}
catch (Exception ex)
{
Log.Warning(ex, "Error in punish queue: {Message}", ex.Message);
}
finally
{
await Task.Delay(1000);
}
}
}
private Task _client_LeftGuild(SocketGuild guild)
{
_ = Task.Run(async () =>
{
TryStopAntiRaid(guild.Id);
TryStopAntiSpam(guild.Id);
await TryStopAntiAlt(guild.Id);
});
return Task.CompletedTask;
}
private Task _bot_JoinedGuild(GuildConfig gc)
{
using var uow = _db.GetDbContext();
var gcWithData = uow.GuildConfigsForId(gc.GuildId,
set => set.Include(x => x.AntiRaidSetting)
.Include(x => x.AntiAltSetting)
.Include(x => x.AntiSpamSetting)
.ThenInclude(x => x.IgnoredChannels));
Initialize(gcWithData);
return Task.CompletedTask;
}
private void Initialize(GuildConfig gc)
{
var raid = gc.AntiRaidSetting;
var spam = gc.AntiSpamSetting;
if (raid is not null)
{
var raidStats = new AntiRaidStats
{
AntiRaidSettings = raid
};
_antiRaidGuilds[gc.GuildId] = raidStats;
}
if (spam is not null)
{
_antiSpamGuilds[gc.GuildId] = new()
{
AntiSpamSettings = spam
};
}
var alt = gc.AntiAltSetting;
if (alt is not null)
_antiAltGuilds[gc.GuildId] = new(alt);
}
private Task HandleUserJoined(SocketGuildUser user)
{
if (user.IsBot)
return Task.CompletedTask;
_antiRaidGuilds.TryGetValue(user.Guild.Id, out var maybeStats);
_antiAltGuilds.TryGetValue(user.Guild.Id, out var maybeAlts);
if (maybeStats is null && maybeAlts is null)
return Task.CompletedTask;
_ = Task.Run(async () =>
{
if (maybeAlts is { } alts)
{
if (user.CreatedAt != default)
{
var diff = DateTime.UtcNow - user.CreatedAt.UtcDateTime;
if (diff < alts.MinAge)
{
alts.Increment();
await PunishUsers(alts.Action,
ProtectionType.Alting,
alts.ActionDurationMinutes,
alts.RoleId,
user);
return;
}
}
}
try
{
if (maybeStats is not { } stats || !stats.RaidUsers.Add(user))
return;
++stats.UsersCount;
if (stats.UsersCount >= stats.AntiRaidSettings.UserThreshold)
{
var users = stats.RaidUsers.ToArray();
stats.RaidUsers.Clear();
var settings = stats.AntiRaidSettings;
await PunishUsers(settings.Action, ProtectionType.Raiding, settings.PunishDuration, null, users);
}
await Task.Delay(1000 * stats.AntiRaidSettings.Seconds);
stats.RaidUsers.TryRemove(user);
--stats.UsersCount;
}
catch
{
// ignored
}
});
return Task.CompletedTask;
}
private Task HandleAntiSpam(SocketMessage arg)
{
if (arg is not SocketUserMessage msg || msg.Author.IsBot)
return Task.CompletedTask;
if (msg.Channel is not ITextChannel channel)
return Task.CompletedTask;
_ = Task.Run(async () =>
{
try
{
if (!_antiSpamGuilds.TryGetValue(channel.Guild.Id, out var spamSettings)
|| spamSettings.AntiSpamSettings.IgnoredChannels.Contains(new()
{
ChannelId = channel.Id
}))
return;
var stats = spamSettings.UserStats.AddOrUpdate(msg.Author.Id,
_ => new(msg),
(_, old) =>
{
old.ApplyNextMessage(msg);
return old;
});
if (stats.Count >= spamSettings.AntiSpamSettings.MessageThreshold)
{
if (spamSettings.UserStats.TryRemove(msg.Author.Id, out stats))
{
var settings = spamSettings.AntiSpamSettings;
await PunishUsers(settings.Action,
ProtectionType.Spamming,
settings.MuteTime,
settings.RoleId,
(IGuildUser)msg.Author);
}
}
}
catch
{
// ignored
}
});
return Task.CompletedTask;
}
private async Task PunishUsers(
PunishmentAction action,
ProtectionType pt,
int muteTime,
ulong? roleId,
params IGuildUser[] gus)
{
Log.Information("[{PunishType}] - Punishing [{Count}] users with [{PunishAction}] in {GuildName} guild",
pt,
gus.Length,
action,
gus[0].Guild.Name);
foreach (var gu in gus)
{
await _punishUserQueue.Writer.WriteAsync(new()
{
Action = action,
Type = pt,
User = gu,
MuteTime = muteTime,
RoleId = roleId
});
}
_ = OnAntiProtectionTriggered(action, pt, gus);
}
public async Task<AntiRaidStats> StartAntiRaidAsync(
ulong guildId,
int userThreshold,
int seconds,
PunishmentAction action,
int minutesDuration)
{
var g = _client.GetGuild(guildId);
await _mute.GetMuteRole(g);
if (action == PunishmentAction.AddRole)
return null;
if (!IsDurationAllowed(action))
minutesDuration = 0;
var stats = new AntiRaidStats
{
AntiRaidSettings = new()
{
Action = action,
Seconds = seconds,
UserThreshold = userThreshold,
PunishDuration = minutesDuration
}
};
_antiRaidGuilds.AddOrUpdate(guildId, stats, (_, _) => stats);
await using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiRaidSetting));
gc.AntiRaidSetting = stats.AntiRaidSettings;
await uow.SaveChangesAsync();
return stats;
}
public bool TryStopAntiRaid(ulong guildId)
{
if (_antiRaidGuilds.TryRemove(guildId, out _))
{
using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiRaidSetting));
gc.AntiRaidSetting = null;
uow.SaveChanges();
return true;
}
return false;
}
public bool TryStopAntiSpam(ulong guildId)
{
if (_antiSpamGuilds.TryRemove(guildId, out _))
{
using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId,
set => set.Include(x => x.AntiSpamSetting).ThenInclude(x => x.IgnoredChannels));
gc.AntiSpamSetting = null;
uow.SaveChanges();
return true;
}
return false;
}
public async Task<AntiSpamStats> StartAntiSpamAsync(
ulong guildId,
int messageCount,
PunishmentAction action,
int punishDurationMinutes,
ulong? roleId)
{
var g = _client.GetGuild(guildId);
await _mute.GetMuteRole(g);
if (!IsDurationAllowed(action))
punishDurationMinutes = 0;
var stats = new AntiSpamStats
{
AntiSpamSettings = new()
{
Action = action,
MessageThreshold = messageCount,
MuteTime = punishDurationMinutes,
RoleId = roleId
}
};
stats = _antiSpamGuilds.AddOrUpdate(guildId,
stats,
(_, old) =>
{
stats.AntiSpamSettings.IgnoredChannels = old.AntiSpamSettings.IgnoredChannels;
return stats;
});
await using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiSpamSetting));
if (gc.AntiSpamSetting is not null)
{
gc.AntiSpamSetting.Action = stats.AntiSpamSettings.Action;
gc.AntiSpamSetting.MessageThreshold = stats.AntiSpamSettings.MessageThreshold;
gc.AntiSpamSetting.MuteTime = stats.AntiSpamSettings.MuteTime;
gc.AntiSpamSetting.RoleId = stats.AntiSpamSettings.RoleId;
}
else
gc.AntiSpamSetting = stats.AntiSpamSettings;
await uow.SaveChangesAsync();
return stats;
}
public async Task<bool?> AntiSpamIgnoreAsync(ulong guildId, ulong channelId)
{
var obj = new AntiSpamIgnore
{
ChannelId = channelId
};
bool added;
await using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId,
set => set.Include(x => x.AntiSpamSetting).ThenInclude(x => x.IgnoredChannels));
var spam = gc.AntiSpamSetting;
if (spam is null)
return null;
if (spam.IgnoredChannels.Add(obj)) // if adding to db is successful
{
if (_antiSpamGuilds.TryGetValue(guildId, out var temp))
temp.AntiSpamSettings.IgnoredChannels.Add(obj); // add to local cache
added = true;
}
else
{
var toRemove = spam.IgnoredChannels.First(x => x.ChannelId == channelId);
uow.Set<AntiSpamIgnore>().Remove(toRemove); // remove from db
if (_antiSpamGuilds.TryGetValue(guildId, out var temp))
temp.AntiSpamSettings.IgnoredChannels.Remove(toRemove); // remove from local cache
added = false;
}
await uow.SaveChangesAsync();
return added;
}
public (AntiSpamStats, AntiRaidStats, AntiAltStats) GetAntiStats(ulong guildId)
{
_antiRaidGuilds.TryGetValue(guildId, out var antiRaidStats);
_antiSpamGuilds.TryGetValue(guildId, out var antiSpamStats);
_antiAltGuilds.TryGetValue(guildId, out var antiAltStats);
return (antiSpamStats, antiRaidStats, antiAltStats);
}
public bool IsDurationAllowed(PunishmentAction action)
{
switch (action)
{
case PunishmentAction.Ban:
case PunishmentAction.Mute:
case PunishmentAction.ChatMute:
case PunishmentAction.VoiceMute:
case PunishmentAction.AddRole:
case PunishmentAction.TimeOut:
return true;
default:
return false;
}
}
public async Task StartAntiAltAsync(
ulong guildId,
int minAgeMinutes,
PunishmentAction action,
int actionDurationMinutes = 0,
ulong? roleId = null)
{
await using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiAltSetting));
gc.AntiAltSetting = new()
{
Action = action,
ActionDurationMinutes = actionDurationMinutes,
MinAge = TimeSpan.FromMinutes(minAgeMinutes),
RoleId = roleId
};
await uow.SaveChangesAsync();
_antiAltGuilds[guildId] = new(gc.AntiAltSetting);
}
public async Task<bool> TryStopAntiAlt(ulong guildId)
{
if (!_antiAltGuilds.TryRemove(guildId, out _))
return false;
await using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.AntiAltSetting));
gc.AntiAltSetting = null;
await uow.SaveChangesAsync();
return true;
}
}

View file

@ -0,0 +1,52 @@
#nullable disable
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration;
public enum ProtectionType
{
Raiding,
Spamming,
Alting
}
public class AntiRaidStats
{
public AntiRaidSetting AntiRaidSettings { get; set; }
public int UsersCount { get; set; }
public ConcurrentHashSet<IGuildUser> RaidUsers { get; set; } = new();
}
public class AntiSpamStats
{
public AntiSpamSetting AntiSpamSettings { get; set; }
public ConcurrentDictionary<ulong, UserSpamStats> UserStats { get; set; } = new();
}
public class AntiAltStats
{
public PunishmentAction Action
=> _setting.Action;
public int ActionDurationMinutes
=> _setting.ActionDurationMinutes;
public ulong? RoleId
=> _setting.RoleId;
public TimeSpan MinAge
=> _setting.MinAge;
public int Counter
=> counter;
private readonly AntiAltSetting _setting;
private int counter;
public AntiAltStats(AntiAltSetting setting)
=> _setting = setting;
public void Increment()
=> Interlocked.Increment(ref counter);
}

View file

@ -0,0 +1,13 @@
#nullable disable
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration;
public class PunishQueueItem
{
public PunishmentAction Action { get; set; }
public ProtectionType Type { get; set; }
public int MuteTime { get; set; }
public ulong? RoleId { get; set; }
public IGuildUser User { get; set; }
}

View file

@ -0,0 +1,64 @@
#nullable disable
namespace EllieBot.Modules.Administration;
public sealed class UserSpamStats
{
public int Count
{
get
{
lock (_applyLock)
{
Cleanup();
return _messageTracker.Count;
}
}
}
private string lastMessage;
private readonly Queue<DateTime> _messageTracker;
private readonly object _applyLock = new();
private readonly TimeSpan _maxTime = TimeSpan.FromMinutes(30);
public UserSpamStats(IUserMessage msg)
{
lastMessage = msg.Content.ToUpperInvariant();
_messageTracker = new();
ApplyNextMessage(msg);
}
public void ApplyNextMessage(IUserMessage message)
{
var upperMsg = message.Content.ToUpperInvariant();
lock (_applyLock)
{
if (upperMsg != lastMessage || (string.IsNullOrWhiteSpace(upperMsg) && message.Attachments.Any()))
{
// if it's a new message, reset spam counter
lastMessage = upperMsg;
_messageTracker.Clear();
}
_messageTracker.Enqueue(DateTime.UtcNow);
}
}
private void Cleanup()
{
lock (_applyLock)
{
while (_messageTracker.TryPeek(out var dateTime))
{
if (DateTime.UtcNow - dateTime < _maxTime)
break;
_messageTracker.Dequeue();
}
}
}
}

View file

@ -0,0 +1,227 @@
#nullable disable
using CommandLine;
using EllieBot.Modules.Administration.Services;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class PruneCommands : EllieModule<PruneService>
{
private static readonly TimeSpan _twoWeeks = TimeSpan.FromDays(14);
public sealed class PruneOptions : IEllieCommandOptions
{
[Option(shortName: 's',
longName: "safe",
Default = false,
HelpText = "Whether pinned messages should be deleted.",
Required = false)]
public bool Safe { get; set; }
[Option(shortName: 'a',
longName: "after",
Default = null,
HelpText = "Prune only messages after the specified message ID.",
Required = false)]
public ulong? After { get; set; }
public void NormalizeOptions()
{
}
}
//deletes her own messages, no perm required
[Cmd]
[RequireContext(ContextType.Guild)]
[EllieOptions<PruneOptions>]
public async Task Prune(params string[] args)
{
var (opts, _) = OptionsParser.ParseFrom(new PruneOptions(), args);
var user = await ctx.Guild.GetCurrentUserAsync();
var progressMsg = await Response().Pending(strs.prune_progress(0, 100)).SendAsync();
var progress = GetProgressTracker(progressMsg);
PruneResult result;
if (opts.Safe)
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
100,
x => x.Author.Id == user.Id && !x.IsPinned,
progress,
opts.After);
else
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
100,
x => x.Author.Id == user.Id,
progress,
opts.After);
ctx.Message.DeleteAfter(3);
await SendResult(result);
await progressMsg.DeleteAsync();
}
// prune x
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(ChannelPerm.ManageMessages)]
[BotPerm(ChannelPerm.ManageMessages)]
[EllieOptions<PruneOptions>]
[Priority(1)]
public async Task Prune(int count, params string[] args)
{
count++;
if (count < 1)
return;
if (count > 1000)
count = 1000;
var (opts, _) = OptionsParser.ParseFrom<PruneOptions>(new PruneOptions(), args);
var progressMsg = await Response().Pending(strs.prune_progress(0, count)).SendAsync();
var progress = GetProgressTracker(progressMsg);
PruneResult result;
if (opts.Safe)
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
count,
x => !x.IsPinned && x.Id != progressMsg.Id,
progress,
opts.After);
else
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
count,
x => x.Id != progressMsg.Id,
progress,
opts.After);
await SendResult(result);
await progressMsg.DeleteAsync();
}
private IProgress<(int, int)> GetProgressTracker(IUserMessage progressMsg)
{
var progress = new Progress<(int, int)>(async (x) =>
{
var (deleted, total) = x;
try
{
await progressMsg.ModifyAsync(props =>
{
props.Embed = _sender.CreateEmbed()
.WithPendingColor()
.WithDescription(GetText(strs.prune_progress(deleted, total)))
.Build();
});
}
catch
{
}
});
return progress;
}
//prune @user [x]
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(ChannelPerm.ManageMessages)]
[BotPerm(ChannelPerm.ManageMessages)]
[EllieOptions<PruneOptions>]
[Priority(0)]
public Task Prune(IGuildUser user, int count = 100, params string[] args)
=> Prune(user.Id, count, args);
//prune userid [x]
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(ChannelPerm.ManageMessages)]
[BotPerm(ChannelPerm.ManageMessages)]
[EllieOptions<PruneOptions>]
[Priority(0)]
public async Task Prune(ulong userId, int count = 100, params string[] args)
{
if (userId == ctx.User.Id)
count++;
if (count < 1)
return;
if (count > 1000)
count = 1000;
var (opts, _) = OptionsParser.ParseFrom<PruneOptions>(new PruneOptions(), args);
var progressMsg = await Response().Pending(strs.prune_progress(0, count)).SendAsync();
var progress = GetProgressTracker(progressMsg);
PruneResult result;
if (opts.Safe)
{
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
count,
m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks && !m.IsPinned,
progress,
opts.After
);
}
else
{
result = await _service.PruneWhere((ITextChannel)ctx.Channel,
count,
m => m.Author.Id == userId && DateTime.UtcNow - m.CreatedAt < _twoWeeks,
progress,
opts.After
);
}
await SendResult(result);
await progressMsg.DeleteAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(ChannelPerm.ManageMessages)]
[BotPerm(ChannelPerm.ManageMessages)]
public async Task PruneCancel()
{
var ok = await _service.CancelAsync(ctx.Guild.Id);
if (!ok)
{
await Response().Error(strs.prune_not_found).SendAsync();
return;
}
await Response().Confirm(strs.prune_cancelled).SendAsync();
}
private async Task SendResult(PruneResult result)
{
switch (result)
{
case PruneResult.Success:
break;
case PruneResult.AlreadyRunning:
var msg = await Response().Pending(strs.prune_already_running).SendAsync();
msg.DeleteAfter(5);
break;
case PruneResult.FeatureLimit:
var msg2 = await Response().Pending(strs.feature_limit_reached_owner).SendAsync();
msg2.DeleteAfter(10);
break;
default:
Log.Error("Unhandled result received in prune: {Result}", result);
await Response().Error(strs.error_occured).SendAsync();
break;
}
}
}
}

View file

@ -0,0 +1,9 @@
#nullable disable
namespace EllieBot.Modules.Administration.Services;
public enum PruneResult
{
Success,
AlreadyRunning,
FeatureLimit,
}

View file

@ -0,0 +1,114 @@
#nullable disable
using EllieBot.Modules.Patronage;
namespace EllieBot.Modules.Administration.Services;
public class PruneService : IEService
{
//channelids where prunes are currently occuring
private readonly ConcurrentDictionary<ulong, CancellationTokenSource> _pruningGuilds = new();
private readonly TimeSpan _twoWeeks = TimeSpan.FromDays(14);
private readonly ILogCommandService _logService;
private readonly IPatronageService _ps;
public PruneService(ILogCommandService logService, IPatronageService ps)
{
_logService = logService;
_ps = ps;
}
public async Task<PruneResult> PruneWhere(
ITextChannel channel,
int amount,
Func<IMessage, bool> predicate,
IProgress<(int deleted, int total)> progress,
ulong? after = null
)
{
ArgumentNullException.ThrowIfNull(channel, nameof(channel));
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
var originalAmount = amount;
using var cancelSource = new CancellationTokenSource();
if (!_pruningGuilds.TryAdd(channel.GuildId, cancelSource))
return PruneResult.AlreadyRunning;
try
{
if (!await _ps.LimitHitAsync(LimitedFeatureName.Prune, channel.Guild.OwnerId))
{
return PruneResult.FeatureLimit;
}
var now = DateTime.UtcNow;
IMessage[] msgs;
IMessage lastMessage = null;
while (amount > 0 && !cancelSource.IsCancellationRequested)
{
var dled = lastMessage is null
? await channel.GetMessagesAsync(50).FlattenAsync()
: await channel.GetMessagesAsync(lastMessage, Direction.Before, 50).FlattenAsync();
msgs = dled
.Where(predicate)
.Where(x => after is not ulong a || x.Id > a)
.Take(amount)
.ToArray();
if (!msgs.Any())
return PruneResult.Success;
lastMessage = msgs[^1];
var bulkDeletable = new List<IMessage>();
var singleDeletable = new List<IMessage>();
foreach (var x in msgs)
{
_logService.AddDeleteIgnore(x.Id);
if (now - x.CreatedAt < _twoWeeks)
bulkDeletable.Add(x);
else
singleDeletable.Add(x);
}
if (bulkDeletable.Count > 0)
{
await channel.DeleteMessagesAsync(bulkDeletable);
amount -= msgs.Length;
progress.Report((originalAmount - amount, originalAmount));
await Task.Delay(2000, cancelSource.Token);
}
foreach (var group in singleDeletable.Chunk(5))
{
await group.Select(x => x.DeleteAsync()).WhenAll();
amount -= 5;
progress.Report((originalAmount - amount, originalAmount));
await Task.Delay(5000, cancelSource.Token);
}
}
}
catch
{
//ignore
}
finally
{
_pruningGuilds.TryRemove(channel.GuildId, out _);
}
return PruneResult.Success;
}
public async Task<bool> CancelAsync(ulong guildId)
{
if (!_pruningGuilds.TryRemove(guildId, out var source))
return false;
await source.CancelAsync();
return true;
}
}

View file

@ -0,0 +1,51 @@
#nullable disable
using EllieBot.Db.Models;
using OneOf;
using OneOf.Types;
namespace EllieBot.Modules.Administration.Services;
public interface IReactionRoleService
{
/// <summary>
/// Adds a single reaction role
/// </summary>
/// <param name="guild">Guild where to add a reaction role</param>
/// <param name="msg">Message to which to add a reaction role</param>
/// <param name="emote"></param>
/// <param name="role"></param>
/// <param name="group"></param>
/// <param name="levelReq"></param>
/// <returns>The result of the operation</returns>
Task<OneOf<Success, Error>> AddReactionRole(
IGuild guild,
IMessage msg,
string emote,
IRole role,
int group = 0,
int levelReq = 0);
/// <summary>
/// Get all reaction roles on the specified server
/// </summary>
/// <param name="guildId"></param>
/// <returns></returns>
Task<IReadOnlyCollection<ReactionRoleV2>> GetReactionRolesAsync(ulong guildId);
/// <summary>
/// Remove reaction roles on the specified message
/// </summary>
/// <param name="guildId"></param>
/// <param name="messageId"></param>
/// <returns></returns>
Task<bool> RemoveReactionRoles(ulong guildId, ulong messageId);
/// <summary>
/// Remove all reaction roles in the specified server
/// </summary>
/// <param name="guildId"></param>
/// <returns></returns>
Task<int> RemoveAllReactionRoles(ulong guildId);
Task<IReadOnlyCollection<IEmote>> TransferReactionRolesAsync(ulong guildId, ulong fromMessageId, ulong toMessageId);
}

View file

@ -0,0 +1,174 @@
using EllieBot.Modules.Administration.Services;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
public partial class ReactionRoleCommands : EllieModule
{
private readonly IReactionRoleService _rero;
public ReactionRoleCommands(IReactionRoleService rero)
{
_rero = rero;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task ReRoAdd(
ulong messageId,
string emoteStr,
IRole role,
int group = 0,
int levelReq = 0)
{
if (group < 0)
return;
if (levelReq < 0)
return;
var msg = await ctx.Channel.GetMessageAsync(messageId);
if (msg is null)
{
await Response().Error(strs.rero_message_not_found).SendAsync();
return;
}
if (ctx.User.Id != ctx.Guild.OwnerId
&& ((IGuildUser)ctx.User).GetRoles().Max(x => x.Position) <= role.Position)
{
await Response().Error(strs.hierarchy).SendAsync();
return;
}
var emote = emoteStr.ToIEmote();
await msg.AddReactionAsync(emote);
var res = await _rero.AddReactionRole(ctx.Guild,
msg,
emoteStr,
role,
group,
levelReq);
await res.Match(
_ => ctx.OkAsync(),
async fl =>
{
_ = msg.RemoveReactionAsync(emote, ctx.Client.CurrentUser);
await Response().Pending(strs.feature_limit_reached_owner).SendAsync();
});
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task ReRoList(int page = 1)
{
if (--page < 0)
return;
var allReros = await _rero.GetReactionRolesAsync(ctx.Guild.Id);
await Response()
.Paginated()
.Items(allReros.OrderBy(x => x.Group).ToList())
.PageSize(10)
.CurrentPage(page)
.Page((items, _) =>
{
var embed = _sender.CreateEmbed()
.WithOkColor();
var content = string.Empty;
foreach (var g in items
.GroupBy(x => x.MessageId)
.OrderBy(x => x.Key))
{
var messageId = g.Key;
content +=
$"[{messageId}](https://discord.com/channels/{ctx.Guild.Id}/{g.First().ChannelId}/{g.Key})\n";
var groupGroups = g.GroupBy(x => x.Group);
foreach (var ggs in groupGroups)
{
content += $"`< {(g.Key == 0 ? ("Not Exclusive (Group 0)") : ($"Group {ggs.Key}"))} >`\n";
foreach (var rero in ggs)
{
content +=
$"\t{rero.Emote} -> {(ctx.Guild.GetRole(rero.RoleId)?.Mention ?? "<missing role>")}";
if (rero.LevelReq > 0)
content += $" (lvl {rero.LevelReq}+)";
content += '\n';
}
}
}
embed.WithDescription(string.IsNullOrWhiteSpace(content)
? "There are no reaction roles on this server"
: content);
return embed;
})
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task ReRoRemove(ulong messageId)
{
var succ = await _rero.RemoveReactionRoles(ctx.Guild.Id, messageId);
if (succ)
await ctx.OkAsync();
else
await ctx.ErrorAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task ReRoDeleteAll()
{
await _rero.RemoveAllReactionRoles(ctx.Guild.Id);
await ctx.OkAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
[Ratelimit(60)]
public async Task ReRoTransfer(ulong fromMessageId, ulong toMessageId)
{
var msg = await ctx.Channel.GetMessageAsync(toMessageId);
if (msg is null)
{
await ctx.ErrorAsync();
return;
}
var reactions = await _rero.TransferReactionRolesAsync(ctx.Guild.Id, fromMessageId, toMessageId);
if (reactions.Count == 0)
{
await ctx.ErrorAsync();
}
else
{
foreach (var r in reactions)
{
await msg.AddReactionAsync(r);
}
}
}
}
}

View file

@ -0,0 +1,404 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Modules.Patronage;
using EllieBot.Db.Models;
using OneOf.Types;
using OneOf;
namespace EllieBot.Modules.Administration.Services;
public sealed class ReactionRolesService : IReadyExecutor, IEService, IReactionRoleService
{
private readonly DbService _db;
private readonly DiscordSocketClient _client;
private readonly IBotCredentials _creds;
private ConcurrentDictionary<ulong, List<ReactionRoleV2>> _cache;
private readonly object _cacheLock = new();
private readonly SemaphoreSlim _assignementLock = new(1, 1);
private readonly IPatronageService _ps;
public ReactionRolesService(
DiscordSocketClient client,
IPatronageService ps,
DbService db,
IBotCredentials creds)
{
_db = db;
_client = client;
_creds = creds;
_ps = ps;
_cache = new();
}
public async Task OnReadyAsync()
{
await using var uow = _db.GetDbContext();
var reros = await uow.GetTable<ReactionRoleV2>()
.Where(
x => Linq2DbExpressions.GuildOnShard(x.GuildId, _creds.TotalShards, _client.ShardId))
.ToListAsyncLinqToDB();
foreach (var group in reros.GroupBy(x => x.MessageId))
{
_cache[group.Key] = group.ToList();
}
_client.ReactionAdded += ClientOnReactionAdded;
_client.ReactionRemoved += ClientOnReactionRemoved;
}
private async Task<(IGuildUser, IRole)> GetUserAndRoleAsync(
ulong userId,
ReactionRoleV2 rero)
{
var guild = _client.GetGuild(rero.GuildId);
var role = guild?.GetRole(rero.RoleId);
if (role is null)
return default;
var user = guild.GetUser(userId) as IGuildUser
?? await _client.Rest.GetGuildUserAsync(guild.Id, userId);
if (user is null)
return default;
return (user, role);
}
private Task ClientOnReactionRemoved(
Cacheable<IUserMessage, ulong> cmsg,
Cacheable<IMessageChannel, ulong> ch,
SocketReaction r)
{
if (!_cache.TryGetValue(cmsg.Id, out var reros))
return Task.CompletedTask;
_ = Task.Run(async () =>
{
var emote = await GetFixedEmoteAsync(cmsg, r.Emote);
var rero = reros.FirstOrDefault(x => x.Emote == emote.Name
|| x.Emote == emote.ToString());
if (rero is null)
return;
var (user, role) = await GetUserAndRoleAsync(r.UserId, rero);
if (user.IsBot)
return;
await _assignementLock.WaitAsync();
try
{
if (user.RoleIds.Contains(role.Id))
{
await user.RemoveRoleAsync(role.Id, new RequestOptions()
{
AuditLogReason = $"Reaction role"
});
}
}
finally
{
_assignementLock.Release();
}
});
return Task.CompletedTask;
}
// had to add this because for some reason, reactionremoved event's reaction doesn't have IsAnimated set,
// causing the .ToString() to be wrong on animated custom emotes
private async Task<IEmote> GetFixedEmoteAsync(
Cacheable<IUserMessage, ulong> cmsg,
IEmote inputEmote)
{
// this should only run for emote
if (inputEmote is not Emote e)
return inputEmote;
// try to get the message and pull
var msg = await cmsg.GetOrDownloadAsync();
var emote = msg.Reactions.Keys.FirstOrDefault(x => e.Equals(x));
return emote ?? inputEmote;
}
private Task ClientOnReactionAdded(
Cacheable<IUserMessage, ulong> msg,
Cacheable<IMessageChannel, ulong> ch,
SocketReaction r)
{
if (!_cache.TryGetValue(msg.Id, out var reros))
return Task.CompletedTask;
_ = Task.Run(async () =>
{
var rero = reros.FirstOrDefault(x => x.Emote == r.Emote.Name || x.Emote == r.Emote.ToString());
if (rero is null)
return;
var (user, role) = await GetUserAndRoleAsync(r.UserId, rero);
if (user.IsBot)
return;
await _assignementLock.WaitAsync();
try
{
if (!user.RoleIds.Contains(role.Id))
{
// first check if there is a level requirement
// and if there is, make sure user satisfies it
if (rero.LevelReq > 0)
{
await using var ctx = _db.GetDbContext();
var levelData = await ctx.GetTable<UserXpStats>()
.GetLevelDataFor(user.GuildId, user.Id);
if (levelData.Level < rero.LevelReq)
return;
}
// remove all other roles from the same group from the user
// execept in group 0, which is a special, non-exclusive group
if (rero.Group != 0)
{
var exclusive = reros
.Where(x => x.Group == rero.Group && x.RoleId != role.Id)
.Select(x => x.RoleId)
.Distinct()
.ToArray();
if (exclusive.Any())
{
try
{
await user.RemoveRolesAsync(exclusive,
new RequestOptions()
{
AuditLogReason = "Reaction role exclusive group"
});
}
catch { }
}
// remove user's previous reaction
try
{
var m = await msg.GetOrDownloadAsync();
if (m is not null)
{
var reactToRemove = m.Reactions
.FirstOrDefault(x => x.Key.ToString() != r.Emote.ToString())
.Key;
if (reactToRemove is not null)
{
await m.RemoveReactionAsync(reactToRemove, user);
}
}
}
catch
{
}
}
await user.AddRoleAsync(role.Id, new()
{
AuditLogReason = "Reaction role"
});
}
}
finally
{
_assignementLock.Release();
}
});
return Task.CompletedTask;
}
/// <summary>
/// Adds a single reaction role
/// </summary>
/// <param name="guild">Guild where to add a reaction role</param>
/// <param name="msg">Message to which to add a reaction role</param>
/// <param name="emote"></param>
/// <param name="role"></param>
/// <param name="group"></param>
/// <param name="levelReq"></param>
/// <returns>The result of the operation</returns>
public async Task<OneOf<Success, Error>> AddReactionRole(
IGuild guild,
IMessage msg,
string emote,
IRole role,
int group = 0,
int levelReq = 0)
{
ArgumentOutOfRangeException.ThrowIfNegative(group);
ArgumentOutOfRangeException.ThrowIfNegative(levelReq);
await using var ctx = _db.GetDbContext();
await using var tran = await ctx.Database.BeginTransactionAsync();
var activeReactionRoles = await ctx.GetTable<ReactionRoleV2>()
.Where(x => x.GuildId == guild.Id)
.CountAsync();
var limit = await _ps.GetUserLimit(LimitedFeatureName.ReactionRole, guild.OwnerId);
if (!_creds.IsOwner(guild.OwnerId) && (activeReactionRoles >= limit.Quota && limit.Quota >= 0))
{
return new Error();
}
await ctx.GetTable<ReactionRoleV2>()
.InsertOrUpdateAsync(() => new()
{
GuildId = guild.Id,
ChannelId = msg.Channel.Id,
MessageId = msg.Id,
Emote = emote,
RoleId = role.Id,
Group = group,
LevelReq = levelReq
},
(old) => new()
{
RoleId = role.Id,
Group = group,
LevelReq = levelReq
},
() => new()
{
MessageId = msg.Id,
Emote = emote,
});
await tran.CommitAsync();
var obj = new ReactionRoleV2()
{
GuildId = guild.Id,
MessageId = msg.Id,
Emote = emote,
RoleId = role.Id,
Group = group,
LevelReq = levelReq
};
lock (_cacheLock)
{
_cache.AddOrUpdate(msg.Id,
_ => [obj],
(_, list) =>
{
list.RemoveAll(x => x.Emote == emote);
list.Add(obj);
return list;
});
}
return new Success();
}
/// <summary>
/// Get all reaction roles on the specified server
/// </summary>
/// <param name="guildId"></param>
/// <returns></returns>
public async Task<IReadOnlyCollection<ReactionRoleV2>> GetReactionRolesAsync(ulong guildId)
{
await using var ctx = _db.GetDbContext();
return await ctx.GetTable<ReactionRoleV2>()
.Where(x => x.GuildId == guildId)
.ToListAsync();
}
/// <summary>
/// Remove reaction roles on the specified message
/// </summary>
/// <param name="guildId"></param>
/// <param name="messageId"></param>
/// <returns></returns>
public async Task<bool> RemoveReactionRoles(ulong guildId, ulong messageId)
{
// guildid is used for quick index lookup
await using var ctx = _db.GetDbContext();
var changed = await ctx.GetTable<ReactionRoleV2>()
.Where(x => x.GuildId == guildId && x.MessageId == messageId)
.DeleteAsync();
_cache.TryRemove(messageId, out _);
if (changed == 0)
return false;
return true;
}
/// <summary>
/// Remove all reaction roles in the specified server
/// </summary>
/// <param name="guildId"></param>
/// <returns></returns>
public async Task<int> RemoveAllReactionRoles(ulong guildId)
{
await using var ctx = _db.GetDbContext();
var output = await ctx.GetTable<ReactionRoleV2>()
.Where(x => x.GuildId == guildId)
.DeleteWithOutputAsync(x => x.MessageId);
lock (_cacheLock)
{
foreach (var o in output)
{
_cache.TryRemove(o, out _);
}
}
return output.Length;
}
public async Task<IReadOnlyCollection<IEmote>> TransferReactionRolesAsync(
ulong guildId,
ulong fromMessageId,
ulong toMessageId)
{
await using var ctx = _db.GetDbContext();
var updated = ctx.GetTable<ReactionRoleV2>()
.Where(x => x.GuildId == guildId && x.MessageId == fromMessageId)
.UpdateWithOutput(old => new()
{
MessageId = toMessageId
},
(old, neu) => neu);
lock (_cacheLock)
{
if (_cache.TryRemove(fromMessageId, out var data))
{
if (_cache.TryGetValue(toMessageId, out var newData))
{
newData.AddRange(data);
}
else
{
_cache[toMessageId] = data;
}
}
}
return updated.Select(x => x.Emote.ToIEmote()).ToList();
}
}

View file

@ -0,0 +1,209 @@
#nullable disable
using SixLabors.ImageSharp.PixelFormats;
using Color = SixLabors.ImageSharp.Color;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
public partial class RoleCommands : EllieModule
{
public enum Exclude
{
Excl
}
private readonly IServiceProvider _services;
private StickyRolesService _stickyRoleSvc;
public RoleCommands(IServiceProvider services, StickyRolesService stickyRoleSvc)
{
_services = services;
_stickyRoleSvc = stickyRoleSvc;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task SetRole(IGuildUser targetUser, [Leftover] IRole roleToAdd)
{
var runnerUser = (IGuildUser)ctx.User;
var runnerMaxRolePosition = runnerUser.GetRoles().Max(x => x.Position);
if (ctx.User.Id != ctx.Guild.OwnerId && runnerMaxRolePosition <= roleToAdd.Position)
return;
try
{
await targetUser.AddRoleAsync(roleToAdd, new RequestOptions()
{
AuditLogReason = $"Added by [{ctx.User.Username}]"
});
await Response().Confirm(strs.setrole(Format.Bold(roleToAdd.Name),
Format.Bold(targetUser.ToString()))).SendAsync();
}
catch (Exception ex)
{
Log.Warning(ex, "Error in setrole command");
await Response().Error(strs.setrole_err).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task RemoveRole(IGuildUser targetUser, [Leftover] IRole roleToRemove)
{
var runnerUser = (IGuildUser)ctx.User;
if (ctx.User.Id != runnerUser.Guild.OwnerId
&& runnerUser.GetRoles().Max(x => x.Position) <= roleToRemove.Position)
return;
try
{
await targetUser.RemoveRoleAsync(roleToRemove);
await Response().Confirm(strs.remrole(Format.Bold(roleToRemove.Name),
Format.Bold(targetUser.ToString()))).SendAsync();
}
catch
{
await Response().Error(strs.remrole_err).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task RenameRole(IRole roleToEdit, [Leftover] string newname)
{
var guser = (IGuildUser)ctx.User;
if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= roleToEdit.Position)
return;
try
{
if (roleToEdit.Position > (await ctx.Guild.GetCurrentUserAsync()).GetRoles().Max(r => r.Position))
{
await Response().Error(strs.renrole_perms).SendAsync();
return;
}
await roleToEdit.ModifyAsync(g => g.Name = newname);
await Response().Confirm(strs.renrole).SendAsync();
}
catch (Exception)
{
await Response().Error(strs.renrole_err).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task RemoveAllRoles([Leftover] IGuildUser user)
{
var guser = (IGuildUser)ctx.User;
var userRoles = user.GetRoles().Where(x => !x.IsManaged && x != x.Guild.EveryoneRole).ToList();
if (user.Id == ctx.Guild.OwnerId
|| (ctx.User.Id != ctx.Guild.OwnerId
&& guser.GetRoles().Max(x => x.Position) <= userRoles.Max(x => x.Position)))
return;
try
{
await user.RemoveRolesAsync(userRoles);
await Response().Confirm(strs.rar(Format.Bold(user.ToString()))).SendAsync();
}
catch (Exception)
{
await Response().Error(strs.rar_err).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task CreateRole([Leftover] string roleName = null)
{
if (string.IsNullOrWhiteSpace(roleName))
return;
var r = await ctx.Guild.CreateRoleAsync(roleName, isMentionable: false);
await Response().Confirm(strs.cr(Format.Bold(r.Name))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task DeleteRole([Leftover] IRole role)
{
var guser = (IGuildUser)ctx.User;
if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position)
return;
await role.DeleteAsync();
await Response().Confirm(strs.dr(Format.Bold(role.Name))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task RoleHoist([Leftover] IRole role)
{
var newHoisted = !role.IsHoisted;
await role.ModifyAsync(r => r.Hoist = newHoisted);
if (newHoisted)
await Response().Confirm(strs.rolehoist_enabled(Format.Bold(role.Name))).SendAsync();
else
await Response().Confirm(strs.rolehoist_disabled(Format.Bold(role.Name))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(1)]
public async Task RoleColor([Leftover] IRole role)
=> await Response().Confirm("Role Color", role.Color.RawValue.ToString("x6")).SendAsync();
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
[Priority(0)]
public async Task RoleColor(Color color, [Leftover] IRole role)
{
try
{
var rgba32 = color.ToPixel<Rgba32>();
await role.ModifyAsync(r => r.Color = new Discord.Color(rgba32.R, rgba32.G, rgba32.B));
await Response().Confirm(strs.rc(Format.Bold(role.Name))).SendAsync();
}
catch (Exception)
{
await Response().Error(strs.rc_perms).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task StickyRoles()
{
var newState = await _stickyRoleSvc.ToggleStickyRoles(ctx.Guild.Id);
if (newState)
{
await Response().Confirm(strs.sticky_roles_enabled).SendAsync();
}
else
{
await Response().Confirm(strs.sticky_roles_disabled).SendAsync();
}
}
}
}

View file

@ -0,0 +1,138 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using EllieBot.Db.Models;
using EllieBot.Common.ModuleBehaviors;
namespace EllieBot.Modules.Administration;
public sealed class StickyRolesService : IEService, IReadyExecutor
{
private readonly DiscordSocketClient _client;
private readonly IBotCredentials _creds;
private readonly DbService _db;
private HashSet<ulong> _stickyRoles = new();
public StickyRolesService(
DiscordSocketClient client,
IBotCredentials creds,
DbService db)
{
_client = client;
_creds = creds;
_db = db;
}
public async Task OnReadyAsync()
{
await using (var ctx = _db.GetDbContext())
{
_stickyRoles = (await ctx
.Set<GuildConfig>()
.ToLinqToDBTable()
.Where(x => Linq2DbExpressions.GuildOnShard(x.GuildId,
_creds.TotalShards,
_client.ShardId))
.Where(x => x.StickyRoles)
.Select(x => x.GuildId)
.ToListAsync())
.ToHashSet();
}
_client.UserJoined += ClientOnUserJoined;
_client.UserLeft += ClientOnUserLeft;
// cleanup old ones every hour
// 30 days retention
if (_client.ShardId == 0)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync())
{
await using var ctx = _db.GetDbContext();
await ctx.GetTable<StickyRole>()
.Where(x => x.DateAdded < DateTime.UtcNow - TimeSpan.FromDays(30))
.DeleteAsync();
}
}
}
private Task ClientOnUserLeft(SocketGuild guild, SocketUser user)
{
if (user is not SocketGuildUser gu)
return Task.CompletedTask;
if (!_stickyRoles.Contains(guild.Id))
return Task.CompletedTask;
_ = Task.Run(async () => await SaveRolesAsync(guild.Id, gu.Id, gu.Roles));
return Task.CompletedTask;
}
private async Task SaveRolesAsync(ulong guildId, ulong userId, IReadOnlyCollection<SocketRole> guRoles)
{
await using var ctx = _db.GetDbContext();
await ctx.GetTable<StickyRole>()
.InsertAsync(() => new()
{
GuildId = guildId,
UserId = userId,
RoleIds = string.Join(',',
guRoles.Where(x => !x.IsEveryone && !x.IsManaged).Select(x => x.Id.ToString())),
DateAdded = DateTime.UtcNow
});
}
private Task ClientOnUserJoined(SocketGuildUser user)
{
if (!_stickyRoles.Contains(user.Guild.Id))
return Task.CompletedTask;
_ = Task.Run(async () =>
{
var roles = await GetRolesAsync(user.Guild.Id, user.Id);
await user.AddRolesAsync(roles);
});
return Task.CompletedTask;
}
private async Task<ulong[]> GetRolesAsync(ulong guildId, ulong userId)
{
await using var ctx = _db.GetDbContext();
var stickyRolesEntry = await ctx
.GetTable<StickyRole>()
.Where(x => x.GuildId == guildId && x.UserId == userId)
.DeleteWithOutputAsync();
if (stickyRolesEntry is { Length: > 0 })
{
return stickyRolesEntry[0].GetRoleIds();
}
return [];
}
public async Task<bool> ToggleStickyRoles(ulong guildId, bool? newState = null)
{
await using var ctx = _db.GetDbContext();
var config = ctx.GuildConfigsForId(guildId, set => set);
config.StickyRoles = newState ?? !config.StickyRoles;
await ctx.SaveChangesAsync();
if (config.StickyRoles)
{
_stickyRoles.Add(guildId);
}
else
{
_stickyRoles.Remove(guildId);
}
return config.StickyRoles;
}
}

View file

@ -0,0 +1,169 @@
using System.Net.Http.Json;
using System.Text;
using EllieBot.Common.ModuleBehaviors;
using System.Text.Json.Serialization;
namespace EllieBot.Modules.Administration.Self;
public sealed class ToastielabReleaseModel
{
[JsonPropertyName("tag_name")]
public required string TagName { get; init; }
}
public sealed class CheckForUpdatesService : IEService, IReadyExecutor
{
private readonly BotConfigService _bcs;
private readonly IBotCredsProvider _bcp;
private readonly IHttpClientFactory _httpFactory;
private readonly DiscordSocketClient _client;
private readonly IMessageSenderService _sender;
private const string RELEASES_URL = "https://toastielab.dev/Emotions-stuff/elliebot/releases";
public CheckForUpdatesService(
BotConfigService bcs,
IBotCredsProvider bcp,
IHttpClientFactory httpFactory,
DiscordSocketClient client,
IMessageSenderService sender)
{
_bcs = bcs;
_bcp = bcp;
_httpFactory = httpFactory;
_client = client;
_sender = sender;
}
public async Task OnReadyAsync()
{
if (_client.ShardId != 0)
return;
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync())
{
var conf = _bcs.Data;
if (!conf.CheckForUpdates)
continue;
try
{
using var http = _httpFactory.CreateClient();
var toastielabRelease = (await http.GetFromJsonAsync<ToastielabReleaseModel[]>(RELEASES_URL))
?.FirstOrDefault();
if (toastielabRelease?.TagName is null)
continue;
var latest = toastielabRelease.TagName;
var latestVersion = Version.Parse(latest);
var lastKnownVersion = GetLastKnownVersion();
if (lastKnownVersion is null)
{
UpdateLastKnownVersion(latestVersion);
continue;
}
if (latestVersion > lastKnownVersion)
{
UpdateLastKnownVersion(latestVersion);
// pull changelog
var changelog = await http.GetStringAsync("https://toastielab.dev/Emotions-stuff/elliebot/raw/branch/v5/CHANGELOG.md");
var thisVersionChangelog = GetVersionChangelog(latestVersion, changelog);
if (string.IsNullOrWhiteSpace(thisVersionChangelog))
{
Log.Warning("New version {BotVersion} was found but changelog is unavailable",
thisVersionChangelog);
continue;
}
var creds = _bcp.GetCreds();
await creds.OwnerIds
.Select(async x =>
{
var user = await _client.GetUserAsync(x);
if (user is null)
return;
var eb = _sender.CreateEmbed()
.WithOkColor()
.WithAuthor($"EllieBot v{latest} Released!")
.WithTitle("Changelog")
.WithUrl("https://toastielab.dev/Emotions-stuff/elliebot/src/branch/v5/CHANGELOG.md")
.WithDescription(thisVersionChangelog.TrimTo(4096))
.WithFooter(
"You may disable these messages by typing '.conf bot checkforupdates false'");
await _sender.Response(user).Embed(eb).SendAsync();
})
.WhenAll();
}
}
catch (Exception ex)
{
Log.Error(ex, "Error while checking for new bot release: {ErrorMessage}", ex.Message);
}
}
}
private string? GetVersionChangelog(Version latestVersion, string changelog)
{
var clSpan = changelog.AsSpan();
var sb = new StringBuilder();
var started = false;
foreach (var line in clSpan.EnumerateLines())
{
// if we're at the current version, keep reading lines and adding to the output
if (started)
{
// if we got to previous version, end
if (line.StartsWith("## ["))
break;
// if we're reading a new segment, reformat it to print it better to discord
if (line.StartsWith("### "))
{
sb.AppendLine(Format.Bold(line.ToString()));
}
else
{
sb.AppendLine(line.ToString());
}
continue;
}
if (line.StartsWith($"## [{latestVersion.ToString()}]"))
{
started = true;
continue;
}
}
return sb.ToString();
}
private const string LAST_KNOWN_VERSION_PATH = "data/last_known_version.txt";
private Version? GetLastKnownVersion()
{
if (!File.Exists(LAST_KNOWN_VERSION_PATH))
return null;
return Version.TryParse(File.ReadAllText(LAST_KNOWN_VERSION_PATH), out var ver)
? ver
: null;
}
private void UpdateLastKnownVersion(Version version)
{
File.WriteAllText("data/last_known_version.txt", version.ToString());
}
}

View file

@ -0,0 +1,597 @@
#nullable disable
using Discord.Rest;
using EllieBot.Modules.Administration.Services;
using EllieBot.Db.Models;
using Ellie.Common.Marmalade;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class SelfCommands : EllieModule<SelfService>
{
public enum SettableUserStatus
{
Online,
Invisible,
Idle,
Dnd
}
private readonly DiscordSocketClient _client;
private readonly IBotStrings _strings;
private readonly IMarmaladeLoaderService _marmaladeLoader;
private readonly ICoordinator _coord;
private readonly DbService _db;
public SelfCommands(
DiscordSocketClient client,
DbService db,
IBotStrings strings,
ICoordinator coord,
IMarmaladeLoaderService marmaladeLoader)
{
_client = client;
_db = db;
_strings = strings;
_coord = coord;
_marmaladeLoader = marmaladeLoader;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[OwnerOnly]
public Task CacheUsers()
=> CacheUsers(ctx.Guild);
[Cmd]
[OwnerOnly]
public async Task CacheUsers(IGuild guild)
{
var downloadUsersTask = guild.DownloadUsersAsync();
var message = await Response().Pending(strs.cache_users_pending).SendAsync();
await downloadUsersTask;
var users = (await guild.GetUsersAsync(CacheMode.CacheOnly))
.Cast<IUser>()
.ToList();
var (added, updated) = await _service.RefreshUsersAsync(users);
await message.ModifyAsync(x =>
x.Embed = _sender.CreateEmbed()
.WithDescription(GetText(strs.cache_users_done(added, updated)))
.WithOkColor()
.Build()
);
}
[Cmd]
[OwnerOnly]
public async Task DoAs(IUser user, [Leftover] string message)
{
if (ctx.User is not IGuildUser { GuildPermissions.Administrator: true })
return;
if (ctx.Guild is SocketGuild sg
&& ctx.Channel is ISocketMessageChannel ch
&& ctx.Message is SocketUserMessage msg)
{
var fakeMessage = new DoAsUserMessage(msg, user, message);
await _cmdHandler.TryRunCommand(sg, ch, fakeMessage);
}
else
{
await Response().Error(strs.error_occured).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[OwnerOnly]
public async Task StartupCommandAdd([Leftover] string cmdText)
{
if (cmdText.StartsWith(prefix + "die", StringComparison.InvariantCulture))
return;
var guser = (IGuildUser)ctx.User;
var cmd = new AutoCommand
{
CommandText = cmdText,
ChannelId = ctx.Channel.Id,
ChannelName = ctx.Channel.Name,
GuildId = ctx.Guild?.Id,
GuildName = ctx.Guild?.Name,
VoiceChannelId = guser.VoiceChannel?.Id,
VoiceChannelName = guser.VoiceChannel?.Name,
Interval = 0
};
_service.AddNewAutoCommand(cmd);
await Response()
.Embed(_sender.CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.scadd))
.AddField(GetText(strs.server),
cmd.GuildId is null ? "-" : $"{cmd.GuildName}/{cmd.GuildId}",
true)
.AddField(GetText(strs.channel), $"{cmd.ChannelName}/{cmd.ChannelId}", true)
.AddField(GetText(strs.command_text), cmdText))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[OwnerOnly]
public async Task AutoCommandAdd(int interval, [Leftover] string cmdText)
{
if (cmdText.StartsWith(prefix + "die", StringComparison.InvariantCulture))
return;
if (interval < 5)
return;
var guser = (IGuildUser)ctx.User;
var cmd = new AutoCommand
{
CommandText = cmdText,
ChannelId = ctx.Channel.Id,
ChannelName = ctx.Channel.Name,
GuildId = ctx.Guild?.Id,
GuildName = ctx.Guild?.Name,
VoiceChannelId = guser.VoiceChannel?.Id,
VoiceChannelName = guser.VoiceChannel?.Name,
Interval = interval
};
_service.AddNewAutoCommand(cmd);
await Response().Confirm(strs.autocmd_add(Format.Code(Format.Sanitize(cmdText)), cmd.Interval)).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[OwnerOnly]
public async Task StartupCommandsList(int page = 1)
{
if (page-- < 1)
return;
var scmds = _service.GetStartupCommands().Skip(page * 5).Take(5).ToList();
if (scmds.Count == 0)
await Response().Error(strs.startcmdlist_none).SendAsync();
else
{
var i = 0;
await Response()
.Confirm(text: string.Join("\n",
scmds.Select(x => $@"```css
#{++i + (page * 5)}
[{GetText(strs.server)}]: {(x.GuildId.HasValue ? $"{x.GuildName} #{x.GuildId}" : "-")}
[{GetText(strs.channel)}]: {x.ChannelName} #{x.ChannelId}
[{GetText(strs.command_text)}]: {x.CommandText}```")),
title: string.Empty,
footer: GetText(strs.page(page + 1)))
.SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[OwnerOnly]
public async Task AutoCommandsList(int page = 1)
{
if (page-- < 1)
return;
var scmds = _service.GetAutoCommands().Skip(page * 5).Take(5).ToList();
if (!scmds.Any())
await Response().Error(strs.autocmdlist_none).SendAsync();
else
{
var i = 0;
await Response()
.Confirm(text: string.Join("\n",
scmds.Select(x => $@"```css
#{++i + (page * 5)}
[{GetText(strs.server)}]: {(x.GuildId.HasValue ? $"{x.GuildName} #{x.GuildId}" : "-")}
[{GetText(strs.channel)}]: {x.ChannelName} #{x.ChannelId}
{GetIntervalText(x.Interval)}
[{GetText(strs.command_text)}]: {x.CommandText}```")),
title: string.Empty,
footer: GetText(strs.page(page + 1)))
.SendAsync();
}
}
private string GetIntervalText(int interval)
=> $"[{GetText(strs.interval)}]: {interval}";
[Cmd]
[OwnerOnly]
public async Task Wait(int miliseconds)
{
if (miliseconds <= 0)
return;
ctx.Message.DeleteAfter(0);
try
{
var msg = await Response().Confirm($"⏲ {miliseconds}ms").SendAsync();
msg.DeleteAfter(miliseconds / 1000);
}
catch { }
await Task.Delay(miliseconds);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[OwnerOnly]
public async Task AutoCommandRemove([Leftover] int index)
{
if (!_service.RemoveAutoCommand(--index, out _))
{
await Response().Error(strs.acrm_fail).SendAsync();
return;
}
await ctx.OkAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[OwnerOnly]
public async Task StartupCommandRemove([Leftover] int index)
{
if (!_service.RemoveStartupCommand(--index, out _))
await Response().Error(strs.scrm_fail).SendAsync();
else
await Response().Confirm(strs.scrm).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[OwnerOnly]
public async Task StartupCommandsClear()
{
_service.ClearStartupCommands();
await Response().Confirm(strs.startcmds_cleared).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task ForwardMessages()
{
var enabled = _service.ForwardMessages();
if (enabled)
await Response().Confirm(strs.fwdm_start).SendAsync();
else
await Response().Pending(strs.fwdm_stop).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task ForwardToAll()
{
var enabled = _service.ForwardToAll();
if (enabled)
await Response().Confirm(strs.fwall_start).SendAsync();
else
await Response().Pending(strs.fwall_stop).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[OwnerOnly]
public async Task ForwardToChannel()
{
var enabled = _service.ForwardToChannel(ctx.Channel.Id);
if (enabled)
await Response().Confirm(strs.fwch_start).SendAsync();
else
await Response().Pending(strs.fwch_stop).SendAsync();
}
[Cmd]
public async Task ShardStats(int page = 1)
{
if (--page < 0)
return;
var statuses = _coord.GetAllShardStatuses();
var status = string.Join(" : ",
statuses.Select(x => (ConnectionStateToEmoji(x), x))
.GroupBy(x => x.Item1)
.Select(x => $"`{x.Count()} {x.Key}`")
.ToArray());
var allShardStrings = statuses.Select(st =>
{
var timeDiff = DateTime.UtcNow - st.LastUpdate;
var stateStr = ConnectionStateToEmoji(st);
var maxGuildCountLength =
statuses.Max(x => x.GuildCount).ToString().Length;
return $"`{stateStr} "
+ $"| #{st.ShardId.ToString().PadBoth(3)} "
+ $"| {timeDiff:mm\\:ss} "
+ $"| {st.GuildCount.ToString().PadBoth(maxGuildCountLength)} `";
})
.ToArray();
await Response()
.Paginated()
.Items(allShardStrings)
.PageSize(25)
.CurrentPage(page)
.Page((items, _) =>
{
var str = string.Join("\n", items);
if (string.IsNullOrWhiteSpace(str))
str = GetText(strs.no_shards_on_page);
return _sender.CreateEmbed().WithOkColor().WithDescription($"{status}\n\n{str}");
})
.SendAsync();
}
private static string ConnectionStateToEmoji(ShardStatus status)
{
var timeDiff = DateTime.UtcNow - status.LastUpdate;
return status.ConnectionState switch
{
ConnectionState.Disconnected => "🔻",
_ when timeDiff > TimeSpan.FromSeconds(30) => " ❗ ",
ConnectionState.Connected => "✅",
_ => " ⏳"
};
}
[Cmd]
[OwnerOnly]
public async Task RestartShard(int shardId)
{
var success = _coord.RestartShard(shardId);
if (success)
await Response().Confirm(strs.shard_reconnecting(Format.Bold("#" + shardId))).SendAsync();
else
await Response().Error(strs.no_shard_id).SendAsync();
}
[Cmd]
[OwnerOnly]
public Task Leave([Leftover] string guildStr)
=> _service.LeaveGuild(guildStr);
[Cmd]
[OwnerOnly]
public async Task DeleteEmptyServers()
{
await ctx.Channel.TriggerTypingAsync();
var toLeave = _client.Guilds
.Where(s => s.MemberCount == 1 && s.Users.Count == 1)
.ToList();
foreach (var server in toLeave)
{
try
{
await server.DeleteAsync();
Log.Information("Deleted server {ServerName} [{ServerId}]",
server.Name,
server.Id);
}
catch (Exception ex)
{
Log.Warning(ex,
"Error leaving server {ServerName} [{ServerId}]",
server.Name,
server.Id);
}
}
await Response().Confirm(strs.deleted_x_servers(toLeave.Count)).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task Die(bool graceful = false)
{
try
{
await _client.SetStatusAsync(UserStatus.Invisible);
_ = _client.StopAsync();
await Response().Confirm(strs.shutting_down).SendAsync();
}
catch
{
// ignored
}
await Task.Delay(2000);
_coord.Die(graceful);
}
[Cmd]
[OwnerOnly]
public async Task Restart()
{
var success = _coord.RestartBot();
if (!success)
{
await Response().Error(strs.restart_fail).SendAsync();
return;
}
try { await Response().Confirm(strs.restarting).SendAsync(); }
catch { }
}
[Cmd]
[OwnerOnly]
public async Task SetName([Leftover] string newName)
{
if (string.IsNullOrWhiteSpace(newName))
return;
try
{
await _client.CurrentUser.ModifyAsync(u => u.Username = newName);
}
catch (RateLimitedException)
{
Log.Warning("You've been ratelimited. Wait 2 hours to change your name");
}
await Response().Confirm(strs.bot_name(Format.Bold(newName))).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task SetStatus([Leftover] SettableUserStatus status)
{
await _client.SetStatusAsync(SettableUserStatusToUserStatus(status));
await Response().Confirm(strs.bot_status(Format.Bold(status.ToString()))).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task SetAvatar([Leftover] string img = null)
{
var success = await _service.SetAvatar(img);
if (success)
await Response().Confirm(strs.set_avatar).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task SetBanner([Leftover] string img = null)
{
var success = await _service.SetBanner(img);
if (success)
await Response().Confirm(strs.set_banner).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task SetActivity(ActivityType? type, [Leftover] string game = null)
{
// var rep = new ReplacementBuilder().WithDefault(Context).Build();
var repCtx = new ReplacementContext(ctx);
await _service.SetActivityAsync(game is null ? game : await repSvc.ReplaceAsync(game, repCtx), type);
await Response().Confirm(strs.set_activity).SendAsync();
}
[Cmd]
[OwnerOnly]
public Task SetActivity([Leftover] string game = null)
=> SetActivity(null, game);
public class SetActivityOptions
{
public ActivityType? Type { get; set; }
public string Game { get; set; }
}
[Cmd]
[OwnerOnly]
public async Task SetStream(string url, [Leftover] string name = null)
{
name ??= "";
await _service.SetStreamAsync(name, url);
await Response().Confirm(strs.set_stream).SendAsync();
}
public enum SendWhere
{
User = 0,
U = 0,
Usr = 0,
Channel = 1,
Ch = 1,
Chan = 1,
}
[Cmd]
[OwnerOnly]
public async Task Send(SendWhere to, ulong id, [Leftover] SmartText text)
{
var ch = to switch
{
SendWhere.User => await ((await _client.Rest.GetUserAsync(id))?.CreateDMChannelAsync()
?? Task.FromResult<RestDMChannel>(null)),
SendWhere.Channel => await _client.Rest.GetChannelAsync(id) as IMessageChannel,
_ => null
};
if (ch is null)
{
await Response().Error(strs.invalid_format).SendAsync();
return;
}
var repCtx = new ReplacementContext(ctx);
text = await repSvc.ReplaceAsync(text, repCtx);
await Response().Channel(ch).Text(text).SendAsync();
await ctx.OkAsync();
}
[Cmd]
[OwnerOnly]
public async Task StringsReload()
{
_strings.Reload();
await _marmaladeLoader.ReloadStrings();
await Response().Confirm(strs.bot_strings_reloaded).SendAsync();
}
[Cmd]
[OwnerOnly]
public async Task CoordReload()
{
await _coord.Reload();
await ctx.OkAsync();
}
private static UserStatus SettableUserStatusToUserStatus(SettableUserStatus sus)
{
switch (sus)
{
case SettableUserStatus.Online:
return UserStatus.Online;
case SettableUserStatus.Invisible:
return UserStatus.Invisible;
case SettableUserStatus.Idle:
return UserStatus.AFK;
case SettableUserStatus.Dnd:
return UserStatus.DoNotDisturb;
}
return UserStatus.Online;
}
}
}

View file

@ -0,0 +1,494 @@
#nullable disable
using EllieBot.Common.ModuleBehaviors;
using EllieBot.Db.Models;
using System.Collections.Immutable;
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace EllieBot.Modules.Administration.Services;
public sealed class SelfService : IExecNoCommand, IReadyExecutor, IEService
{
private readonly CommandHandler _cmdHandler;
private readonly DbService _db;
private readonly IBotStrings _strings;
private readonly DiscordSocketClient _client;
private readonly IBotCredentials _creds;
private ImmutableDictionary<ulong, IDMChannel> ownerChannels =
new Dictionary<ulong, IDMChannel>().ToImmutableDictionary();
private ConcurrentDictionary<ulong?, ConcurrentDictionary<int, Timer>> autoCommands = new();
private readonly IHttpClientFactory _httpFactory;
private readonly BotConfigService _bss;
private readonly IPubSub _pubSub;
private readonly IMessageSenderService _sender;
//keys
private readonly TypedKey<ActivityPubData> _activitySetKey;
private readonly TypedKey<string> _guildLeaveKey;
public SelfService(
DiscordSocketClient client,
CommandHandler cmdHandler,
DbService db,
IBotStrings strings,
IBotCredentials creds,
IHttpClientFactory factory,
BotConfigService bss,
IPubSub pubSub,
IMessageSenderService sender)
{
_cmdHandler = cmdHandler;
_db = db;
_strings = strings;
_client = client;
_creds = creds;
_httpFactory = factory;
_bss = bss;
_pubSub = pubSub;
_sender = sender;
_activitySetKey = new("activity.set");
_guildLeaveKey = new("guild.leave");
HandleStatusChanges();
_pubSub.Sub(_guildLeaveKey,
async input =>
{
var guildStr = input.ToString().Trim().ToUpperInvariant();
if (string.IsNullOrWhiteSpace(guildStr))
return;
var server = _client.Guilds.FirstOrDefault(g => g.Id.ToString() == guildStr
|| g.Name.Trim().ToUpperInvariant() == guildStr);
if (server is null)
return;
if (server.OwnerId != _client.CurrentUser.Id)
{
await server.LeaveAsync();
Log.Information("Left server {Name} [{Id}]", server.Name, server.Id);
}
else
{
await server.DeleteAsync();
Log.Information("Deleted server {Name} [{Id}]", server.Name, server.Id);
}
});
}
public async Task OnReadyAsync()
{
await using var uow = _db.GetDbContext();
autoCommands = uow.Set<AutoCommand>()
.AsNoTracking()
.Where(x => x.Interval >= 5)
.AsEnumerable()
.GroupBy(x => x.GuildId)
.ToDictionary(x => x.Key,
y => y.ToDictionary(x => x.Id, TimerFromAutoCommand).ToConcurrent())
.ToConcurrent();
var startupCommands = uow.Set<AutoCommand>().AsNoTracking().Where(x => x.Interval == 0);
foreach (var cmd in startupCommands)
{
try
{
await ExecuteCommand(cmd);
}
catch
{
}
}
if (_client.ShardId == 0)
await LoadOwnerChannels();
}
private Timer TimerFromAutoCommand(AutoCommand x)
=> new(async obj => await ExecuteCommand((AutoCommand)obj), x, x.Interval * 1000, x.Interval * 1000);
private async Task ExecuteCommand(AutoCommand cmd)
{
try
{
if (cmd.GuildId is null)
return;
var guildShard = (int)((cmd.GuildId.Value >> 22) % (ulong)_creds.TotalShards);
if (guildShard != _client.ShardId)
return;
var prefix = _cmdHandler.GetPrefix(cmd.GuildId);
//if someone already has .die as their startup command, ignore it
if (cmd.CommandText.StartsWith(prefix + "die", StringComparison.InvariantCulture))
return;
await _cmdHandler.ExecuteExternal(cmd.GuildId, cmd.ChannelId, cmd.CommandText);
}
catch (Exception ex)
{
Log.Warning(ex, "Error in SelfService ExecuteCommand");
}
}
public void AddNewAutoCommand(AutoCommand cmd)
{
using (var uow = _db.GetDbContext())
{
uow.Set<AutoCommand>().Add(cmd);
uow.SaveChanges();
}
if (cmd.Interval >= 5)
{
var autos = autoCommands.GetOrAdd(cmd.GuildId, new ConcurrentDictionary<int, Timer>());
autos.AddOrUpdate(cmd.Id,
_ => TimerFromAutoCommand(cmd),
(_, old) =>
{
old.Change(Timeout.Infinite, Timeout.Infinite);
return TimerFromAutoCommand(cmd);
});
}
}
public IEnumerable<AutoCommand> GetStartupCommands()
{
using var uow = _db.GetDbContext();
return uow.Set<AutoCommand>().AsNoTracking().Where(x => x.Interval == 0).OrderBy(x => x.Id).ToList();
}
public IEnumerable<AutoCommand> GetAutoCommands()
{
using var uow = _db.GetDbContext();
return uow.Set<AutoCommand>().AsNoTracking().Where(x => x.Interval >= 5).OrderBy(x => x.Id).ToList();
}
private async Task LoadOwnerChannels()
{
var channels = await _creds.OwnerIds.Select(id =>
{
var user = _client.GetUser(id);
if (user is null)
return Task.FromResult<IDMChannel>(null);
return user.CreateDMChannelAsync();
})
.WhenAll();
ownerChannels = channels.Where(x => x is not null)
.ToDictionary(x => x.Recipient.Id, x => x)
.ToImmutableDictionary();
if (!ownerChannels.Any())
{
Log.Warning(
"No owner channels created! Make sure you've specified the correct OwnerId in the creds.yml file and invited the bot to a Discord server");
}
else
{
Log.Information("Created {OwnerChannelCount} out of {TotalOwnerChannelCount} owner message channels",
ownerChannels.Count,
_creds.OwnerIds.Count);
}
}
public Task LeaveGuild(string guildStr)
=> _pubSub.Pub(_guildLeaveKey, guildStr);
// forwards dms
public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
{
var bs = _bss.Data;
if (msg.Channel is IDMChannel && bs.ForwardMessages && (ownerChannels.Any() || bs.ForwardToChannel is not null))
{
var title = _strings.GetText(strs.dm_from) + $" [{msg.Author}]({msg.Author.Id})";
var attachamentsTxt = _strings.GetText(strs.attachments);
var toSend = msg.Content;
if (msg.Attachments.Count > 0)
{
toSend += $"\n\n{Format.Code(attachamentsTxt)}:\n"
+ string.Join("\n", msg.Attachments.Select(a => a.ProxyUrl));
}
if (bs.ForwardToAllOwners)
{
var allOwnerChannels = ownerChannels.Values;
foreach (var ownerCh in allOwnerChannels.Where(ch => ch.Recipient.Id != msg.Author.Id))
{
try
{
await _sender.Response(ownerCh).Confirm(title, toSend).SendAsync();
}
catch
{
Log.Warning("Can't contact owner with id {OwnerId}", ownerCh.Recipient.Id);
}
}
}
else if (bs.ForwardToChannel is ulong cid)
{
try
{
if (_client.GetChannel(cid) is ITextChannel ch)
await _sender.Response(ch).Confirm(title, toSend).SendAsync();
}
catch
{
Log.Warning("Error forwarding message to the channel");
}
}
else
{
var firstOwnerChannel = ownerChannels.Values.First();
if (firstOwnerChannel.Recipient.Id != msg.Author.Id)
{
try
{
await _sender.Response(firstOwnerChannel).Confirm(title, toSend).SendAsync();
}
catch
{
// ignored
}
}
}
}
}
public bool RemoveStartupCommand(int index, out AutoCommand cmd)
{
using var uow = _db.GetDbContext();
cmd = uow.Set<AutoCommand>().AsNoTracking().Where(x => x.Interval == 0).Skip(index).FirstOrDefault();
if (cmd is not null)
{
uow.Remove(cmd);
uow.SaveChanges();
return true;
}
return false;
}
public bool RemoveAutoCommand(int index, out AutoCommand cmd)
{
using var uow = _db.GetDbContext();
cmd = uow.Set<AutoCommand>().AsNoTracking().Where(x => x.Interval >= 5).Skip(index).FirstOrDefault();
if (cmd is not null)
{
uow.Remove(cmd);
if (autoCommands.TryGetValue(cmd.GuildId, out var autos))
{
if (autos.TryRemove(cmd.Id, out var timer))
timer.Change(Timeout.Infinite, Timeout.Infinite);
}
uow.SaveChanges();
return true;
}
return false;
}
public async Task<bool> SetAvatar(string img)
{
if (string.IsNullOrWhiteSpace(img))
return false;
if (!Uri.IsWellFormedUriString(img, UriKind.Absolute))
return false;
var uri = new Uri(img);
using var http = _httpFactory.CreateClient();
using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
if (!sr.IsImage())
return false;
// i can't just do ReadAsStreamAsync because dicord.net's image poops itself
var imgData = await sr.Content.ReadAsByteArrayAsync();
await using var imgStream = imgData.ToStream();
await _client.CurrentUser.ModifyAsync(u => u.Avatar = new Image(imgStream));
return true;
}
public async Task<bool> SetBanner(string img)
{
if (string.IsNullOrWhiteSpace(img))
{
return false;
}
if (!Uri.IsWellFormedUriString(img, UriKind.Absolute))
{
return false;
}
var uri = new Uri(img);
using var http = _httpFactory.CreateClient();
using var sr = await http.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
if (!sr.IsImage())
{
return false;
}
if (sr.GetContentLength() > 8.Megabytes())
{
return false;
}
await using var imageStream = await sr.Content.ReadAsStreamAsync();
await _client.CurrentUser.ModifyAsync(x => x.Banner = new Image(imageStream));
return true;
}
public void ClearStartupCommands()
{
using var uow = _db.GetDbContext();
var toRemove = uow.Set<AutoCommand>().AsNoTracking().Where(x => x.Interval == 0);
uow.Set<AutoCommand>().RemoveRange(toRemove);
uow.SaveChanges();
}
public bool ForwardMessages()
{
var isForwarding = false;
_bss.ModifyConfig(config => { isForwarding = config.ForwardMessages = !config.ForwardMessages; });
return isForwarding;
}
public bool ForwardToAll()
{
var isToAll = false;
_bss.ModifyConfig(config => { isToAll = config.ForwardToAllOwners = !config.ForwardToAllOwners; });
return isToAll;
}
public bool ForwardToChannel(ulong? channelId)
{
using var uow = _db.GetDbContext();
_bss.ModifyConfig(config =>
{
config.ForwardToChannel = channelId == config.ForwardToChannel
? null
: channelId;
});
return channelId is not null;
}
private void HandleStatusChanges()
=> _pubSub.Sub(_activitySetKey,
async data =>
{
try
{
if (data.Type is { } activityType)
await _client.SetGameAsync(data.Name, data.Link, activityType);
else
await _client.SetCustomStatusAsync(data.Name);
}
catch (Exception ex)
{
Log.Warning(ex, "Error setting activity");
}
});
public Task SetActivityAsync(string game, ActivityType? type)
=> _pubSub.Pub(_activitySetKey,
new()
{
Name = game,
Link = null,
Type = type
});
public Task SetStreamAsync(string name, string link)
=> _pubSub.Pub(_activitySetKey,
new()
{
Name = name,
Link = link,
Type = ActivityType.Streaming
});
private sealed class ActivityPubData
{
public string Name { get; init; }
public string Link { get; init; }
public ActivityType? Type { get; init; }
}
/// <summary>
/// Adds the specified <paramref name="users"/> to the database. If a database user with placeholder name
/// and discriminator is present in <paramref name="users"/>, their name and discriminator get updated accordingly.
/// </summary>
/// <param name="ctx">This database context.</param>
/// <param name="users">The users to add or update in the database.</param>
/// <returns>A tuple with the amount of new users added and old users updated.</returns>
public async Task<(long UsersAdded, long UsersUpdated)> RefreshUsersAsync(List<IUser> users)
{
await using var ctx = _db.GetDbContext();
var presentDbUsers = await ctx.GetTable<DiscordUser>()
.Select(x => new
{
x.UserId,
x.Username,
x.Discriminator
})
.Where(x => users.Select(y => y.Id).Contains(x.UserId))
.ToArrayAsyncEF();
var usersToAdd = users
.Where(x => !presentDbUsers.Select(x => x.UserId).Contains(x.Id))
.Select(x => new DiscordUser()
{
UserId = x.Id,
AvatarId = x.AvatarId,
Username = x.Username,
Discriminator = x.Discriminator
});
var added = (await ctx.BulkCopyAsync(usersToAdd)).RowsCopied;
var toUpdateUserIds = presentDbUsers
.Where(x => x.Username == "Unknown" && x.Discriminator == "????")
.Select(x => x.UserId)
.ToArray();
foreach (var user in users.Where(x => toUpdateUserIds.Contains(x.Id)))
{
await ctx.GetTable<DiscordUser>()
.Where(x => x.UserId == user.Id)
.UpdateAsync(x => new DiscordUser()
{
Username = user.Username,
Discriminator = user.Discriminator,
// .award tends to set AvatarId and DateAdded to NULL, so account for that.
AvatarId = user.AvatarId,
DateAdded = x.DateAdded ?? DateTime.UtcNow
});
}
return (added, toUpdateUserIds.Length);
}
}

View file

@ -0,0 +1,239 @@
#nullable disable
using EllieBot.Modules.Administration.Services;
using System.Text;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class SelfAssignedRolesCommands : EllieModule<SelfAssignedRolesService>
{
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageMessages)]
[BotPerm(GuildPerm.ManageMessages)]
public async Task AdSarm()
{
var newVal = _service.ToggleAdSarm(ctx.Guild.Id);
if (newVal)
await Response().Confirm(strs.adsarm_enable(prefix)).SendAsync();
else
await Response().Confirm(strs.adsarm_disable(prefix)).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
[Priority(1)]
public Task Asar([Leftover] IRole role)
=> Asar(0, role);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
[Priority(0)]
public async Task Asar(int group, [Leftover] IRole role)
{
var guser = (IGuildUser)ctx.User;
if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position)
return;
var succ = _service.AddNew(ctx.Guild.Id, role, group);
if (succ)
{
await Response()
.Confirm(strs.role_added(Format.Bold(role.Name), Format.Bold(group.ToString())))
.SendAsync();
}
else
await Response().Error(strs.role_in_list(Format.Bold(role.Name))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
[Priority(0)]
public async Task Sargn(int group, [Leftover] string name = null)
{
var set = await _service.SetNameAsync(ctx.Guild.Id, group, name);
if (set)
{
await Response()
.Confirm(strs.group_name_added(Format.Bold(group.ToString()), Format.Bold(name)))
.SendAsync();
}
else
await Response().Confirm(strs.group_name_removed(Format.Bold(group.ToString()))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
public async Task Rsar([Leftover] IRole role)
{
var guser = (IGuildUser)ctx.User;
if (ctx.User.Id != guser.Guild.OwnerId && guser.GetRoles().Max(x => x.Position) <= role.Position)
return;
var success = _service.RemoveSar(role.Guild.Id, role.Id);
if (!success)
await Response().Error(strs.self_assign_not).SendAsync();
else
await Response().Confirm(strs.self_assign_rem(Format.Bold(role.Name))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Lsar(int page = 1)
{
if (--page < 0)
return;
var (exclusive, roles, groups) = _service.GetRoles(ctx.Guild);
await Response()
.Paginated()
.Items(roles.OrderBy(x => x.Model.Group).ToList())
.PageSize(20)
.CurrentPage(page)
.Page((items, _) =>
{
var rolesStr = new StringBuilder();
var roleGroups = items
.GroupBy(x => x.Model.Group)
.OrderBy(x => x.Key);
foreach (var kvp in roleGroups)
{
string groupNameText;
if (!groups.TryGetValue(kvp.Key, out var name))
groupNameText = Format.Bold(GetText(strs.self_assign_group(kvp.Key)));
else
groupNameText = Format.Bold($"{kvp.Key} - {name.TrimTo(25, true)}");
rolesStr.AppendLine("\t\t\t\t ⟪" + groupNameText + "⟫");
foreach (var (model, role) in kvp.AsEnumerable())
{
if (role is null)
{
}
else
{
// first character is invisible space
if (model.LevelRequirement == 0)
rolesStr.AppendLine(" " + role.Name);
else
rolesStr.AppendLine(" " + role.Name + $" (lvl {model.LevelRequirement}+)");
}
}
rolesStr.AppendLine();
}
return _sender.CreateEmbed()
.WithOkColor()
.WithTitle(Format.Bold(GetText(strs.self_assign_list(roles.Count()))))
.WithDescription(rolesStr.ToString())
.WithFooter(exclusive
? GetText(strs.self_assign_are_exclusive)
: GetText(strs.self_assign_are_not_exclusive));
})
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task Togglexclsar()
{
var areExclusive = _service.ToggleEsar(ctx.Guild.Id);
if (areExclusive)
await Response().Confirm(strs.self_assign_excl).SendAsync();
else
await Response().Confirm(strs.self_assign_no_excl).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
public async Task RoleLevelReq(int level, [Leftover] IRole role)
{
if (level < 0)
return;
var succ = _service.SetLevelReq(ctx.Guild.Id, role, level);
if (!succ)
{
await Response().Error(strs.self_assign_not).SendAsync();
return;
}
await Response()
.Confirm(strs.self_assign_level_req(Format.Bold(role.Name),
Format.Bold(level.ToString())))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Iam([Leftover] IRole role)
{
var guildUser = (IGuildUser)ctx.User;
var (result, autoDelete, extra) = await _service.Assign(guildUser, role);
IUserMessage msg;
if (result == SelfAssignedRolesService.AssignResult.ErrNotAssignable)
msg = await Response().Error(strs.self_assign_not).SendAsync();
else if (result == SelfAssignedRolesService.AssignResult.ErrLvlReq)
msg = await Response().Error(strs.self_assign_not_level(Format.Bold(extra.ToString()))).SendAsync();
else if (result == SelfAssignedRolesService.AssignResult.ErrAlreadyHave)
msg = await Response().Error(strs.self_assign_already(Format.Bold(role.Name))).SendAsync();
else if (result == SelfAssignedRolesService.AssignResult.ErrNotPerms)
msg = await Response().Error(strs.self_assign_perms).SendAsync();
else
msg = await Response().Confirm(strs.self_assign_success(Format.Bold(role.Name))).SendAsync();
if (autoDelete)
{
msg.DeleteAfter(3);
ctx.Message.DeleteAfter(3);
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Iamnot([Leftover] IRole role)
{
var guildUser = (IGuildUser)ctx.User;
var (result, autoDelete) = await _service.Remove(guildUser, role);
IUserMessage msg;
if (result == SelfAssignedRolesService.RemoveResult.ErrNotAssignable)
msg = await Response().Error(strs.self_assign_not).SendAsync();
else if (result == SelfAssignedRolesService.RemoveResult.ErrNotHave)
msg = await Response().Error(strs.self_assign_not_have(Format.Bold(role.Name))).SendAsync();
else if (result == SelfAssignedRolesService.RemoveResult.ErrNotPerms)
msg = await Response().Error(strs.self_assign_perms).SendAsync();
else
msg = await Response().Confirm(strs.self_assign_remove(Format.Bold(role.Name))).SendAsync();
if (autoDelete)
{
msg.DeleteAfter(3);
ctx.Message.DeleteAfter(3);
}
}
}
}

View file

@ -0,0 +1,233 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration.Services;
public class SelfAssignedRolesService : IEService
{
public enum AssignResult
{
Assigned, // successfully removed
ErrNotAssignable, // not assignable (error)
ErrAlreadyHave, // you already have that role (error)
ErrNotPerms, // bot doesn't have perms (error)
ErrLvlReq // you are not required level (error)
}
public enum RemoveResult
{
Removed, // successfully removed
ErrNotAssignable, // not assignable (error)
ErrNotHave, // you don't have a role you want to remove (error)
ErrNotPerms // bot doesn't have perms (error)
}
private readonly DbService _db;
public SelfAssignedRolesService(DbService db)
=> _db = db;
public bool AddNew(ulong guildId, IRole role, int group)
{
using var uow = _db.GetDbContext();
var roles = uow.Set<SelfAssignedRole>().GetFromGuild(guildId);
if (roles.Any(s => s.RoleId == role.Id && s.GuildId == role.Guild.Id))
return false;
uow.Set<SelfAssignedRole>().Add(new()
{
Group = group,
RoleId = role.Id,
GuildId = role.Guild.Id
});
uow.SaveChanges();
return true;
}
public bool ToggleAdSarm(ulong guildId)
{
bool newval;
using var uow = _db.GetDbContext();
var config = uow.GuildConfigsForId(guildId, set => set);
newval = config.AutoDeleteSelfAssignedRoleMessages = !config.AutoDeleteSelfAssignedRoleMessages;
uow.SaveChanges();
return newval;
}
public async Task<(AssignResult Result, bool AutoDelete, object extra)> Assign(IGuildUser guildUser, IRole role)
{
LevelStats userLevelData;
await using (var uow = _db.GetDbContext())
{
var stats = uow.GetOrCreateUserXpStats(guildUser.Guild.Id, guildUser.Id);
userLevelData = new(stats.Xp + stats.AwardedXp);
}
var (autoDelete, exclusive, roles) = GetAdAndRoles(guildUser.Guild.Id);
var theRoleYouWant = roles.FirstOrDefault(r => r.RoleId == role.Id);
if (theRoleYouWant is null)
return (AssignResult.ErrNotAssignable, autoDelete, null);
if (theRoleYouWant.LevelRequirement > userLevelData.Level)
return (AssignResult.ErrLvlReq, autoDelete, theRoleYouWant.LevelRequirement);
if (guildUser.RoleIds.Contains(role.Id))
return (AssignResult.ErrAlreadyHave, autoDelete, null);
var roleIds = roles.Where(x => x.Group == theRoleYouWant.Group).Select(x => x.RoleId).ToArray();
if (exclusive)
{
var sameRoles = guildUser.RoleIds.Where(r => roleIds.Contains(r));
foreach (var roleId in sameRoles)
{
var sameRole = guildUser.Guild.GetRole(roleId);
if (sameRole is not null)
{
try
{
await guildUser.RemoveRoleAsync(sameRole);
await Task.Delay(300);
}
catch
{
// ignored
}
}
}
}
try
{
await guildUser.AddRoleAsync(role);
}
catch (Exception ex)
{
return (AssignResult.ErrNotPerms, autoDelete, ex);
}
return (AssignResult.Assigned, autoDelete, null);
}
public async Task<bool> SetNameAsync(ulong guildId, int group, string name)
{
var set = false;
await using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, y => y.Include(x => x.SelfAssignableRoleGroupNames));
var toUpdate = gc.SelfAssignableRoleGroupNames.FirstOrDefault(x => x.Number == group);
if (string.IsNullOrWhiteSpace(name))
{
if (toUpdate is not null)
gc.SelfAssignableRoleGroupNames.Remove(toUpdate);
}
else if (toUpdate is null)
{
gc.SelfAssignableRoleGroupNames.Add(new()
{
Name = name,
Number = group
});
set = true;
}
else
{
toUpdate.Name = name;
set = true;
}
await uow.SaveChangesAsync();
return set;
}
public async Task<(RemoveResult Result, bool AutoDelete)> Remove(IGuildUser guildUser, IRole role)
{
var (autoDelete, _, roles) = GetAdAndRoles(guildUser.Guild.Id);
if (roles.FirstOrDefault(r => r.RoleId == role.Id) is null)
return (RemoveResult.ErrNotAssignable, autoDelete);
if (!guildUser.RoleIds.Contains(role.Id))
return (RemoveResult.ErrNotHave, autoDelete);
try
{
await guildUser.RemoveRoleAsync(role);
}
catch (Exception)
{
return (RemoveResult.ErrNotPerms, autoDelete);
}
return (RemoveResult.Removed, autoDelete);
}
public bool RemoveSar(ulong guildId, ulong roleId)
{
bool success;
using var uow = _db.GetDbContext();
success = uow.Set<SelfAssignedRole>().DeleteByGuildAndRoleId(guildId, roleId);
uow.SaveChanges();
return success;
}
public (bool AutoDelete, bool Exclusive, IReadOnlyCollection<SelfAssignedRole>) GetAdAndRoles(ulong guildId)
{
using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, set => set);
var autoDelete = gc.AutoDeleteSelfAssignedRoleMessages;
var exclusive = gc.ExclusiveSelfAssignedRoles;
var roles = uow.Set<SelfAssignedRole>().GetFromGuild(guildId);
return (autoDelete, exclusive, roles);
}
public bool SetLevelReq(ulong guildId, IRole role, int level)
{
using var uow = _db.GetDbContext();
var roles = uow.Set<SelfAssignedRole>().GetFromGuild(guildId);
var sar = roles.FirstOrDefault(x => x.RoleId == role.Id);
if (sar is not null)
{
sar.LevelRequirement = level;
uow.SaveChanges();
}
else
return false;
return true;
}
public bool ToggleEsar(ulong guildId)
{
bool areExclusive;
using var uow = _db.GetDbContext();
var config = uow.GuildConfigsForId(guildId, set => set);
areExclusive = config.ExclusiveSelfAssignedRoles = !config.ExclusiveSelfAssignedRoles;
uow.SaveChanges();
return areExclusive;
}
public (bool Exclusive, IReadOnlyCollection<(SelfAssignedRole Model, IRole Role)> Roles, IDictionary<int, string>
GroupNames
) GetRoles(IGuild guild)
{
var exclusive = false;
IReadOnlyCollection<(SelfAssignedRole Model, IRole Role)> roles;
IDictionary<int, string> groupNames;
using (var uow = _db.GetDbContext())
{
var gc = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.SelfAssignableRoleGroupNames));
exclusive = gc.ExclusiveSelfAssignedRoles;
groupNames = gc.SelfAssignableRoleGroupNames.ToDictionary(x => x.Number, x => x.Name);
var roleModels = uow.Set<SelfAssignedRole>().GetFromGuild(guild.Id);
roles = roleModels.Select(x => (Model: x, Role: guild.GetRole(x.RoleId)))
.ToList();
uow.Set<SelfAssignedRole>().RemoveRange(roles.Where(x => x.Role is null).Select(x => x.Model).ToArray());
uow.SaveChanges();
}
return (exclusive, roles.Where(x => x.Role is not null).ToList(), groupNames);
}
}

View file

@ -0,0 +1,25 @@
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration;
public sealed class DummyLogCommandService : ILogCommandService
#if GLOBAL_ELLIE
, IEService
#endif
{
public void AddDeleteIgnore(ulong xId)
{
}
public Task LogServer(ulong guildId, ulong channelId, bool actionValue)
=> Task.CompletedTask;
public bool LogIgnore(ulong guildId, ulong itemId, IgnoredItemType itemType)
=> false;
public LogSetting? GetGuildLogSettings(ulong guildId)
=> default;
public bool Log(ulong guildId, ulong? channelId, LogType type)
=> false;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,175 @@
using EllieBot.Common.TypeReaders.Models;
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
[NoPublicBot]
public partial class LogCommands : EllieModule<ILogCommandService>
{
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[OwnerOnly]
public async Task LogServer(PermissionAction action)
{
await _service.LogServer(ctx.Guild.Id, ctx.Channel.Id, action.Value);
if (action.Value)
await Response().Confirm(strs.log_all).SendAsync();
else
await Response().Confirm(strs.log_disabled).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[OwnerOnly]
public async Task LogIgnore()
{
var settings = _service.GetGuildLogSettings(ctx.Guild.Id);
var chs = settings?.LogIgnores.Where(x => x.ItemType == IgnoredItemType.Channel).ToList()
?? new List<IgnoredLogItem>();
var usrs = settings?.LogIgnores.Where(x => x.ItemType == IgnoredItemType.User).ToList()
?? new List<IgnoredLogItem>();
var eb = _sender.CreateEmbed()
.WithOkColor()
.AddField(GetText(strs.log_ignored_channels),
chs.Count == 0
? "-"
: string.Join('\n', chs.Select(x => $"{x.LogItemId} | <#{x.LogItemId}>")))
.AddField(GetText(strs.log_ignored_users),
usrs.Count == 0
? "-"
: string.Join('\n', usrs.Select(x => $"{x.LogItemId} | <@{x.LogItemId}>")));
await Response().Embed(eb).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[OwnerOnly]
public async Task LogIgnore([Leftover] ITextChannel target)
{
var removed = _service.LogIgnore(ctx.Guild.Id, target.Id, IgnoredItemType.Channel);
if (!removed)
{
await Response()
.Confirm(
strs.log_ignore_chan(Format.Bold(target.Mention + "(" + target.Id + ")")))
.SendAsync();
}
else
{
await Response()
.Confirm(
strs.log_not_ignore_chan(Format.Bold(target.Mention + "(" + target.Id + ")")))
.SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[OwnerOnly]
public async Task LogIgnore([Leftover] IUser target)
{
var removed = _service.LogIgnore(ctx.Guild.Id, target.Id, IgnoredItemType.User);
if (!removed)
{
await Response()
.Confirm(strs.log_ignore_user(Format.Bold(target.Mention + "(" + target.Id + ")")))
.SendAsync();
}
else
{
await Response()
.Confirm(strs.log_not_ignore_user(Format.Bold(target.Mention + "(" + target.Id + ")")))
.SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[OwnerOnly]
public async Task LogEvents()
{
var logSetting = _service.GetGuildLogSettings(ctx.Guild.Id);
var str = string.Join("\n",
Enum.GetNames<LogType>()
.Select(x =>
{
var val = logSetting is null ? null : GetLogProperty(logSetting, Enum.Parse<LogType>(x));
if (val is not null)
return $"{Format.Bold(x)} <#{val}>";
return Format.Bold(x);
}));
await Response().Confirm(Format.Bold(GetText(strs.log_events)) + "\n" + str).SendAsync();
}
private static ulong? GetLogProperty(LogSetting l, LogType type)
{
switch (type)
{
case LogType.Other:
return l.LogOtherId;
case LogType.MessageUpdated:
return l.MessageUpdatedId;
case LogType.MessageDeleted:
return l.MessageDeletedId;
case LogType.UserJoined:
return l.UserJoinedId;
case LogType.UserLeft:
return l.UserLeftId;
case LogType.UserBanned:
return l.UserBannedId;
case LogType.UserUnbanned:
return l.UserUnbannedId;
case LogType.UserUpdated:
return l.UserUpdatedId;
case LogType.ChannelCreated:
return l.ChannelCreatedId;
case LogType.ChannelDestroyed:
return l.ChannelDestroyedId;
case LogType.ChannelUpdated:
return l.ChannelUpdatedId;
case LogType.UserPresence:
return l.LogUserPresenceId;
case LogType.VoicePresence:
return l.LogVoicePresenceId;
case LogType.UserMuted:
return l.UserMutedId;
case LogType.UserWarned:
return l.LogWarnsId;
case LogType.ThreadDeleted:
return l.ThreadDeletedId;
case LogType.ThreadCreated:
return l.ThreadCreatedId;
default:
return null;
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[OwnerOnly]
public async Task Log(LogType type)
{
var val = _service.Log(ctx.Guild.Id, ctx.Channel.Id, type);
if (val)
await Response().Confirm(strs.log(Format.Bold(type.ToString()))).SendAsync();
else
await Response().Confirm(strs.log_stop(Format.Bold(type.ToString()))).SendAsync();
}
}
}

View file

@ -0,0 +1,14 @@
// namespace EllieBot.Modules.Administration;
//
// public partial class Administration
// {
// [Group]
// public partial class TicketCommands : EllieModule
// {
// [Cmd]
// public async Task Ticket()
// {
//
// }
// }
// }

View file

@ -0,0 +1,94 @@
#nullable disable
using EllieBot.Db.Models;
using EllieBot.Common.ModuleBehaviors;
namespace EllieBot.Modules.Administration.Services;
public sealed class GuildTimezoneService : ITimezoneService, IReadyExecutor, IEService
{
private readonly ConcurrentDictionary<ulong, TimeZoneInfo> _timezones;
private readonly DbService _db;
private readonly IReplacementPatternStore _repStore;
public GuildTimezoneService(IBot bot, DbService db, IReplacementPatternStore repStore)
{
_timezones = bot.AllGuildConfigs.Select(GetTimzezoneTuple)
.Where(x => x.Timezone is not null)
.ToDictionary(x => x.GuildId, x => x.Timezone)
.ToConcurrent();
_db = db;
_repStore = repStore;
bot.JoinedGuild += Bot_JoinedGuild;
}
private Task Bot_JoinedGuild(GuildConfig arg)
{
var (guildId, tz) = GetTimzezoneTuple(arg);
if (tz is not null)
_timezones.TryAdd(guildId, tz);
return Task.CompletedTask;
}
private static (ulong GuildId, TimeZoneInfo Timezone) GetTimzezoneTuple(GuildConfig x)
{
TimeZoneInfo tz;
try
{
if (x.TimeZoneId is null)
tz = null;
else
tz = TimeZoneInfo.FindSystemTimeZoneById(x.TimeZoneId);
}
catch
{
tz = null;
}
return (x.GuildId, Timezone: tz);
}
public TimeZoneInfo GetTimeZoneOrDefault(ulong? guildId)
{
if (guildId is ulong gid && _timezones.TryGetValue(gid, out var tz))
return tz;
return null;
}
public void SetTimeZone(ulong guildId, TimeZoneInfo tz)
{
using var uow = _db.GetDbContext();
var gc = uow.GuildConfigsForId(guildId, set => set);
gc.TimeZoneId = tz?.Id;
uow.SaveChanges();
if (tz is null)
_timezones.TryRemove(guildId, out tz);
else
_timezones.AddOrUpdate(guildId, tz, (_, _) => tz);
}
public TimeZoneInfo GetTimeZoneOrUtc(ulong? guildId)
=> GetTimeZoneOrDefault(guildId) ?? TimeZoneInfo.Utc;
public Task OnReadyAsync()
{
_repStore.Register("%server.time%",
(IGuild g) =>
{
var to = TimeZoneInfo.Local;
if (g is not null)
{
to = GetTimeZoneOrDefault(g.Id) ?? TimeZoneInfo.Local;
}
return TimeZoneInfo.ConvertTime(DateTime.UtcNow, TimeZoneInfo.Utc, to).ToString("HH:mm ")
+ to.StandardName.GetInitials();
});
return Task.CompletedTask;
}
}

View file

@ -0,0 +1,78 @@
#nullable disable
using EllieBot.Modules.Administration.Services;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class TimeZoneCommands : EllieModule<GuildTimezoneService>
{
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Timezones(int page = 1)
{
page--;
if (page is < 0 or > 20)
return;
var timezones = TimeZoneInfo.GetSystemTimeZones().OrderBy(x => x.BaseUtcOffset).ToArray();
var timezonesPerPage = 20;
var curTime = DateTimeOffset.UtcNow;
var i = 0;
var timezoneStrings = timezones.Select(x => (x, ++i % 2 == 0))
.Select(data =>
{
var (tzInfo, flip) = data;
var nameStr = $"{tzInfo.Id,-30}";
var offset = curTime.ToOffset(tzInfo.GetUtcOffset(curTime))
.ToString("zzz");
if (flip)
return $"{offset} {Format.Code(nameStr)}";
return $"{Format.Code(offset)} {nameStr}";
})
.ToList();
await Response()
.Paginated()
.Items(timezoneStrings)
.PageSize(timezonesPerPage)
.CurrentPage(page)
.Page((items, _) => _sender.CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.timezones_available))
.WithDescription(string.Join("\n", items)))
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task Timezone()
=> await Response().Confirm(strs.timezone_guild(_service.GetTimeZoneOrUtc(ctx.Guild.Id))).SendAsync();
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task Timezone([Leftover] string id)
{
TimeZoneInfo tz;
try { tz = TimeZoneInfo.FindSystemTimeZoneById(id); }
catch { tz = null; }
if (tz is null)
{
await Response().Error(strs.timezone_not_found).SendAsync();
return;
}
_service.SetTimeZone(ctx.Guild.Id, tz);
await Response().Confirm(tz.ToString()).SendAsync();
}
}
}

View file

@ -0,0 +1,986 @@
#nullable disable
using CommandLine;
using EllieBot.Common.TypeReaders.Models;
using EllieBot.Modules.Administration.Services;
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class UserPunishCommands : EllieModule<UserPunishService>
{
public enum AddRole
{
AddRole
}
private readonly MuteService _mute;
public UserPunishCommands(MuteService mute)
{
_mute = mute;
}
private async Task<bool> CheckRoleHierarchy(IGuildUser target)
{
var curUser = ((SocketGuild)ctx.Guild).CurrentUser;
var ownerId = ctx.Guild.OwnerId;
var modMaxRole = ((IGuildUser)ctx.User).GetRoles().Max(r => r.Position);
var targetMaxRole = target.GetRoles().Max(r => r.Position);
var botMaxRole = curUser.GetRoles().Max(r => r.Position);
// bot can't punish a user who is higher in the hierarchy. Discord will return 403
// moderator can be owner, in which case role hierarchy doesn't matter
// otherwise, moderator has to have a higher role
if (botMaxRole <= targetMaxRole
|| (ctx.User.Id != ownerId && targetMaxRole >= modMaxRole)
|| target.Id == ownerId)
{
await Response().Error(strs.hierarchy).SendAsync();
return false;
}
return true;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
public Task Warn(IGuildUser user, [Leftover] string reason = null)
=> Warn(1, user, reason);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
public async Task Warn(int weight, IGuildUser user, [Leftover] string reason = null)
{
if (weight <= 0)
return;
if (!await CheckRoleHierarchy(user))
return;
var dmFailed = false;
try
{
await _sender.Response(user)
.Embed(_sender.CreateEmbed()
.WithErrorColor()
.WithDescription(GetText(strs.warned_on(ctx.Guild.ToString())))
.AddField(GetText(strs.moderator), ctx.User.ToString())
.AddField(GetText(strs.reason), reason ?? "-"))
.SendAsync();
}
catch
{
dmFailed = true;
}
WarningPunishment punishment;
try
{
punishment = await _service.Warn(ctx.Guild, user.Id, ctx.User, weight, reason);
}
catch (Exception ex)
{
Log.Warning(ex, "Exception occured while warning a user");
var errorEmbed = _sender.CreateEmbed().WithErrorColor()
.WithDescription(GetText(strs.cant_apply_punishment));
if (dmFailed)
errorEmbed.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
await Response().Embed(errorEmbed).SendAsync();
return;
}
var embed = _sender.CreateEmbed().WithOkColor();
if (punishment is null)
embed.WithDescription(GetText(strs.user_warned(Format.Bold(user.ToString()))));
else
{
embed.WithDescription(GetText(strs.user_warned_and_punished(Format.Bold(user.ToString()),
Format.Bold(punishment.Punishment.ToString()))));
}
if (dmFailed)
embed.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
await Response().Embed(embed).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[EllieOptions<WarnExpireOptions>]
[Priority(1)]
public async Task WarnExpire()
{
var expireDays = await _service.GetWarnExpire(ctx.Guild.Id);
if (expireDays == 0)
await Response().Confirm(strs.warns_dont_expire).SendAsync();
else
await Response().Error(strs.warns_expire_in(expireDays)).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
[EllieOptions<WarnExpireOptions>]
[Priority(2)]
public async Task WarnExpire(int days, params string[] args)
{
if (days is < 0 or > 366)
return;
var opts = OptionsParser.ParseFrom<WarnExpireOptions>(args);
await ctx.Channel.TriggerTypingAsync();
await _service.WarnExpireAsync(ctx.Guild.Id, days, opts.Delete);
if (days == 0)
{
await Response().Confirm(strs.warn_expire_reset).SendAsync();
return;
}
if (opts.Delete)
await Response().Confirm(strs.warn_expire_set_delete(Format.Bold(days.ToString()))).SendAsync();
else
await Response().Confirm(strs.warn_expire_set_clear(Format.Bold(days.ToString()))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[Priority(2)]
public Task Warnlog(int page, [Leftover] IGuildUser user = null)
{
user ??= (IGuildUser)ctx.User;
return Warnlog(page, user.Id);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[Priority(3)]
public Task Warnlog(IGuildUser user = null)
{
user ??= (IGuildUser)ctx.User;
return ctx.User.Id == user.Id || ((IGuildUser)ctx.User).GuildPermissions.BanMembers
? Warnlog(user.Id)
: Task.CompletedTask;
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[Priority(0)]
public Task Warnlog(int page, ulong userId)
=> InternalWarnlog(userId, page - 1);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[Priority(1)]
public Task Warnlog(ulong userId)
=> InternalWarnlog(userId, 0);
private async Task InternalWarnlog(ulong userId, int inputPage)
{
if (inputPage < 0)
return;
var allWarnings = _service.UserWarnings(ctx.Guild.Id, userId);
await Response()
.Paginated()
.Items(allWarnings)
.PageSize(9)
.CurrentPage(inputPage)
.Page((warnings, page) =>
{
var user = (ctx.Guild as SocketGuild)?.GetUser(userId)?.ToString() ?? userId.ToString();
var embed = _sender.CreateEmbed().WithOkColor().WithTitle(GetText(strs.warnlog_for(user)));
if (!warnings.Any())
embed.WithDescription(GetText(strs.warnings_none));
else
{
var descText = GetText(strs.warn_count(
Format.Bold(warnings.Where(x => !x.Forgiven).Sum(x => x.Weight).ToString()),
Format.Bold(warnings.Sum(x => x.Weight).ToString())));
embed.WithDescription(descText);
var i = page * 9;
foreach (var w in warnings)
{
i++;
var name = GetText(strs.warned_on_by(w.DateAdded?.ToString("dd.MM.yyy"),
w.DateAdded?.ToString("HH:mm"),
w.Moderator));
if (w.Forgiven)
name = $"{Format.Strikethrough(name)} {GetText(strs.warn_cleared_by(w.ForgivenBy))}";
embed.AddField($"#`{i}` " + name,
Format.Code(GetText(strs.warn_weight(w.Weight))) + '\n' + w.Reason.TrimTo(1000));
}
}
return embed;
})
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
public async Task WarnlogAll(int page = 1)
{
if (--page < 0)
return;
var allWarnings = _service.WarnlogAll(ctx.Guild.Id);
await Response()
.Paginated()
.Items(allWarnings)
.PageSize(15)
.CurrentPage(page)
.Page((warnings, _) =>
{
var ws = warnings
.Select(x =>
{
var all = x.Count();
var forgiven = x.Count(y => y.Forgiven);
var total = all - forgiven;
var usr = ((SocketGuild)ctx.Guild).GetUser(x.Key);
return (usr?.ToString() ?? x.Key.ToString())
+ $" | {total} ({all} - {forgiven})";
});
return _sender.CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.warnings_list))
.WithDescription(string.Join("\n", ws));
})
.SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public Task WarnDelete(IGuildUser user, int index)
=> WarnDelete(user.Id, index);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.Administrator)]
public async Task WarnDelete(ulong userId, int index)
{
if (--index < 0)
return;
var warn = await _service.WarnDelete(userId, index);
if (warn is null)
{
await Response().Error(strs.warning_not_found).SendAsync();
return;
}
await Response().Confirm(strs.warning_deleted(Format.Bold(index.ToString()))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
public Task Warnclear(IGuildUser user, int index = 0)
=> Warnclear(user.Id, index);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
public async Task Warnclear(ulong userId, int index = 0)
{
if (index < 0)
return;
var success = await _service.WarnClearAsync(ctx.Guild.Id, userId, index, ctx.User.ToString());
var userStr = Format.Bold((ctx.Guild as SocketGuild)?.GetUser(userId)?.ToString() ?? userId.ToString());
if (index == 0)
await Response().Error(strs.warnings_cleared(userStr)).SendAsync();
else
{
if (success)
await Response().Confirm(strs.warning_cleared(Format.Bold(index.ToString()), userStr)).SendAsync();
else
await Response().Error(strs.warning_clear_fail).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[Priority(1)]
public async Task WarnPunish(
int number,
AddRole _,
IRole role,
StoopidTime time = null)
{
var punish = PunishmentAction.AddRole;
if (ctx.Guild.OwnerId != ctx.User.Id
&& role.Position >= ((IGuildUser)ctx.User).GetRoles().Max(x => x.Position))
{
await Response().Error(strs.role_too_high).SendAsync();
return;
}
var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time, role);
if (!success)
return;
if (time is null)
{
await Response()
.Confirm(strs.warn_punish_set(Format.Bold(punish.ToString()),
Format.Bold(number.ToString())))
.SendAsync();
}
else
{
await Response()
.Confirm(strs.warn_punish_set_timed(Format.Bold(punish.ToString()),
Format.Bold(number.ToString()),
Format.Bold(time.Input)))
.SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
public async Task WarnPunish(int number, PunishmentAction punish, StoopidTime time = null)
{
// this should never happen. Addrole has its own method with higher priority
// also disallow warn punishment for getting warned
if (punish is PunishmentAction.AddRole or PunishmentAction.Warn)
return;
// you must specify the time for timeout
if (punish is PunishmentAction.TimeOut && time is null)
return;
var success = _service.WarnPunish(ctx.Guild.Id, number, punish, time);
if (!success)
return;
if (time is null)
{
await Response()
.Confirm(strs.warn_punish_set(Format.Bold(punish.ToString()),
Format.Bold(number.ToString())))
.SendAsync();
}
else
{
await Response()
.Confirm(strs.warn_punish_set_timed(Format.Bold(punish.ToString()),
Format.Bold(number.ToString()),
Format.Bold(time.Input)))
.SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
public async Task WarnPunish(int number)
{
if (!_service.WarnPunishRemove(ctx.Guild.Id, number))
return;
await Response().Confirm(strs.warn_punish_rem(Format.Bold(number.ToString()))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task WarnPunishList()
{
var ps = _service.WarnPunishList(ctx.Guild.Id);
string list;
if (ps.Any())
{
list = string.Join("\n",
ps.Select(x
=> $"{x.Count} -> {x.Punishment} {(x.Punishment == PunishmentAction.AddRole ? $"<@&{x.RoleId}>" : "")} {(x.Time <= 0 ? "" : x.Time + "m")} "));
}
else
list = GetText(strs.warnpl_none);
await Response().Confirm(GetText(strs.warn_punish_list), list).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[BotPerm(GuildPerm.BanMembers)]
[Priority(1)]
public Task Ban(StoopidTime time, IUser user, [Leftover] string msg = null)
=> Ban(time, user.Id, msg);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[BotPerm(GuildPerm.BanMembers)]
[Priority(0)]
public async Task Ban(StoopidTime time, ulong userId, [Leftover] string msg = null)
{
if (time.Time > TimeSpan.FromDays(49))
return;
var guildUser = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId);
if (guildUser is not null && !await CheckRoleHierarchy(guildUser))
return;
var dmFailed = false;
if (guildUser is not null)
{
try
{
var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), msg));
var smartText =
await _service.GetBanUserDmEmbed(Context, guildUser, defaultMessage, msg, time.Time);
if (smartText is not null)
await Response().User(guildUser).Text(smartText).SendAsync();
}
catch
{
dmFailed = true;
}
}
var user = await ctx.Client.GetUserAsync(userId);
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
await _mute.TimedBan(ctx.Guild, userId, time.Time, (ctx.User + " | " + msg).TrimTo(512), banPrune);
var toSend = _sender.CreateEmbed()
.WithOkColor()
.WithTitle("⛔️ " + GetText(strs.banned_user))
.AddField(GetText(strs.username), user?.ToString() ?? userId.ToString(), true)
.AddField("ID", userId.ToString(), true)
.AddField(GetText(strs.duration),
time.Time.ToPrettyStringHm(),
true);
if (dmFailed)
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
await Response().Embed(toSend).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[BotPerm(GuildPerm.BanMembers)]
[Priority(0)]
public async Task Ban(ulong userId, [Leftover] string msg = null)
{
var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId);
if (user is null)
{
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
await ctx.Guild.AddBanAsync(userId, banPrune, (ctx.User + " | " + msg).TrimTo(512));
await Response().Embed(_sender.CreateEmbed()
.WithOkColor()
.WithTitle("⛔️ " + GetText(strs.banned_user))
.AddField("ID", userId.ToString(), true))
.SendAsync();
}
else
await Ban(user, msg);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[BotPerm(GuildPerm.BanMembers)]
[Priority(2)]
public async Task Ban(IGuildUser user, [Leftover] string msg = null)
{
if (!await CheckRoleHierarchy(user))
return;
var dmFailed = false;
try
{
var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), msg));
var embed = await _service.GetBanUserDmEmbed(Context, user, defaultMessage, msg, null);
if (embed is not null)
await Response().User(user).Text(embed).SendAsync();
}
catch
{
dmFailed = true;
}
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
await ctx.Guild.AddBanAsync(user, banPrune, (ctx.User + " | " + msg).TrimTo(512));
var toSend = _sender.CreateEmbed()
.WithOkColor()
.WithTitle("⛔️ " + GetText(strs.banned_user))
.AddField(GetText(strs.username), user.ToString(), true)
.AddField("ID", user.Id.ToString(), true);
if (dmFailed)
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
await Response().Embed(toSend).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[BotPerm(GuildPerm.BanMembers)]
public async Task BanPrune(int days)
{
if (days < 0 || days > 7)
{
await Response().Error(strs.invalid_input).SendAsync();
return;
}
await _service.SetBanPruneAsync(ctx.Guild.Id, days);
if (days == 0)
await Response().Confirm(strs.ban_prune_disabled).SendAsync();
else
await Response().Confirm(strs.ban_prune(days)).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[BotPerm(GuildPerm.BanMembers)]
public async Task BanMessage([Leftover] string message = null)
{
if (message is null)
{
var template = _service.GetBanTemplate(ctx.Guild.Id);
if (template is null)
{
await Response().Confirm(strs.banmsg_default).SendAsync();
return;
}
await Response().Confirm(template).SendAsync();
return;
}
_service.SetBanTemplate(ctx.Guild.Id, message);
await ctx.OkAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[BotPerm(GuildPerm.BanMembers)]
public async Task BanMsgReset()
{
_service.SetBanTemplate(ctx.Guild.Id, null);
await ctx.OkAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[BotPerm(GuildPerm.BanMembers)]
[Priority(0)]
public Task BanMessageTest([Leftover] string reason = null)
=> InternalBanMessageTest(reason, null);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[BotPerm(GuildPerm.BanMembers)]
[Priority(1)]
public Task BanMessageTest(StoopidTime duration, [Leftover] string reason = null)
=> InternalBanMessageTest(reason, duration.Time);
private async Task InternalBanMessageTest(string reason, TimeSpan? duration)
{
var defaultMessage = GetText(strs.bandm(Format.Bold(ctx.Guild.Name), reason));
var smartText = await _service.GetBanUserDmEmbed(Context,
(IGuildUser)ctx.User,
defaultMessage,
reason,
duration);
if (smartText is null)
await Response().Confirm(strs.banmsg_disabled).SendAsync();
else
{
try
{
await Response().User(ctx.User).Text(smartText).SendAsync();
}
catch (Exception)
{
await Response().Error(strs.unable_to_dm_user).SendAsync();
return;
}
await ctx.OkAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[BotPerm(GuildPerm.BanMembers)]
public async Task Unban([Leftover] string user)
{
var bans = await ctx.Guild.GetBansAsync().FlattenAsync();
var bun = bans.FirstOrDefault(x => x.User.ToString()!.ToLowerInvariant() == user.ToLowerInvariant());
if (bun is null)
{
await Response().Error(strs.user_not_found).SendAsync();
return;
}
await UnbanInternal(bun.User);
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[BotPerm(GuildPerm.BanMembers)]
public async Task Unban(ulong userId)
{
var bun = await ctx.Guild.GetBanAsync(userId);
if (bun is null)
{
await Response().Error(strs.user_not_found).SendAsync();
return;
}
await UnbanInternal(bun.User);
}
private async Task UnbanInternal(IUser user)
{
await ctx.Guild.RemoveBanAsync(user);
await Response().Confirm(strs.unbanned_user(Format.Bold(user.ToString()))).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.KickMembers | GuildPerm.ManageMessages)]
[BotPerm(GuildPerm.BanMembers)]
public Task Softban(IGuildUser user, [Leftover] string msg = null)
=> SoftbanInternal(user, msg);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.KickMembers | GuildPerm.ManageMessages)]
[BotPerm(GuildPerm.BanMembers)]
public async Task Softban(ulong userId, [Leftover] string msg = null)
{
var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId);
if (user is null)
return;
await SoftbanInternal(user, msg);
}
private async Task SoftbanInternal(IGuildUser user, [Leftover] string msg = null)
{
if (!await CheckRoleHierarchy(user))
return;
var dmFailed = false;
try
{
await Response()
.Channel(await user.CreateDMChannelAsync())
.Error(strs.sbdm(Format.Bold(ctx.Guild.Name), msg))
.SendAsync();
}
catch
{
dmFailed = true;
}
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
await ctx.Guild.AddBanAsync(user, banPrune, ("Softban | " + ctx.User + " | " + msg).TrimTo(512));
try { await ctx.Guild.RemoveBanAsync(user); }
catch { await ctx.Guild.RemoveBanAsync(user); }
var toSend = _sender.CreateEmbed()
.WithOkColor()
.WithTitle("☣ " + GetText(strs.sb_user))
.AddField(GetText(strs.username), user.ToString(), true)
.AddField("ID", user.Id.ToString(), true);
if (dmFailed)
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
await Response().Embed(toSend).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.KickMembers)]
[BotPerm(GuildPerm.KickMembers)]
[Priority(1)]
public Task Kick(IGuildUser user, [Leftover] string msg = null)
=> KickInternal(user, msg);
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.KickMembers)]
[BotPerm(GuildPerm.KickMembers)]
[Priority(0)]
public async Task Kick(ulong userId, [Leftover] string msg = null)
{
var user = await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id, userId);
if (user is null)
return;
await KickInternal(user, msg);
}
private async Task KickInternal(IGuildUser user, string msg = null)
{
if (!await CheckRoleHierarchy(user))
return;
var dmFailed = false;
try
{
await Response()
.Channel(await user.CreateDMChannelAsync())
.Error(GetText(strs.kickdm(Format.Bold(ctx.Guild.Name), msg)))
.SendAsync();
}
catch
{
dmFailed = true;
}
await user.KickAsync((ctx.User + " | " + msg).TrimTo(512));
var toSend = _sender.CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.kicked_user))
.AddField(GetText(strs.username), user.ToString(), true)
.AddField("ID", user.Id.ToString(), true);
if (dmFailed)
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
await Response().Embed(toSend).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ModerateMembers)]
[BotPerm(GuildPerm.ModerateMembers)]
[Priority(2)]
public async Task Timeout(IUser globalUser, StoopidTime time, [Leftover] string msg = null)
{
var user = await ctx.Guild.GetUserAsync(globalUser.Id);
if (user is null)
return;
if (!await CheckRoleHierarchy(user))
return;
var dmFailed = false;
try
{
var dmMessage = GetText(strs.timeoutdm(Format.Bold(ctx.Guild.Name), msg));
await _sender.Response(user)
.Embed(_sender.CreateEmbed()
.WithPendingColor()
.WithDescription(dmMessage))
.SendAsync();
}
catch
{
dmFailed = true;
}
await user.SetTimeOutAsync(time.Time);
var toSend = _sender.CreateEmbed()
.WithOkColor()
.WithTitle("⏳ " + GetText(strs.timedout_user))
.AddField(GetText(strs.username), user.ToString(), true)
.AddField("ID", user.Id.ToString(), true);
if (dmFailed)
toSend.WithFooter("⚠️ " + GetText(strs.unable_to_dm_user));
await Response().Embed(toSend).SendAsync();
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[BotPerm(GuildPerm.BanMembers)]
[Ratelimit(30)]
public async Task MassBan(params string[] userStrings)
{
if (userStrings.Length == 0)
return;
var missing = new List<string>();
var banning = new HashSet<IUser>();
await ctx.Channel.TriggerTypingAsync();
foreach (var userStr in userStrings)
{
if (ulong.TryParse(userStr, out var userId))
{
IUser user = await ctx.Guild.GetUserAsync(userId)
?? await ((DiscordSocketClient)Context.Client).Rest.GetGuildUserAsync(ctx.Guild.Id,
userId);
if (user is null)
{
// if IGuildUser is null, try to get IUser
user = await ((DiscordSocketClient)Context.Client).Rest.GetUserAsync(userId);
// only add to missing if *still* null
if (user is null)
{
missing.Add(userStr);
continue;
}
}
//Hierachy checks only if the user is in the guild
if (user is IGuildUser gu && !await CheckRoleHierarchy(gu))
return;
banning.Add(user);
}
else
missing.Add(userStr);
}
var missStr = string.Join("\n", missing);
if (string.IsNullOrWhiteSpace(missStr))
missStr = "-";
var toSend = _sender.CreateEmbed()
.WithDescription(GetText(strs.mass_ban_in_progress(banning.Count)))
.AddField(GetText(strs.invalid(missing.Count)), missStr)
.WithPendingColor();
var banningMessage = await Response().Embed(toSend).SendAsync();
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
foreach (var toBan in banning)
{
try
{
await ctx.Guild.AddBanAsync(toBan.Id, banPrune, $"{ctx.User} | Massban");
}
catch (Exception ex)
{
Log.Warning(ex, "Error banning {User} user in {GuildId} server", toBan.Id, ctx.Guild.Id);
}
}
await banningMessage.ModifyAsync(x => x.Embed = _sender.CreateEmbed()
.WithDescription(
GetText(strs.mass_ban_completed(banning.Count())))
.AddField(GetText(strs.invalid(missing.Count)), missStr)
.WithOkColor()
.Build());
}
[Cmd]
[RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.BanMembers)]
[BotPerm(GuildPerm.BanMembers)]
[OwnerOnly]
public async Task MassKill([Leftover] string people)
{
if (string.IsNullOrWhiteSpace(people))
return;
var (bans, missing) = _service.MassKill((SocketGuild)ctx.Guild, people);
var missStr = string.Join("\n", missing);
if (string.IsNullOrWhiteSpace(missStr))
missStr = "-";
//send a message but don't wait for it
var banningMessageTask = Response()
.Embed(_sender.CreateEmbed()
.WithDescription(
GetText(strs.mass_kill_in_progress(bans.Count())))
.AddField(GetText(strs.invalid(missing)), missStr)
.WithPendingColor())
.SendAsync();
var banPrune = await _service.GetBanPruneAsync(ctx.Guild.Id) ?? 7;
//do the banning
await Task.WhenAll(bans.Where(x => x.Id.HasValue)
.Select(x => ctx.Guild.AddBanAsync(x.Id.Value,
banPrune,
x.Reason,
new()
{
RetryMode = RetryMode.AlwaysRetry
})));
//wait for the message and edit it
var banningMessage = await banningMessageTask;
await banningMessage.ModifyAsync(x => x.Embed = _sender.CreateEmbed()
.WithDescription(
GetText(strs.mass_kill_completed(bans.Count())))
.AddField(GetText(strs.invalid(missing)), missStr)
.WithOkColor()
.Build());
}
public class WarnExpireOptions : IEllieCommandOptions
{
[Option('d', "delete", Default = false, HelpText = "Delete warnings instead of clearing them.")]
public bool Delete { get; set; } = false;
public void NormalizeOptions()
{
}
}
}
}

View file

@ -0,0 +1,629 @@
#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 nadeko-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;
}
}

View file

@ -0,0 +1,77 @@
#nullable disable
using EllieBot.Modules.Administration.Services;
namespace EllieBot.Modules.Administration;
public partial class Administration
{
[Group]
public partial class VcRoleCommands : EllieModule<VcRoleService>
{
[Cmd]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
[RequireContext(ContextType.Guild)]
public async Task VcRoleRm(ulong vcId)
{
if (_service.RemoveVcRole(ctx.Guild.Id, vcId))
await Response().Confirm(strs.vcrole_removed(Format.Bold(vcId.ToString()))).SendAsync();
else
await Response().Error(strs.vcrole_not_found).SendAsync();
}
[Cmd]
[UserPerm(GuildPerm.ManageRoles)]
[BotPerm(GuildPerm.ManageRoles)]
[RequireContext(ContextType.Guild)]
public async Task VcRole([Leftover] IRole role = null)
{
var user = (IGuildUser)ctx.User;
var vc = user.VoiceChannel;
if (vc is null || vc.GuildId != user.GuildId)
{
await Response().Error(strs.must_be_in_voice).SendAsync();
return;
}
if (role is null)
{
if (_service.RemoveVcRole(ctx.Guild.Id, vc.Id))
await Response().Confirm(strs.vcrole_removed(Format.Bold(vc.Name))).SendAsync();
}
else
{
_service.AddVcRole(ctx.Guild.Id, role, vc.Id);
await Response().Confirm(strs.vcrole_added(Format.Bold(vc.Name), Format.Bold(role.Name))).SendAsync();
}
}
[Cmd]
[RequireContext(ContextType.Guild)]
public async Task VcRoleList()
{
var guild = (SocketGuild)ctx.Guild;
string text;
if (_service.VcRoles.TryGetValue(ctx.Guild.Id, out var roles))
{
if (!roles.Any())
text = GetText(strs.no_vcroles);
else
{
text = string.Join("\n",
roles.Select(x
=> $"{Format.Bold(guild.GetVoiceChannel(x.Key)?.Name ?? x.Key.ToString())} => {x.Value}"));
}
}
else
text = GetText(strs.no_vcroles);
await Response().Embed(_sender.CreateEmbed()
.WithOkColor()
.WithTitle(GetText(strs.vc_role_list))
.WithDescription(text)).SendAsync();
}
}
}

View file

@ -0,0 +1,207 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using EllieBot.Db.Models;
namespace EllieBot.Modules.Administration.Services;
public class VcRoleService : IEService
{
public ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, IRole>> VcRoles { get; }
public ConcurrentDictionary<ulong, System.Collections.Concurrent.ConcurrentQueue<(bool, IGuildUser, IRole)>> ToAssign { get; }
private readonly DbService _db;
private readonly DiscordSocketClient _client;
public VcRoleService(DiscordSocketClient client, IBot bot, DbService db)
{
_db = db;
_client = client;
_client.UserVoiceStateUpdated += ClientOnUserVoiceStateUpdated;
VcRoles = new();
ToAssign = new();
using (var uow = db.GetDbContext())
{
var guildIds = client.Guilds.Select(x => x.Id).ToList();
uow.Set<GuildConfig>()
.AsQueryable()
.Include(x => x.VcRoleInfos)
.Where(x => guildIds.Contains(x.GuildId))
.AsEnumerable()
.Select(InitializeVcRole)
.WhenAll();
}
Task.Run(async () =>
{
while (true)
{
Task Selector(System.Collections.Concurrent.ConcurrentQueue<(bool, IGuildUser, IRole)> queue)
{
return Task.Run(async () =>
{
while (queue.TryDequeue(out var item))
{
var (add, user, role) = item;
try
{
if (add)
{
if (!user.RoleIds.Contains(role.Id))
await user.AddRoleAsync(role);
}
else
{
if (user.RoleIds.Contains(role.Id))
await user.RemoveRoleAsync(role);
}
}
catch
{
}
await Task.Delay(250);
}
});
}
await ToAssign.Values.Select(Selector).Append(Task.Delay(1000)).WhenAll();
}
});
_client.LeftGuild += _client_LeftGuild;
bot.JoinedGuild += Bot_JoinedGuild;
}
private Task Bot_JoinedGuild(GuildConfig arg)
{
// includeall no longer loads vcrole
// need to load new guildconfig with vc role included
using (var uow = _db.GetDbContext())
{
var configWithVcRole = uow.GuildConfigsForId(arg.GuildId, set => set.Include(x => x.VcRoleInfos));
_ = InitializeVcRole(configWithVcRole);
}
return Task.CompletedTask;
}
private Task _client_LeftGuild(SocketGuild arg)
{
VcRoles.TryRemove(arg.Id, out _);
ToAssign.TryRemove(arg.Id, out _);
return Task.CompletedTask;
}
private async Task InitializeVcRole(GuildConfig gconf)
{
var g = _client.GetGuild(gconf.GuildId);
if (g is null)
return;
var infos = new ConcurrentDictionary<ulong, IRole>();
var missingRoles = new List<VcRoleInfo>();
VcRoles.AddOrUpdate(gconf.GuildId, infos, delegate { return infos; });
foreach (var ri in gconf.VcRoleInfos)
{
var role = g.GetRole(ri.RoleId);
if (role is null)
{
missingRoles.Add(ri);
continue;
}
infos.TryAdd(ri.VoiceChannelId, role);
}
if (missingRoles.Any())
{
await using var uow = _db.GetDbContext();
uow.RemoveRange(missingRoles);
await uow.SaveChangesAsync();
Log.Warning("Removed {MissingRoleCount} missing roles from {ServiceName}",
missingRoles.Count,
nameof(VcRoleService));
}
}
public void AddVcRole(ulong guildId, IRole role, ulong vcId)
{
ArgumentNullException.ThrowIfNull(role);
var guildVcRoles = VcRoles.GetOrAdd(guildId, new ConcurrentDictionary<ulong, IRole>());
guildVcRoles.AddOrUpdate(vcId, role, (_, _) => role);
using var uow = _db.GetDbContext();
var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.VcRoleInfos));
var toDelete = conf.VcRoleInfos.FirstOrDefault(x => x.VoiceChannelId == vcId); // remove old one
if (toDelete is not null)
uow.Remove(toDelete);
conf.VcRoleInfos.Add(new()
{
VoiceChannelId = vcId,
RoleId = role.Id
}); // add new one
uow.SaveChanges();
}
public bool RemoveVcRole(ulong guildId, ulong vcId)
{
if (!VcRoles.TryGetValue(guildId, out var guildVcRoles))
return false;
if (!guildVcRoles.TryRemove(vcId, out _))
return false;
using var uow = _db.GetDbContext();
var conf = uow.GuildConfigsForId(guildId, set => set.Include(x => x.VcRoleInfos));
var toRemove = conf.VcRoleInfos.Where(x => x.VoiceChannelId == vcId).ToList();
uow.RemoveRange(toRemove);
uow.SaveChanges();
return true;
}
private Task ClientOnUserVoiceStateUpdated(SocketUser usr, SocketVoiceState oldState, SocketVoiceState newState)
{
if (usr is not SocketGuildUser gusr)
return Task.CompletedTask;
var oldVc = oldState.VoiceChannel;
var newVc = newState.VoiceChannel;
_ = Task.Run(() =>
{
try
{
if (oldVc != newVc)
{
ulong guildId;
guildId = newVc?.Guild.Id ?? oldVc.Guild.Id;
if (VcRoles.TryGetValue(guildId, out var guildVcRoles))
{
//remove old
if (oldVc is not null && guildVcRoles.TryGetValue(oldVc.Id, out var role))
Assign(false, gusr, role);
//add new
if (newVc is not null && guildVcRoles.TryGetValue(newVc.Id, out role))
Assign(true, gusr, role);
}
}
}
catch (Exception ex)
{
Log.Warning(ex, "Error in VcRoleService VoiceStateUpdate");
}
});
return Task.CompletedTask;
}
private void Assign(bool v, SocketGuildUser gusr, IRole role)
{
var queue = ToAssign.GetOrAdd(gusr.Guild.Id, new System.Collections.Concurrent.ConcurrentQueue<(bool, IGuildUser, IRole)>());
queue.Enqueue((v, gusr, role));
}
}

View file

@ -0,0 +1,9 @@
namespace EllieBot.Modules.Administration._common.results;
public enum SetServerBannerResult
{
Success,
InvalidFileType,
Toolarge,
InvalidURL
}

View file

@ -0,0 +1,8 @@
namespace EllieBot.Modules.Administration._common.results;
public enum SetServerIconResult
{
Success,
InvalidFileType,
InvalidURL
}