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