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>
|
||||
/// Processes module-specific configuration.
|
||||
/// This method is not called if the user did not provide configuration for the module.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </returns>
|
||||
/// <exception cref="ConfigItem.RuleImportException">
|
||||
/// This method should throw RuleImportException in the event of any error.
|
||||
/// The exception message will be properly logged.
|
||||
/// This method should throw <see cref="ConfigItem.RuleImportException"/>
|
||||
/// in the event of configuration errors. The exception message will be properly displayed.
|
||||
/// </exception>
|
||||
public abstract Task<object> ProcessConfiguration(JToken configSection);
|
||||
|
||||
|
|
|
@ -3,9 +3,7 @@
|
|||
enum EntityType { Channel, Role, User }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
struct EntityName
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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<SocketGuildUser> users)
|
||||
{
|
||||
var db = await RegexBot.Config.Database.GetOpenConnectionAsync();
|
||||
var db = await RegexBot.Config.GetOpenDatabaseConnectionAsync();
|
||||
if (db == null) return;
|
||||
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.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
|
||||
|
|
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