Merge branch 'dev/modlogs'

This commit is contained in:
Noikoio 2018-03-17 14:20:34 -07:00
commit dd1f3c3bd7
13 changed files with 798 additions and 12 deletions

View file

@ -25,6 +25,7 @@ namespace Noikoio.RegexBot
/// <summary> /// <summary>
/// Processes module-specific configuration. /// Processes module-specific configuration.
/// This method is not called if the user did not provide configuration for the module.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Module code <i>should not</i> hold on to this data, but instead use <see cref="GetConfig(ulong)"/> to retrieve /// Module code <i>should not</i> hold on to this data, but instead use <see cref="GetConfig(ulong)"/> to retrieve
@ -36,8 +37,8 @@ namespace Noikoio.RegexBot
/// Processed configuration data prepared for later use. /// Processed configuration data prepared for later use.
/// </returns> /// </returns>
/// <exception cref="ConfigItem.RuleImportException"> /// <exception cref="ConfigItem.RuleImportException">
/// This method should throw RuleImportException in the event of any error. /// This method should throw <see cref="ConfigItem.RuleImportException"/>
/// The exception message will be properly logged. /// in the event of configuration errors. The exception message will be properly displayed.
/// </exception> /// </exception>
public abstract Task<object> ProcessConfiguration(JToken configSection); public abstract Task<object> ProcessConfiguration(JToken configSection);

View file

@ -3,9 +3,7 @@
enum EntityType { Channel, Role, User } enum EntityType { Channel, Role, User }
/// <summary> /// <summary>
/// Used to join together an entity ID and its name, particularly when read from configuration. /// Used to join together an entity ID and its name 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.
/// In configuration, entities are fully specified with a prefix (if necessary), an ID, two colons, and a name. /// In configuration, entities are fully specified with a prefix (if necessary), an ID, two colons, and a name.
/// </summary> /// </summary>
struct EntityName struct EntityName

View file

