diff --git a/Module/ModLogs/Entry.cs b/Module/ModLogs/Entry.cs
new file mode 100644
index 0000000..4e7918b
--- /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.Database.GetOpenConnectionAsync())
+ {
+ 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.Database.GetOpenConnectionAsync())
+ {
+ 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