diff --git a/Module/EntryAutoRole/EntryAutoRole.cs b/Module/EntryAutoRole/EntryAutoRole.cs new file mode 100644 index 0000000..1d210ec --- /dev/null +++ b/Module/EntryAutoRole/EntryAutoRole.cs @@ -0,0 +1,186 @@ +using Discord.WebSocket; +using Newtonsoft.Json.Linq; +using Noikoio.RegexBot.ConfigItem; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot.Module.EntryAutoRole +{ + /// + /// Automatically sets a specified role + /// + class EntryAutoRole : BotModule + { + public override string Name => "EntryAutoRole"; + + private List _roleWaitlist; + private object _roleWaitLock = new object(); + + // TODO make use of this later if/when some shutdown handler gets added + // (else it continues running in debug after the client has been disposed) + private readonly CancellationTokenSource _workerCancel; + + // Config: + // Role: string - Name or ID of the role to apply. Takes EntityName format. + // WaitTime: number - Amount of time in seconds to wait until applying the role to a new user. + public EntryAutoRole(DiscordSocketClient client) : base(client) + { + client.GuildAvailable += Client_GuildAvailable; + client.UserJoined += Client_UserJoined; + client.UserLeft += Client_UserLeft; + + _roleWaitlist = new List(); + + _workerCancel = new CancellationTokenSource(); + Task.Factory.StartNew(Worker, _workerCancel.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + [ConfigSection("EntryAutoRole")] + public override Task ProcessConfiguration(JToken configSection) + { + if (configSection.Type != JTokenType.Object) + { + throw new RuleImportException("Configuration for this section is invalid."); + } + return Task.FromResult(new ModuleConfig((JObject)configSection)); + } + + private Task Client_GuildAvailable(SocketGuild arg) + { + var conf = (ModuleConfig)GetConfig(arg.Id); + if (conf == null) return Task.CompletedTask; + SocketRole trole = GetRole(arg); + if (trole == null) return Task.CompletedTask; + + lock (_roleWaitLock) + foreach (var item in arg.Users) + { + if (item.IsBot) continue; + if (item.IsWebhook) continue; + if (item.Roles.Contains(trole)) continue; + _roleWaitlist.Add(new AutoRoleEntry() + { + GuildId = arg.Id, + UserId = item.Id, + ExpireTime = DateTimeOffset.UtcNow.AddSeconds(conf.TimeDelay) + }); + } + + return Task.CompletedTask; + } + + private Task Client_UserLeft(SocketGuildUser arg) + { + if (GetConfig(arg.Guild.Id) == null) return Task.CompletedTask; + lock (_roleWaitLock) _roleWaitlist.RemoveAll(m => m.GuildId == arg.Guild.Id && m.UserId == arg.Id); + return Task.CompletedTask; + } + + private Task Client_UserJoined(SocketGuildUser arg) + { + if (GetConfig(arg.Guild.Id) == null) return Task.CompletedTask; + lock (_roleWaitLock) _roleWaitlist.Add(new AutoRoleEntry() + { + GuildId = arg.Guild.Id, + UserId = arg.Id, + ExpireTime = DateTimeOffset.UtcNow.AddSeconds(((ModuleConfig)GetConfig(arg.Guild.Id)).TimeDelay) + }); + return Task.CompletedTask; + } + + // can return null + private SocketRole GetRole(SocketGuild g) + { + var conf = (ModuleConfig)GetConfig(g.Id); + if (conf == null) return null; + var roleInfo = conf.Role; + + if (roleInfo.Id.HasValue) + { + var result = g.GetRole(roleInfo.Id.Value); + if (result != null) return result; + } + else + { + foreach (var role in g.Roles) + if (string.Equals(roleInfo.Name, role.Name)) return role; + } + Log("Unable to find role in " + g.Name).Wait(); + return null; + } + + struct AutoRoleEntry + { + public ulong GuildId; + public ulong UserId; + public DateTimeOffset ExpireTime; + } + + async Task Worker() + { + while (!_workerCancel.IsCancellationRequested) + { + await Task.Delay(5000); + + AutoRoleEntry[] jobsList; + lock (_roleWaitLock) + { + var chk = DateTimeOffset.UtcNow; + // Attempt to avoid throttling: only 3 per run are processed + var jobs = _roleWaitlist.Where(i => chk > i.ExpireTime).Take(3); + jobsList = jobs.ToArray(); // force evaluation + + // remove selected entries from current list + foreach (var item in jobsList) + { + _roleWaitlist.Remove(item); + } + } + + // Temporary SocketRole cache. key = guild ID + Dictionary cr = new Dictionary(); + foreach (var item in jobsList) + { + if (_workerCancel.IsCancellationRequested) return; + + // do we have the guild? + var g = Client.GetGuild(item.GuildId); + if (g == null) continue; // bot probably left the guild + + // do we have the user? + var u = g.GetUser(item.UserId); + if (u == null) continue; // user is probably gone + + // do we have the role? + SocketRole r; + if (!cr.TryGetValue(g.Id, out r)) + { + r = GetRole(g); + if (r != null) cr[g.Id] = r; + } + if (r == null) + { + await Log($"Skipping {g.Name}/{u.ToString()}"); + await Log("Was the role renamed or deleted?"); + } + + // do the work + try + { + await u.AddRoleAsync(r); + } + catch (Discord.Net.HttpException ex) + { + if (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) + { + await Log($"WARNING: Cannot set roles! Skipping {g.Name}/{u.ToString()}"); + } + } + } + } + } + } +} diff --git a/Module/EntryAutoRole/ModuleConfig.cs b/Module/EntryAutoRole/ModuleConfig.cs new file mode 100644 index 0000000..cc94817 --- /dev/null +++ b/Module/EntryAutoRole/ModuleConfig.cs @@ -0,0 +1,35 @@ +using Newtonsoft.Json.Linq; +using Noikoio.RegexBot.ConfigItem; + +namespace Noikoio.RegexBot.Module.EntryAutoRole +{ + class ModuleConfig + { + private EntityName _cfgRole; + private int _cfgTime; + + public EntityName Role => _cfgRole; + public int TimeDelay => _cfgTime; + + public ModuleConfig(JObject conf) + { + var cfgRole = conf["Role"]?.Value(); + if (string.IsNullOrWhiteSpace(cfgRole)) + throw new RuleImportException("Role was not specified."); + _cfgRole = new EntityName(cfgRole, EntityType.Role); + + var inTime = conf["WaitTime"]?.Value(); + if (string.IsNullOrWhiteSpace(inTime)) + throw new RuleImportException("WaitTime was not specified."); + + if (!int.TryParse(inTime, out _cfgTime)) + { + throw new RuleImportException("WaitTime must be a numeric value."); + } + if (_cfgTime < 0) + { + throw new RuleImportException("WaitTime must be a positive integer."); + } + } + } +} diff --git a/RegexBot.cs b/RegexBot.cs index e33db08..d8e6a03 100644 --- a/RegexBot.cs +++ b/RegexBot.cs @@ -58,6 +58,7 @@ namespace Noikoio.RegexBot new Module.AutoMod.AutoMod(_client), new Module.ModCommands.CommandListener(_client), new Module.AutoRespond.AutoRespond(_client), + new Module.EntryAutoRole.EntryAutoRole(_client), // EntityCache loads before anything using it new EntityCache.Module(_client), diff --git a/RegexBot.csproj b/RegexBot.csproj index b30fb40..a64a725 100644 --- a/RegexBot.csproj +++ b/RegexBot.csproj @@ -4,7 +4,7 @@ Exe netcoreapp2.0 Noikoio.RegexBot - 2.5.0.0 + 2.5.1.0 Highly configurable Discord moderation bot Noikoio diff --git a/docs/entryautorole.md b/docs/entryautorole.md new file mode 100644 index 0000000..bf1a4c8 --- /dev/null +++ b/docs/entryautorole.md @@ -0,0 +1,21 @@ +## EntryAutoRole + +EntryAutoRole is a component that automatically assigns a role to users after a set amount of time. It is useful for limiting access to incoming users and as a basic means of controlling raids. + +Roles set by this component do not persist. Should a user leave the server and rejoin, they will not be given the role again immediately and must wait to have it reassigned. + +Sample within a [server definition](serverdef.html): +``` +"EntryAutoRole": { + "Role": "123451234512345::Newbie", + "WaitTime": 600 +} +``` + +### Configuration options +EntryAutoRole is simple to configure. All the following values are **required**. +* Role (*string*) - The role to set. If specified by string, it will search for a role matching that name. If specified by ID, the ID will be used and server managers are free to edit the role name without modifying this value. + * If a name is given, then an role matching the name will be applied. Renaming the role will cause the component to fail to make use of the role until configuration is updated. + * If an ID is specified, server managers are free to rename the role and still have it be used by the bot. + * To find your role IDs, you may use a tool such as [Role ID Query Bot](https://discordapp.com/oauth2/authorize?client_id=425050329068077057&scope=bot). +* WaitTime (*number*) - Amount of time in seconds to wait until a new user is applied the role. \ No newline at end of file diff --git a/docs/serverdef.md b/docs/serverdef.md index 00d9980..b3d794a 100644 --- a/docs/serverdef.md +++ b/docs/serverdef.md @@ -26,6 +26,8 @@ The following is a list of accepted members within a server definition. * id (*integer*) - **Required.** A value containing the server's [unique ID](https://support.discordapp.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-). * name (*string*) - Preferably a readable version of the server's name. Not used for anything other than internal logging. * moderators (*[entity list](entitylist.html)*) - A list of entities to consider as moderators. Actions done by members of this list are able to execute *ModCommands* commands and are exempt from certain *AutoMod* rules. See their respective pages for more details. -* [automod](automod.html) (*name/value pairs*) - See respective page. -* [autoresponses](autorespond.html) (*name/value pairs*) - See respective page. -* [ModCommands](modcommands.html) (*name/value pairs*) - See respective page. \ No newline at end of file +* [automod](automod.html) - See respective page. +* [autoresponses](autorespond.html) - See respective page. +* [EntryAutoRole](entryautorole.html) - See respective page. +* [ModCommands](modcommands.html) - See respective page. +* [ModLogs](modlogs.html) - See respective page. \ No newline at end of file