@ -19,9 +19,7 @@ namespace Noikoio.RegexBot.EntityCache
public Module(DiscordSocketClient client) : base(client) public Module(DiscordSocketClient client) : base(client)
{ {
_db = RegexBot.Config.Database; if (RegexBot.Config.DatabaseAvailable)
if (_db.Available)
{ {
SqlHelper.CreateCacheTablesAsync().Wait(); SqlHelper.CreateCacheTablesAsync().Wait();

View file

@ -19,7 +19,7 @@ namespace Noikoio.RegexBot.EntityCache
// Reminder: Check Cache query methods if making changes to tables // Reminder: Check Cache query methods if making changes to tables
internal static async Task CreateCacheTablesAsync() internal static async Task CreateCacheTablesAsync()
{ {
var db = await RegexBot.Config.Database.GetOpenConnectionAsync(); var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync();
if (db == null) return; if (db == null) return;
using (db) using (db)
{ {
@ -83,7 +83,7 @@ namespace Noikoio.RegexBot.EntityCache
#region Insertions and updates #region Insertions and updates
internal static async Task UpdateGuildAsync(SocketGuild g) internal static async Task UpdateGuildAsync(SocketGuild g)
{ {
var db = await RegexBot.Config.Database.GetOpenConnectionAsync(); var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync();
if (db == null) return; if (db == null) return;
using (db) using (db)
{ {
@ -109,7 +109,7 @@ namespace Noikoio.RegexBot.EntityCache
} }
internal static async Task UpdateGuildMemberAsync(IEnumerable<SocketGuildUser> users) internal static async Task UpdateGuildMemberAsync(IEnumerable<SocketGuildUser> users)
{ {
var db = await RegexBot.Config.Database.GetOpenConnectionAsync(); var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync();
if (db == null) return; if (db == null) return;
using (db) using (db)
{ {

168
Module/ModLogs/Entry.cs Normal file
View file

@ -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
{
/// <summary>
/// Represents a log entry in the database.
/// </summary>
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;
/// <summary>
/// Gets the ID value of this log entry.
/// </summary>
public int Id => _logId;
/// <summary>
/// Gets the timestamp (a <see cref="DateTime"/> with <see cref="DateTimeKind.Utc"/>) of the entry.
/// </summary>
public DateTime Timestamp => _ts;
/// <summary>
/// Gets the ID of the guild to which this log entry corresponds.
/// </summary>
public ulong Guild => _guildId;
/// <summary>
/// Gets the ID of the user to which this log entry corresponds.
/// </summary>
public ulong Target => _targetId;
/// <summary>
/// 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.
/// </summary>
public ulong? Invoker => _invokeId;
/// <summary>
/// Gets the guild channel ID to which this log entry corresponds, if any.
/// </summary>
public ulong? TargetChannel => _channelId;
/// <summary>
/// Gets this log entry's category.
/// </summary>
public string Category => _type;
/// <summary>
/// Gets the content of this log entry.
/// </summary>
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";
/// <summary>
/// Attempts to look up a log entry with the given ID.
/// </summary>
/// <returns>Null if no result.</returns>
public static async Task<Entry> 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<IEnumerable<Entry>> QueryLogAsync
(ulong guild,
ulong? target = null,
ulong? invoker = null,
ulong? channel = null,
IEnumerable<string> 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<Entry>();
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
}
}

View file

@ -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
{
/// <summary>
/// Listens for Discord-based events and writes them to the log (database).
/// Additionally writes certain messages to a designated logging channel if configured.
/// </summary>
class EventListener : BotModule
{
public override string Name => "ModLogs";
public EventListener(DiscordSocketClient client) : base(client)
{
}
[ConfigSection("modlogs")]
public override Task<object> ProcessConfiguration(JToken configSection)
{
throw new NotImplementedException();
}
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,127 @@
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using System;
namespace Noikoio.RegexBot.Module.ModLogs
{
/// <summary>
/// ModLogs guild-specific configuration values.
/// </summary>
class GuildConfig
{
// Event reporting
private readonly EntityName _rptTarget;
private EventType _rptTypes;
/// <summary>
/// Target reporting channel.
/// </summary>
public EntityName? RptTarget => _rptTarget;
/// <summary>
/// Event types to send to the reporting channel.
/// </summary>
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
/// <summary>
/// Query command. The first word in an incoming message, including prefix, that triggers a query.
/// </summary>
public string QrCommand => _qCmd;
/// <summary>
/// List of users permitted to invoke the query command.
/// If null, refer to the guild's Moderators list.
/// </summary>
public EntityList QrPermittedUsers => _qAccess;
/// <summary>
/// Event types to display in a query.
/// </summary>
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<string>();
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<string>();
_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<string>();
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<string>();
_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<EventType>(item, true);
endResult |= result;
}
catch (ArgumentException)
{
throw new RuleImportException($"Unable to determine the given event type \"{item}\"");
}
}
return endResult;
}
}
}

View file

@ -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
{
/// <summary>
/// Helper class for <see cref="ModLogs"/>. 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.
/// </summary>
class MessageCache
{
private readonly DiscordSocketClient _dClient;
private readonly AsyncLogger _outLog;
private readonly Func<ulong, object> _outGetConfig;
// TODO: How to clear the cache after a time? Can't hold on to this forever.
public MessageCache(DiscordSocketClient client, AsyncLogger logger, Func<ulong, object> 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<IMessage, ulong> 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<Discord.IMessage, ulong> 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<EmbedFieldBuilder>(),
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<string> 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
}
}

49
Module/ModLogs/ModLogs.cs Normal file
View file

@ -0,0 +1,49 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using Noikoio.RegexBot.ConfigItem;
using System.Threading.Tasks;
namespace Noikoio.RegexBot.Module.ModLogs
{
/// <summary>
/// Logs certain events of note to a database for moderators to keep track of user behavior.
/// Makes use of a helper class, <see cref="MessageCache"/>.
/// </summary>
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<object> 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;
}
}
}

47
Module/ModLogs/Sql.cs Normal file
View file

@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace Noikoio.RegexBot.Module.ModLogs
{
/// <summary>
/// Contains common constants and static methods used for accessing the log database.
/// </summary>
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
}
}

View file

@ -58,7 +58,10 @@ namespace Noikoio.RegexBot
new Module.AutoMod.AutoMod(_client), new Module.AutoMod.AutoMod(_client),
new Module.ModTools.ModTools(_client), new Module.ModTools.ModTools(_client),
new Module.AutoRespond.AutoRespond(_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 // Set up logging

38
docs/modlogs.md Normal file
View file

@ -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.