diff --git a/BotModule.cs b/BotModule.cs index 959b6e3..e0e5fd5 100644 --- a/BotModule.cs +++ b/BotModule.cs @@ -25,6 +25,7 @@ namespace Noikoio.RegexBot /// /// Processes module-specific configuration. + /// This method is not called if the user did not provide configuration for the module. /// /// /// Module code should not hold on to this data, but instead use to retrieve @@ -36,8 +37,8 @@ namespace Noikoio.RegexBot /// Processed configuration data prepared for later use. /// /// - /// This method should throw RuleImportException in the event of any error. - /// The exception message will be properly logged. + /// This method should throw + /// in the event of configuration errors. The exception message will be properly displayed. /// public abstract Task ProcessConfiguration(JToken configSection); diff --git a/ConfigItem/EntityName.cs b/ConfigItem/EntityName.cs index 046ded7..b738f82 100644 --- a/ConfigItem/EntityName.cs +++ b/ConfigItem/EntityName.cs @@ -3,9 +3,7 @@ enum EntityType { Channel, Role, User } /// - /// Used to join together an entity ID and its name, particularly when read from configuration. - /// In the event of an unknown ID, the ID is found and cached. The ID should preferably be used - /// over the entity's string-based name, as it can change at any time. + /// Used to join together an entity ID and its name when read from configuration. /// In configuration, entities are fully specified with a prefix (if necessary), an ID, two colons, and a name. /// struct EntityName diff --git a/EntityCache/Module.cs b/EntityCache/Module.cs index f976632..0a4da61 100644 --- a/EntityCache/Module.cs +++ b/EntityCache/Module.cs @@ -19,9 +19,7 @@ namespace Noikoio.RegexBot.EntityCache public Module(DiscordSocketClient client) : base(client) { - _db = RegexBot.Config.Database; - - if (_db.Available) + if (RegexBot.Config.DatabaseAvailable) { SqlHelper.CreateCacheTablesAsync().Wait(); diff --git a/EntityCache/SqlHelper.cs b/EntityCache/SqlHelper.cs index d19489c..bd666e9 100644 --- a/EntityCache/SqlHelper.cs +++ b/EntityCache/SqlHelper.cs @@ -19,7 +19,7 @@ namespace Noikoio.RegexBot.EntityCache // Reminder: Check Cache query methods if making changes to tables internal static async Task CreateCacheTablesAsync() { - var db = await RegexBot.Config.Database.GetOpenConnectionAsync(); + var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync(); if (db == null) return; using (db) { @@ -83,7 +83,7 @@ namespace Noikoio.RegexBot.EntityCache #region Insertions and updates internal static async Task UpdateGuildAsync(SocketGuild g) { - var db = await RegexBot.Config.Database.GetOpenConnectionAsync(); + var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync(); if (db == null) return; using (db) { @@ -109,7 +109,7 @@ namespace Noikoio.RegexBot.EntityCache } internal static async Task UpdateGuildMemberAsync(IEnumerable users) { - var db = await RegexBot.Config.Database.GetOpenConnectionAsync(); + var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync(); if (db == null) return; using (db) { diff --git a/Module/ModLogs/Entry.cs b/Module/ModLogs/Entry.cs new file mode 100644 index 0000000..d09e1e3 --- /dev/null +++ b/Module/ModLogs/Entry.cs @@ -0,0 +1,168 @@ +using Npgsql; +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Text; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot.Module.ModLogs +{ + /// + /// Represents a log entry in the database. + /// + class Entry + { + readonly int _logId; + readonly DateTime _ts; + readonly ulong _guildId; + readonly ulong? _invokeId; + readonly ulong _targetId; + readonly ulong? _channelId; + readonly string _type; + readonly string _message; + + /// + /// Gets the ID value of this log entry. + /// + public int Id => _logId; + /// + /// Gets the timestamp (a with ) of the entry. + /// + public DateTime Timestamp => _ts; + /// + /// Gets the ID of the guild to which this log entry corresponds. + /// + public ulong Guild => _guildId; + /// + /// Gets the ID of the user to which this log entry corresponds. + /// + public ulong Target => _targetId; + /// + /// Gets the ID of the invoking user. + /// This value exists only if this entry was created through action of another user that is not the target. + /// + public ulong? Invoker => _invokeId; + /// + /// Gets the guild channel ID to which this log entry corresponds, if any. + /// + public ulong? TargetChannel => _channelId; + /// + /// Gets this log entry's category. + /// + public string Category => _type; + /// + /// Gets the content of this log entry. + /// + public string Message => _message; + + public Entry(DbDataReader r) + { + // Double-check ordinals if making changes to QueryColumns + + _logId = r.GetInt32(0); + _ts = r.GetDateTime(1).ToUniversalTime(); + unchecked + { + _guildId = (ulong)r.GetInt64(2); + _targetId = (ulong)r.GetInt64(3); + if (r.IsDBNull(4)) _invokeId = null; + else _invokeId = (ulong)r.GetInt64(4); + if (r.IsDBNull(5)) _channelId = null; + else _channelId = (ulong)r.GetInt64(5); + } + _type = r.GetString(6); + _message = r.GetString(7); + } + + // TODO lazy loading of channel, user, etc from caches + // TODO methods for updating this log entry(?) + + // TODO figure out some helper methods to retrieve data of other entities by ID, if it becomes necessary + + #region Queries + // Double-check constructor if making changes to this constant + const string QueryColumns = "id, entry_ts, guild_id, target_id, invoke_id, target_channel_id, category, message"; + + /// + /// Attempts to look up a log entry with the given ID. + /// + /// Null if no result. + public static async Task QueryIdAsync(ulong guild, int id) + { + using (var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = $"SELECT {QueryColumns} FROM {Sql.TableLog} " + + "WHERE guild_id = @Guild and id = @Id"; + c.Parameters.Add("@Guild", NpgsqlTypes.NpgsqlDbType.Bigint).Value = guild; + c.Parameters.Add("@Id", NpgsqlTypes.NpgsqlDbType.Numeric).Value = id; + c.Prepare(); + using (var r = await c.ExecuteReaderAsync()) + { + if (r.Read()) return new Entry(r); + else return null; + } + } + } + } + + public static async Task> QueryLogAsync + (ulong guild, + ulong? target = null, + ulong? invoker = null, + ulong? channel = null, + IEnumerable category = null) + { + // Enforce some limits - can't search too broadly here. Requires this at a minimum: + if (target.HasValue == false && invoker.HasValue == false) + { + throw new ArgumentNullException("Query requires at minimum searching of a target or invoker."); + } + + var result = new List(); + using (var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = $"SELECT {QueryColumns} FROM {Sql.TableLog} WHERE"; + + bool and = false; + if (target.HasValue) + { + if (and) c.CommandText += " AND"; + else and = true; + c.CommandText += " target_id = @TargetId"; + c.Parameters.Add("@TargetId", NpgsqlTypes.NpgsqlDbType.Bigint).Value = target.Value; + } + if (invoker.HasValue) + { + if (and) c.CommandText += " AND"; + else and = true; + c.CommandText += " invoke_id = @InvokeId"; + c.Parameters.Add("@InvokeId", NpgsqlTypes.NpgsqlDbType.Bigint).Value = invoker.Value; + } + if (channel.HasValue) + { + if (and) c.CommandText += " AND"; + else and = true; + c.CommandText += " target_channel_id = @ChannelId"; + c.Parameters.Add("@ChannelId", NpgsqlTypes.NpgsqlDbType.Bigint).Value = channel.Value; + } + c.Prepare(); + + using (var r = await c.ExecuteReaderAsync()) + { + while (r.Read()) + { + result.Add(new Entry(r)); + } + } + } + } + + return result; + } + #endregion + } +} diff --git a/Module/ModLogs/EventListener.cs b/Module/ModLogs/EventListener.cs new file mode 100644 index 0000000..2ed1932 --- /dev/null +++ b/Module/ModLogs/EventListener.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Discord.WebSocket; +using Newtonsoft.Json.Linq; + +namespace Noikoio.RegexBot.Module.ModLogs +{ + /// + /// Listens for Discord-based events and writes them to the log (database). + /// Additionally writes certain messages to a designated logging channel if configured. + /// + class EventListener : BotModule + { + public override string Name => "ModLogs"; + public EventListener(DiscordSocketClient client) : base(client) + { + + } + + [ConfigSection("modlogs")] + public override Task ProcessConfiguration(JToken configSection) + { + throw new NotImplementedException(); + } + } +} diff --git a/Module/ModLogs/EventType.cs b/Module/ModLogs/EventType.cs new file mode 100644 index 0000000..6a0a964 --- /dev/null +++ b/Module/ModLogs/EventType.cs @@ -0,0 +1,22 @@ +using System; + +namespace Noikoio.RegexBot.Module.ModLogs +{ + // Types of non-custom events that can be referenced by ModLogs in configuration. + // Enum value names will show themselves to the user in the form of strings valid in configuration, + // so try not to change those without good reason. + [Flags] + enum EventType + { + None = 0x0, + Note = 0x1, + Warn = 0x2, + Kick = 0x4, + Ban = 0x8, + JoinGuild = 0x10, + LeaveGuild = 0x20, + NameChange = 0x40, + MsgEdit = 0x80, + MsgDelete = 0x100 + } +} diff --git a/Module/ModLogs/GuildConfig.cs b/Module/ModLogs/GuildConfig.cs new file mode 100644 index 0000000..f150c5a --- /dev/null +++ b/Module/ModLogs/GuildConfig.cs @@ -0,0 +1,127 @@ +using Newtonsoft.Json.Linq; +using Noikoio.RegexBot.ConfigItem; +using System; + +namespace Noikoio.RegexBot.Module.ModLogs +{ + /// + /// ModLogs guild-specific configuration values. + /// + class GuildConfig + { + // Event reporting + private readonly EntityName _rptTarget; + private EventType _rptTypes; + /// + /// Target reporting channel. + /// + public EntityName? RptTarget => _rptTarget; + /// + /// Event types to send to the reporting channel. + /// + public EventType RptTypes => _rptTypes; + + // Query command + private readonly string _qCmd; // command name + private readonly EntityList _qAccess; // list of those able to issue the command + private readonly EventType _qDefaultAnswer; // default entry types to display + /// + /// Query command. The first word in an incoming message, including prefix, that triggers a query. + /// + public string QrCommand => _qCmd; + /// + /// List of users permitted to invoke the query command. + /// If null, refer to the guild's Moderators list. + /// + public EntityList QrPermittedUsers => _qAccess; + /// + /// Event types to display in a query. + /// + public EventType QrTypes => _qDefaultAnswer; + + public GuildConfig(JObject cfgRoot) + { + // AutoReporting settings + var arcfg = cfgRoot["AutoReporting"]; + if (arcfg == null) + { + _rptTarget = default(EntityName); // NOTE: Change this if EntityName becomes a class later + _rptTypes = EventType.None; + } + else if (arcfg.Type == JTokenType.Object) + { + string chval = arcfg["Channel"]?.Value(); + if (chval == null) throw new RuleImportException("Reporting channel is not defined."); + if (!string.IsNullOrWhiteSpace(chval) && chval[0] == '#') + _rptTarget = new EntityName(chval.Substring(1, chval.Length-1), EntityType.Channel); + else + throw new RuleImportException("Reporting channel is not properly defined."); + // Require the channel's ID for now. + if (!_rptTarget.Id.HasValue) throw new RuleImportException("Reporting channel's ID must be specified."); + + // TODO make optional + string rpval = arcfg["Events"]?.Value(); + _rptTypes = GetTypesFromString(rpval); + } + else + { + throw new RuleImportException("Section for AutoReporting is not correctly defined."); + } + + // QueryCommand settings + var qccfg = cfgRoot["QueryCommand"]; + if (qccfg == null) + { + _qCmd = null; + _qAccess = null; + _qDefaultAnswer = EventType.None; + } + else if (arcfg.Type == JTokenType.Object) + { + _qCmd = arcfg["Command"]?.Value(); + if (string.IsNullOrWhiteSpace(_qCmd)) + throw new RuleImportException("Query command option must have a value."); + if (_qCmd.Contains(" ")) + throw new RuleImportException("Query command must not contain spaces."); + + var acl = arcfg["AllowedUsers"]; + if (acl == null) _qAccess = null; + else _qAccess = new EntityList(acl); + + // TODO make optional + string ansval = arcfg["DefaultEvents"]?.Value(); + _qDefaultAnswer = GetTypesFromString(ansval); + } + else + { + throw new RuleImportException("Section for QueryCommand is not correctly defined."); + } + } + + public static EventType GetTypesFromString(string input) + { + if (string.IsNullOrWhiteSpace(input)) + throw new RuleImportException("Types are not properly defined."); + + var strTypes = input.Split( + new char[] { ' ', ',', '/', '+' }, // and more? + StringSplitOptions.RemoveEmptyEntries); + + EventType endResult = EventType.None; + foreach (var item in strTypes) + { + try + { + var result = Enum.Parse(item, true); + endResult |= result; + } + catch (ArgumentException) + { + throw new RuleImportException($"Unable to determine the given event type \"{item}\""); + } + } + + return endResult; + } + } +} diff --git a/Module/ModLogs/MessageCache.cs b/Module/ModLogs/MessageCache.cs new file mode 100644 index 0000000..6ab2dfb --- /dev/null +++ b/Module/ModLogs/MessageCache.cs @@ -0,0 +1,307 @@ +using Discord; +using Discord.WebSocket; +using Npgsql; +using NpgsqlTypes; +using System; +using System.Text; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot.Module.ModLogs +{ + /// + /// Helper class for . Keeps a database-backed cache of recent messages for use + /// in reporting message changes and deletions, if configured to do so. + /// Despite its place, it does not manipulate moderation logs. It simply pulls from the same configuration. + /// + class MessageCache + { + private readonly DiscordSocketClient _dClient; + private readonly AsyncLogger _outLog; + private readonly Func _outGetConfig; + + // TODO: How to clear the cache after a time? Can't hold on to this forever. + + public MessageCache(DiscordSocketClient client, AsyncLogger logger, Func getConfFunc) + { + _dClient = client; + _outLog = logger; + _outGetConfig = getConfFunc; + + CreateCacheTables(); + + client.MessageReceived += Client_MessageReceived; + client.MessageUpdated += Client_MessageUpdated; + client.MessageDeleted += Client_MessageDeleted; + } + + #region Event handling + private async Task Client_MessageReceived(SocketMessage arg) + { + if (arg.Author.IsBot) return; + + await AddOrUpdateCacheItemAsync(arg); + } + + private async Task Client_MessageUpdated( + Cacheable before, SocketMessage after, ISocketMessageChannel channel) + { + if (after.Author.IsBot) return; + + // We only want channel messages + if (after is SocketUserMessage afterMsg && !(afterMsg is IDMChannel)) + { + if (after.Author.IsBot) return; + + // We're not interested in all message updates, only those that leave a timestamp. + if (!afterMsg.EditedTimestamp.HasValue) return; + } + else return; // probably unnecessary? + + // Once an edited message is cached, the original message contents are lost. + // This is the only time available to report it. + await ProcessReportMessage(false, before.Id, channel, after.Content); + + await AddOrUpdateCacheItemAsync(after); + } + + private async Task Client_MessageDeleted(Cacheable msg, ISocketMessageChannel channel) + { + if (channel is IDMChannel) return; // No DMs + await ProcessReportMessage(true, msg.Id, channel, null); + } + #endregion + + #region Reporting + // Reports an edited or deleted message as if it were a log entry (even though it's not). + private async Task ProcessReportMessage( + bool isDelete, ulong messageId, ISocketMessageChannel ch, string editMsg) + { + ulong guildId; + if (ch is SocketTextChannel sch) + { + if (sch is IDMChannel) return; + guildId = sch.Guild.Id; + } + else return; + + // Check if this feature is enabled before doing anything else. + var cfg = _outGetConfig(guildId) as GuildConfig; + if (cfg == null) return; + if (isDelete && (cfg.RptTypes & EventType.MsgDelete) == 0) return; + if (!isDelete && (cfg.RptTypes & EventType.MsgEdit) == 0) return; + + // Ignore if it's a message being deleted withing the reporting channel. + if (isDelete && cfg.RptTarget.Value.Id.Value == ch.Id) return; + + // Regardless of delete or edit, it is necessary to get the equivalent database information. + EntityCache.CacheUser ucd = null; + ulong userId; + string cacheMsg; + try + { + using (var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = "SELECT author_id, message FROM " + TableMessage + + " WHERE message_id = @MessageId"; + c.Parameters.Add("@MessageId", NpgsqlDbType.Bigint).Value = messageId; + c.Prepare(); + using (var r = await c.ExecuteReaderAsync()) + { + if (await r.ReadAsync()) + { + userId = unchecked((ulong)r.GetInt64(0)); + cacheMsg = r.GetString(1); + } + else + { + userId = 0; + cacheMsg = "*(Message not in cache.)*"; + } + } + } + } + if (userId != 0) ucd = await EntityCache.EntityCache.QueryAsync(guildId, userId); + } + catch (NpgsqlException ex) + { + await _outLog($"SQL error in {nameof(ProcessReportMessage)}: " + ex.Message); + cacheMsg = "**Database error. See log.**"; + } + + // Find target channel, prepare and send out message + var g = _dClient.GetGuild(guildId); + var rptTargetChannel = g?.GetTextChannel(cfg.RptTarget.Value.Id.Value); + if (rptTargetChannel == null) + { + await _outLog($"WARNING: Reporting channel {cfg.RptTarget.Value.ToString()} could not be determined."); + return; + } + var em = CreateReportEmbed(isDelete, ucd, messageId, ch, (cacheMsg, editMsg)); + await rptTargetChannel.SendMessageAsync("", embed: em); + } + + const int ReportCutoffLength = 500; + const string ReportCutoffNotify = "**Message length too long; showing first {0} characters.**\n\n"; + private EmbedBuilder CreateReportEmbed( + bool isDelete, + EntityCache.CacheUser ucd, ulong messageId, ISocketMessageChannel chInfo, + (string, string) content) // Item1 = cached content. Item2 = after-edit message (null if isDelete) + { + string msgCached = content.Item1; + string msgPostEdit = content.Item2; + if (content.Item1.Length > ReportCutoffLength) + { + msgCached = string.Format(ReportCutoffNotify, ReportCutoffLength) + + content.Item1.Substring(0, ReportCutoffLength); + } + if (!isDelete && content.Item2.Length > ReportCutoffLength) + { + msgPostEdit = string.Format(ReportCutoffNotify, ReportCutoffLength) + + content.Item2.Substring(0, ReportCutoffLength); + } + + // Note: Value for ucb can be null if cached user could not be determined. + var eb = new EmbedBuilder + { + Author = new EmbedAuthorBuilder() + { + IconUrl = ucd?.AvatarUrl + }, + Fields = new System.Collections.Generic.List(), + Footer = new EmbedFooterBuilder() + { + Text = "User ID: " + ucd?.UserId.ToString() ?? "Unknown", + IconUrl = _dClient.CurrentUser.GetAvatarUrl() + }, + Timestamp = DateTimeOffset.UtcNow + }; + + if (isDelete) + { + eb.Author.Name = "Deleted message by "; + eb.Color = new Color(0xff7373); + eb.Description = msgCached; + } + else + { + eb.Author.Name = "Edited message by "; + eb.Color = new Color(0xffcc40); + eb.Fields.Add(new EmbedFieldBuilder() + { + Name = "Before", + Value = msgCached + }); + eb.Fields.Add(new EmbedFieldBuilder() + { + Name = "After", + Value = msgPostEdit + }); + } + + eb.Author.Name += ucd == null ? "unknown user" : $"{ucd.Username}#{ucd.Discriminator}"; + + var context = new StringBuilder(); + if (ucd != null) context.AppendLine($"Username: <@!{ucd.UserId}>"); + context.AppendLine($"Channel: <#{chInfo.Id}> #{chInfo.Name}"); + context.Append($"Message ID: {messageId}"); + eb.Fields.Add(new EmbedFieldBuilder() + { + Name = "Context", + Value = context.ToString() + }); + + return eb; + } + #endregion + + #region Database storage/retrieval + const string TableMessage = "cache_messages"; + + private void CreateCacheTables() + { + using (var db = RegexBot.Config.GetOpenDatabaseConnectionAsync().GetAwaiter().GetResult()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = "CREATE TABLE IF NOT EXISTS " + TableMessage + " (" + + "message_id bigint primary key, " + + "author_id bigint not null, " + + "guild_id bigint not null, " + + "channel_id bigint not null, " // TODO channel cache fk when that gets implemented + + "created_ts timestamptz not null, " + + "edited_ts timestamptz null, " + + "message text not null, " + + $"FOREIGN KEY (author_id, guild_id) references {EntityCache.SqlHelper.TableUser} (user_id, guild_id)" + + ")"; + c.ExecuteNonQuery(); + } + } + } + + private async Task AddOrUpdateCacheItemAsync(SocketMessage msg) + { + try + { + using (var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = "INSERT INTO " + TableMessage + + " (message_id, author_id, guild_id, channel_id, created_ts, edited_ts, message) VALUES" + + " (@MessageId, @UserId, @GuildId, @ChannelId, @Date, @Edit, @Message)" + + " ON CONFLICT (message_id) DO UPDATE" + + " SET message = EXCLUDED.message, edited_ts = EXCLUDED.edited_ts"; + c.Parameters.Add("@MessageId", NpgsqlDbType.Bigint).Value = msg.Id; + c.Parameters.Add("@UserId", NpgsqlDbType.Bigint).Value = msg.Author.Id; + c.Parameters.Add("@GuildId", NpgsqlDbType.Bigint).Value = ((SocketGuildUser)msg.Author).Guild.Id; + c.Parameters.Add("@ChannelId", NpgsqlDbType.Bigint).Value = msg.Channel.Id; + c.Parameters.Add("@Date", NpgsqlDbType.TimestampTZ).Value = msg.Timestamp; + if (msg.EditedTimestamp.HasValue) + c.Parameters.Add("@Edit", NpgsqlDbType.TimestampTZ).Value = msg.EditedTimestamp.Value; + else + c.Parameters.Add("@Edit", NpgsqlDbType.TimestampTZ).Value = DBNull.Value; + c.Parameters.Add("@Message", NpgsqlDbType.Text).Value = msg.Content; + c.Prepare(); + await c.ExecuteNonQueryAsync(); + } + } + } + catch (NpgsqlException ex) + { + await _outLog($"SQL error in {nameof(AddOrUpdateCacheItemAsync)}: " + ex.Message); + } + } + + private async Task GetCachedMessageAsync(ulong messageId) + { + try + { + using (var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = "SELECT message FROM " + TableMessage + + " WHERE message_id = @MessageId"; + c.Parameters.Add("@MessageId", NpgsqlDbType.Bigint).Value = messageId; + c.Prepare(); + using (var r = await c.ExecuteReaderAsync()) + { + if (await r.ReadAsync()) + return r.GetString(0); + else + return null; + } + } + } + } + catch (NpgsqlException ex) + { + await _outLog($"SQL error in {nameof(GetCachedMessageAsync)}: " + ex.Message); + return null; + } + } + #endregion + } +} diff --git a/Module/ModLogs/ModLogs.cs b/Module/ModLogs/ModLogs.cs new file mode 100644 index 0000000..2024510 --- /dev/null +++ b/Module/ModLogs/ModLogs.cs @@ -0,0 +1,49 @@ +using Discord.WebSocket; +using Newtonsoft.Json.Linq; +using Noikoio.RegexBot.ConfigItem; +using System.Threading.Tasks; + +namespace Noikoio.RegexBot.Module.ModLogs +{ + /// + /// Logs certain events of note to a database for moderators to keep track of user behavior. + /// Makes use of a helper class, . + /// + class ModLogs : BotModule + { + public override string Name => "ModLogs"; + + private readonly MessageCache _msgCacheInstance; + + public ModLogs(DiscordSocketClient client) : base(client) + { + // Do nothing if database unavailable. The user will be informed by ProcessConfiguration. + if (!RegexBot.Config.DatabaseAvailable) return; + + // MessageCache (reporting of MessageEdit, MessageDelete) handled by helper class + _msgCacheInstance = new MessageCache(client, Log, GetConfig); + + // TODO add handlers for detecting joins, leaves, bans, kicks, user edits (nick/username/discr) + // TODO add handler for processing the log query command + } + + [ConfigSection("ModLogs")] + public override async Task ProcessConfiguration(JToken configSection) + { + if (configSection.Type != JTokenType.Object) + throw new RuleImportException("Configuration for this section is invalid."); + + if (!RegexBot.Config.DatabaseAvailable) + { + await Log("Database access is not available. This module be unavailable."); + return null; + } + + var conf = new GuildConfig((JObject)configSection); + if (conf.RptTypes != EventType.None) + await Log("Enabled event autoreporting to " + conf.RptTarget); + + return conf; + } + } +} \ No newline at end of file diff --git a/Module/ModLogs/Sql.cs b/Module/ModLogs/Sql.cs new file mode 100644 index 0000000..238b26f --- /dev/null +++ b/Module/ModLogs/Sql.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Noikoio.RegexBot.Module.ModLogs +{ + /// + /// Contains common constants and static methods used for accessing the log database. + /// + class Sql + { + public const string TableLog = "modlogs_entries"; + public const string TableLogIncr = TableLog + "_id"; + public const string TableMsgCache = "modlogs_msgcache"; + + static void CreateTables() + { + using (var db = RegexBot.Config.GetOpenDatabaseConnectionAsync().GetAwaiter().GetResult()) + { + using (var c = db.CreateCommand()) + { + c.CommandText = "CREATE TABLE IF NOT EXISTS " + TableLog + " (" + + "id int primary key, " + + "entry_ts timestamptz not null, " + + "guild_id bigint not null, " + + "target_id bigint not null, " + + $"invoke_id bigint null references {EntityCache.SqlHelper.TableUser}.user_id, " + + "target_channel_id bigint null, " // TODO channel cache reference? + + "category text not null, " + + "message text not null, " + + $"FOREIGN KEY (target_id, guild_id) REFERENCES {EntityCache.SqlHelper.TableUser} (user_id, guild_id)"; + c.ExecuteNonQuery(); + } + using (var c = db.CreateCommand()) + { + c.CommandText = $"CREATE SEQUENCE IF NOT EXISTS {TableLogIncr} " + + $"START 100 MAXVALUE {int.MaxValue}"; + c.ExecuteNonQuery(); + } + } + } + + #region Log entry manipulation + // what was I doing again + #endregion + } +} diff --git a/RegexBot.cs b/RegexBot.cs index e4e8669..ef73229 100644 --- a/RegexBot.cs +++ b/RegexBot.cs @@ -58,7 +58,10 @@ namespace Noikoio.RegexBot new Module.AutoMod.AutoMod(_client), new Module.ModTools.ModTools(_client), new Module.AutoRespond.AutoRespond(_client), - new EntityCache.Module(_client) // EntityCache goes before anything else that uses its data + + // EntityCache loads before anything using it + new EntityCache.Module(_client), + new Module.ModLogs.ModLogs(_client) }; // Set up logging diff --git a/docs/modlogs.md b/docs/modlogs.md new file mode 100644 index 0000000..318918a --- /dev/null +++ b/docs/modlogs.md @@ -0,0 +1,38 @@ +## ModLogs + +ModLogs is a work in progress and not all features are yet available. +When completed, it will be the component that records certain information and notifies moderators of actions on the server deemed important enough to show as they happen. + +Sample within a [server definition](serverdef.html): +``` +"ModLogs": { + "AutoReporting": { + "Channel": "#99999999:mod-events", + "Events": "msgedit,msgdelete" + } +} +``` + +### Definition structure +Behavior of the ModLogs component is defined within a JSON object named `ModLogs`. Omitting this section from a server definition will disable the component for the given server. + +The following values can be defined within the `ModLogs` object: +* AutoReporting (*object*) - See below for details +* QueryCommand (*object*) - Unavailable; Work in progress + +#### AutoReporting +As its name implies, the `AutoReporting` section allows the bot operator to configure automatic reporting of one or more events as they occur to a designated reporting channel. Omitting this section in configuration disables this function. + +The following values are accepted within this object: +* Channel (*string*) - **Required.** The channel name in which to report events. + * The channel ID is currently required to be specified (see [EntityList](entitylist.html)). This limitation will be removed in a future update. +* Events (*string*) - **Required** for now. A comma-separated list of event types to be sent to the reporting channel. + +#### Event types +All events fall into one of a number of categories. +* Custom - The catch-all term for all event types that are not built in, created either by an AutoMod response or an external module. +* (name) - (description) + +Additionally, the following event types are also valid only for `AutoReporting` and are otherwise not logged: +* MsgEdit - Message was edited by the message author. +* MsgDelete - Message was deleted either by the message author or another user. \ No newline at end of file