Merge branch 'dev/modlogs'
This commit is contained in:
commit
dd1f3c3bd7
13 changed files with 798 additions and 12 deletions
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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
168
Module/ModLogs/Entry.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
28
Module/ModLogs/EventListener.cs
Normal file
28
Module/ModLogs/EventListener.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
22
Module/ModLogs/EventType.cs
Normal file
22
Module/ModLogs/EventType.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
127
Module/ModLogs/GuildConfig.cs
Normal file
127
Module/ModLogs/GuildConfig.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
307
Module/ModLogs/MessageCache.cs
Normal file
307
Module/ModLogs/MessageCache.cs
Normal 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
49
Module/ModLogs/ModLogs.cs
Normal 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
47
Module/ModLogs/Sql.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
38
docs/modlogs.md
Normal 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.
|
Loading…
Reference in a new issue