diff --git a/Common/EntityList.cs b/Common/EntityList.cs index d233a03..4755a6f 100644 --- a/Common/EntityList.cs +++ b/Common/EntityList.cs @@ -22,13 +22,12 @@ public class EntityList : IEnumerable { /// /// Creates a new EntityList instance with no data. /// - public EntityList() : this(null, false) { } + public EntityList() : this(null) { } /// /// Creates a new EntityList instance using the given JSON token as input. /// /// JSON array to be used for input. For ease of use, null values are also accepted. - /// Specifies if all entities defined in configuration must have their type specified. /// The input is not a JSON array. /// /// Unintiutively, this exception is thrown if a user-provided configuration value is blank. @@ -36,7 +35,7 @@ public class EntityList : IEnumerable { /// /// When enforceTypes is set, this is thrown if an EntityName results in having its Type be Unspecified. /// - public EntityList(JToken? input, bool enforceTypes) { + public EntityList(JToken? input) { if (input == null) { _innerList = new List().AsReadOnly(); return; @@ -50,8 +49,6 @@ public class EntityList : IEnumerable { foreach (var item in inputArray.Values()) { if (string.IsNullOrWhiteSpace(item)) continue; var itemName = new EntityName(item); - if (enforceTypes && itemName.Type == EntityType.Unspecified) - throw new FormatException($"The following value is not prefixed: {item}"); list.Add(itemName); } _innerList = list.AsReadOnly(); @@ -82,7 +79,7 @@ public class EntityList : IEnumerable { } else { foreach (var r in authorRoles) { if (!string.Equals(r.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break; - if (keepId) entry.SetId(r.Id); + if (keepId) entry.Id = r.Id; return true; } } @@ -91,7 +88,7 @@ public class EntityList : IEnumerable { if (entry.Id.Value == channel.Id) return true; } else { if (!string.Equals(channel.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break; - if (keepId) entry.SetId(channel.Id); + if (keepId) entry.Id = channel.Id; return true; } } else { // User @@ -99,7 +96,7 @@ public class EntityList : IEnumerable { if (entry.Id.Value == author.Id) return true; } else { if (!string.Equals(author.Username, entry.Name, StringComparison.OrdinalIgnoreCase)) break; - if (keepId) entry.SetId(author.Id); + if (keepId) entry.Id = author.Id; return true; } } diff --git a/Common/EntityName.cs b/Common/EntityName.cs index dc7a7bc..91596ad 100644 --- a/Common/EntityName.cs +++ b/Common/EntityName.cs @@ -6,23 +6,28 @@ /// public class EntityName { /// - /// The entity's type, if specified in configuration. + /// The entity's type, as specified in configuration. /// public EntityType Type { get; private set; } + private ulong? _id; /// /// Entity's unique ID value (snowflake). May be null if the value is not known. /// - public ulong? Id { get; private set; } + /// + /// This property may be updated during runtime if instructed to update the ID for persistence. + /// + public ulong? Id { + get => _id; + internal set => _id ??= value; + } /// /// Entity's name as specified in configuration. May be null if it was not specified. - /// This value is not updated during runtime. /// + /// This value is not updated during runtime. public string? Name { get; private set; } - // TODO elsewhere: find a way to emit a warning if the user specified a name without ID in configuration. - /// /// Creates a new object instance from the given input string. /// Documentation for the EntityName format can be found elsewhere in this project's documentation. @@ -33,16 +38,12 @@ public class EntityName { public EntityName(string input) { if (string.IsNullOrWhiteSpace(input)) throw new ArgumentNullException(nameof(input), "Specified name is blank."); + if (input.Length < 2) throw new ArgumentException("Input is not in a valid entity name format."); - // Check if type prefix was specified and extract it - Type = default; - if (input.Length >= 2) { - if (input[0] == '&') Type = EntityType.Role; - else if (input[0] == '#') Type = EntityType.Channel; - else if (input[0] == '@') Type = EntityType.User; - } - if (Type == default) - throw new ArgumentException("Entity type unable to be inferred by given input."); + if (input[0] == '&') Type = EntityType.Role; + else if (input[0] == '#') Type = EntityType.Channel; + else if (input[0] == '@') Type = EntityType.User; + else throw new ArgumentException("Entity type unable to be inferred by given input."); input = input[1..]; // Remove prefix @@ -72,14 +73,23 @@ public class EntityName { } } - internal void SetId(ulong id) { - if (!Id.HasValue) Id = id; + /// + /// Creates a new object instance from the given input string. + /// Documentation for the EntityName format can be found elsewhere in this project's documentation. + /// + /// Input string in EntityName format. + /// The expected for this instance. + /// Input string is null or blank. + /// Input string cannot be resolved to an entity type. + /// Input string was resolved to a type other than specified. + public EntityName(string input, EntityType expectedType) : this(input) { + if (Type != expectedType) throw new FormatException("Resolved EntityType does not match expected type."); } /// /// Returns the appropriate prefix corresponding to an EntityType. /// - public static char Prefix(EntityType t) => t switch { + public static char GetPrefix(EntityType t) => t switch { EntityType.Role => '&', EntityType.Channel => '#', EntityType.User => '@', @@ -90,7 +100,7 @@ public class EntityName { /// Returns a string representation of this item in proper EntityName format. /// public override string ToString() { - var pf = Prefix(Type); + var pf = GetPrefix(Type); if (Id.HasValue && Name != null) return $"{pf}{Id.Value}::{Name}"; @@ -101,6 +111,7 @@ public class EntityName { } #region Helper methods + // TODO convert all to extension methods /// /// Attempts to find the corresponding role within the given guild. /// diff --git a/Common/EntityType.cs b/Common/EntityType.cs index 43e2352..8dd1445 100644 --- a/Common/EntityType.cs +++ b/Common/EntityType.cs @@ -3,8 +3,6 @@ /// The type of entity specified in an . /// public enum EntityType { - /// Default value. Is never referenced in regular usage. - Unspecified, /// /// Userd when the represents a role. /// diff --git a/Common/FilterList.cs b/Common/FilterList.cs index 5dec12d..41ab2d1 100644 --- a/Common/FilterList.cs +++ b/Common/FilterList.cs @@ -75,7 +75,7 @@ public class FilterList { if (incoming.Type != JTokenType.Array) throw new FormatException("Filtering list must be a JSON array."); - FilteredList = new EntityList((JArray)incoming, true); + FilteredList = new EntityList((JArray)incoming); // Verify the same for the exemption list. if (exemptKey != null) { @@ -85,7 +85,7 @@ public class FilterList { } else if (incomingEx.Type != JTokenType.Array) { throw new FormatException("Filtering exemption list must be a JSON array."); } else { - FilterExemptions = new EntityList(incomingEx, true); + FilterExemptions = new EntityList(incomingEx); } } else { FilterExemptions = new EntityList(); diff --git a/Common/Utilities.cs b/Common/Utilities.cs index 55578eb..ee2a4fc 100644 --- a/Common/Utilities.cs +++ b/Common/Utilities.cs @@ -1,5 +1,6 @@ using Discord; using System.Diagnostics.CodeAnalysis; +using System.Text; using System.Text.RegularExpressions; namespace RegexBot.Common; @@ -64,4 +65,55 @@ public static class Utilities { } return results; } + + /// + /// Builds and returns an embed which displays this log entry. + /// + public static Embed BuildEmbed(this Data.ModLogEntry entry, RegexbotClient bot) { + var issuedDisplay = TryFromEntityNameString(entry.IssuedBy, bot); + string targetDisplay; + var targetq = bot.EcQueryUser(entry.UserId.ToString()); + if (targetq != null) targetDisplay = $"<@{targetq.UserId}> - {targetq.Username}#{targetq.Discriminator} `{targetq.UserId}`"; + else targetDisplay = $"User with ID `{entry.UserId}`"; + + var logEmbed = new EmbedBuilder() + .WithTitle(Enum.GetName(typeof(ModLogType), entry.LogType) + " logged:") + .WithTimestamp(entry.Timestamp) + .WithFooter($"Log #{entry.LogId}", bot.DiscordClient.CurrentUser.GetAvatarUrl()); // Escaping '#' not necessary here + if (entry.Message != null) { + logEmbed.Description = entry.Message; + } + + var contextStr = new StringBuilder(); + contextStr.AppendLine($"User: {targetDisplay}"); + contextStr.AppendLine($"Logged by: {issuedDisplay}"); + + logEmbed.AddField(new EmbedFieldBuilder() { + Name = "Context", + Value = contextStr.ToString() + }); + + return logEmbed.Build(); + } + + /// + /// Returns a representation of this entity that can be parsed by the constructor. + /// + public static string AsEntityNameString(this IUser entity) => $"@{entity.Id}::{entity.Username}"; + + /// + /// If given string is in an EntityName format, returns a displayable representation of it based on + /// a cache query. Otherwise, returns the input string as-is. + /// + [return: NotNullIfNotNull("input")] + public static string? TryFromEntityNameString(string? input, RegexbotClient bot) { + string? result = null; + try { + var entityTry = new EntityName(input!, EntityType.User); + var issueq = bot.EcQueryUser(entityTry.Id!.Value.ToString()); + if (issueq != null) result = $"<@{issueq.UserId}> - {issueq.Username}#{issueq.Discriminator} `{issueq.UserId}`"; + else result = $"Unknown user with ID `{entityTry.Id!.Value}`"; + } catch (Exception) { } + return result ?? input; + } } diff --git a/Data/BotDatabaseContext.cs b/Data/BotDatabaseContext.cs index 88ab07b..319594d 100644 --- a/Data/BotDatabaseContext.cs +++ b/Data/BotDatabaseContext.cs @@ -12,6 +12,9 @@ public class BotDatabaseContext : DbContext { // Get our own config loaded just for the SQL stuff var conf = new InstanceConfig(); _connectionString = new NpgsqlConnectionStringBuilder() { +#if DEBUG + IncludeErrorDetail = true, +#endif Host = conf.SqlHost ?? "localhost", // default to localhost Database = conf.SqlDatabase, Username = conf.SqlUsername, @@ -34,6 +37,11 @@ public class BotDatabaseContext : DbContext { /// public DbSet GuildMessageCache { get; set; } = null!; + /// + /// Retrieves the moderator logs. + /// + public DbSet ModLogs { get; set; } = null!; + /// protected sealed override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder @@ -43,10 +51,17 @@ public class BotDatabaseContext : DbContext { /// protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => entity.Property(e => e.Discriminator).HasMaxLength(4).IsFixedLength()); - modelBuilder.Entity(entity => { - entity.HasKey(e => new { e.UserId, e.GuildId }); - entity.Property(e => e.FirstSeenTime).HasDefaultValueSql("now()"); + modelBuilder.Entity(e => { + e.HasKey(p => new { p.GuildId, p.UserId }); + e.Property(p => p.FirstSeenTime).HasDefaultValueSql("now()"); + }); + modelBuilder.Entity(e => e.Property(p => p.CreatedAt).HasDefaultValueSql("now()")); + modelBuilder.HasPostgresEnum(); + modelBuilder.Entity(e => { + e.Property(p => p.Timestamp).HasDefaultValueSql("now()"); + e.HasOne(entry => entry.User) + .WithMany(gu => gu.Logs) + .HasForeignKey(entry => new { entry.GuildId, entry.UserId }); }); - modelBuilder.Entity(entity => entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()")); } } diff --git a/Data/CachedGuildMessage.cs b/Data/CachedGuildMessage.cs index 34b7b3b..c39aa4d 100644 --- a/Data/CachedGuildMessage.cs +++ b/Data/CachedGuildMessage.cs @@ -53,12 +53,11 @@ public class CachedGuildMessage { /// public string? Content { get; set; } = null!; - /// - /// If included in the query, references the associated for this entry. - /// + /// [ForeignKey(nameof(AuthorId))] [InverseProperty(nameof(CachedUser.GuildMessages))] public CachedUser Author { get; set; } = null!; + // TODO set up composite foreign key. will require rewriting some parts in modules... // Used by MessageCachingSubservice internal static CachedGuildMessage? Clone(CachedGuildMessage? original) { diff --git a/Data/CachedGuildUser.cs b/Data/CachedGuildUser.cs index 3a6f14a..e259e96 100644 --- a/Data/CachedGuildUser.cs +++ b/Data/CachedGuildUser.cs @@ -6,14 +6,14 @@ namespace RegexBot.Data; /// [Table("cache_usersinguild")] public class CachedGuildUser { - /// - public long UserId { get; set; } - /// /// Gets the associated guild's snowflake ID. /// public long GuildId { get; set; } + /// + public long UserId { get; set; } + /// public DateTimeOffset GULastUpdateTime { get; set; } @@ -33,4 +33,9 @@ public class CachedGuildUser { [ForeignKey(nameof(UserId))] [InverseProperty(nameof(CachedUser.Guilds))] public CachedUser User { get; set; } = null!; + + /// + /// If included in the query, references all items associated with this entry. + /// + public ICollection Logs { get; set; } = null!; } diff --git a/Data/CachedUser.cs b/Data/CachedUser.cs index dfdcfae..939d27b 100644 --- a/Data/CachedUser.cs +++ b/Data/CachedUser.cs @@ -8,7 +8,7 @@ namespace RegexBot.Data; [Table("cache_users")] public class CachedUser { /// - /// Gets the user's snowflake ID. + /// Gets the associated user's snowflake ID. /// [Key] [DatabaseGenerated(DatabaseGeneratedOption.None)] diff --git a/Data/Migrations/20220827041853_AddModLogs.Designer.cs b/Data/Migrations/20220827041853_AddModLogs.Designer.cs new file mode 100644 index 0000000..06cd61e --- /dev/null +++ b/Data/Migrations/20220827041853_AddModLogs.Designer.cs @@ -0,0 +1,235 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using RegexBot.Data; + +#nullable disable + +namespace RegexBot.Data.Migrations +{ + [DbContext(typeof(BotDatabaseContext))] + [Migration("20220827041853_AddModLogs")] + partial class AddModLogs + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "mod_log_type", new[] { "other", "note", "warn", "timeout", "kick", "ban" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b => + { + b.Property("MessageId") + .HasColumnType("bigint") + .HasColumnName("message_id"); + + b.Property>("AttachmentNames") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("attachment_names"); + + b.Property("AuthorId") + .HasColumnType("bigint") + .HasColumnName("author_id"); + + b.Property("ChannelId") + .HasColumnType("bigint") + .HasColumnName("channel_id"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("EditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("edited_at"); + + b.Property("GuildId") + .HasColumnType("bigint") + .HasColumnName("guild_id"); + + b.HasKey("MessageId") + .HasName("pk_cache_guildmessages"); + + b.HasIndex("AuthorId") + .HasDatabaseName("ix_cache_guildmessages_author_id"); + + b.ToTable("cache_guildmessages", (string)null); + }); + + modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b => + { + b.Property("GuildId") + .HasColumnType("bigint") + .HasColumnName("guild_id"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.Property("FirstSeenTime") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("first_seen_time") + .HasDefaultValueSql("now()"); + + b.Property("GULastUpdateTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("gu_last_update_time"); + + b.Property("Nickname") + .HasColumnType("text") + .HasColumnName("nickname"); + + b.HasKey("GuildId", "UserId") + .HasName("pk_cache_usersinguild"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_cache_usersinguild_user_id"); + + b.ToTable("cache_usersinguild", (string)null); + }); + + modelBuilder.Entity("RegexBot.Data.CachedUser", b => + { + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.Property("AvatarUrl") + .HasColumnType("text") + .HasColumnName("avatar_url"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("character(4)") + .HasColumnName("discriminator") + .IsFixedLength(); + + b.Property("ULastUpdateTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("u_last_update_time"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("UserId") + .HasName("pk_cache_users"); + + b.ToTable("cache_users", (string)null); + }); + + modelBuilder.Entity("RegexBot.Data.ModLogEntry", b => + { + b.Property("LogId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("log_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LogId")); + + b.Property("GuildId") + .HasColumnType("bigint") + .HasColumnName("guild_id"); + + b.Property("IssuedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("issued_by"); + + b.Property("LogType") + .HasColumnType("integer") + .HasColumnName("log_type"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("LogId") + .HasName("pk_modlogs"); + + b.HasIndex("GuildId", "UserId") + .HasDatabaseName("ix_modlogs_guild_id_user_id"); + + b.ToTable("modlogs", (string)null); + }); + + modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b => + { + b.HasOne("RegexBot.Data.CachedUser", "Author") + .WithMany("GuildMessages") + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_cache_guildmessages_cache_users_author_id"); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b => + { + b.HasOne("RegexBot.Data.CachedUser", "User") + .WithMany("Guilds") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_cache_usersinguild_cache_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RegexBot.Data.ModLogEntry", b => + { + b.HasOne("RegexBot.Data.CachedGuildUser", "User") + .WithMany("Logs") + .HasForeignKey("GuildId", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_modlogs_cache_usersinguild_user_temp_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("RegexBot.Data.CachedUser", b => + { + b.Navigation("GuildMessages"); + + b.Navigation("Guilds"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20220827041853_AddModLogs.cs b/Data/Migrations/20220827041853_AddModLogs.cs new file mode 100644 index 0000000..ef19f9d --- /dev/null +++ b/Data/Migrations/20220827041853_AddModLogs.cs @@ -0,0 +1,82 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace RegexBot.Data.Migrations +{ + public partial class AddModLogs : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropPrimaryKey( + name: "pk_cache_usersinguild", + table: "cache_usersinguild"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:Enum:mod_log_type", "other,note,warn,timeout,kick,ban"); + + migrationBuilder.AddPrimaryKey( + name: "pk_cache_usersinguild", + table: "cache_usersinguild", + columns: new[] { "guild_id", "user_id" }); + + migrationBuilder.CreateTable( + name: "modlogs", + columns: table => new + { + log_id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + timestamp = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"), + guild_id = table.Column(type: "bigint", nullable: false), + user_id = table.Column(type: "bigint", nullable: false), + log_type = table.Column(type: "integer", nullable: false), + issued_by = table.Column(type: "text", nullable: false), + message = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_modlogs", x => x.log_id); + table.ForeignKey( + name: "fk_modlogs_cache_usersinguild_user_temp_id", + columns: x => new { x.guild_id, x.user_id }, + principalTable: "cache_usersinguild", + principalColumns: new[] { "guild_id", "user_id" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_cache_usersinguild_user_id", + table: "cache_usersinguild", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_modlogs_guild_id_user_id", + table: "modlogs", + columns: new[] { "guild_id", "user_id" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "modlogs"); + + migrationBuilder.DropPrimaryKey( + name: "pk_cache_usersinguild", + table: "cache_usersinguild"); + + migrationBuilder.DropIndex( + name: "ix_cache_usersinguild_user_id", + table: "cache_usersinguild"); + + migrationBuilder.AlterDatabase() + .OldAnnotation("Npgsql:Enum:mod_log_type", "other,note,warn,timeout,kick,ban"); + + migrationBuilder.AddPrimaryKey( + name: "pk_cache_usersinguild", + table: "cache_usersinguild", + columns: new[] { "user_id", "guild_id" }); + } + } +} diff --git a/Data/Migrations/BotDatabaseContextModelSnapshot.cs b/Data/Migrations/BotDatabaseContextModelSnapshot.cs index 038cc06..e19c408 100644 --- a/Data/Migrations/BotDatabaseContextModelSnapshot.cs +++ b/Data/Migrations/BotDatabaseContextModelSnapshot.cs @@ -21,6 +21,7 @@ namespace RegexBot.Data.Migrations .HasAnnotation("ProductVersion", "6.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 63); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "mod_log_type", new[] { "other", "note", "warn", "timeout", "kick", "ban" }); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b => @@ -71,14 +72,14 @@ namespace RegexBot.Data.Migrations modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b => { - b.Property("UserId") - .HasColumnType("bigint") - .HasColumnName("user_id"); - b.Property("GuildId") .HasColumnType("bigint") .HasColumnName("guild_id"); + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + b.Property("FirstSeenTime") .ValueGeneratedOnAdd() .HasColumnType("timestamp with time zone") @@ -93,9 +94,12 @@ namespace RegexBot.Data.Migrations .HasColumnType("text") .HasColumnName("nickname"); - b.HasKey("UserId", "GuildId") + b.HasKey("GuildId", "UserId") .HasName("pk_cache_usersinguild"); + b.HasIndex("UserId") + .HasDatabaseName("ix_cache_usersinguild_user_id"); + b.ToTable("cache_usersinguild", (string)null); }); @@ -131,6 +135,51 @@ namespace RegexBot.Data.Migrations b.ToTable("cache_users", (string)null); }); + modelBuilder.Entity("RegexBot.Data.ModLogEntry", b => + { + b.Property("LogId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("log_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LogId")); + + b.Property("GuildId") + .HasColumnType("bigint") + .HasColumnName("guild_id"); + + b.Property("IssuedBy") + .IsRequired() + .HasColumnType("text") + .HasColumnName("issued_by"); + + b.Property("LogType") + .HasColumnType("integer") + .HasColumnName("log_type"); + + b.Property("Message") + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp") + .HasDefaultValueSql("now()"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("LogId") + .HasName("pk_modlogs"); + + b.HasIndex("GuildId", "UserId") + .HasDatabaseName("ix_modlogs_guild_id_user_id"); + + b.ToTable("modlogs", (string)null); + }); + modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b => { b.HasOne("RegexBot.Data.CachedUser", "Author") @@ -155,6 +204,23 @@ namespace RegexBot.Data.Migrations b.Navigation("User"); }); + modelBuilder.Entity("RegexBot.Data.ModLogEntry", b => + { + b.HasOne("RegexBot.Data.CachedGuildUser", "User") + .WithMany("Logs") + .HasForeignKey("GuildId", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_modlogs_cache_usersinguild_user_temp_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b => + { + b.Navigation("Logs"); + }); + modelBuilder.Entity("RegexBot.Data.CachedUser", b => { b.Navigation("GuildMessages"); diff --git a/Data/ModLogEntry.cs b/Data/ModLogEntry.cs new file mode 100644 index 0000000..1b553f3 --- /dev/null +++ b/Data/ModLogEntry.cs @@ -0,0 +1,50 @@ +using RegexBot.Common; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace RegexBot.Data; +/// +/// Represents a moderation log entry. +/// +[Table("modlogs")] +public class ModLogEntry : ISharedEvent { + /// + /// Gets the ID number for this entry. + /// + [Key] + public int LogId { get; set; } + + /// + /// Gets the date and time when this entry was logged. + /// + public DateTimeOffset Timestamp { get; set; } + + /// + public long GuildId { get; set; } + + /// + /// Gets the ID of the users for which this log entry pertains. + /// + public long UserId { get; set; } + + /// + /// Gets the type of log message this represents. + /// + public ModLogType LogType { get; set; } + + /// + /// Gets the the entity which issued this log item. + /// If it was a user, this value preferably is in the format. + /// + public string IssuedBy { get; set; } = null!; + + /// + /// Gets any additional message associated with this log entry. + /// + public string? Message { get; set; } + + /// + /// If included in the query, gets the associated for this entry. + /// + public CachedGuildUser User { get; set; } = null!; +} \ No newline at end of file diff --git a/InstanceConfig.cs b/InstanceConfig.cs index b6db68b..413633a 100644 --- a/InstanceConfig.cs +++ b/InstanceConfig.cs @@ -4,7 +4,6 @@ using System.Diagnostics.CodeAnalysis; using System.Reflection; namespace RegexBot; - /// /// Contains essential instance configuration for this bot including Discord connection settings, service configuration, /// and command-line options. @@ -67,17 +66,10 @@ class InstanceConfig { return default; } - /// - /// Command line options - /// class CommandLineParameters { - [Option('c', "config", Default = null, - HelpText = "Custom path to instance configuration. Defaults to instance.json in bot directory.")] - public string ConfigFile { get; set; } = null!; + [Option('c', "config", Default = null)] + public string? ConfigFile { get; set; } = null!; - /// - /// Command line arguments parsed here. Depending on inputs, the program can exit here. - /// public static CommandLineParameters? Parse(string[] args) { CommandLineParameters? result = null; diff --git a/Modules/AutoResponder/AutoResponder.cs b/Modules/AutoResponder/AutoResponder.cs index f806705..96a8d58 100644 --- a/Modules/AutoResponder/AutoResponder.cs +++ b/Modules/AutoResponder/AutoResponder.cs @@ -33,26 +33,23 @@ internal class AutoResponder : RegexbotModule { var definitions = GetGuildState>(ch.Guild.Id); if (definitions == null) return; // No configuration in this guild; do no further processing - var tasks = new List(); foreach (var def in definitions) { - tasks.Add(Task.Run(async () => await ProcessMessageAsync(arg, def))); + await ProcessMessageAsync(arg, def, ch); } - - await Task.WhenAll(tasks); } - private async Task ProcessMessageAsync(SocketMessage msg, Definition def) { + private async Task ProcessMessageAsync(SocketMessage msg, Definition def, SocketTextChannel ch) { if (!def.Match(msg)) return; + Log(ch.Guild, $"Definition '{def.Label}' triggered by {msg.Author}."); if (def.Command == null) { await msg.Channel.SendMessageAsync(def.GetResponse()); } else { - var ch = (SocketGuildChannel)msg.Channel; var cmdline = def.Command.Split(new char[] { ' ' }, 2); var ps = new ProcessStartInfo() { FileName = cmdline[0], - Arguments = (cmdline.Length == 2 ? cmdline[1] : ""), + Arguments = cmdline.Length == 2 ? cmdline[1] : "", UseShellExecute = false, // ??? CreateNoWindow = true, RedirectStandardOutput = true diff --git a/Modules/EntryRole/GuildData.cs b/Modules/EntryRole/GuildData.cs index 8aa8c39..b87c8ba 100644 --- a/Modules/EntryRole/GuildData.cs +++ b/Modules/EntryRole/GuildData.cs @@ -1,7 +1,6 @@ using RegexBot.Common; namespace RegexBot.Modules.EntryRole; - /// /// Contains configuration data as well as per-guild timers for those awaiting role assignment. /// @@ -27,13 +26,10 @@ class GuildData { public GuildData(JObject conf, Dictionary _waitingList) { WaitingList = _waitingList; - var cfgRole = conf["Role"]?.Value(); - if (string.IsNullOrWhiteSpace(cfgRole)) - throw new ModuleLoadException("Role value not specified."); try { - TargetRole = new EntityName(cfgRole); - } catch (ArgumentException) { - throw new ModuleLoadException("Role config value was not properly specified to be a role."); + TargetRole = new EntityName(conf["Role"]?.Value()!, EntityType.Role); + } catch (Exception) { + throw new ModuleLoadException("'Role' was not properly specified."); } try { diff --git a/Modules/ModCommands/Commands/BanKick.cs b/Modules/ModCommands/Commands/BanKick.cs index a518834..a5a8842 100644 --- a/Modules/ModCommands/Commands/BanKick.cs +++ b/Modules/ModCommands/Commands/BanKick.cs @@ -13,14 +13,9 @@ class Ban : BanKick { ulong targetId, CachedGuildUser? targetQuery, SocketUser? targetUser) { // Ban: Unlike kick, the minimum required is just the target ID var result = await Module.Bot.BanAsync(g, msg.Author.ToString(), targetId, PurgeDays, reason, SendNotify); - if (result.OperationSuccess) { - if (SuccessMessage != null) { - // TODO customization - await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}"); - } else { - // TODO custom fail message? - await msg.Channel.SendMessageAsync(SuccessMessage); - } + if (result.OperationSuccess && SuccessMessage != null) { + // TODO string replacement, formatting, etc + await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}"); } else { await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot)); } @@ -39,13 +34,12 @@ class Kick : BanKick { } var result = await Module.Bot.KickAsync(g, msg.Author.ToString(), targetId, reason, SendNotify); - if (result.OperationSuccess) { - if (SuccessMessage != null) { - // TODO string replacement, formatting, etc - await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}"); - } + if (result.OperationSuccess && SuccessMessage != null) { + // TODO string replacement, formatting, etc + await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}"); + } else { + await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot)); } - await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot)); } } @@ -60,7 +54,7 @@ abstract class BanKick : CommandConfig { // "PurgeDays" - integer; Number of days of target's post history to delete, if banning. // Must be between 0-7 inclusive. Defaults to 0. // "SendNotify" - boolean; Whether to send a notification DM explaining the action. Defaults to true. - // "SuccessMessage" - string; Message to display on command success. Overrides default. + // "SuccessMessage" - string; Additional message to display on command success. protected BanKick(ModCommands module, JObject config, bool ban) : base(module, config) { ForceReason = config[nameof(ForceReason)]?.Value() ?? false; PurgeDays = config[nameof(PurgeDays)]?.Value() ?? 0; @@ -68,7 +62,7 @@ abstract class BanKick : CommandConfig { SendNotify = config[nameof(SendNotify)]?.Value() ?? true; SuccessMessage = config[nameof(SuccessMessage)]?.Value(); - _usage = $"{Command} `user or user ID` `" + (ForceReason ? "reason" : "[reason]") + "`\n" + _usage = $"{Command} `user ID or tag` `" + (ForceReason ? "reason" : "[reason]") + "`\n" + "Removes the given user from this server" + (ban ? " and prevents the user from rejoining" : "") + ". " + (ForceReason ? "L" : "Optionally l") + "ogs the reason for the " @@ -80,7 +74,7 @@ abstract class BanKick : CommandConfig { private readonly string _usage; protected override string DefaultUsageMsg => _usage; - // Usage: (command) (mention) (reason) + // Usage: (command) (user) (reason) public override async Task Invoke(SocketGuild g, SocketMessage msg) { var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries); string targetstr; diff --git a/Modules/ModCommands/Commands/NoteWarn.cs b/Modules/ModCommands/Commands/NoteWarn.cs new file mode 100644 index 0000000..4b820f1 --- /dev/null +++ b/Modules/ModCommands/Commands/NoteWarn.cs @@ -0,0 +1,76 @@ +using RegexBot.Common; + +namespace RegexBot.Modules.ModCommands.Commands; +// Note and Warn commands are highly similar in implementnation, and thus are handled in a single class. +class Note : NoteWarn { + public Note(ModCommands module, JObject config) : base(module, config) { } + + protected override string DefaultUsageMsg => string.Format(_usageHeader, Command) + + "Appends a note to the moderation log for the given user."; + protected override async Task ContinueInvoke(SocketGuild g, SocketMessage msg, string logMessage, SocketUser targetUser) { + var result = await Module.Bot.AddUserNoteAsync(g, targetUser.Id, msg.Author.AsEntityNameString(), logMessage); + await msg.Channel.SendMessageAsync($":white_check_mark: Note \\#{result.LogId} logged for {targetUser}."); + } +} + +class Warn : NoteWarn { + public Warn(ModCommands module, JObject config) : base(module, config) { } + + protected override string DefaultUsageMsg => string.Format(_usageHeader, Command) + + "Issues a warning to the given user, logging the instance to this bot's moderation log " + + "and notifying the offending user over DM of the issued warning."; + + protected override async Task ContinueInvoke(SocketGuild g, SocketMessage msg, string logMessage, SocketUser targetUser) { + // Won't warn a bot + if (targetUser.IsBot) { + await SendUsageMessageAsync(msg.Channel, ":x: I don't want to do that. If you must, please warn bots manually."); + return; + } + + var (_, result) = await Module.Bot.AddUserWarnAsync(g, targetUser.Id, msg.Author.AsEntityNameString(), logMessage); + await msg.Channel.SendMessageAsync(result.GetResultString()); + } +} + +abstract class NoteWarn : CommandConfig { + protected string? SuccessMessage { get; } + + protected const string _usageHeader = "{0} `user ID or tag` `message`\n"; + + // Configuration: + // "SuccessMessage" - string; Additional message to display on command success. + protected NoteWarn(ModCommands module, JObject config) : base(module, config) { + SuccessMessage = config[nameof(SuccessMessage)]?.Value(); + } + + // Usage: (command) (user) (message) + public override async Task Invoke(SocketGuild g, SocketMessage msg) { + var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries); + if (line.Length != 3) { + await SendUsageMessageAsync(msg.Channel, ":x: Not all required parameters were specified."); + return; + } + var targetstr = line[1]; + var logMessage = line[2]; + + // Get target user. Required to find for our purposes. + var targetQuery = Module.Bot.EcQueryGuildUser(g.Id, targetstr); + ulong targetId; + if (targetQuery != null) targetId = (ulong)targetQuery.UserId; + else if (ulong.TryParse(targetstr, out var parsed)) targetId = parsed; + else { + await SendUsageMessageAsync(msg.Channel, TargetNotFound); + return; + } + var targetUser = g.GetUser(targetId); + + // Go to specific action + try { + await ContinueInvoke(g, msg, logMessage, targetUser); + } catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) { + await msg.Channel.SendMessageAsync(":x: " + Messages.ForbiddenGenericError); + } + } + + protected abstract Task ContinueInvoke(SocketGuild g, SocketMessage msg, string logMessage, SocketUser targetUser); +} \ No newline at end of file diff --git a/Modules/ModCommands/Commands/RoleManipulation.cs b/Modules/ModCommands/Commands/RoleManipulation.cs index efc75e3..99e0483 100644 --- a/Modules/ModCommands/Commands/RoleManipulation.cs +++ b/Modules/ModCommands/Commands/RoleManipulation.cs @@ -29,10 +29,14 @@ abstract class RoleManipulation : CommandConfig { // "role" - string; The given role that applies to this command. // "successmsg" - string; Messages to display on command success. Overrides default. protected RoleManipulation(ModCommands module, JObject config) : base(module, config) { - var rolestr = config[nameof(Role)]?.Value(); - if (string.IsNullOrWhiteSpace(rolestr)) throw new ModuleLoadException($"'{nameof(Role)}' must be provided."); - Role = new EntityName(rolestr); - if (Role.Type != EntityType.Role) throw new ModuleLoadException($"The value in '{nameof(Role)}' is not a role."); + try { + Role = new EntityName(config[nameof(Role)]?.Value()!, EntityType.Role); + } catch (ArgumentNullException) { + throw new ModuleLoadException($"'{nameof(Role)}' must be provided."); + } catch (FormatException) { + throw new ModuleLoadException($"The value in '{nameof(Role)}' is not a role."); + } + SuccessMessage = config[nameof(SuccessMessage)]?.Value(); _usage = $"{Command} `user or user ID`\n" + diff --git a/Modules/ModCommands/Commands/ShowModLogs.cs b/Modules/ModCommands/Commands/ShowModLogs.cs new file mode 100644 index 0000000..9837bb2 --- /dev/null +++ b/Modules/ModCommands/Commands/ShowModLogs.cs @@ -0,0 +1,82 @@ +using Discord; +using Microsoft.EntityFrameworkCore; +using RegexBot.Common; +using RegexBot.Data; + +namespace RegexBot.Modules.ModCommands.Commands; +class ShowModLogs : CommandConfig { + const int LogEntriesPerMessage = 10; + private readonly string _usage; + + protected override string DefaultUsageMsg => _usage; + + // No configuration. + // TODO bring in some options from BanKick. Particularly custom success msg. + // TODO when ModLogs fully implemented, add a reason? + public ShowModLogs(ModCommands module, JObject config) : base(module, config) { + _usage = $"{Command} `user or user ID` [page]\n" + + "Retrieves moderation log entries regarding the specified user."; + } + + // Usage: (command) (query) [page] + public override async Task Invoke(SocketGuild g, SocketMessage msg) { + var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries); + if (line.Length < 2) { + await SendUsageMessageAsync(msg.Channel, null); + return; + } + int pagenum; + if (line.Length == 3) { + const string PageNumError = ":x: Requested page must be a non-negative number."; + if (!int.TryParse(line[2], out pagenum)) { + await SendUsageMessageAsync(msg.Channel, PageNumError); + } + if (pagenum <= 0) await SendUsageMessageAsync(msg.Channel, PageNumError); + } else pagenum = 1; + + var query = Module.Bot.EcQueryGuildUser(g.Id, line[1]); + if (query == null) { + await msg.Channel.SendMessageAsync(":x: Unable to find the given user."); + return; + } + + int totalPages; + List results; + using (var db = new BotDatabaseContext()) { + var totalEntries = db.ModLogs + .Where(l => l.GuildId == query.GuildId && l.UserId == query.UserId) + .Count(); + totalPages = (int)Math.Ceiling((double)totalEntries / LogEntriesPerMessage); + results = db.ModLogs + .Where(l => l.GuildId == query.GuildId && l.UserId == query.UserId) + .OrderByDescending(l => l.LogId) + .Skip((pagenum - 1) * LogEntriesPerMessage) + .Take(LogEntriesPerMessage) + .AsNoTracking() + .ToList(); + } + + var resultList = new EmbedBuilder() { + Author = new EmbedAuthorBuilder() { + Name = $"{query.User.Username}#{query.User.Discriminator}", + IconUrl = query.User.AvatarUrl + }, + Footer = new EmbedFooterBuilder() { + Text = $"Page {pagenum} of {totalPages}", + IconUrl = Module.Bot.DiscordClient.CurrentUser.GetAvatarUrl() + }, + Title = "Moderation logs" + }; + foreach (var item in results) { + var f = new EmbedFieldBuilder() { + Name = $"{Enum.GetName(item.LogType)} \\#{item.LogId}", + Value = $"**Timestamp**: \n" + + $"**Issued by**: {Utilities.TryFromEntityNameString(item.IssuedBy, Module.Bot)}\n" + + $"**Message**: {item.Message ?? "*none specified*"}" + }; + resultList.AddField(f); + } + + await msg.Channel.SendMessageAsync(embed: resultList.Build()); + } +} \ No newline at end of file diff --git a/Modules/ModCommands/Commands/Timeout.cs b/Modules/ModCommands/Commands/Timeout.cs new file mode 100644 index 0000000..7b755ab --- /dev/null +++ b/Modules/ModCommands/Commands/Timeout.cs @@ -0,0 +1,73 @@ +using RegexBot.Common; + +namespace RegexBot.Modules.ModCommands.Commands; +class Timeout : CommandConfig { + protected bool ForceReason { get; } + protected bool SendNotify { get; } + protected string? SuccessMessage { get; } + + // Configuration: + // "ForceReason" - boolean; Force a reason to be given. Defaults to false. + // "SendNotify" - boolean; Whether to send a notification DM explaining the action. Defaults to true. + // "SuccessMessage" - string; Additional message to display on command success. + // TODO future configuration ideas: max timeout, min timeout, default timeout span... + public Timeout(ModCommands module, JObject config) : base(module, config) { + ForceReason = config[nameof(ForceReason)]?.Value() ?? false; + SendNotify = config[nameof(SendNotify)]?.Value() ?? true; + SuccessMessage = config[nameof(SuccessMessage)]?.Value(); + + _usage = $"{Command} `user ID or tag` `time in minutes` `" + (ForceReason ? "reason" : "[reason]") + "`\n" + + "Issues a timeout to the given user, preventing them from participating in the server for a set amount of time. " + + (ForceReason ? "L" : "Optionally l") + "ogs the reason for the timeout to the Audit Log."; + } + + private readonly string _usage; + protected override string DefaultUsageMsg => _usage; + + // Usage: (command) (user) (duration) (reason) + public override async Task Invoke(SocketGuild g, SocketMessage msg) { + var line = msg.Content.Split(new char[] { ' ' }, 4, StringSplitOptions.RemoveEmptyEntries); + string targetstr; + string? reason; + if (line.Length < 3) { + await SendUsageMessageAsync(msg.Channel, null); + return; + } + targetstr = line[1]; + + if (line.Length == 4) reason = line[3]; // Reason given - keep it + else { + // No reason given + if (ForceReason) { + await SendUsageMessageAsync(msg.Channel, ":x: **You must specify a reason.**"); + return; + } + reason = null; + } + + if (!int.TryParse(line[2], out var timeParam)) { + await SendUsageMessageAsync(msg.Channel, ":x: You must specify a duration for the timeout (in minutes)."); + return; + } + + // Get target user. Required to find for our purposes. + var targetQuery = Module.Bot.EcQueryGuildUser(g.Id, targetstr); + ulong targetId; + if (targetQuery != null) targetId = (ulong)targetQuery.UserId; + else if (ulong.TryParse(targetstr, out var parsed)) targetId = parsed; + else { + await SendUsageMessageAsync(msg.Channel, TargetNotFound); + return; + } + var targetUser = g.GetUser(targetId); + + var result = await Module.Bot.SetTimeoutAsync(g, msg.Author.AsEntityNameString(), targetUser, + TimeSpan.FromMinutes(timeParam), reason, SendNotify); + if (result.Success && SuccessMessage != null) { + // TODO string replacement, formatting, etc + await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.ToResultString()}"); + } else { + await msg.Channel.SendMessageAsync(result.ToResultString()); + } + } +} \ No newline at end of file diff --git a/Modules/ModCommands/Commands/Untimeout.cs b/Modules/ModCommands/Commands/Untimeout.cs new file mode 100644 index 0000000..87903a6 --- /dev/null +++ b/Modules/ModCommands/Commands/Untimeout.cs @@ -0,0 +1,51 @@ +using RegexBot.Common; + +namespace RegexBot.Modules.ModCommands.Commands; +class Untimeout : CommandConfig { + private readonly string _usage; + + protected override string DefaultUsageMsg => _usage; + + // No configuration. + // TODO bring in some options from BanKick. Particularly custom success msg. + // TODO when ModLogs fully implemented, add a reason? + public Untimeout(ModCommands module, JObject config) : base(module, config) { + _usage = $"{Command} `user or user ID`\n" + + "Unsets a timeout from a given user."; + } + + // Usage: (command) (user query) + public override async Task Invoke(SocketGuild g, SocketMessage msg) { + var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries); + string targetstr; + if (line.Length < 2) { + await SendUsageMessageAsync(msg.Channel, null); + return; + } + targetstr = line[1]; + + SocketGuildUser? target = null; + var query = Module.Bot.EcQueryUser(targetstr); + if (query != null) { + target = g.GetUser((ulong)query.UserId); + } + if (target == null) { + await SendUsageMessageAsync(msg.Channel, TargetNotFound); + return; + } + + // Check if timed out, respond accordingly + if (target.TimedOutUntil.HasValue && target.TimedOutUntil.Value <= DateTimeOffset.UtcNow) { + await msg.Channel.SendMessageAsync($":x: **{target}** is not timed out."); + return; + } + + // Do the action + try { + await target.RemoveTimeOutAsync(); + } catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) { + const string FailPrefix = ":x: **Could not remove timeout:** "; + await msg.Channel.SendMessageAsync(FailPrefix + Messages.ForbiddenGenericError); + } + } +} \ No newline at end of file diff --git a/Modules/ModCommands/ModCommands.cs b/Modules/ModCommands/ModCommands.cs index f13382b..d9575ce 100644 --- a/Modules/ModCommands/ModCommands.cs +++ b/Modules/ModCommands/ModCommands.cs @@ -41,9 +41,9 @@ internal class ModCommands : RegexbotModule { if (cfg.Commands.TryGetValue(cmdchk, out var c)) { try { await c.Invoke(g, arg); - Log($"[{g.Name}] {c.Command} invoked by {arg.Author} in #{arg.Channel.Name}."); + Log(g, $"{c.Command} invoked by {arg.Author} in #{arg.Channel.Name}."); } catch (Exception ex) { - Log($"Unhandled exception while processing '{c.Label}':\n" + ex.ToString()); + Log(g, $"Unhandled exception while processing '{c.Label}':\n" + ex.ToString()); await arg.Channel.SendMessageAsync($":x: An error occurred during processing ({ex.GetType().FullName}). " + "Check the console for details."); } diff --git a/Modules/ModCommands/ModuleConfig.cs b/Modules/ModCommands/ModuleConfig.cs index ca0ac3f..9b7418e 100644 --- a/Modules/ModCommands/ModuleConfig.cs +++ b/Modules/ModCommands/ModuleConfig.cs @@ -33,10 +33,17 @@ class ModuleConfig { { "kick", typeof(Kick) }, { "say", typeof(Say) }, { "unban", typeof(Unban) }, + { "note", typeof(Note) }, + { "addnote", typeof(Note) }, + { "warn", typeof(Warn) }, + { "timeout", typeof(Commands.Timeout) }, + { "untimeout", typeof(Untimeout)}, { "addrole", typeof(RoleAdd) }, { "roleadd", typeof(RoleAdd) }, { "delrole", typeof(RoleDel) }, - { "roledel", typeof(RoleDel) } + { "roledel", typeof(RoleDel) }, + { "modlogs", typeof(ShowModLogs) }, + { "showmodlogs", typeof(ShowModLogs) } } ); diff --git a/Modules/ModLogs/ModLogs.cs b/Modules/ModLogs/ModLogs.cs index 213611a..5eb3a0d 100644 --- a/Modules/ModLogs/ModLogs.cs +++ b/Modules/ModLogs/ModLogs.cs @@ -10,9 +10,9 @@ internal partial class ModLogs : RegexbotModule { // TODO consider resurrecting 2.x idea of logging actions to db, making it searchable? public ModLogs(RegexbotClient bot) : base(bot) { - // TODO missing logging features: joins, leaves, bans, kicks, user edits (nick/username/discr) + // TODO missing logging features: joins, leaves, user edits (nick/username/discr) DiscordClient.MessageDeleted += HandleDelete; - bot.EcOnMessageUpdate += HandleUpdate; + bot.SharedEventReceived += HandleReceivedSharedEvent; } public override Task CreateGuildStateAsync(ulong guildID, JToken config) { @@ -24,6 +24,11 @@ internal partial class ModLogs : RegexbotModule { return Task.FromResult(new ModuleConfig((JObject)config)); } + private async Task HandleReceivedSharedEvent(ISharedEvent ev) { + if (ev is MessageCacheUpdateEvent upd) await HandleUpdate(upd.OldMessage, upd.NewMessage); + else if (ev is Data.ModLogEntry log) await HandleLog(log); + } + private static string MakeTimestamp(DateTimeOffset time) { var result = new StringBuilder(); //result.Append(time.ToString("yyyy-MM-dd hh:mm:ss")); diff --git a/Modules/ModLogs/ModLogs_Logging.cs b/Modules/ModLogs/ModLogs_Logging.cs new file mode 100644 index 0000000..147078a --- /dev/null +++ b/Modules/ModLogs/ModLogs_Logging.cs @@ -0,0 +1,17 @@ +using RegexBot.Common; +using RegexBot.Data; + +namespace RegexBot.Modules.ModLogs; +// Contains all logic relating to reporting new database mod log entries +internal partial class ModLogs { + public async Task HandleLog(ModLogEntry entry) { + var guild = Bot.DiscordClient.GetGuild((ulong)entry.GuildId); + if (guild == null) return; + var conf = GetGuildState(guild.Id); + if ((conf?.LogModLogs ?? false) == false) return; + var reportChannel = conf?.ReportingChannel?.FindChannelIn(guild, true); + if (reportChannel == null) return; + + await reportChannel.SendMessageAsync(embed: entry.BuildEmbed(Bot)); + } +} \ No newline at end of file diff --git a/Modules/ModLogs/ModLogs_Messages.cs b/Modules/ModLogs/ModLogs_Messages.cs index 35f63a4..1c07284 100644 --- a/Modules/ModLogs/ModLogs_Messages.cs +++ b/Modules/ModLogs/ModLogs_Messages.cs @@ -14,11 +14,11 @@ internal partial class ModLogs { const int MaxPreviewLength = 750; if (argChannel.Value is not SocketTextChannel channel) return; var conf = GetGuildState(channel.Guild.Id); + if ((conf?.LogMessageDeletions ?? false) == false) return; var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true); if (reportChannel == null) return; - if ((conf?.LogMessageDeletions ?? false) == false) return; if (reportChannel.Id == channel.Id) { - Log($"[{channel.Guild.Name}] Message deletion detected in the reporting channel. Regular report has been suppressed."); + Log(channel.Guild, "Message deleted in the reporting channel. Suppressing report."); return; } @@ -53,22 +53,14 @@ internal partial class ModLogs { IconUrl = cachedMsg.Author.AvatarUrl ?? GetDefaultAvatarUrl(cachedMsg.Author.Discriminator) }; } - var attach = CheckAttachments(cachedMsg.AttachmentNames); - if (attach != null) reportEmbed.AddField(attach); + SetAttachmentsField(reportEmbed, cachedMsg.AttachmentNames); } else { reportEmbed.Description = NotCached; } - - var contextStr = new StringBuilder(); - contextStr.AppendLine($"User: {(cachedMsg != null ? $"<@!{cachedMsg.AuthorId}>" : "Unknown")}"); - contextStr.AppendLine($"Channel: <#{channel.Id}> (#{channel.Name})"); - contextStr.AppendLine($"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(argMsg.Id))}"); - if (cachedMsg?.EditedAt != null) contextStr.AppendLine($"Last edit: {MakeTimestamp(cachedMsg.EditedAt.Value)}"); - contextStr.AppendLine($"Message ID: {argMsg.Id}"); - reportEmbed.AddField(new EmbedFieldBuilder() { - Name = "Context", - Value = contextStr.ToString() - }); + + var editLine = $"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(argMsg.Id))}"; + if (cachedMsg?.EditedAt != null) editLine += $"\nLast edit: {MakeTimestamp(cachedMsg.EditedAt.Value)}"; + SetContextField(reportEmbed, (ulong?)cachedMsg?.AuthorId, channel, editLine, argMsg.Id); await reportChannel.SendMessageAsync(embed: reportEmbed.Build()); } @@ -77,11 +69,11 @@ internal partial class ModLogs { const int MaxPreviewLength = 500; var channel = (SocketTextChannel)newMsg.Channel; var conf = GetGuildState(channel.Guild.Id); + var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true); if (reportChannel == null) return; - if ((conf?.LogMessageEdits ?? false) == false) return; if (reportChannel.Id == channel.Id) { - Log($"[{channel.Guild.Name}] Message edit detected in the reporting channel. Regular report has been suppressed."); + Log(channel.Guild, "Message edited in the reporting channel. Suppressing report."); return; } @@ -122,25 +114,39 @@ internal partial class ModLogs { } reportEmbed.AddField(newField); - var attach = CheckAttachments(newMsg.Attachments.Select(a => a.Filename)); - if (attach != null) reportEmbed.AddField(attach); - - var contextStr = new StringBuilder(); - contextStr.AppendLine($"User: <@!{newMsg.Author.Id}>"); - contextStr.AppendLine($"Channel: <#{channel.Id}> (#{channel.Name})"); - if ((oldMsg?.EditedAt) == null) contextStr.AppendLine($"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(newMsg.Id))}"); - else contextStr.AppendLine($"Previous edit: {MakeTimestamp(oldMsg.EditedAt.Value)}"); - contextStr.AppendLine($"Message ID: {newMsg.Id}"); - var contextField = new EmbedFieldBuilder() { - Name = "Context", - Value = contextStr.ToString() - }; - reportEmbed.AddField(contextField); + SetAttachmentsField(reportEmbed, newMsg.Attachments.Select(a => a.Filename)); + + string editLine; + if ((oldMsg?.EditedAt) == null) editLine = $"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(newMsg.Id))}"; + else editLine = $"Previous edit: {MakeTimestamp(oldMsg.EditedAt.Value)}"; + SetContextField(reportEmbed, newMsg.Author.Id, channel, editLine, newMsg.Id); await reportChannel.SendMessageAsync(embed: reportEmbed.Build()); } - private static EmbedFieldBuilder? CheckAttachments(IEnumerable attachments) { + private void SetContextField(EmbedBuilder e, ulong? userId, SocketTextChannel channel, string editLine, ulong msgId) { + string userDisplay; + if (userId.HasValue) { + var q = Bot.EcQueryUser(userId.Value.ToString()); + if (q != null) userDisplay = $"<@{q.UserId}> - {q.Username}#{q.Discriminator} `{q.UserId}`"; + else userDisplay = $"Unknown user with ID `{userId}`"; + } else { + userDisplay = "Unknown"; + } + + var contextStr = new StringBuilder(); + contextStr.AppendLine($"User: {userDisplay}"); + contextStr.AppendLine($"Channel: <#{channel.Id}> (#{channel.Name})"); + contextStr.AppendLine(editLine); + contextStr.AppendLine($"Message ID: {msgId}"); + + e.AddField(new EmbedFieldBuilder() { + Name = "Context", + Value = contextStr.ToString() + }); + } + + private static void SetAttachmentsField(EmbedBuilder e, IEnumerable attachments) { if (attachments.Any()) { var field = new EmbedFieldBuilder { Name = "Attachments" }; var attachNames = new StringBuilder(); @@ -148,8 +154,7 @@ internal partial class ModLogs { attachNames.AppendLine($"`{name}`"); } field.Value = attachNames.ToString().TrimEnd(); - return field; + e.AddField(field); } - return null; } } \ No newline at end of file diff --git a/Modules/ModLogs/ModuleConfig.cs b/Modules/ModLogs/ModuleConfig.cs index c7cf0e7..2f134c3 100644 --- a/Modules/ModLogs/ModuleConfig.cs +++ b/Modules/ModLogs/ModuleConfig.cs @@ -6,16 +6,19 @@ class ModuleConfig { public bool LogMessageDeletions { get; } public bool LogMessageEdits { get; } + public bool LogModLogs { get; } public ModuleConfig(JObject config) { const string RptChError = $"'{nameof(ReportingChannel)}' must be set to a valid channel name."; - var rptch = config[nameof(ReportingChannel)]?.Value(); - if (string.IsNullOrWhiteSpace(rptch)) throw new ModuleLoadException(RptChError); - ReportingChannel = new EntityName(rptch); - if (ReportingChannel.Type != EntityType.Channel) throw new ModuleLoadException(RptChError); + try { + ReportingChannel = new EntityName(config[nameof(ReportingChannel)]?.Value()!, EntityType.Channel); + } catch (Exception) { + throw new ModuleLoadException(RptChError); + } // Individual logging settings - all default to false LogMessageDeletions = config[nameof(LogMessageDeletions)]?.Value() ?? false; LogMessageEdits = config[nameof(LogMessageEdits)]?.Value() ?? false; + LogModLogs = config[nameof(LogModLogs)]?.Value() ?? false; } } \ No newline at end of file diff --git a/Modules/PendingOutRole/ModuleConfig.cs b/Modules/PendingOutRole/ModuleConfig.cs index 94de96a..90c601a 100644 --- a/Modules/PendingOutRole/ModuleConfig.cs +++ b/Modules/PendingOutRole/ModuleConfig.cs @@ -5,11 +5,13 @@ class ModuleConfig { public EntityName Role { get; } public ModuleConfig(JObject conf) { - var cfgRole = conf[nameof(Role)]?.Value(); - if (string.IsNullOrWhiteSpace(cfgRole)) - throw new ModuleLoadException("Role was not specified."); - Role = new EntityName(cfgRole); - if (Role.Type != EntityType.Role) - throw new ModuleLoadException("Name specified in configuration is not a role."); + try { + Role = new EntityName(conf[nameof(Role)]?.Value()!, EntityType.Role); + } catch (ArgumentException) { + throw new ModuleLoadException("Role was not properly specified."); + } catch (FormatException) { + throw new ModuleLoadException("Name specified in configuration is not a role."); + } + } } diff --git a/Modules/RegexModerator/ConfDefinition.cs b/Modules/RegexModerator/ConfDefinition.cs index a26a25a..00a0c1c 100644 --- a/Modules/RegexModerator/ConfDefinition.cs +++ b/Modules/RegexModerator/ConfDefinition.cs @@ -23,8 +23,8 @@ class ConfDefinition { public EntityName? ReportingChannel { get; } public IReadOnlyList Response { get; } public int BanPurgeDays { get; } - public bool NotifyChannelOfRemoval { get; } - public bool NotifyUserOfRemoval { get; } + public bool NotifyChannel { get; } + public bool NotifyUser { get; } public ConfDefinition(JObject def) { Label = def[nameof(Label)]?.Value() @@ -34,9 +34,11 @@ class ConfDefinition { var rptch = def[nameof(ReportingChannel)]?.Value(); if (rptch != null) { - ReportingChannel = new EntityName(rptch); - if (ReportingChannel.Type != EntityType.Channel) + try { + ReportingChannel = new EntityName(rptch, EntityType.Channel); + } catch (FormatException) { throw new ModuleLoadException($"'{nameof(ReportingChannel)}' is not defined as a channel{errpostfx}"); + } } // Regex loading @@ -81,8 +83,8 @@ class ConfDefinition { throw new ModuleLoadException($"'{nameof(Response)}' is not properly defined{errpostfx}"); } BanPurgeDays = def[nameof(BanPurgeDays)]?.Value() ?? 0; - NotifyChannelOfRemoval = def[nameof(NotifyChannelOfRemoval)]?.Value() ?? true; - NotifyUserOfRemoval = def[nameof(NotifyUserOfRemoval)]?.Value() ?? true; + NotifyChannel = def[nameof(NotifyChannel)]?.Value() ?? true; + NotifyUser = def[nameof(NotifyUser)]?.Value() ?? true; } /// diff --git a/Modules/RegexModerator/RegexModerator.cs b/Modules/RegexModerator/RegexModerator.cs index 6c7e1e0..7239390 100644 --- a/Modules/RegexModerator/RegexModerator.cs +++ b/Modules/RegexModerator/RegexModerator.cs @@ -46,29 +46,15 @@ internal class RegexModerator : RegexbotModule { var defs = GetGuildState>(ch.Guild.Id); if (defs == null) return; - // Send further processing to thread pool. - // Match checking is a CPU-intensive task, thus very little checking is done here. - var msgProcessingTasks = new List(); + // Matching and response processing foreach (var item in defs) { // Need to check sender's moderator status here. Definition can't access mod list. var isMod = GetModerators(ch.Guild.Id).IsListMatch(msg, true); - var match = item.IsMatch(msg, isMod); - msgProcessingTasks.Add(Task.Run(async () => await ProcessMessage(item, ch.Guild, msg, isMod))); + if (!item.IsMatch(msg, isMod)) continue; + Log(ch.Guild, $"Rule '{item.Label}' triggered by {msg.Author}."); + var exec = new ResponseExecutor(item, Bot, msg, (string logLine) => Log(ch.Guild, logLine)); + await exec.Execute(); } - await Task.WhenAll(msgProcessingTasks); - } - - /// - /// Does further message checking and response execution. - /// Invocations of this method are meant to be placed onto a thread separate from the caller. - /// - private async Task ProcessMessage(ConfDefinition def, SocketGuild g, SocketMessage msg, bool isMod) { - if (!def.IsMatch(msg, isMod)) return; - - // TODO logging options for match result; handle here? - - var executor = new ResponseExecutor(def, Bot, msg, (string logLine) => Log(g, logLine)); - await executor.Execute(); } } diff --git a/Modules/RegexModerator/ResponseExecutor.cs b/Modules/RegexModerator/ResponseExecutor.cs index 6aa6ea2..e7fba21 100644 --- a/Modules/RegexModerator/ResponseExecutor.cs +++ b/Modules/RegexModerator/ResponseExecutor.cs @@ -3,11 +3,14 @@ using RegexBot.Common; using System.Text; namespace RegexBot.Modules.RegexModerator; - /// /// Transient helper class which handles response interpreting and execution. /// class ResponseExecutor { + private const string ErrParamNeedNone = "This response type does not accept parameters."; + private const string ErrParamWrongAmount = "Incorrect number of parameters defined in the response."; + private const string ErrMissingUser = "The target user is no longer in the server."; + delegate Task ResponseHandler(string? parameter); private readonly ConfDefinition _rule; @@ -20,6 +23,8 @@ class ResponseExecutor { private readonly List<(string, ResponseResult)> _reports; private Action Log { get; } + private string LogSource => $"{_rule.Label} ({nameof(RegexModerator)})"; + public ResponseExecutor(ConfDefinition rule, RegexbotClient bot, SocketMessage msg, Action logger) { _rule = rule; _bot = bot; @@ -114,7 +119,7 @@ class ResponseExecutor { ) .WithDescription(invokingLine) .WithFooter( - text: $"Rule: {_rule.Label}", + text: LogSource, iconUrl: _bot.DiscordClient.CurrentUser.GetAvatarUrl() ) .WithCurrentTimestamp() @@ -135,15 +140,15 @@ class ResponseExecutor { private async Task CmdBanKick(RemovalType rt, string? parameter) { BanKickResult result; if (rt == RemovalType.Ban) { - result = await _bot.BanAsync(_guild, $"Rule '{_rule.Label}'", _user.Id, - _rule.BanPurgeDays, parameter, _rule.NotifyUserOfRemoval); + result = await _bot.BanAsync(_guild, LogSource, _user.Id, + _rule.BanPurgeDays, parameter, _rule.NotifyUser); } else { - result = await _bot.KickAsync(_guild, $"Rule '{_rule.Label}'", _user.Id, - parameter, _rule.NotifyUserOfRemoval); + result = await _bot.KickAsync(_guild, LogSource, _user.Id, + parameter, _rule.NotifyUser); } if (result.ErrorForbidden) return FromError(Messages.ForbiddenGenericError); - if (result.ErrorNotFound) return FromError("The target user is no longer in the server."); - if (_rule.NotifyChannelOfRemoval) await _msg.Channel.SendMessageAsync(result.GetResultString(_bot)); + if (result.ErrorNotFound) return FromError(ErrMissingUser); + if (_rule.NotifyChannel) await _msg.Channel.SendMessageAsync(result.GetResultString(_bot)); return FromSuccess(result.MessageSendSuccess ? null : "Unable to send notification DM."); } @@ -151,23 +156,22 @@ class ResponseExecutor { private Task CmdRoleDel(string? parameter) => CmdRoleManipulation(parameter, false); private async Task CmdRoleManipulation(string? parameter, bool add) { // parameters: @_, &, reason? - // TODO add persistence option if/when implemented - if (string.IsNullOrWhiteSpace(parameter)) return FromError("This response requires parameters."); + if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount); var param = parameter.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (param.Length < 2) return FromError("Incorrect number of parameters."); + if (param.Length != 2) return FromError(ErrParamWrongAmount); // Find targets SocketGuildUser? tuser; SocketRole? trole; try { - var userName = new EntityName(param[0]); + var userName = new EntityName(param[0], EntityType.User); if (userName.Id.HasValue) tuser = _guild.GetUser(userName.Id.Value); else { if (userName.Name == "_") tuser = _user; else tuser = userName.FindUserIn(_guild); } if (tuser == null) return FromError($"Unable to find user '{userName.Name}'."); - var roleName = new EntityName(param[1]); + var roleName = new EntityName(param[1], EntityType.Role); if (roleName.Id.HasValue) trole = _guild.GetRole(roleName.Id.Value); else trole = roleName.FindRoleIn(_guild); if (trole == null) return FromError($"Unable to find role '{roleName.Name}'."); @@ -176,21 +180,17 @@ class ResponseExecutor { } // Do action - var rq = new RequestOptions() { AuditLogReason = $"Rule '{_rule.Label}'" }; - if (param.Length == 3 && !string.IsNullOrWhiteSpace(param[2])) { - rq.AuditLogReason += " - " + param[2]; - } + var rq = new RequestOptions() { AuditLogReason = LogSource }; if (add) await tuser.AddRoleAsync(trole, rq); else await tuser.RemoveRoleAsync(trole, rq); return FromSuccess($"{(add ? "Set" : "Unset")} {trole.Mention}."); } private async Task CmdDelete(string? parameter) { - // TODO detailed audit log deletion reason? - if (parameter != null) return FromError("This response does not accept parameters."); + if (!string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamNeedNone); try { - await _msg.DeleteAsync(new RequestOptions { AuditLogReason = $"Rule {_rule.Label}" }); + await _msg.DeleteAsync(new RequestOptions { AuditLogReason = LogSource }); return FromSuccess(); } catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.NotFound) { return FromError("The message had already been deleted."); @@ -199,9 +199,9 @@ class ResponseExecutor { private async Task CmdSay(string? parameter) { // parameters: [#_/@_] message - if (string.IsNullOrWhiteSpace(parameter)) return FromError("This response requires parameters."); + if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount); var param = parameter.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (param.Length != 2) return FromError("Incorrect number of parameters."); + if (param.Length != 2) return FromError(ErrParamWrongAmount); // Get target IMessageChannel? targetCh; @@ -233,17 +233,39 @@ class ResponseExecutor { return FromSuccess($"Sent to {(isUser ? "user DM" : $"<#{targetCh.Id}>")}."); } - private Task CmdNote(string? parameter) { - #warning Not implemented - return Task.FromResult(FromError("not implemented")); + private async Task CmdNote(string? parameter) { + if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount); + var log = await _bot.AddUserNoteAsync(_guild, _user.Id, LogSource, parameter); + return FromSuccess($"Note \\#{log.LogId} logged for {_user}."); } - private Task CmdTimeout(string? parameter) { - #warning Not implemented - return Task.FromResult(FromError("not implemented")); + + private async Task CmdWarn(string? parameter) { + if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount); + var (log, result) = await _bot.AddUserWarnAsync(_guild, _user.Id, LogSource, parameter); + var resultMsg = $"Warning \\#{log.LogId} logged for {_user}."; + if (result.Success) return FromSuccess(resultMsg); + else return FromError(resultMsg + " Failed to send DM."); } - private Task CmdWarn(string? parameter) { - #warning Not implemented - return Task.FromResult(FromError("not implemented")); + + private async Task CmdTimeout(string? parameter) { + // parameters: (time in minutes) [reason] + if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount); + var param = parameter.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (param.Length < 1) return FromError(ErrParamWrongAmount); + + if (!int.TryParse(param[0], out var timemin)) { + return FromError($"Couldn't parse '{param[0]}' as amount of time in minutes."); + } + string? reason = null; + if (param.Length == 2) reason = param[1]; + + var result = await _bot.SetTimeoutAsync(_guild, LogSource, _user, + TimeSpan.FromMinutes(timemin), reason, _rule.NotifyUser); + if (result.ErrorForbidden) return FromError(Messages.ForbiddenGenericError); + if (result.ErrorNotFound) return FromError(ErrMissingUser); + if (result.Error != null) return FromError(result.Error.Message); + if (_rule.NotifyChannel) await _msg.Channel.SendMessageAsync(result.ToResultString()); + return FromSuccess(result.Success ? null : "Unable to send notification DM."); } #endregion diff --git a/Modules/VoiceRoleSync/ModuleConfig.cs b/Modules/VoiceRoleSync/ModuleConfig.cs index 0e584a6..b959b27 100644 --- a/Modules/VoiceRoleSync/ModuleConfig.cs +++ b/Modules/VoiceRoleSync/ModuleConfig.cs @@ -1,32 +1,40 @@ +using RegexBot.Common; using System.Collections.ObjectModel; namespace RegexBot.Modules.VoiceRoleSync; -/// -/// Dictionary wrapper. Key = voice channel ID, Value = role. -/// class ModuleConfig { + /// + /// Key = voice channel ID, Value = role ID. + /// private readonly ReadOnlyDictionary _values; public int Count { get => _values.Count; } - public ModuleConfig(JObject config) { - // Configuration format is expected to be an object that contains other objects. - // The objects themselves should have their name be the voice channel, - // and the value be the role to be applied. - - // TODO Make it accept names; currently only accepts ulongs - + public ModuleConfig(JObject config, SocketGuild g) { + // Configuration: Object with properties. + // Property name is a role entity name + // Value is a string or array of voice channel IDs. var values = new Dictionary(); foreach (var item in config.Properties()) { - if (!ulong.TryParse(item.Name, out var voice)) throw new ModuleLoadException($"{item.Name} is not a voice channel ID."); - var valstr = item.Value.Value(); - if (!ulong.TryParse(valstr, out var role)) throw new ModuleLoadException($"{valstr} is not a role ID."); + EntityName name; + try { + name = new EntityName(item.Name, EntityType.Role); + } catch (FormatException) { + throw new ModuleLoadException($"'{item.Name}' is not specified as a role."); + } + var role = name.FindRoleIn(g); + if (role == null) throw new ModuleLoadException($"Unable to find role '{name}'."); - values[voice] = role; + var channels = Utilities.LoadStringOrStringArray(item.Value); + if (channels.Count == 0) throw new ModuleLoadException($"One or more channels must be defined under '{name}'."); + foreach (var id in channels) { + if (!ulong.TryParse(id, out var channelId)) throw new ModuleLoadException("Voice channel IDs must be numeric."); + if (values.ContainsKey(channelId)) throw new ModuleLoadException($"'{channelId}' cannot be specified more than once."); + values.Add(channelId, role.Id); + } } - - _values = new ReadOnlyDictionary(values); + _values = new(values); } public SocketRole? GetAssociatedRoleFor(SocketVoiceChannel voiceChannel) { @@ -36,8 +44,9 @@ class ModuleConfig { } public IEnumerable GetTrackedRoles(SocketGuild guild) { - foreach (var pair in _values) { - var r = guild.GetRole(pair.Value); + var roles = _values.Select(v => v.Value).Distinct(); + foreach (var id in roles) { + var r = guild.GetRole(id); if (r != null) yield return r; } } diff --git a/Modules/VoiceRoleSync/VoiceRoleSync.cs b/Modules/VoiceRoleSync/VoiceRoleSync.cs index e92a841..b7c675f 100644 --- a/Modules/VoiceRoleSync/VoiceRoleSync.cs +++ b/Modules/VoiceRoleSync/VoiceRoleSync.cs @@ -5,8 +5,6 @@ namespace RegexBot.Modules.VoiceRoleSync; /// [RegexbotModule] internal class VoiceRoleSync : RegexbotModule { - // TODO wishlist? specify multiple definitions - multiple channels associated with multiple roles. - public VoiceRoleSync(RegexbotClient bot) : base(bot) { DiscordClient.UserVoiceStateUpdated += Client_UserVoiceStateUpdated; } @@ -18,7 +16,8 @@ internal class VoiceRoleSync : RegexbotModule { if (settings == null) return; // not enabled here async Task RemoveAllAssociatedRoles() - => await user.RemoveRolesAsync(settings.GetTrackedRoles(user.Guild).Intersect(user.Roles)); + => await user.RemoveRolesAsync(settings.GetTrackedRoles(user.Guild).Intersect(user.Roles), + new Discord.RequestOptions() { AuditLogReason = nameof(VoiceRoleSync) + ": No longer in associated voice channel." }); if (after.VoiceChannel == null) { // Not in any voice channel. Remove all roles being tracked by this instance. Clear. @@ -35,10 +34,10 @@ internal class VoiceRoleSync : RegexbotModule { await RemoveAllAssociatedRoles(); } else { // In a tracked voice channel: Clear all except target, add target if needed. - await user.RemoveRolesAsync(settings.GetTrackedRoles(user.Guild) - .Where(role => role.Id != targetRole.Id) - .Intersect(user.Roles)); - if (!user.Roles.Contains(targetRole)) await user.AddRoleAsync(targetRole); + var toRemove = settings.GetTrackedRoles(user.Guild).Where(role => role.Id != targetRole.Id).Intersect(user.Roles); + if (toRemove.Any()) await user.RemoveRolesAsync(toRemove); + if (!user.Roles.Contains(targetRole)) await user.AddRoleAsync(targetRole, + new Discord.RequestOptions() { AuditLogReason = nameof(VoiceRoleSync) + ": Joined associated voice channel." }); } } } @@ -49,7 +48,7 @@ internal class VoiceRoleSync : RegexbotModule { if (config.Type != JTokenType.Object) throw new ModuleLoadException("Configuration for this section is invalid."); - var newconf = new ModuleConfig((JObject)config); + var newconf = new ModuleConfig((JObject)config, Bot.DiscordClient.GetGuild(guildID)); Log(DiscordClient.GetGuild(guildID), $"Configured with {newconf.Count} pairing(s)."); return Task.FromResult(newconf); } diff --git a/RegexBot.csproj b/RegexBot.csproj index bf6076f..8d25f46 100644 --- a/RegexBot.csproj +++ b/RegexBot.csproj @@ -5,7 +5,7 @@ net6.0 NoiTheCat Advanced and flexible Discord moderation bot. - 3.0.0 + 3.1.0 enable enable True @@ -21,7 +21,7 @@ - + @@ -30,8 +30,8 @@ - - + + diff --git a/RegexbotClient.cs b/RegexbotClient.cs index 3c2c754..bb5d161 100644 --- a/RegexbotClient.cs +++ b/RegexbotClient.cs @@ -26,6 +26,7 @@ public partial class RegexbotClient { // Get all services started up _svcLogging = new Services.Logging.LoggingService(this); + _svcSharedEvents = new Services.SharedEventService.SharedEventService(this); _svcGuildState = new Services.ModuleState.ModuleStateService(this); _svcCommonFunctions = new Services.CommonFunctions.CommonFunctionsService(this); _svcEntityCache = new Services.EntityCache.EntityCacheService(this); diff --git a/Services/CommonFunctions/BanKickResult.cs b/Services/CommonFunctions/BanKickResult.cs index 9d5bcc3..1021179 100644 --- a/Services/CommonFunctions/BanKickResult.cs +++ b/Services/CommonFunctions/BanKickResult.cs @@ -1,8 +1,6 @@ using Discord.Net; using RegexBot.Common; -// Instances of this class are created by CommonFunctionService and are meant to be sent to modules, -// therefore we put this in the root RegexBot namespace despite being specific to this service. namespace RegexBot; /// /// Contains information on various success/failure outcomes for a ban or kick operation. diff --git a/Services/CommonFunctions/CF_ModLogs.Hooks.cs b/Services/CommonFunctions/CF_ModLogs.Hooks.cs new file mode 100644 index 0000000..5dce387 --- /dev/null +++ b/Services/CommonFunctions/CF_ModLogs.Hooks.cs @@ -0,0 +1,80 @@ +#pragma warning disable CA1822 // "Mark members as static" - members should only be callable by code with access to this instance +using Discord.Net; +using RegexBot.Common; +using RegexBot.Data; + +namespace RegexBot; +partial class RegexbotClient { + /// + /// Appends a note to the moderation log regarding the given user, containing the given message. + /// + /// + /// Unlike warnings, notes are private and intended for moderators only. Users are never notified and may + /// never be aware of notes associated with them. Otherwise, they function as any other entry in the log. + /// + /// The guild which the target user is associated. + /// The snowflake ID of the target user. + /// + /// The the entity which issued this log item. + /// If it was a user, this value preferably is in the format. + /// + /// The message to add to this entry. + /// + /// The resulting from the creation of this note. + /// + public async Task AddUserNoteAsync(SocketGuild guild, ulong targetUser, string source, string? message) { + var entry = new ModLogEntry() { + GuildId = (long)guild.Id, + UserId = (long)targetUser, + LogType = ModLogType.Note, + IssuedBy = source, + Message = message + }; + using (var db = new BotDatabaseContext()) { + db.Add(entry); + await db.SaveChangesAsync(); + } + await PushSharedEventAsync(entry); + return entry; + } + + /// + /// Warns a user, adding an entry to the moderation log and also attempting to notify the user. + /// + /// The guild which the target user is associated. + /// The snowflake ID of the target user. + /// + /// The the entity which issued this log item. + /// If it was a user, this value preferably is in the format. + /// + /// The message to add to this entry. + /// + /// A tuple containing the resulting and . + /// + public async Task<(ModLogEntry, LogAppendResult)> AddUserWarnAsync(SocketGuild guild, ulong targetUser, string source, string? message) { + var entry = new ModLogEntry() { + GuildId = (long)guild.Id, + UserId = (long)targetUser, + LogType = ModLogType.Warn, + IssuedBy = source, + Message = message + }; + using (var db = new BotDatabaseContext()) { + db.Add(entry); + await db.SaveChangesAsync(); + } + await PushSharedEventAsync(entry); + + // Attempt warning message + var userSearch = _svcEntityCache.QueryUserCache(targetUser.ToString()); + var userDisp = userSearch != null + ? $"**{userSearch.Username}#{userSearch.Discriminator}**" + : $"user with ID **{targetUser}**"; + var targetGuildUser = guild.GetUser(targetUser); + if (targetGuildUser == null) return (entry, new LogAppendResult( + new HttpException(System.Net.HttpStatusCode.NotFound, null), entry.LogId, userDisp)); + + var sendStatus = await _svcCommonFunctions.SendUserWarningAsync(targetGuildUser, message); + return (entry, new LogAppendResult(sendStatus, entry.LogId, userDisp)); + } +} \ No newline at end of file diff --git a/Services/CommonFunctions/CF_ModLogs.cs b/Services/CommonFunctions/CF_ModLogs.cs new file mode 100644 index 0000000..ab1cf81 --- /dev/null +++ b/Services/CommonFunctions/CF_ModLogs.cs @@ -0,0 +1,39 @@ +#pragma warning disable CA1822 // "Mark members as static" - members should only be callable by code with access to this instance +using Discord.Net; +using RegexBot.Data; + +namespace RegexBot.Services.CommonFunctions; +internal partial class CommonFunctionsService : Service { + // Called by EF_Removals, this processes a removal into a log entry. + // A notification for this entry is then propagated. + private void ModLogsProcessRemoval(ulong guildId, ulong targetId, ModLogType remType, string source, string? logReason) { + var entry = new ModLogEntry() { + GuildId = (long)guildId, + UserId = (long)targetId, + LogType = remType, + IssuedBy = source, + Message = logReason + }; + using (var db = new BotDatabaseContext()) { + db.Add(entry); + db.SaveChanges(); + } + BotClient.PushSharedEventAsync(entry); + } + + internal async Task SendUserWarningAsync(SocketGuildUser target, string? reason) { + const string DMTemplate = "You have been issued a warning in {0}"; + const string DMTemplateReason = " with the following message:\n{1}"; + + var outMessage = string.IsNullOrWhiteSpace(reason) + ? string.Format(DMTemplate + ".", target.Guild.Name) + : string.Format(DMTemplate + DMTemplateReason, target.Guild.Name, reason); + try { + var dch = await target.CreateDMChannelAsync(); + await dch.SendMessageAsync(outMessage); + } catch (HttpException ex) { + return ex; + } + return null; + } +} \ No newline at end of file diff --git a/Services/CommonFunctions/Hooks.cs b/Services/CommonFunctions/CF_Removals.Hooks.cs similarity index 97% rename from Services/CommonFunctions/Hooks.cs rename to Services/CommonFunctions/CF_Removals.Hooks.cs index c6b61ef..bb62344 100644 --- a/Services/CommonFunctions/Hooks.cs +++ b/Services/CommonFunctions/CF_Removals.Hooks.cs @@ -1,9 +1,5 @@ -using RegexBot.Services.CommonFunctions; - -namespace RegexBot; +namespace RegexBot; partial class RegexbotClient { - private readonly CommonFunctionsService _svcCommonFunctions; - /// /// Attempts to ban the given user from the specified guild. It is greatly preferred to call this method /// instead of manually executing the equivalent method found in Discord.Net. It notifies other services diff --git a/Services/CommonFunctions/CF_Removals.cs b/Services/CommonFunctions/CF_Removals.cs new file mode 100644 index 0000000..e543203 --- /dev/null +++ b/Services/CommonFunctions/CF_Removals.cs @@ -0,0 +1,48 @@ +#pragma warning disable CA1822 // "Mark members as static" - members should only be callable by code with access to this instance +using Discord.Net; + +namespace RegexBot.Services.CommonFunctions; +internal partial class CommonFunctionsService : Service { + // Hooked (indirectly) + internal async Task BanOrKickAsync(RemovalType t, SocketGuild guild, string source, ulong target, + int banPurgeDays, string? logReason, bool sendDmToTarget) { + if (t == RemovalType.None) throw new ArgumentException("Removal type must be 'ban' or 'kick'."); + var dmSuccess = true; + + SocketGuildUser utarget = guild.GetUser(target); + // Can't kick without obtaining user object. Quit here. + if (t == RemovalType.Kick && utarget == null) return new BanKickResult(null, false, true, RemovalType.Kick, 0); + + // Send DM notification + // Must be done before removal, or we risk not being able to send a notification afterwards + if (sendDmToTarget) { + if (utarget != null) dmSuccess = await BanKickSendNotificationAsync(utarget, t, logReason); + else dmSuccess = false; + } + + // Perform the action + var auditReason = $"(By: {source}) {logReason}"; + try { + if (t == RemovalType.Ban) await guild.AddBanAsync(target, banPurgeDays, auditReason); + else await utarget!.KickAsync(auditReason); + } catch (HttpException ex) { + return new BanKickResult(ex, dmSuccess, false, t, target); + } + ModLogsProcessRemoval(guild.Id, target, t == RemovalType.Ban ? ModLogType.Ban : ModLogType.Kick, source, logReason); + + return new BanKickResult(null, dmSuccess, false, t, target); + } + + private async Task BanKickSendNotificationAsync(SocketGuildUser target, RemovalType action, string? reason) { + const string DMTemplate = "You have been {0} from {1}"; + const string DMTemplateReason = " for the following reason:\n{2}"; + + var outMessage = string.IsNullOrWhiteSpace(reason) + ? string.Format(DMTemplate + ".", action == RemovalType.Ban ? "banned" : "kicked", target.Guild.Name) + : string.Format(DMTemplate + DMTemplateReason, action == RemovalType.Ban ? "banned" : "kicked", target.Guild.Name, reason); + + var dch = await target.CreateDMChannelAsync(); + try { await dch.SendMessageAsync(outMessage); } catch (HttpException) { return false; } + return true; + } +} \ No newline at end of file diff --git a/Services/CommonFunctions/CF_Timeout.Hooks.cs b/Services/CommonFunctions/CF_Timeout.Hooks.cs new file mode 100644 index 0000000..7188d47 --- /dev/null +++ b/Services/CommonFunctions/CF_Timeout.Hooks.cs @@ -0,0 +1,18 @@ +namespace RegexBot; +partial class RegexbotClient { + /// + /// Sets a timeout on a user while also adding an entry to the moderation log and attempting to notify the user. + /// + /// The guild which the target user is associated. + /// + /// The the entity which issued this log item. + /// If it was a user, this value preferably is in the format. + /// + /// The user to be issued a timeout. + /// The duration of the timeout. + /// Reason for the action. Sent to the Audit Log and user (if specified). + /// Specify whether to send a direct message to the target user informing them of the action. + public Task SetTimeoutAsync(SocketGuild guild, string source, SocketGuildUser target, + TimeSpan duration, string? reason, bool sendNotificationDM) + => _svcCommonFunctions.SetTimeoutAsync(guild, source, target, duration, reason, sendNotificationDM); +} \ No newline at end of file diff --git a/Services/CommonFunctions/CF_Timeout.cs b/Services/CommonFunctions/CF_Timeout.cs new file mode 100644 index 0000000..a8527cb --- /dev/null +++ b/Services/CommonFunctions/CF_Timeout.cs @@ -0,0 +1,66 @@ +#pragma warning disable CA1822 // "Mark members as static" - members should only be callable by code with access to this instance +using Discord.Net; +using RegexBot.Data; + +namespace RegexBot.Services.CommonFunctions; +internal partial class CommonFunctionsService : Service { + // Hooked + internal async Task SetTimeoutAsync(SocketGuild guild, string source, SocketGuildUser target, + TimeSpan duration, string? reason, bool sendNotificationDM) { + if (duration < TimeSpan.FromMinutes(1)) + return new TimeoutSetResult(new ArgumentOutOfRangeException( + nameof(duration), "Cannot set a timeout with a duration less than 60 seconds."), true, target); + if (duration > TimeSpan.FromDays(28)) + return new TimeoutSetResult(new ArgumentOutOfRangeException( + nameof(duration), "Cannot set a timeout with a duration greater than 28 days."), true, target); + if (target.TimedOutUntil != null && DateTimeOffset.UtcNow < target.TimedOutUntil) + return new TimeoutSetResult(new InvalidOperationException( + "Cannot set a timeout. The user is already timed out."), true, target); + + Discord.RequestOptions? audit = null; + if (reason != null) audit = new() { AuditLogReason = reason }; + try { + await target.SetTimeOutAsync(duration, audit); + } catch (HttpException ex) { + return new TimeoutSetResult(ex, false, target); + } + var entry = new ModLogEntry() { + GuildId = (long)guild.Id, + UserId = (long)target.Id, + LogType = ModLogType.Timeout, + IssuedBy = source, + Message = $"Duration: {Math.Floor(duration.TotalMinutes)}min{(reason == null ? "." : " - " + reason)}" + }; + using (var db = new BotDatabaseContext()) { + db.Add(entry); + await db.SaveChangesAsync(); + } + // TODO check if this log entry should be propagated now or if (to be implemented) will do it for us later + await BotClient.PushSharedEventAsync(entry); // Until then, we for sure propagate our own + + bool dmSuccess; + // DM notification + if (sendNotificationDM) { + dmSuccess = await SendUserTimeoutNoticeAsync(target, duration, reason); + } else dmSuccess = true; + + return new TimeoutSetResult(null, dmSuccess, target); + } + + internal async Task SendUserTimeoutNoticeAsync(SocketGuildUser target, TimeSpan duration, string? reason) { + // you have been issued a timeout in x. + // the timeout will expire on + const string DMTemplate1 = "You have been issued a timeout in {0}"; + const string DMTemplateReason = " for the following reason:\n{2}"; + const string DMTemplate2 = "\nThe timeout will expire on ()."; + + var expireTime = (DateTimeOffset.UtcNow + duration).ToUnixTimeSeconds(); + var outMessage = string.IsNullOrWhiteSpace(reason) + ? string.Format($"{DMTemplate1}.{DMTemplate2}", target.Guild.Name, expireTime) + : string.Format($"{DMTemplate1}{DMTemplateReason}\n{DMTemplate2}", target.Guild.Name, expireTime, reason); + + var dch = await target.CreateDMChannelAsync(); + try { await dch.SendMessageAsync(outMessage); } catch (HttpException) { return false; } + return true; + } +} \ No newline at end of file diff --git a/Services/CommonFunctions/CommonFunctionsService.cs b/Services/CommonFunctions/CommonFunctionsService.cs index 4acae85..5b33f8a 100644 --- a/Services/CommonFunctions/CommonFunctionsService.cs +++ b/Services/CommonFunctions/CommonFunctionsService.cs @@ -1,64 +1,20 @@ -using Discord.Net; +using RegexBot.Services.CommonFunctions; -namespace RegexBot.Services.CommonFunctions; -/// -/// Implements certain common actions that modules may want to perform. Using this service to perform those -/// functions may help enforce a sense of consistency across modules when performing common actions, and may -/// inform services which provide any additional features the ability to respond to those actions ahead of time. -/// -internal class CommonFunctionsService : Service { - public CommonFunctionsService(RegexbotClient bot) : base(bot) { } - - #region Guild member removal - // Hooked (indirectly) - [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static")] - internal async Task BanOrKickAsync(RemovalType t, - SocketGuild guild, - string source, - ulong target, - int banPurgeDays, - string? logReason, - bool sendDmToTarget) { - if (t == RemovalType.None) throw new ArgumentException("Removal type must be 'ban' or 'kick'."); - var dmSuccess = true; - - SocketGuildUser utarget = guild.GetUser(target); - // Can't kick without obtaining user object. Quit here. - if (t == RemovalType.Kick && utarget == null) return new BanKickResult(null, false, true, RemovalType.Kick, 0); - - // TODO notify services here as soon as we get some who will want to listen to this (use source parameter) - - // Send DM notification - if (sendDmToTarget) { - if (utarget != null) dmSuccess = await BanKickSendNotificationAsync(utarget, t, logReason); - else dmSuccess = false; - } - - // Perform the action - var auditReason = $"(By: {source}) {logReason}"; - try { - if (t == RemovalType.Ban) await guild.AddBanAsync(target, banPurgeDays, auditReason); - else await utarget!.KickAsync(auditReason); - // TODO for kick: Figure out a way to specify invoker properly in audit log (as in mee6, etc). - } catch (HttpException ex) { - return new BanKickResult(ex, dmSuccess, false, t, target); - } - - return new BanKickResult(null, dmSuccess, false, t, target); +namespace RegexBot.Services.CommonFunctions { + /// + /// Implements certain common actions that modules may want to perform. Using this service to perform those + /// functions may help enforce a sense of consistency across modules when performing common actions, and may + /// inform services which provide any additional features the ability to respond to those actions ahead of time. + /// + internal partial class CommonFunctionsService : Service { + // Note: Several classes within this service created by its hooks are meant to be sent to modules, + // therefore those public classes are placed into the root RegexBot namespace for the developer's convenience. + public CommonFunctionsService(RegexbotClient bot) : base(bot) { } } - - private static async Task BanKickSendNotificationAsync(SocketGuildUser target, RemovalType action, string? reason) { - const string DMTemplate = "You have been {0} from {1}"; - const string DMTemplateReason = " for the following reason:\n{2}"; - - var outMessage = string.IsNullOrWhiteSpace(reason) - ? string.Format(DMTemplate + ".", action == RemovalType.Ban ? "banned" : "kicked", target.Guild.Name) - : string.Format(DMTemplate + DMTemplateReason, action == RemovalType.Ban ? "banned" : "kicked", target.Guild.Name, reason); - var dch = await target.CreateDMChannelAsync(); - - try { await dch.SendMessageAsync(outMessage); } catch (HttpException) { return false; } - - return true; - } - #endregion } + +namespace RegexBot { + partial class RegexbotClient { + private readonly CommonFunctionsService _svcCommonFunctions; + } +} \ No newline at end of file diff --git a/Services/CommonFunctions/IOperationResult.cs b/Services/CommonFunctions/IOperationResult.cs new file mode 100644 index 0000000..a7a1532 --- /dev/null +++ b/Services/CommonFunctions/IOperationResult.cs @@ -0,0 +1,40 @@ +namespace RegexBot; +/// +/// Contains information on success or failure outcomes for certain operations. +/// +public interface IOperationResult { + /// + /// Indicates whether the operation was successful. + /// + /// + /// Be aware this value may return while + /// returns . + /// + bool Success { get; } + + /// + /// The exception thrown, if any, when attempting to perform the operation. + /// + Exception? Error { get; } + + /// + /// Indicates if the operation failed due to being unable to find the user. + /// + bool ErrorNotFound { get; } + + /// + /// Indicates if the operation failed due to a permissions issue. + /// + bool ErrorForbidden { get; } + + /// + /// Indicates if user DM notification for this event was successful. + /// Always returns in cases where no notification was requested. + /// + bool NotificationSuccess { get; } + + /// + /// Returns a message representative of this result that may be posted as-is within a Discord channel. + /// + string ToResultString(); +} \ No newline at end of file diff --git a/Services/CommonFunctions/LogAppendResult.cs b/Services/CommonFunctions/LogAppendResult.cs new file mode 100644 index 0000000..9a7bc3f --- /dev/null +++ b/Services/CommonFunctions/LogAppendResult.cs @@ -0,0 +1,46 @@ +using Discord.Net; + +namespace RegexBot; +/// +/// Contains information on success/failure outcomes for a warn operation. +/// +public class LogAppendResult { + private readonly int _logId; + private readonly string _rptDisplayName; + + /// + /// Gets the exception thrown, if any, when attempting to send the warning to the target. + /// + public HttpException? MessageSendError { get; } + + /// + /// Indicates if the operation failed due to being unable to find the user. + /// + public bool ErrorNotFound => MessageSendError?.HttpCode == System.Net.HttpStatusCode.NotFound; + + /// + /// Indicates if the operation failed due to a permissions issue. + /// + public bool ErrorForbidden => MessageSendError?.HttpCode == System.Net.HttpStatusCode.Forbidden; + + /// + /// Indicates if the operation completed successfully. + /// + public bool Success => MessageSendError == null; + + internal LogAppendResult(HttpException? error, int logId, string reportDispName) { + _logId = logId; + MessageSendError = error; + _rptDisplayName = reportDispName; + } + + /// + /// Returns a message representative of this result that may be posted as-is + /// within a Discord channel. + /// + public string GetResultString() { + var msg = $":white_check_mark: Warning \\#{_logId} logged for {_rptDisplayName}."; + if (!Success) msg += "\n:warning: **User did not receive warning message.** Consider sending a message manually."; + return msg; + } +} \ No newline at end of file diff --git a/Services/CommonFunctions/ModLogType.cs b/Services/CommonFunctions/ModLogType.cs new file mode 100644 index 0000000..332214f --- /dev/null +++ b/Services/CommonFunctions/ModLogType.cs @@ -0,0 +1,31 @@ +namespace RegexBot; +/// +/// Specifies the type of action or event represented by a +/// or . +/// +public enum ModLogType { + /// + /// An unspecified logging type. + /// + Other, + /// + /// A note appended to a user's log for moderator reference. + /// + Note, + /// + /// A warning. Similar to a note, but with higher priority and presented to the user when issued. + /// + Warn, + /// + /// A timeout, preventing the user from speaking for some amount of time. + /// + Timeout, + /// + /// A forced removal from the server. + /// + Kick, + /// + /// A forced removal from the server, with the user additionally getting added to the ban list. + /// + Ban +} \ No newline at end of file diff --git a/Services/CommonFunctions/TimeoutSetResult.cs b/Services/CommonFunctions/TimeoutSetResult.cs new file mode 100644 index 0000000..5274a29 --- /dev/null +++ b/Services/CommonFunctions/TimeoutSetResult.cs @@ -0,0 +1,47 @@ +using Discord.Net; +using RegexBot.Common; + +namespace RegexBot; +/// +/// Contains information on various success/failure outcomes for setting a timeout. +/// +public class TimeoutSetResult : IOperationResult { + private readonly SocketGuildUser? _target; + + /// + public bool Success => Error == null; + + /// + public Exception? Error { get; } + + /// + public bool ErrorNotFound => (_target == null) || ((Error as HttpException)?.HttpCode == System.Net.HttpStatusCode.NotFound); + + /// + public bool ErrorForbidden => (Error as HttpException)?.HttpCode == System.Net.HttpStatusCode.Forbidden; + + /// + public bool NotificationSuccess { get; } + + internal TimeoutSetResult(Exception? error, bool notificationSuccess, SocketGuildUser? target) { + Error = error; + NotificationSuccess = notificationSuccess; + _target = target; + } + + /// + public string ToResultString() { + if (Success) { + var msg = $":white_check_mark: Timeout set for **{_target!.Username}#{_target.Discriminator}**."; + if (!NotificationSuccess) msg += "\n(User was unable to receive notification message.)"; + return msg; + } else { + var msg = ":x: Failed to set timeout: "; + if (ErrorNotFound) msg += "The specified user could not be found."; + else if (ErrorForbidden) msg += Messages.ForbiddenGenericError; + else if (Error != null) msg += Error.Message; + else msg += "Unknown error."; + return msg; + } + } +} \ No newline at end of file diff --git a/Services/EntityCache/EntityCacheService.cs b/Services/EntityCache/EntityCacheService.cs index 509f7d6..580c133 100644 --- a/Services/EntityCache/EntityCacheService.cs +++ b/Services/EntityCache/EntityCacheService.cs @@ -6,12 +6,13 @@ namespace RegexBot.Services.EntityCache; /// class EntityCacheService : Service { private readonly UserCachingSubservice _uc; + #pragma warning disable IDE0052 private readonly MessageCachingSubservice _mc; + #pragma warning restore IDE0052 internal EntityCacheService(RegexbotClient bot) : base(bot) { - // Currently we only have UserCache. May add Channel and Server caches later. _uc = new UserCachingSubservice(bot, Log); - _mc = new MessageCachingSubservice(bot, Log); + _mc = new MessageCachingSubservice(bot); } // Hooked @@ -21,10 +22,4 @@ class EntityCacheService : Service { // Hooked internal CachedGuildUser? QueryGuildUserCache(ulong guildId, string search) => _uc.DoGuildUserQuery(guildId, search); - - // Hooked - internal event RegexbotClient.EcMessageUpdateHandler? OnCachePreUpdate { - add { lock (_mc) _mc.OnCachePreUpdate += value; } - remove { lock (_mc) _mc.OnCachePreUpdate -= value; } - } } diff --git a/Services/EntityCache/Hooks.cs b/Services/EntityCache/Hooks.cs index 019756a..0476e78 100644 --- a/Services/EntityCache/Hooks.cs +++ b/Services/EntityCache/Hooks.cs @@ -23,25 +23,4 @@ partial class RegexbotClient { /// Search string. May be a name with discriminator, a name, or an ID. /// A instance containing cached information, or null if no result. public CachedGuildUser? EcQueryGuildUser(ulong guildId, string search) => _svcEntityCache.QueryGuildUserCache(guildId, search); - - /// - /// Fired after a message edit, when the message cache is about to be updated with the edited message. - /// - /// - /// This event serves as an alternative to , - /// pulling the previous state of the message from the entity cache instead of the library's cache. - /// - public event EcMessageUpdateHandler? EcOnMessageUpdate { - add { _svcEntityCache.OnCachePreUpdate += value; } - remove { _svcEntityCache.OnCachePreUpdate -= value; } - } - - /// - /// Delegate used for the event. - /// - /// - /// The previous state of the message prior to being updated, as known by the entity cache. - /// The new, updated incoming message. - /// - public delegate Task EcMessageUpdateHandler(CachedGuildMessage? oldMsg, SocketMessage newMsg); } diff --git a/Services/EntityCache/MessageCacheUpdateEvent.cs b/Services/EntityCache/MessageCacheUpdateEvent.cs new file mode 100644 index 0000000..a396078 --- /dev/null +++ b/Services/EntityCache/MessageCacheUpdateEvent.cs @@ -0,0 +1,26 @@ +using RegexBot.Data; + +namespace RegexBot; +/// +/// Fired after a message edit, when the message cache is about to be updated with the edited message. +/// +/// +/// Processing this serves as an alternative to , +/// pulling the previous state of the message from the entity cache instead of the library's cache. +/// +public class MessageCacheUpdateEvent : ISharedEvent { + /// + /// Gets the previous state of the message prior to being updated, as known by the entity cache. + /// + public CachedGuildMessage? OldMessage { get; } + + /// + /// Gets the new, updated incoming message. + /// + public SocketMessage NewMessage { get; } + + internal MessageCacheUpdateEvent(CachedGuildMessage? old, SocketMessage @new) { + OldMessage = old; + NewMessage = @new; + } +} \ No newline at end of file diff --git a/Services/EntityCache/MessageCachingSubservice.cs b/Services/EntityCache/MessageCachingSubservice.cs index 86ee9ab..6f4f894 100644 --- a/Services/EntityCache/MessageCachingSubservice.cs +++ b/Services/EntityCache/MessageCachingSubservice.cs @@ -1,16 +1,12 @@ using Discord; using RegexBot.Data; -using static RegexBot.RegexbotClient; namespace RegexBot.Services.EntityCache; class MessageCachingSubservice { - // Hooked - public event EcMessageUpdateHandler? OnCachePreUpdate; + private readonly RegexbotClient _bot; - private readonly Action _log; - - internal MessageCachingSubservice(RegexbotClient bot, Action logMethod) { - _log = logMethod; + internal MessageCachingSubservice(RegexbotClient bot) { + _bot = bot; bot.DiscordClient.MessageReceived += DiscordClient_MessageReceived; bot.DiscordClient.MessageUpdated += DiscordClient_MessageUpdated; } @@ -34,7 +30,8 @@ class MessageCachingSubservice { // Alternative for Discord.Net's MessageUpdated handler: // Notify subscribers of message update using EC entry for the previous message state var oldMsg = CachedGuildMessage.Clone(cachedMsg); - await Task.Factory.StartNew(async () => await RunPreUpdateHandlersAsync(oldMsg, arg)); + var updEvent = new MessageCacheUpdateEvent(oldMsg, arg); + await _bot.PushSharedEventAsync(updEvent); } if (cachedMsg == null) { @@ -55,21 +52,4 @@ class MessageCachingSubservice { } await db.SaveChangesAsync(); } - - private async Task RunPreUpdateHandlersAsync(CachedGuildMessage? oldMsg, SocketMessage newMsg) { - Delegate[]? subscribers; - lock (this) { - subscribers = OnCachePreUpdate?.GetInvocationList(); - if (subscribers == null || subscribers.Length == 0) return; - } - - foreach (var handler in subscribers) { - try { - await (Task)handler.DynamicInvoke(oldMsg, newMsg)!; - } catch (Exception ex) { - _log($"Unhandled exception in {nameof(RegexbotClient.EcOnMessageUpdate)} handler '{handler.Method.Name}':\n" - + ex.ToString()); - } - } - } } diff --git a/Services/EntityCache/UserCachingSubservice.cs b/Services/EntityCache/UserCachingSubservice.cs index 1ffb072..292ec19 100644 --- a/Services/EntityCache/UserCachingSubservice.cs +++ b/Services/EntityCache/UserCachingSubservice.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore; +#pragma warning disable CA1822 // "Mark members as static" - members should only be callable by code with access to this instance +using Microsoft.EntityFrameworkCore; using RegexBot.Common; using RegexBot.Data; @@ -8,7 +9,6 @@ namespace RegexBot.Services.EntityCache; /// It is meant to work as a supplement to Discord.Net's own user caching capabilities. Its purpose is to /// provide information on users which the library may not be aware about, such as users no longer in a guild. /// -[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static")] class UserCachingSubservice { private readonly Action _log; diff --git a/Services/Logging/LoggingService.cs b/Services/Logging/LoggingService.cs index 2e51d6a..d3744e0 100644 --- a/Services/Logging/LoggingService.cs +++ b/Services/Logging/LoggingService.cs @@ -8,7 +8,7 @@ namespace RegexBot.Services.Logging; /// class LoggingService : Service { // NOTE: Service.Log's functionality is implemented here. DO NOT use within this class. - private readonly string? _logBasePath; + private readonly string _logBasePath; internal LoggingService(RegexbotClient bot) : base(bot) { _logBasePath = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location) @@ -17,8 +17,7 @@ class LoggingService : Service { if (!Directory.Exists(_logBasePath)) Directory.CreateDirectory(_logBasePath); Directory.GetFiles(_logBasePath); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - _logBasePath = null; - DoLog(Name, "Cannot create or access logging directory. File logging will be disabled."); + throw new Exception("Cannot create or access logging directory."); } bot.DiscordClient.Log += DiscordClient_Log; @@ -50,17 +49,15 @@ class LoggingService : Service { // Hooked internal void DoLog(string source, string? message) { message ??= "(null)"; - var now = DateTimeOffset.UtcNow; + var now = DateTimeOffset.Now; var output = new StringBuilder(); - var prefix = $"[{now:u}] [{source}] "; + var prefix = $"[{now:s}] [{source}] "; foreach (var line in message.Split(new string[] { "\r\n", "\n" }, StringSplitOptions.None)) { output.Append(prefix).AppendLine(line); } var outstr = output.ToString(); Console.Write(outstr); - if (_logBasePath != null) { - var filename = _logBasePath + Path.DirectorySeparatorChar + $"{now:yyyy-MM}.log"; - File.AppendAllText(filename, outstr, Encoding.UTF8); - } + var filename = _logBasePath + Path.DirectorySeparatorChar + $"{now:yyyy-MM}.log"; + File.AppendAllText(filename, outstr, Encoding.UTF8); } } diff --git a/Services/ModuleState/ModuleStateService.cs b/Services/ModuleState/ModuleStateService.cs index b5945e1..e2592f6 100644 --- a/Services/ModuleState/ModuleStateService.cs +++ b/Services/ModuleState/ModuleStateService.cs @@ -22,7 +22,8 @@ class ModuleStateService : Service { } private async Task RefreshGuildState(SocketGuild arg) { - if (await ProcessConfiguration(arg)) Log($"Configuration refreshed for '{arg.Name}'."); + if (await ProcessConfiguration(arg)) Log($"'{arg.Name}': Configuration refreshed."); + else Log($"'{arg.Name}': Configuration refresh failed. Retaining existing configuration and state, if any."); } private Task RemoveGuildData(SocketGuild arg) { @@ -52,14 +53,6 @@ class ModuleStateService : Service { } } - /// - /// Configuration is loaded from database, and appropriate sections dispatched to their - /// respective methods for further processing. - /// - /// - /// This takes an all-or-nothing approach. Should there be a single issue in processing - /// configuration, all existing state data is kept. - /// private async Task ProcessConfiguration(SocketGuild guild) { var jstr = await LoadConfigFile(guild); JObject guildConf; @@ -76,7 +69,7 @@ class ModuleStateService : Service { } // Load moderator list - var mods = new EntityList(guildConf["Moderators"]!, true); + var mods = new EntityList(guildConf["Moderators"]!); // Create guild state objects for all existing modules var newStates = new Dictionary(); diff --git a/Services/SharedEventService/Hooks.cs b/Services/SharedEventService/Hooks.cs new file mode 100644 index 0000000..d333afc --- /dev/null +++ b/Services/SharedEventService/Hooks.cs @@ -0,0 +1,34 @@ +using RegexBot.Services.SharedEventService; + +namespace RegexBot; +partial class RegexbotClient { + private readonly SharedEventService _svcSharedEvents; + + /// + /// Delegate used for the event. + /// + /// The incoming event instance. + public delegate Task IncomingSharedEventHandler(ISharedEvent ev); + + /// + /// Sends an object instance implementing to all modules and services + /// subscribed to the event. + /// + /// + /// This method is non-blocking. Event handlers are executed in their own thread. + /// + public Task PushSharedEventAsync(ISharedEvent ev) => _svcSharedEvents.PushSharedEventAsync(ev); + + /// + /// This event is fired after a module or internal service calls . + /// + /// + /// Subscribers to this event are handled on a "fire and forget" basis and may execute on a thread + /// separate from the main one handling Discord events. Ensure that the code executed by the handler + /// executes quickly, is thread-safe, and throws no exceptions. + /// + public event IncomingSharedEventHandler? SharedEventReceived { + add { lock (_svcSharedEvents) _svcSharedEvents.Subscribers += value; } + remove { lock (_svcSharedEvents) _svcSharedEvents.Subscribers -= value; } + } +} \ No newline at end of file diff --git a/Services/SharedEventService/SharedEvent.cs b/Services/SharedEventService/SharedEvent.cs new file mode 100644 index 0000000..f8186f0 --- /dev/null +++ b/Services/SharedEventService/SharedEvent.cs @@ -0,0 +1,6 @@ +namespace RegexBot; // Note: Within RegexBot namespace, for ease of use by modules +/// +/// An empty interface which denotes that the implementing object instance may be passed through +/// the shared event service. +/// +public interface ISharedEvent { } \ No newline at end of file diff --git a/Services/SharedEventService/SharedEventService.cs b/Services/SharedEventService/SharedEventService.cs new file mode 100644 index 0000000..728cf5e --- /dev/null +++ b/Services/SharedEventService/SharedEventService.cs @@ -0,0 +1,48 @@ +using System.Threading.Channels; + +namespace RegexBot.Services.SharedEventService; +/// +/// Implements a queue which any service or module may send objects into, +/// which are then sent to subscribing services and/or modules. Allows for simple, +/// basic sharing of information between separate parts of the program. +/// +class SharedEventService : Service { + private readonly Channel _items; + //private readonly Task _itemPropagationWorker; + + internal SharedEventService(RegexbotClient bot) : base(bot) { + _items = Channel.CreateUnbounded(); + _ = Task.Factory.StartNew(ItemPropagator, CancellationToken.None, + TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + + // Hooked (lock this on self) + internal event RegexbotClient.IncomingSharedEventHandler? Subscribers; + + internal async Task PushSharedEventAsync(ISharedEvent ev) { + await _items.Writer.WriteAsync(ev); + } + + private async Task ItemPropagator() { + while (true) { + var ev = await _items.Reader.ReadAsync(); + + Delegate[]? subscribed; + lock (this) { + subscribed = Subscribers?.GetInvocationList(); + if (subscribed == null || subscribed.Length == 0) return; + } + + foreach (var handler in subscribed) { + // Fire and forget! + _ = Task.Run(async () => { + try { + await (Task)handler.DynamicInvoke(ev)!; + } catch (Exception ex) { + Log("Unhandled exception in shared event handler:" + ex.ToString()); + } + }); + } + } + } +}