Merge pull request #1 from NoiTheCat/dev (v3.1.0 fixes and additions)
Just a big mess of random changes and additions. Most notably, logging tools for moderators. There is very little polish. That may come later.
This commit is contained in:
commit
d3414f7b04
58 changed files with 1625 additions and 369 deletions
|
@ -22,13 +22,12 @@ public class EntityList : IEnumerable<EntityName> {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new EntityList instance with no data.
|
/// Creates a new EntityList instance with no data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public EntityList() : this(null, false) { }
|
public EntityList() : this(null) { }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new EntityList instance using the given JSON token as input.
|
/// Creates a new EntityList instance using the given JSON token as input.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="input">JSON array to be used for input. For ease of use, null values are also accepted.</param>
|
/// <param name="input">JSON array to be used for input. For ease of use, null values are also accepted.</param>
|
||||||
/// <param name="enforceTypes">Specifies if all entities defined in configuration must have their type specified.</param>
|
|
||||||
/// <exception cref="ArgumentException">The input is not a JSON array.</exception>
|
/// <exception cref="ArgumentException">The input is not a JSON array.</exception>
|
||||||
/// <exception cref="ArgumentNullException">
|
/// <exception cref="ArgumentNullException">
|
||||||
/// Unintiutively, this exception is thrown if a user-provided configuration value is blank.
|
/// Unintiutively, this exception is thrown if a user-provided configuration value is blank.
|
||||||
|
@ -36,7 +35,7 @@ public class EntityList : IEnumerable<EntityName> {
|
||||||
/// <exception cref="FormatException">
|
/// <exception cref="FormatException">
|
||||||
/// When enforceTypes is set, this is thrown if an EntityName results in having its Type be Unspecified.
|
/// When enforceTypes is set, this is thrown if an EntityName results in having its Type be Unspecified.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public EntityList(JToken? input, bool enforceTypes) {
|
public EntityList(JToken? input) {
|
||||||
if (input == null) {
|
if (input == null) {
|
||||||
_innerList = new List<EntityName>().AsReadOnly();
|
_innerList = new List<EntityName>().AsReadOnly();
|
||||||
return;
|
return;
|
||||||
|
@ -50,8 +49,6 @@ public class EntityList : IEnumerable<EntityName> {
|
||||||
foreach (var item in inputArray.Values<string>()) {
|
foreach (var item in inputArray.Values<string>()) {
|
||||||
if (string.IsNullOrWhiteSpace(item)) continue;
|
if (string.IsNullOrWhiteSpace(item)) continue;
|
||||||
var itemName = new EntityName(item);
|
var itemName = new EntityName(item);
|
||||||
if (enforceTypes && itemName.Type == EntityType.Unspecified)
|
|
||||||
throw new FormatException($"The following value is not prefixed: {item}");
|
|
||||||
list.Add(itemName);
|
list.Add(itemName);
|
||||||
}
|
}
|
||||||
_innerList = list.AsReadOnly();
|
_innerList = list.AsReadOnly();
|
||||||
|
@ -82,7 +79,7 @@ public class EntityList : IEnumerable<EntityName> {
|
||||||
} else {
|
} else {
|
||||||
foreach (var r in authorRoles) {
|
foreach (var r in authorRoles) {
|
||||||
if (!string.Equals(r.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
|
if (!string.Equals(r.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
|
||||||
if (keepId) entry.SetId(r.Id);
|
if (keepId) entry.Id = r.Id;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -91,7 +88,7 @@ public class EntityList : IEnumerable<EntityName> {
|
||||||
if (entry.Id.Value == channel.Id) return true;
|
if (entry.Id.Value == channel.Id) return true;
|
||||||
} else {
|
} else {
|
||||||
if (!string.Equals(channel.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
|
if (!string.Equals(channel.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
|
||||||
if (keepId) entry.SetId(channel.Id);
|
if (keepId) entry.Id = channel.Id;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} else { // User
|
} else { // User
|
||||||
|
@ -99,7 +96,7 @@ public class EntityList : IEnumerable<EntityName> {
|
||||||
if (entry.Id.Value == author.Id) return true;
|
if (entry.Id.Value == author.Id) return true;
|
||||||
} else {
|
} else {
|
||||||
if (!string.Equals(author.Username, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
|
if (!string.Equals(author.Username, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
|
||||||
if (keepId) entry.SetId(author.Id);
|
if (keepId) entry.Id = author.Id;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,23 +6,28 @@
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class EntityName {
|
public class EntityName {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The entity's type, if specified in configuration.
|
/// The entity's type, as specified in configuration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public EntityType Type { get; private set; }
|
public EntityType Type { get; private set; }
|
||||||
|
|
||||||
|
private ulong? _id;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entity's unique ID value (snowflake). May be null if the value is not known.
|
/// Entity's unique ID value (snowflake). May be null if the value is not known.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ulong? Id { get; private set; }
|
/// <remarks>
|
||||||
|
/// This property may be updated during runtime if instructed to update the ID for persistence.
|
||||||
|
/// </remarks>
|
||||||
|
public ulong? Id {
|
||||||
|
get => _id;
|
||||||
|
internal set => _id ??= value;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entity's name as specified in configuration. May be null if it was not specified.
|
/// Entity's name as specified in configuration. May be null if it was not specified.
|
||||||
/// This value is not updated during runtime.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>This value is not updated during runtime.</remarks>
|
||||||
public string? Name { get; private set; }
|
public string? Name { get; private set; }
|
||||||
|
|
||||||
// TODO elsewhere: find a way to emit a warning if the user specified a name without ID in configuration.
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new object instance from the given input string.
|
/// Creates a new object instance from the given input string.
|
||||||
/// Documentation for the EntityName format can be found elsewhere in this project's documentation.
|
/// Documentation for the EntityName format can be found elsewhere in this project's documentation.
|
||||||
|
@ -33,16 +38,12 @@ public class EntityName {
|
||||||
public EntityName(string input) {
|
public EntityName(string input) {
|
||||||
if (string.IsNullOrWhiteSpace(input))
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
throw new ArgumentNullException(nameof(input), "Specified name is blank.");
|
throw new ArgumentNullException(nameof(input), "Specified name is blank.");
|
||||||
|
if (input.Length < 2) throw new ArgumentException("Input is not in a valid entity name format.");
|
||||||
|
|
||||||
// Check if type prefix was specified and extract it
|
|
||||||
Type = default;
|
|
||||||
if (input.Length >= 2) {
|
|
||||||
if (input[0] == '&') Type = EntityType.Role;
|
if (input[0] == '&') Type = EntityType.Role;
|
||||||
else if (input[0] == '#') Type = EntityType.Channel;
|
else if (input[0] == '#') Type = EntityType.Channel;
|
||||||
else if (input[0] == '@') Type = EntityType.User;
|
else if (input[0] == '@') Type = EntityType.User;
|
||||||
}
|
else throw new ArgumentException("Entity type unable to be inferred by given input.");
|
||||||
if (Type == default)
|
|
||||||
throw new ArgumentException("Entity type unable to be inferred by given input.");
|
|
||||||
|
|
||||||
input = input[1..]; // Remove prefix
|
input = input[1..]; // Remove prefix
|
||||||
|
|
||||||
|
@ -72,14 +73,23 @@ public class EntityName {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void SetId(ulong id) {
|
/// <summary>
|
||||||
if (!Id.HasValue) Id = id;
|
/// Creates a new object instance from the given input string.
|
||||||
|
/// Documentation for the EntityName format can be found elsewhere in this project's documentation.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input">Input string in EntityName format.</param>
|
||||||
|
/// <param name="expectedType">The <see cref="EntityType"/> expected for this instance.</param>
|
||||||
|
/// <exception cref="ArgumentNullException">Input string is null or blank.</exception>
|
||||||
|
/// <exception cref="ArgumentException">Input string cannot be resolved to an entity type.</exception>
|
||||||
|
/// <exception cref="FormatException">Input string was resolved to a type other than specified.</exception>
|
||||||
|
public EntityName(string input, EntityType expectedType) : this(input) {
|
||||||
|
if (Type != expectedType) throw new FormatException("Resolved EntityType does not match expected type.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the appropriate prefix corresponding to an EntityType.
|
/// Returns the appropriate prefix corresponding to an EntityType.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static char Prefix(EntityType t) => t switch {
|
public static char GetPrefix(EntityType t) => t switch {
|
||||||
EntityType.Role => '&',
|
EntityType.Role => '&',
|
||||||
EntityType.Channel => '#',
|
EntityType.Channel => '#',
|
||||||
EntityType.User => '@',
|
EntityType.User => '@',
|
||||||
|
@ -90,7 +100,7 @@ public class EntityName {
|
||||||
/// Returns a string representation of this item in proper EntityName format.
|
/// Returns a string representation of this item in proper EntityName format.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public override string ToString() {
|
public override string ToString() {
|
||||||
var pf = Prefix(Type);
|
var pf = GetPrefix(Type);
|
||||||
|
|
||||||
if (Id.HasValue && Name != null)
|
if (Id.HasValue && Name != null)
|
||||||
return $"{pf}{Id.Value}::{Name}";
|
return $"{pf}{Id.Value}::{Name}";
|
||||||
|
@ -101,6 +111,7 @@ public class EntityName {
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Helper methods
|
#region Helper methods
|
||||||
|
// TODO convert all to extension methods
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to find the corresponding role within the given guild.
|
/// Attempts to find the corresponding role within the given guild.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
@ -3,8 +3,6 @@
|
||||||
/// The type of entity specified in an <see cref="EntityName"/>.
|
/// The type of entity specified in an <see cref="EntityName"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum EntityType {
|
public enum EntityType {
|
||||||
/// <summary>Default value. Is never referenced in regular usage.</summary>
|
|
||||||
Unspecified,
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Userd when the <see cref="EntityName"/> represents a role.
|
/// Userd when the <see cref="EntityName"/> represents a role.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
@ -75,7 +75,7 @@ public class FilterList {
|
||||||
|
|
||||||
if (incoming.Type != JTokenType.Array)
|
if (incoming.Type != JTokenType.Array)
|
||||||
throw new FormatException("Filtering list must be a JSON array.");
|
throw new FormatException("Filtering list must be a JSON array.");
|
||||||
FilteredList = new EntityList((JArray)incoming, true);
|
FilteredList = new EntityList((JArray)incoming);
|
||||||
|
|
||||||
// Verify the same for the exemption list.
|
// Verify the same for the exemption list.
|
||||||
if (exemptKey != null) {
|
if (exemptKey != null) {
|
||||||
|
@ -85,7 +85,7 @@ public class FilterList {
|
||||||
} else if (incomingEx.Type != JTokenType.Array) {
|
} else if (incomingEx.Type != JTokenType.Array) {
|
||||||
throw new FormatException("Filtering exemption list must be a JSON array.");
|
throw new FormatException("Filtering exemption list must be a JSON array.");
|
||||||
} else {
|
} else {
|
||||||
FilterExemptions = new EntityList(incomingEx, true);
|
FilterExemptions = new EntityList(incomingEx);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
FilterExemptions = new EntityList();
|
FilterExemptions = new EntityList();
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
using Discord;
|
using Discord;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace RegexBot.Common;
|
namespace RegexBot.Common;
|
||||||
|
@ -64,4 +65,55 @@ public static class Utilities {
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds and returns an embed which displays this log entry.
|
||||||
|
/// </summary>
|
||||||
|
public static Embed BuildEmbed(this Data.ModLogEntry entry, RegexbotClient bot) {
|
||||||
|
var issuedDisplay = TryFromEntityNameString(entry.IssuedBy, bot);
|
||||||
|
string targetDisplay;
|
||||||
|
var targetq = bot.EcQueryUser(entry.UserId.ToString());
|
||||||
|
if (targetq != null) targetDisplay = $"<@{targetq.UserId}> - {targetq.Username}#{targetq.Discriminator} `{targetq.UserId}`";
|
||||||
|
else targetDisplay = $"User with ID `{entry.UserId}`";
|
||||||
|
|
||||||
|
var logEmbed = new EmbedBuilder()
|
||||||
|
.WithTitle(Enum.GetName(typeof(ModLogType), entry.LogType) + " logged:")
|
||||||
|
.WithTimestamp(entry.Timestamp)
|
||||||
|
.WithFooter($"Log #{entry.LogId}", bot.DiscordClient.CurrentUser.GetAvatarUrl()); // Escaping '#' not necessary here
|
||||||
|
if (entry.Message != null) {
|
||||||
|
logEmbed.Description = entry.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
var contextStr = new StringBuilder();
|
||||||
|
contextStr.AppendLine($"User: {targetDisplay}");
|
||||||
|
contextStr.AppendLine($"Logged by: {issuedDisplay}");
|
||||||
|
|
||||||
|
logEmbed.AddField(new EmbedFieldBuilder() {
|
||||||
|
Name = "Context",
|
||||||
|
Value = contextStr.ToString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return logEmbed.Build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a representation of this entity that can be parsed by the <seealso cref="EntityName"/> constructor.
|
||||||
|
/// </summary>
|
||||||
|
public static string AsEntityNameString(this IUser entity) => $"@{entity.Id}::{entity.Username}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If given string is in an EntityName format, returns a displayable representation of it based on
|
||||||
|
/// a cache query. Otherwise, returns the input string as-is.
|
||||||
|
/// </summary>
|
||||||
|
[return: NotNullIfNotNull("input")]
|
||||||
|
public static string? TryFromEntityNameString(string? input, RegexbotClient bot) {
|
||||||
|
string? result = null;
|
||||||
|
try {
|
||||||
|
var entityTry = new EntityName(input!, EntityType.User);
|
||||||
|
var issueq = bot.EcQueryUser(entityTry.Id!.Value.ToString());
|
||||||
|
if (issueq != null) result = $"<@{issueq.UserId}> - {issueq.Username}#{issueq.Discriminator} `{issueq.UserId}`";
|
||||||
|
else result = $"Unknown user with ID `{entityTry.Id!.Value}`";
|
||||||
|
} catch (Exception) { }
|
||||||
|
return result ?? input;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,9 @@ public class BotDatabaseContext : DbContext {
|
||||||
// Get our own config loaded just for the SQL stuff
|
// Get our own config loaded just for the SQL stuff
|
||||||
var conf = new InstanceConfig();
|
var conf = new InstanceConfig();
|
||||||
_connectionString = new NpgsqlConnectionStringBuilder() {
|
_connectionString = new NpgsqlConnectionStringBuilder() {
|
||||||
|
#if DEBUG
|
||||||
|
IncludeErrorDetail = true,
|
||||||
|
#endif
|
||||||
Host = conf.SqlHost ?? "localhost", // default to localhost
|
Host = conf.SqlHost ?? "localhost", // default to localhost
|
||||||
Database = conf.SqlDatabase,
|
Database = conf.SqlDatabase,
|
||||||
Username = conf.SqlUsername,
|
Username = conf.SqlUsername,
|
||||||
|
@ -34,6 +37,11 @@ public class BotDatabaseContext : DbContext {
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<CachedGuildMessage> GuildMessageCache { get; set; } = null!;
|
public DbSet<CachedGuildMessage> GuildMessageCache { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the <seealso cref="ModLogEntry">moderator logs</seealso>.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<ModLogEntry> ModLogs { get; set; } = null!;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected sealed override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected sealed override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
=> optionsBuilder
|
=> optionsBuilder
|
||||||
|
@ -43,10 +51,17 @@ public class BotDatabaseContext : DbContext {
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||||
modelBuilder.Entity<CachedUser>(entity => entity.Property(e => e.Discriminator).HasMaxLength(4).IsFixedLength());
|
modelBuilder.Entity<CachedUser>(entity => entity.Property(e => e.Discriminator).HasMaxLength(4).IsFixedLength());
|
||||||
modelBuilder.Entity<CachedGuildUser>(entity => {
|
modelBuilder.Entity<CachedGuildUser>(e => {
|
||||||
entity.HasKey(e => new { e.UserId, e.GuildId });
|
e.HasKey(p => new { p.GuildId, p.UserId });
|
||||||
entity.Property(e => e.FirstSeenTime).HasDefaultValueSql("now()");
|
e.Property(p => p.FirstSeenTime).HasDefaultValueSql("now()");
|
||||||
|
});
|
||||||
|
modelBuilder.Entity<CachedGuildMessage>(e => e.Property(p => p.CreatedAt).HasDefaultValueSql("now()"));
|
||||||
|
modelBuilder.HasPostgresEnum<ModLogType>();
|
||||||
|
modelBuilder.Entity<ModLogEntry>(e => {
|
||||||
|
e.Property(p => p.Timestamp).HasDefaultValueSql("now()");
|
||||||
|
e.HasOne(entry => entry.User)
|
||||||
|
.WithMany(gu => gu.Logs)
|
||||||
|
.HasForeignKey(entry => new { entry.GuildId, entry.UserId });
|
||||||
});
|
});
|
||||||
modelBuilder.Entity<CachedGuildMessage>(entity => entity.Property(e => e.CreatedAt).HasDefaultValueSql("now()"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -53,12 +53,11 @@ public class CachedGuildMessage {
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Content { get; set; } = null!;
|
public string? Content { get; set; } = null!;
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc cref="CachedGuildUser.User" />
|
||||||
/// If included in the query, references the associated <seealso cref="CachedUser"/> for this entry.
|
|
||||||
/// </summary>
|
|
||||||
[ForeignKey(nameof(AuthorId))]
|
[ForeignKey(nameof(AuthorId))]
|
||||||
[InverseProperty(nameof(CachedUser.GuildMessages))]
|
[InverseProperty(nameof(CachedUser.GuildMessages))]
|
||||||
public CachedUser Author { get; set; } = null!;
|
public CachedUser Author { get; set; } = null!;
|
||||||
|
// TODO set up composite foreign key. will require rewriting some parts in modules...
|
||||||
|
|
||||||
// Used by MessageCachingSubservice
|
// Used by MessageCachingSubservice
|
||||||
internal static CachedGuildMessage? Clone(CachedGuildMessage? original) {
|
internal static CachedGuildMessage? Clone(CachedGuildMessage? original) {
|
||||||
|
|
|
@ -6,14 +6,14 @@ namespace RegexBot.Data;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Table("cache_usersinguild")]
|
[Table("cache_usersinguild")]
|
||||||
public class CachedGuildUser {
|
public class CachedGuildUser {
|
||||||
/// <inheritdoc cref="CachedUser.UserId"/>
|
|
||||||
public long UserId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the associated guild's snowflake ID.
|
/// Gets the associated guild's snowflake ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long GuildId { get; set; }
|
public long GuildId { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc cref="CachedUser.UserId"/>
|
||||||
|
public long UserId { get; set; }
|
||||||
|
|
||||||
/// <inheritdoc cref="CachedUser.ULastUpdateTime"/>
|
/// <inheritdoc cref="CachedUser.ULastUpdateTime"/>
|
||||||
public DateTimeOffset GULastUpdateTime { get; set; }
|
public DateTimeOffset GULastUpdateTime { get; set; }
|
||||||
|
|
||||||
|
@ -33,4 +33,9 @@ public class CachedGuildUser {
|
||||||
[ForeignKey(nameof(UserId))]
|
[ForeignKey(nameof(UserId))]
|
||||||
[InverseProperty(nameof(CachedUser.Guilds))]
|
[InverseProperty(nameof(CachedUser.Guilds))]
|
||||||
public CachedUser User { get; set; } = null!;
|
public CachedUser User { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If included in the query, references all <seealso cref="ModLogEntry"/> items associated with this entry.
|
||||||
|
/// </summary>
|
||||||
|
public ICollection<ModLogEntry> Logs { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ namespace RegexBot.Data;
|
||||||
[Table("cache_users")]
|
[Table("cache_users")]
|
||||||
public class CachedUser {
|
public class CachedUser {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the user's snowflake ID.
|
/// Gets the associated user's snowflake ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Key]
|
[Key]
|
||||||
[DatabaseGenerated(DatabaseGeneratedOption.None)]
|
[DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||||
|
|
235
Data/Migrations/20220827041853_AddModLogs.Designer.cs
generated
Normal file
235
Data/Migrations/20220827041853_AddModLogs.Designer.cs
generated
Normal file
|
@ -0,0 +1,235 @@
|
||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using RegexBot.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RegexBot.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(BotDatabaseContext))]
|
||||||
|
[Migration("20220827041853_AddModLogs")]
|
||||||
|
partial class AddModLogs
|
||||||
|
{
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "6.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "mod_log_type", new[] { "other", "note", "warn", "timeout", "kick", "ban" });
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("MessageId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("message_id");
|
||||||
|
|
||||||
|
b.Property<List<string>>("AttachmentNames")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]")
|
||||||
|
.HasColumnName("attachment_names");
|
||||||
|
|
||||||
|
b.Property<long>("AuthorId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("author_id");
|
||||||
|
|
||||||
|
b.Property<long>("ChannelId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("channel_id");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("content");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("EditedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("edited_at");
|
||||||
|
|
||||||
|
b.Property<long>("GuildId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("guild_id");
|
||||||
|
|
||||||
|
b.HasKey("MessageId")
|
||||||
|
.HasName("pk_cache_guildmessages");
|
||||||
|
|
||||||
|
b.HasIndex("AuthorId")
|
||||||
|
.HasDatabaseName("ix_cache_guildmessages_author_id");
|
||||||
|
|
||||||
|
b.ToTable("cache_guildmessages", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("GuildId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("guild_id");
|
||||||
|
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("user_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("FirstSeenTime")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("first_seen_time")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("GULastUpdateTime")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("gu_last_update_time");
|
||||||
|
|
||||||
|
b.Property<string>("Nickname")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("nickname");
|
||||||
|
|
||||||
|
b.HasKey("GuildId", "UserId")
|
||||||
|
.HasName("pk_cache_usersinguild");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.HasDatabaseName("ix_cache_usersinguild_user_id");
|
||||||
|
|
||||||
|
b.ToTable("cache_usersinguild", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RegexBot.Data.CachedUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("user_id");
|
||||||
|
|
||||||
|
b.Property<string>("AvatarUrl")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("avatar_url");
|
||||||
|
|
||||||
|
b.Property<string>("Discriminator")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4)
|
||||||
|
.HasColumnType("character(4)")
|
||||||
|
.HasColumnName("discriminator")
|
||||||
|
.IsFixedLength();
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ULastUpdateTime")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("u_last_update_time");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("username");
|
||||||
|
|
||||||
|
b.HasKey("UserId")
|
||||||
|
.HasName("pk_cache_users");
|
||||||
|
|
||||||
|
b.ToTable("cache_users", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RegexBot.Data.ModLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("LogId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("log_id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("LogId"));
|
||||||
|
|
||||||
|
b.Property<long>("GuildId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("guild_id");
|
||||||
|
|
||||||
|
b.Property<string>("IssuedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("issued_by");
|
||||||
|
|
||||||
|
b.Property<int>("LogType")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("log_type");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("message");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("Timestamp")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("timestamp")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("user_id");
|
||||||
|
|
||||||
|
b.HasKey("LogId")
|
||||||
|
.HasName("pk_modlogs");
|
||||||
|
|
||||||
|
b.HasIndex("GuildId", "UserId")
|
||||||
|
.HasDatabaseName("ix_modlogs_guild_id_user_id");
|
||||||
|
|
||||||
|
b.ToTable("modlogs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("RegexBot.Data.CachedUser", "Author")
|
||||||
|
.WithMany("GuildMessages")
|
||||||
|
.HasForeignKey("AuthorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_cache_guildmessages_cache_users_author_id");
|
||||||
|
|
||||||
|
b.Navigation("Author");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("RegexBot.Data.CachedUser", "User")
|
||||||
|
.WithMany("Guilds")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_cache_usersinguild_cache_users_user_id");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RegexBot.Data.ModLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("RegexBot.Data.CachedGuildUser", "User")
|
||||||
|
.WithMany("Logs")
|
||||||
|
.HasForeignKey("GuildId", "UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_modlogs_cache_usersinguild_user_temp_id");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Logs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RegexBot.Data.CachedUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("GuildMessages");
|
||||||
|
|
||||||
|
b.Navigation("Guilds");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
82
Data/Migrations/20220827041853_AddModLogs.cs
Normal file
82
Data/Migrations/20220827041853_AddModLogs.cs
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace RegexBot.Data.Migrations
|
||||||
|
{
|
||||||
|
public partial class AddModLogs : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropPrimaryKey(
|
||||||
|
name: "pk_cache_usersinguild",
|
||||||
|
table: "cache_usersinguild");
|
||||||
|
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:Enum:mod_log_type", "other,note,warn,timeout,kick,ban");
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "pk_cache_usersinguild",
|
||||||
|
table: "cache_usersinguild",
|
||||||
|
columns: new[] { "guild_id", "user_id" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "modlogs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
log_id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
timestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
guild_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
user_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
log_type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
issued_by = table.Column<string>(type: "text", nullable: false),
|
||||||
|
message = table.Column<string>(type: "text", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_modlogs", x => x.log_id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_modlogs_cache_usersinguild_user_temp_id",
|
||||||
|
columns: x => new { x.guild_id, x.user_id },
|
||||||
|
principalTable: "cache_usersinguild",
|
||||||
|
principalColumns: new[] { "guild_id", "user_id" },
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_cache_usersinguild_user_id",
|
||||||
|
table: "cache_usersinguild",
|
||||||
|
column: "user_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_modlogs_guild_id_user_id",
|
||||||
|
table: "modlogs",
|
||||||
|
columns: new[] { "guild_id", "user_id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "modlogs");
|
||||||
|
|
||||||
|
migrationBuilder.DropPrimaryKey(
|
||||||
|
name: "pk_cache_usersinguild",
|
||||||
|
table: "cache_usersinguild");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "ix_cache_usersinguild_user_id",
|
||||||
|
table: "cache_usersinguild");
|
||||||
|
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.OldAnnotation("Npgsql:Enum:mod_log_type", "other,note,warn,timeout,kick,ban");
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "pk_cache_usersinguild",
|
||||||
|
table: "cache_usersinguild",
|
||||||
|
columns: new[] { "user_id", "guild_id" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,6 +21,7 @@ namespace RegexBot.Data.Migrations
|
||||||
.HasAnnotation("ProductVersion", "6.0.7")
|
.HasAnnotation("ProductVersion", "6.0.7")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "mod_log_type", new[] { "other", "note", "warn", "timeout", "kick", "ban" });
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
|
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
|
||||||
|
@ -71,14 +72,14 @@ namespace RegexBot.Data.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
|
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("UserId")
|
|
||||||
.HasColumnType("bigint")
|
|
||||||
.HasColumnName("user_id");
|
|
||||||
|
|
||||||
b.Property<long>("GuildId")
|
b.Property<long>("GuildId")
|
||||||
.HasColumnType("bigint")
|
.HasColumnType("bigint")
|
||||||
.HasColumnName("guild_id");
|
.HasColumnName("guild_id");
|
||||||
|
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("user_id");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("FirstSeenTime")
|
b.Property<DateTimeOffset>("FirstSeenTime")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
|
@ -93,9 +94,12 @@ namespace RegexBot.Data.Migrations
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("nickname");
|
.HasColumnName("nickname");
|
||||||
|
|
||||||
b.HasKey("UserId", "GuildId")
|
b.HasKey("GuildId", "UserId")
|
||||||
.HasName("pk_cache_usersinguild");
|
.HasName("pk_cache_usersinguild");
|
||||||
|
|
||||||
|
b.HasIndex("UserId")
|
||||||
|
.HasDatabaseName("ix_cache_usersinguild_user_id");
|
||||||
|
|
||||||
b.ToTable("cache_usersinguild", (string)null);
|
b.ToTable("cache_usersinguild", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -131,6 +135,51 @@ namespace RegexBot.Data.Migrations
|
||||||
b.ToTable("cache_users", (string)null);
|
b.ToTable("cache_users", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RegexBot.Data.ModLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("LogId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("log_id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("LogId"));
|
||||||
|
|
||||||
|
b.Property<long>("GuildId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("guild_id");
|
||||||
|
|
||||||
|
b.Property<string>("IssuedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("issued_by");
|
||||||
|
|
||||||
|
b.Property<int>("LogType")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("log_type");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("message");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("Timestamp")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("timestamp")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("user_id");
|
||||||
|
|
||||||
|
b.HasKey("LogId")
|
||||||
|
.HasName("pk_modlogs");
|
||||||
|
|
||||||
|
b.HasIndex("GuildId", "UserId")
|
||||||
|
.HasDatabaseName("ix_modlogs_guild_id_user_id");
|
||||||
|
|
||||||
|
b.ToTable("modlogs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
|
modelBuilder.Entity("RegexBot.Data.CachedGuildMessage", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("RegexBot.Data.CachedUser", "Author")
|
b.HasOne("RegexBot.Data.CachedUser", "Author")
|
||||||
|
@ -155,6 +204,23 @@ namespace RegexBot.Data.Migrations
|
||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RegexBot.Data.ModLogEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("RegexBot.Data.CachedGuildUser", "User")
|
||||||
|
.WithMany("Logs")
|
||||||
|
.HasForeignKey("GuildId", "UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_modlogs_cache_usersinguild_user_temp_id");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Logs");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("RegexBot.Data.CachedUser", b =>
|
modelBuilder.Entity("RegexBot.Data.CachedUser", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("GuildMessages");
|
b.Navigation("GuildMessages");
|
||||||
|
|
50
Data/ModLogEntry.cs
Normal file
50
Data/ModLogEntry.cs
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
using RegexBot.Common;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace RegexBot.Data;
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a moderation log entry.
|
||||||
|
/// </summary>
|
||||||
|
[Table("modlogs")]
|
||||||
|
public class ModLogEntry : ISharedEvent {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the ID number for this entry.
|
||||||
|
/// </summary>
|
||||||
|
[Key]
|
||||||
|
public int LogId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the date and time when this entry was logged.
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset Timestamp { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc cref="CachedGuildUser.GuildId"/>
|
||||||
|
public long GuildId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the ID of the users for which this log entry pertains.
|
||||||
|
/// </summary>
|
||||||
|
public long UserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the type of log message this represents.
|
||||||
|
/// </summary>
|
||||||
|
public ModLogType LogType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the the entity which issued this log item.
|
||||||
|
/// If it was a user, this value preferably is in the <seealso cref="EntityName"/> format.
|
||||||
|
/// </summary>
|
||||||
|
public string IssuedBy { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets any additional message associated with this log entry.
|
||||||
|
/// </summary>
|
||||||
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If included in the query, gets the associated <seealso cref="CachedGuildUser"/> for this entry.
|
||||||
|
/// </summary>
|
||||||
|
public CachedGuildUser User { get; set; } = null!;
|
||||||
|
}
|
|
@ -4,7 +4,6 @@ using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
namespace RegexBot;
|
namespace RegexBot;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Contains essential instance configuration for this bot including Discord connection settings, service configuration,
|
/// Contains essential instance configuration for this bot including Discord connection settings, service configuration,
|
||||||
/// and command-line options.
|
/// and command-line options.
|
||||||
|
@ -67,17 +66,10 @@ class InstanceConfig {
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command line options
|
|
||||||
/// </summary>
|
|
||||||
class CommandLineParameters {
|
class CommandLineParameters {
|
||||||
[Option('c', "config", Default = null,
|
[Option('c', "config", Default = null)]
|
||||||
HelpText = "Custom path to instance configuration. Defaults to instance.json in bot directory.")]
|
public string? ConfigFile { get; set; } = null!;
|
||||||
public string ConfigFile { get; set; } = null!;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command line arguments parsed here. Depending on inputs, the program can exit here.
|
|
||||||
/// </summary>
|
|
||||||
public static CommandLineParameters? Parse(string[] args) {
|
public static CommandLineParameters? Parse(string[] args) {
|
||||||
CommandLineParameters? result = null;
|
CommandLineParameters? result = null;
|
||||||
|
|
||||||
|
|
|
@ -33,26 +33,23 @@ internal class AutoResponder : RegexbotModule {
|
||||||
var definitions = GetGuildState<IEnumerable<Definition>>(ch.Guild.Id);
|
var definitions = GetGuildState<IEnumerable<Definition>>(ch.Guild.Id);
|
||||||
if (definitions == null) return; // No configuration in this guild; do no further processing
|
if (definitions == null) return; // No configuration in this guild; do no further processing
|
||||||
|
|
||||||
var tasks = new List<Task>();
|
|
||||||
foreach (var def in definitions) {
|
foreach (var def in definitions) {
|
||||||
tasks.Add(Task.Run(async () => await ProcessMessageAsync(arg, def)));
|
await ProcessMessageAsync(arg, def, ch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.WhenAll(tasks);
|
private async Task ProcessMessageAsync(SocketMessage msg, Definition def, SocketTextChannel ch) {
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ProcessMessageAsync(SocketMessage msg, Definition def) {
|
|
||||||
if (!def.Match(msg)) return;
|
if (!def.Match(msg)) return;
|
||||||
|
|
||||||
|
Log(ch.Guild, $"Definition '{def.Label}' triggered by {msg.Author}.");
|
||||||
if (def.Command == null) {
|
if (def.Command == null) {
|
||||||
await msg.Channel.SendMessageAsync(def.GetResponse());
|
await msg.Channel.SendMessageAsync(def.GetResponse());
|
||||||
} else {
|
} else {
|
||||||
var ch = (SocketGuildChannel)msg.Channel;
|
|
||||||
var cmdline = def.Command.Split(new char[] { ' ' }, 2);
|
var cmdline = def.Command.Split(new char[] { ' ' }, 2);
|
||||||
|
|
||||||
var ps = new ProcessStartInfo() {
|
var ps = new ProcessStartInfo() {
|
||||||
FileName = cmdline[0],
|
FileName = cmdline[0],
|
||||||
Arguments = (cmdline.Length == 2 ? cmdline[1] : ""),
|
Arguments = cmdline.Length == 2 ? cmdline[1] : "",
|
||||||
UseShellExecute = false, // ???
|
UseShellExecute = false, // ???
|
||||||
CreateNoWindow = true,
|
CreateNoWindow = true,
|
||||||
RedirectStandardOutput = true
|
RedirectStandardOutput = true
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
using RegexBot.Common;
|
using RegexBot.Common;
|
||||||
|
|
||||||
namespace RegexBot.Modules.EntryRole;
|
namespace RegexBot.Modules.EntryRole;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Contains configuration data as well as per-guild timers for those awaiting role assignment.
|
/// Contains configuration data as well as per-guild timers for those awaiting role assignment.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
@ -27,13 +26,10 @@ class GuildData {
|
||||||
public GuildData(JObject conf, Dictionary<ulong, DateTimeOffset> _waitingList) {
|
public GuildData(JObject conf, Dictionary<ulong, DateTimeOffset> _waitingList) {
|
||||||
WaitingList = _waitingList;
|
WaitingList = _waitingList;
|
||||||
|
|
||||||
var cfgRole = conf["Role"]?.Value<string>();
|
|
||||||
if (string.IsNullOrWhiteSpace(cfgRole))
|
|
||||||
throw new ModuleLoadException("Role value not specified.");
|
|
||||||
try {
|
try {
|
||||||
TargetRole = new EntityName(cfgRole);
|
TargetRole = new EntityName(conf["Role"]?.Value<string>()!, EntityType.Role);
|
||||||
} catch (ArgumentException) {
|
} catch (Exception) {
|
||||||
throw new ModuleLoadException("Role config value was not properly specified to be a role.");
|
throw new ModuleLoadException("'Role' was not properly specified.");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -13,14 +13,9 @@ class Ban : BanKick {
|
||||||
ulong targetId, CachedGuildUser? targetQuery, SocketUser? targetUser) {
|
ulong targetId, CachedGuildUser? targetQuery, SocketUser? targetUser) {
|
||||||
// Ban: Unlike kick, the minimum required is just the target ID
|
// Ban: Unlike kick, the minimum required is just the target ID
|
||||||
var result = await Module.Bot.BanAsync(g, msg.Author.ToString(), targetId, PurgeDays, reason, SendNotify);
|
var result = await Module.Bot.BanAsync(g, msg.Author.ToString(), targetId, PurgeDays, reason, SendNotify);
|
||||||
if (result.OperationSuccess) {
|
if (result.OperationSuccess && SuccessMessage != null) {
|
||||||
if (SuccessMessage != null) {
|
// TODO string replacement, formatting, etc
|
||||||
// TODO customization
|
|
||||||
await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}");
|
await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}");
|
||||||
} else {
|
|
||||||
// TODO custom fail message?
|
|
||||||
await msg.Channel.SendMessageAsync(SuccessMessage);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot));
|
await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot));
|
||||||
}
|
}
|
||||||
|
@ -39,15 +34,14 @@ class Kick : BanKick {
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await Module.Bot.KickAsync(g, msg.Author.ToString(), targetId, reason, SendNotify);
|
var result = await Module.Bot.KickAsync(g, msg.Author.ToString(), targetId, reason, SendNotify);
|
||||||
if (result.OperationSuccess) {
|
if (result.OperationSuccess && SuccessMessage != null) {
|
||||||
if (SuccessMessage != null) {
|
|
||||||
// TODO string replacement, formatting, etc
|
// TODO string replacement, formatting, etc
|
||||||
await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}");
|
await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}");
|
||||||
}
|
} else {
|
||||||
}
|
|
||||||
await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot));
|
await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
abstract class BanKick : CommandConfig {
|
abstract class BanKick : CommandConfig {
|
||||||
protected bool ForceReason { get; }
|
protected bool ForceReason { get; }
|
||||||
|
@ -60,7 +54,7 @@ abstract class BanKick : CommandConfig {
|
||||||
// "PurgeDays" - integer; Number of days of target's post history to delete, if banning.
|
// "PurgeDays" - integer; Number of days of target's post history to delete, if banning.
|
||||||
// Must be between 0-7 inclusive. Defaults to 0.
|
// Must be between 0-7 inclusive. Defaults to 0.
|
||||||
// "SendNotify" - boolean; Whether to send a notification DM explaining the action. Defaults to true.
|
// "SendNotify" - boolean; Whether to send a notification DM explaining the action. Defaults to true.
|
||||||
// "SuccessMessage" - string; Message to display on command success. Overrides default.
|
// "SuccessMessage" - string; Additional message to display on command success.
|
||||||
protected BanKick(ModCommands module, JObject config, bool ban) : base(module, config) {
|
protected BanKick(ModCommands module, JObject config, bool ban) : base(module, config) {
|
||||||
ForceReason = config[nameof(ForceReason)]?.Value<bool>() ?? false;
|
ForceReason = config[nameof(ForceReason)]?.Value<bool>() ?? false;
|
||||||
PurgeDays = config[nameof(PurgeDays)]?.Value<int>() ?? 0;
|
PurgeDays = config[nameof(PurgeDays)]?.Value<int>() ?? 0;
|
||||||
|
@ -68,7 +62,7 @@ abstract class BanKick : CommandConfig {
|
||||||
SendNotify = config[nameof(SendNotify)]?.Value<bool>() ?? true;
|
SendNotify = config[nameof(SendNotify)]?.Value<bool>() ?? true;
|
||||||
SuccessMessage = config[nameof(SuccessMessage)]?.Value<string>();
|
SuccessMessage = config[nameof(SuccessMessage)]?.Value<string>();
|
||||||
|
|
||||||
_usage = $"{Command} `user or user ID` `" + (ForceReason ? "reason" : "[reason]") + "`\n"
|
_usage = $"{Command} `user ID or tag` `" + (ForceReason ? "reason" : "[reason]") + "`\n"
|
||||||
+ "Removes the given user from this server"
|
+ "Removes the given user from this server"
|
||||||
+ (ban ? " and prevents the user from rejoining" : "") + ". "
|
+ (ban ? " and prevents the user from rejoining" : "") + ". "
|
||||||
+ (ForceReason ? "L" : "Optionally l") + "ogs the reason for the "
|
+ (ForceReason ? "L" : "Optionally l") + "ogs the reason for the "
|
||||||
|
@ -80,7 +74,7 @@ abstract class BanKick : CommandConfig {
|
||||||
private readonly string _usage;
|
private readonly string _usage;
|
||||||
protected override string DefaultUsageMsg => _usage;
|
protected override string DefaultUsageMsg => _usage;
|
||||||
|
|
||||||
// Usage: (command) (mention) (reason)
|
// Usage: (command) (user) (reason)
|
||||||
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
|
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
|
||||||
var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
|
var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
|
||||||
string targetstr;
|
string targetstr;
|
||||||
|
|
76
Modules/ModCommands/Commands/NoteWarn.cs
Normal file
76
Modules/ModCommands/Commands/NoteWarn.cs
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
using RegexBot.Common;
|
||||||
|
|
||||||
|
namespace RegexBot.Modules.ModCommands.Commands;
|
||||||
|
// Note and Warn commands are highly similar in implementnation, and thus are handled in a single class.
|
||||||
|
class Note : NoteWarn {
|
||||||
|
public Note(ModCommands module, JObject config) : base(module, config) { }
|
||||||
|
|
||||||
|
protected override string DefaultUsageMsg => string.Format(_usageHeader, Command)
|
||||||
|
+ "Appends a note to the moderation log for the given user.";
|
||||||
|
protected override async Task ContinueInvoke(SocketGuild g, SocketMessage msg, string logMessage, SocketUser targetUser) {
|
||||||
|
var result = await Module.Bot.AddUserNoteAsync(g, targetUser.Id, msg.Author.AsEntityNameString(), logMessage);
|
||||||
|
await msg.Channel.SendMessageAsync($":white_check_mark: Note \\#{result.LogId} logged for {targetUser}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Warn : NoteWarn {
|
||||||
|
public Warn(ModCommands module, JObject config) : base(module, config) { }
|
||||||
|
|
||||||
|
protected override string DefaultUsageMsg => string.Format(_usageHeader, Command)
|
||||||
|
+ "Issues a warning to the given user, logging the instance to this bot's moderation log "
|
||||||
|
+ "and notifying the offending user over DM of the issued warning.";
|
||||||
|
|
||||||
|
protected override async Task ContinueInvoke(SocketGuild g, SocketMessage msg, string logMessage, SocketUser targetUser) {
|
||||||
|
// Won't warn a bot
|
||||||
|
if (targetUser.IsBot) {
|
||||||
|
await SendUsageMessageAsync(msg.Channel, ":x: I don't want to do that. If you must, please warn bots manually.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (_, result) = await Module.Bot.AddUserWarnAsync(g, targetUser.Id, msg.Author.AsEntityNameString(), logMessage);
|
||||||
|
await msg.Channel.SendMessageAsync(result.GetResultString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class NoteWarn : CommandConfig {
|
||||||
|
protected string? SuccessMessage { get; }
|
||||||
|
|
||||||
|
protected const string _usageHeader = "{0} `user ID or tag` `message`\n";
|
||||||
|
|
||||||
|
// Configuration:
|
||||||
|
// "SuccessMessage" - string; Additional message to display on command success.
|
||||||
|
protected NoteWarn(ModCommands module, JObject config) : base(module, config) {
|
||||||
|
SuccessMessage = config[nameof(SuccessMessage)]?.Value<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage: (command) (user) (message)
|
||||||
|
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
|
||||||
|
var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (line.Length != 3) {
|
||||||
|
await SendUsageMessageAsync(msg.Channel, ":x: Not all required parameters were specified.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var targetstr = line[1];
|
||||||
|
var logMessage = line[2];
|
||||||
|
|
||||||
|
// Get target user. Required to find for our purposes.
|
||||||
|
var targetQuery = Module.Bot.EcQueryGuildUser(g.Id, targetstr);
|
||||||
|
ulong targetId;
|
||||||
|
if (targetQuery != null) targetId = (ulong)targetQuery.UserId;
|
||||||
|
else if (ulong.TryParse(targetstr, out var parsed)) targetId = parsed;
|
||||||
|
else {
|
||||||
|
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var targetUser = g.GetUser(targetId);
|
||||||
|
|
||||||
|
// Go to specific action
|
||||||
|
try {
|
||||||
|
await ContinueInvoke(g, msg, logMessage, targetUser);
|
||||||
|
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) {
|
||||||
|
await msg.Channel.SendMessageAsync(":x: " + Messages.ForbiddenGenericError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract Task ContinueInvoke(SocketGuild g, SocketMessage msg, string logMessage, SocketUser targetUser);
|
||||||
|
}
|
|
@ -29,10 +29,14 @@ abstract class RoleManipulation : CommandConfig {
|
||||||
// "role" - string; The given role that applies to this command.
|
// "role" - string; The given role that applies to this command.
|
||||||
// "successmsg" - string; Messages to display on command success. Overrides default.
|
// "successmsg" - string; Messages to display on command success. Overrides default.
|
||||||
protected RoleManipulation(ModCommands module, JObject config) : base(module, config) {
|
protected RoleManipulation(ModCommands module, JObject config) : base(module, config) {
|
||||||
var rolestr = config[nameof(Role)]?.Value<string>();
|
try {
|
||||||
if (string.IsNullOrWhiteSpace(rolestr)) throw new ModuleLoadException($"'{nameof(Role)}' must be provided.");
|
Role = new EntityName(config[nameof(Role)]?.Value<string>()!, EntityType.Role);
|
||||||
Role = new EntityName(rolestr);
|
} catch (ArgumentNullException) {
|
||||||
if (Role.Type != EntityType.Role) throw new ModuleLoadException($"The value in '{nameof(Role)}' is not a role.");
|
throw new ModuleLoadException($"'{nameof(Role)}' must be provided.");
|
||||||
|
} catch (FormatException) {
|
||||||
|
throw new ModuleLoadException($"The value in '{nameof(Role)}' is not a role.");
|
||||||
|
}
|
||||||
|
|
||||||
SuccessMessage = config[nameof(SuccessMessage)]?.Value<string>();
|
SuccessMessage = config[nameof(SuccessMessage)]?.Value<string>();
|
||||||
|
|
||||||
_usage = $"{Command} `user or user ID`\n" +
|
_usage = $"{Command} `user or user ID`\n" +
|
||||||
|
|
82
Modules/ModCommands/Commands/ShowModLogs.cs
Normal file
82
Modules/ModCommands/Commands/ShowModLogs.cs
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
using Discord;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using RegexBot.Common;
|
||||||
|
using RegexBot.Data;
|
||||||
|
|
||||||
|
namespace RegexBot.Modules.ModCommands.Commands;
|
||||||
|
class ShowModLogs : CommandConfig {
|
||||||
|
const int LogEntriesPerMessage = 10;
|
||||||
|
private readonly string _usage;
|
||||||
|
|
||||||
|
protected override string DefaultUsageMsg => _usage;
|
||||||
|
|
||||||
|
// No configuration.
|
||||||
|
// TODO bring in some options from BanKick. Particularly custom success msg.
|
||||||
|
// TODO when ModLogs fully implemented, add a reason?
|
||||||
|
public ShowModLogs(ModCommands module, JObject config) : base(module, config) {
|
||||||
|
_usage = $"{Command} `user or user ID` [page]\n"
|
||||||
|
+ "Retrieves moderation log entries regarding the specified user.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage: (command) (query) [page]
|
||||||
|
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
|
||||||
|
var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (line.Length < 2) {
|
||||||
|
await SendUsageMessageAsync(msg.Channel, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int pagenum;
|
||||||
|
if (line.Length == 3) {
|
||||||
|
const string PageNumError = ":x: Requested page must be a non-negative number.";
|
||||||
|
if (!int.TryParse(line[2], out pagenum)) {
|
||||||
|
await SendUsageMessageAsync(msg.Channel, PageNumError);
|
||||||
|
}
|
||||||
|
if (pagenum <= 0) await SendUsageMessageAsync(msg.Channel, PageNumError);
|
||||||
|
} else pagenum = 1;
|
||||||
|
|
||||||
|
var query = Module.Bot.EcQueryGuildUser(g.Id, line[1]);
|
||||||
|
if (query == null) {
|
||||||
|
await msg.Channel.SendMessageAsync(":x: Unable to find the given user.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalPages;
|
||||||
|
List<ModLogEntry> results;
|
||||||
|
using (var db = new BotDatabaseContext()) {
|
||||||
|
var totalEntries = db.ModLogs
|
||||||
|
.Where(l => l.GuildId == query.GuildId && l.UserId == query.UserId)
|
||||||
|
.Count();
|
||||||
|
totalPages = (int)Math.Ceiling((double)totalEntries / LogEntriesPerMessage);
|
||||||
|
results = db.ModLogs
|
||||||
|
.Where(l => l.GuildId == query.GuildId && l.UserId == query.UserId)
|
||||||
|
.OrderByDescending(l => l.LogId)
|
||||||
|
.Skip((pagenum - 1) * LogEntriesPerMessage)
|
||||||
|
.Take(LogEntriesPerMessage)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultList = new EmbedBuilder() {
|
||||||
|
Author = new EmbedAuthorBuilder() {
|
||||||
|
Name = $"{query.User.Username}#{query.User.Discriminator}",
|
||||||
|
IconUrl = query.User.AvatarUrl
|
||||||
|
},
|
||||||
|
Footer = new EmbedFooterBuilder() {
|
||||||
|
Text = $"Page {pagenum} of {totalPages}",
|
||||||
|
IconUrl = Module.Bot.DiscordClient.CurrentUser.GetAvatarUrl()
|
||||||
|
},
|
||||||
|
Title = "Moderation logs"
|
||||||
|
};
|
||||||
|
foreach (var item in results) {
|
||||||
|
var f = new EmbedFieldBuilder() {
|
||||||
|
Name = $"{Enum.GetName(item.LogType)} \\#{item.LogId}",
|
||||||
|
Value = $"**Timestamp**: <t:{item.Timestamp.ToUnixTimeSeconds()}:f>\n"
|
||||||
|
+ $"**Issued by**: {Utilities.TryFromEntityNameString(item.IssuedBy, Module.Bot)}\n"
|
||||||
|
+ $"**Message**: {item.Message ?? "*none specified*"}"
|
||||||
|
};
|
||||||
|
resultList.AddField(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
await msg.Channel.SendMessageAsync(embed: resultList.Build());
|
||||||
|
}
|
||||||
|
}
|
73
Modules/ModCommands/Commands/Timeout.cs
Normal file
73
Modules/ModCommands/Commands/Timeout.cs
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
using RegexBot.Common;
|
||||||
|
|
||||||
|
namespace RegexBot.Modules.ModCommands.Commands;
|
||||||
|
class Timeout : CommandConfig {
|
||||||
|
protected bool ForceReason { get; }
|
||||||
|
protected bool SendNotify { get; }
|
||||||
|
protected string? SuccessMessage { get; }
|
||||||
|
|
||||||
|
// Configuration:
|
||||||
|
// "ForceReason" - boolean; Force a reason to be given. Defaults to false.
|
||||||
|
// "SendNotify" - boolean; Whether to send a notification DM explaining the action. Defaults to true.
|
||||||
|
// "SuccessMessage" - string; Additional message to display on command success.
|
||||||
|
// TODO future configuration ideas: max timeout, min timeout, default timeout span...
|
||||||
|
public Timeout(ModCommands module, JObject config) : base(module, config) {
|
||||||
|
ForceReason = config[nameof(ForceReason)]?.Value<bool>() ?? false;
|
||||||
|
SendNotify = config[nameof(SendNotify)]?.Value<bool>() ?? true;
|
||||||
|
SuccessMessage = config[nameof(SuccessMessage)]?.Value<string>();
|
||||||
|
|
||||||
|
_usage = $"{Command} `user ID or tag` `time in minutes` `" + (ForceReason ? "reason" : "[reason]") + "`\n"
|
||||||
|
+ "Issues a timeout to the given user, preventing them from participating in the server for a set amount of time. "
|
||||||
|
+ (ForceReason ? "L" : "Optionally l") + "ogs the reason for the timeout to the Audit Log.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly string _usage;
|
||||||
|
protected override string DefaultUsageMsg => _usage;
|
||||||
|
|
||||||
|
// Usage: (command) (user) (duration) (reason)
|
||||||
|
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
|
||||||
|
var line = msg.Content.Split(new char[] { ' ' }, 4, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
string targetstr;
|
||||||
|
string? reason;
|
||||||
|
if (line.Length < 3) {
|
||||||
|
await SendUsageMessageAsync(msg.Channel, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targetstr = line[1];
|
||||||
|
|
||||||
|
if (line.Length == 4) reason = line[3]; // Reason given - keep it
|
||||||
|
else {
|
||||||
|
// No reason given
|
||||||
|
if (ForceReason) {
|
||||||
|
await SendUsageMessageAsync(msg.Channel, ":x: **You must specify a reason.**");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reason = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(line[2], out var timeParam)) {
|
||||||
|
await SendUsageMessageAsync(msg.Channel, ":x: You must specify a duration for the timeout (in minutes).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target user. Required to find for our purposes.
|
||||||
|
var targetQuery = Module.Bot.EcQueryGuildUser(g.Id, targetstr);
|
||||||
|
ulong targetId;
|
||||||
|
if (targetQuery != null) targetId = (ulong)targetQuery.UserId;
|
||||||
|
else if (ulong.TryParse(targetstr, out var parsed)) targetId = parsed;
|
||||||
|
else {
|
||||||
|
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var targetUser = g.GetUser(targetId);
|
||||||
|
|
||||||
|
var result = await Module.Bot.SetTimeoutAsync(g, msg.Author.AsEntityNameString(), targetUser,
|
||||||
|
TimeSpan.FromMinutes(timeParam), reason, SendNotify);
|
||||||
|
if (result.Success && SuccessMessage != null) {
|
||||||
|
// TODO string replacement, formatting, etc
|
||||||
|
await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.ToResultString()}");
|
||||||
|
} else {
|
||||||
|
await msg.Channel.SendMessageAsync(result.ToResultString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
51
Modules/ModCommands/Commands/Untimeout.cs
Normal file
51
Modules/ModCommands/Commands/Untimeout.cs
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
using RegexBot.Common;
|
||||||
|
|
||||||
|
namespace RegexBot.Modules.ModCommands.Commands;
|
||||||
|
class Untimeout : CommandConfig {
|
||||||
|
private readonly string _usage;
|
||||||
|
|
||||||
|
protected override string DefaultUsageMsg => _usage;
|
||||||
|
|
||||||
|
// No configuration.
|
||||||
|
// TODO bring in some options from BanKick. Particularly custom success msg.
|
||||||
|
// TODO when ModLogs fully implemented, add a reason?
|
||||||
|
public Untimeout(ModCommands module, JObject config) : base(module, config) {
|
||||||
|
_usage = $"{Command} `user or user ID`\n"
|
||||||
|
+ "Unsets a timeout from a given user.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage: (command) (user query)
|
||||||
|
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
|
||||||
|
var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
string targetstr;
|
||||||
|
if (line.Length < 2) {
|
||||||
|
await SendUsageMessageAsync(msg.Channel, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targetstr = line[1];
|
||||||
|
|
||||||
|
SocketGuildUser? target = null;
|
||||||
|
var query = Module.Bot.EcQueryUser(targetstr);
|
||||||
|
if (query != null) {
|
||||||
|
target = g.GetUser((ulong)query.UserId);
|
||||||
|
}
|
||||||
|
if (target == null) {
|
||||||
|
await SendUsageMessageAsync(msg.Channel, TargetNotFound);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if timed out, respond accordingly
|
||||||
|
if (target.TimedOutUntil.HasValue && target.TimedOutUntil.Value <= DateTimeOffset.UtcNow) {
|
||||||
|
await msg.Channel.SendMessageAsync($":x: **{target}** is not timed out.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do the action
|
||||||
|
try {
|
||||||
|
await target.RemoveTimeOutAsync();
|
||||||
|
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.Forbidden) {
|
||||||
|
const string FailPrefix = ":x: **Could not remove timeout:** ";
|
||||||
|
await msg.Channel.SendMessageAsync(FailPrefix + Messages.ForbiddenGenericError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -41,9 +41,9 @@ internal class ModCommands : RegexbotModule {
|
||||||
if (cfg.Commands.TryGetValue(cmdchk, out var c)) {
|
if (cfg.Commands.TryGetValue(cmdchk, out var c)) {
|
||||||
try {
|
try {
|
||||||
await c.Invoke(g, arg);
|
await c.Invoke(g, arg);
|
||||||
Log($"[{g.Name}] {c.Command} invoked by {arg.Author} in #{arg.Channel.Name}.");
|
Log(g, $"{c.Command} invoked by {arg.Author} in #{arg.Channel.Name}.");
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Log($"Unhandled exception while processing '{c.Label}':\n" + ex.ToString());
|
Log(g, $"Unhandled exception while processing '{c.Label}':\n" + ex.ToString());
|
||||||
await arg.Channel.SendMessageAsync($":x: An error occurred during processing ({ex.GetType().FullName}). " +
|
await arg.Channel.SendMessageAsync($":x: An error occurred during processing ({ex.GetType().FullName}). " +
|
||||||
"Check the console for details.");
|
"Check the console for details.");
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,10 +33,17 @@ class ModuleConfig {
|
||||||
{ "kick", typeof(Kick) },
|
{ "kick", typeof(Kick) },
|
||||||
{ "say", typeof(Say) },
|
{ "say", typeof(Say) },
|
||||||
{ "unban", typeof(Unban) },
|
{ "unban", typeof(Unban) },
|
||||||
|
{ "note", typeof(Note) },
|
||||||
|
{ "addnote", typeof(Note) },
|
||||||
|
{ "warn", typeof(Warn) },
|
||||||
|
{ "timeout", typeof(Commands.Timeout) },
|
||||||
|
{ "untimeout", typeof(Untimeout)},
|
||||||
{ "addrole", typeof(RoleAdd) },
|
{ "addrole", typeof(RoleAdd) },
|
||||||
{ "roleadd", typeof(RoleAdd) },
|
{ "roleadd", typeof(RoleAdd) },
|
||||||
{ "delrole", typeof(RoleDel) },
|
{ "delrole", typeof(RoleDel) },
|
||||||
{ "roledel", typeof(RoleDel) }
|
{ "roledel", typeof(RoleDel) },
|
||||||
|
{ "modlogs", typeof(ShowModLogs) },
|
||||||
|
{ "showmodlogs", typeof(ShowModLogs) }
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -10,9 +10,9 @@ internal partial class ModLogs : RegexbotModule {
|
||||||
// TODO consider resurrecting 2.x idea of logging actions to db, making it searchable?
|
// TODO consider resurrecting 2.x idea of logging actions to db, making it searchable?
|
||||||
|
|
||||||
public ModLogs(RegexbotClient bot) : base(bot) {
|
public ModLogs(RegexbotClient bot) : base(bot) {
|
||||||
// TODO missing logging features: joins, leaves, bans, kicks, user edits (nick/username/discr)
|
// TODO missing logging features: joins, leaves, user edits (nick/username/discr)
|
||||||
DiscordClient.MessageDeleted += HandleDelete;
|
DiscordClient.MessageDeleted += HandleDelete;
|
||||||
bot.EcOnMessageUpdate += HandleUpdate;
|
bot.SharedEventReceived += HandleReceivedSharedEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken config) {
|
public override Task<object?> CreateGuildStateAsync(ulong guildID, JToken config) {
|
||||||
|
@ -24,6 +24,11 @@ internal partial class ModLogs : RegexbotModule {
|
||||||
return Task.FromResult<object?>(new ModuleConfig((JObject)config));
|
return Task.FromResult<object?>(new ModuleConfig((JObject)config));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HandleReceivedSharedEvent(ISharedEvent ev) {
|
||||||
|
if (ev is MessageCacheUpdateEvent upd) await HandleUpdate(upd.OldMessage, upd.NewMessage);
|
||||||
|
else if (ev is Data.ModLogEntry log) await HandleLog(log);
|
||||||
|
}
|
||||||
|
|
||||||
private static string MakeTimestamp(DateTimeOffset time) {
|
private static string MakeTimestamp(DateTimeOffset time) {
|
||||||
var result = new StringBuilder();
|
var result = new StringBuilder();
|
||||||
//result.Append(time.ToString("yyyy-MM-dd hh:mm:ss"));
|
//result.Append(time.ToString("yyyy-MM-dd hh:mm:ss"));
|
||||||
|
|
17
Modules/ModLogs/ModLogs_Logging.cs
Normal file
17
Modules/ModLogs/ModLogs_Logging.cs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
using RegexBot.Common;
|
||||||
|
using RegexBot.Data;
|
||||||
|
|
||||||
|
namespace RegexBot.Modules.ModLogs;
|
||||||
|
// Contains all logic relating to reporting new database mod log entries
|
||||||
|
internal partial class ModLogs {
|
||||||
|
public async Task HandleLog(ModLogEntry entry) {
|
||||||
|
var guild = Bot.DiscordClient.GetGuild((ulong)entry.GuildId);
|
||||||
|
if (guild == null) return;
|
||||||
|
var conf = GetGuildState<ModuleConfig>(guild.Id);
|
||||||
|
if ((conf?.LogModLogs ?? false) == false) return;
|
||||||
|
var reportChannel = conf?.ReportingChannel?.FindChannelIn(guild, true);
|
||||||
|
if (reportChannel == null) return;
|
||||||
|
|
||||||
|
await reportChannel.SendMessageAsync(embed: entry.BuildEmbed(Bot));
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,11 +14,11 @@ internal partial class ModLogs {
|
||||||
const int MaxPreviewLength = 750;
|
const int MaxPreviewLength = 750;
|
||||||
if (argChannel.Value is not SocketTextChannel channel) return;
|
if (argChannel.Value is not SocketTextChannel channel) return;
|
||||||
var conf = GetGuildState<ModuleConfig>(channel.Guild.Id);
|
var conf = GetGuildState<ModuleConfig>(channel.Guild.Id);
|
||||||
|
if ((conf?.LogMessageDeletions ?? false) == false) return;
|
||||||
var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true);
|
var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true);
|
||||||
if (reportChannel == null) return;
|
if (reportChannel == null) return;
|
||||||
if ((conf?.LogMessageDeletions ?? false) == false) return;
|
|
||||||
if (reportChannel.Id == channel.Id) {
|
if (reportChannel.Id == channel.Id) {
|
||||||
Log($"[{channel.Guild.Name}] Message deletion detected in the reporting channel. Regular report has been suppressed.");
|
Log(channel.Guild, "Message deleted in the reporting channel. Suppressing report.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,22 +53,14 @@ internal partial class ModLogs {
|
||||||
IconUrl = cachedMsg.Author.AvatarUrl ?? GetDefaultAvatarUrl(cachedMsg.Author.Discriminator)
|
IconUrl = cachedMsg.Author.AvatarUrl ?? GetDefaultAvatarUrl(cachedMsg.Author.Discriminator)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
var attach = CheckAttachments(cachedMsg.AttachmentNames);
|
SetAttachmentsField(reportEmbed, cachedMsg.AttachmentNames);
|
||||||
if (attach != null) reportEmbed.AddField(attach);
|
|
||||||
} else {
|
} else {
|
||||||
reportEmbed.Description = NotCached;
|
reportEmbed.Description = NotCached;
|
||||||
}
|
}
|
||||||
|
|
||||||
var contextStr = new StringBuilder();
|
var editLine = $"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(argMsg.Id))}";
|
||||||
contextStr.AppendLine($"User: {(cachedMsg != null ? $"<@!{cachedMsg.AuthorId}>" : "Unknown")}");
|
if (cachedMsg?.EditedAt != null) editLine += $"\nLast edit: {MakeTimestamp(cachedMsg.EditedAt.Value)}";
|
||||||
contextStr.AppendLine($"Channel: <#{channel.Id}> (#{channel.Name})");
|
SetContextField(reportEmbed, (ulong?)cachedMsg?.AuthorId, channel, editLine, argMsg.Id);
|
||||||
contextStr.AppendLine($"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(argMsg.Id))}");
|
|
||||||
if (cachedMsg?.EditedAt != null) contextStr.AppendLine($"Last edit: {MakeTimestamp(cachedMsg.EditedAt.Value)}");
|
|
||||||
contextStr.AppendLine($"Message ID: {argMsg.Id}");
|
|
||||||
reportEmbed.AddField(new EmbedFieldBuilder() {
|
|
||||||
Name = "Context",
|
|
||||||
Value = contextStr.ToString()
|
|
||||||
});
|
|
||||||
|
|
||||||
await reportChannel.SendMessageAsync(embed: reportEmbed.Build());
|
await reportChannel.SendMessageAsync(embed: reportEmbed.Build());
|
||||||
}
|
}
|
||||||
|
@ -77,11 +69,11 @@ internal partial class ModLogs {
|
||||||
const int MaxPreviewLength = 500;
|
const int MaxPreviewLength = 500;
|
||||||
var channel = (SocketTextChannel)newMsg.Channel;
|
var channel = (SocketTextChannel)newMsg.Channel;
|
||||||
var conf = GetGuildState<ModuleConfig>(channel.Guild.Id);
|
var conf = GetGuildState<ModuleConfig>(channel.Guild.Id);
|
||||||
|
|
||||||
var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true);
|
var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true);
|
||||||
if (reportChannel == null) return;
|
if (reportChannel == null) return;
|
||||||
if ((conf?.LogMessageEdits ?? false) == false) return;
|
|
||||||
if (reportChannel.Id == channel.Id) {
|
if (reportChannel.Id == channel.Id) {
|
||||||
Log($"[{channel.Guild.Name}] Message edit detected in the reporting channel. Regular report has been suppressed.");
|
Log(channel.Guild, "Message edited in the reporting channel. Suppressing report.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -122,25 +114,39 @@ internal partial class ModLogs {
|
||||||
}
|
}
|
||||||
reportEmbed.AddField(newField);
|
reportEmbed.AddField(newField);
|
||||||
|
|
||||||
var attach = CheckAttachments(newMsg.Attachments.Select(a => a.Filename));
|
SetAttachmentsField(reportEmbed, newMsg.Attachments.Select(a => a.Filename));
|
||||||
if (attach != null) reportEmbed.AddField(attach);
|
|
||||||
|
|
||||||
var contextStr = new StringBuilder();
|
string editLine;
|
||||||
contextStr.AppendLine($"User: <@!{newMsg.Author.Id}>");
|
if ((oldMsg?.EditedAt) == null) editLine = $"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(newMsg.Id))}";
|
||||||
contextStr.AppendLine($"Channel: <#{channel.Id}> (#{channel.Name})");
|
else editLine = $"Previous edit: {MakeTimestamp(oldMsg.EditedAt.Value)}";
|
||||||
if ((oldMsg?.EditedAt) == null) contextStr.AppendLine($"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(newMsg.Id))}");
|
SetContextField(reportEmbed, newMsg.Author.Id, channel, editLine, newMsg.Id);
|
||||||
else contextStr.AppendLine($"Previous edit: {MakeTimestamp(oldMsg.EditedAt.Value)}");
|
|
||||||
contextStr.AppendLine($"Message ID: {newMsg.Id}");
|
|
||||||
var contextField = new EmbedFieldBuilder() {
|
|
||||||
Name = "Context",
|
|
||||||
Value = contextStr.ToString()
|
|
||||||
};
|
|
||||||
reportEmbed.AddField(contextField);
|
|
||||||
|
|
||||||
await reportChannel.SendMessageAsync(embed: reportEmbed.Build());
|
await reportChannel.SendMessageAsync(embed: reportEmbed.Build());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static EmbedFieldBuilder? CheckAttachments(IEnumerable<string> attachments) {
|
private void SetContextField(EmbedBuilder e, ulong? userId, SocketTextChannel channel, string editLine, ulong msgId) {
|
||||||
|
string userDisplay;
|
||||||
|
if (userId.HasValue) {
|
||||||
|
var q = Bot.EcQueryUser(userId.Value.ToString());
|
||||||
|
if (q != null) userDisplay = $"<@{q.UserId}> - {q.Username}#{q.Discriminator} `{q.UserId}`";
|
||||||
|
else userDisplay = $"Unknown user with ID `{userId}`";
|
||||||
|
} else {
|
||||||
|
userDisplay = "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
var contextStr = new StringBuilder();
|
||||||
|
contextStr.AppendLine($"User: {userDisplay}");
|
||||||
|
contextStr.AppendLine($"Channel: <#{channel.Id}> (#{channel.Name})");
|
||||||
|
contextStr.AppendLine(editLine);
|
||||||
|
contextStr.AppendLine($"Message ID: {msgId}");
|
||||||
|
|
||||||
|
e.AddField(new EmbedFieldBuilder() {
|
||||||
|
Name = "Context",
|
||||||
|
Value = contextStr.ToString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetAttachmentsField(EmbedBuilder e, IEnumerable<string> attachments) {
|
||||||
if (attachments.Any()) {
|
if (attachments.Any()) {
|
||||||
var field = new EmbedFieldBuilder { Name = "Attachments" };
|
var field = new EmbedFieldBuilder { Name = "Attachments" };
|
||||||
var attachNames = new StringBuilder();
|
var attachNames = new StringBuilder();
|
||||||
|
@ -148,8 +154,7 @@ internal partial class ModLogs {
|
||||||
attachNames.AppendLine($"`{name}`");
|
attachNames.AppendLine($"`{name}`");
|
||||||
}
|
}
|
||||||
field.Value = attachNames.ToString().TrimEnd();
|
field.Value = attachNames.ToString().TrimEnd();
|
||||||
return field;
|
e.AddField(field);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -6,16 +6,19 @@ class ModuleConfig {
|
||||||
|
|
||||||
public bool LogMessageDeletions { get; }
|
public bool LogMessageDeletions { get; }
|
||||||
public bool LogMessageEdits { get; }
|
public bool LogMessageEdits { get; }
|
||||||
|
public bool LogModLogs { get; }
|
||||||
|
|
||||||
public ModuleConfig(JObject config) {
|
public ModuleConfig(JObject config) {
|
||||||
const string RptChError = $"'{nameof(ReportingChannel)}' must be set to a valid channel name.";
|
const string RptChError = $"'{nameof(ReportingChannel)}' must be set to a valid channel name.";
|
||||||
var rptch = config[nameof(ReportingChannel)]?.Value<string>();
|
try {
|
||||||
if (string.IsNullOrWhiteSpace(rptch)) throw new ModuleLoadException(RptChError);
|
ReportingChannel = new EntityName(config[nameof(ReportingChannel)]?.Value<string>()!, EntityType.Channel);
|
||||||
ReportingChannel = new EntityName(rptch);
|
} catch (Exception) {
|
||||||
if (ReportingChannel.Type != EntityType.Channel) throw new ModuleLoadException(RptChError);
|
throw new ModuleLoadException(RptChError);
|
||||||
|
}
|
||||||
|
|
||||||
// Individual logging settings - all default to false
|
// Individual logging settings - all default to false
|
||||||
LogMessageDeletions = config[nameof(LogMessageDeletions)]?.Value<bool>() ?? false;
|
LogMessageDeletions = config[nameof(LogMessageDeletions)]?.Value<bool>() ?? false;
|
||||||
LogMessageEdits = config[nameof(LogMessageEdits)]?.Value<bool>() ?? false;
|
LogMessageEdits = config[nameof(LogMessageEdits)]?.Value<bool>() ?? false;
|
||||||
|
LogModLogs = config[nameof(LogModLogs)]?.Value<bool>() ?? false;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -5,11 +5,13 @@ class ModuleConfig {
|
||||||
public EntityName Role { get; }
|
public EntityName Role { get; }
|
||||||
|
|
||||||
public ModuleConfig(JObject conf) {
|
public ModuleConfig(JObject conf) {
|
||||||
var cfgRole = conf[nameof(Role)]?.Value<string>();
|
try {
|
||||||
if (string.IsNullOrWhiteSpace(cfgRole))
|
Role = new EntityName(conf[nameof(Role)]?.Value<string>()!, EntityType.Role);
|
||||||
throw new ModuleLoadException("Role was not specified.");
|
} catch (ArgumentException) {
|
||||||
Role = new EntityName(cfgRole);
|
throw new ModuleLoadException("Role was not properly specified.");
|
||||||
if (Role.Type != EntityType.Role)
|
} catch (FormatException) {
|
||||||
throw new ModuleLoadException("Name specified in configuration is not a role.");
|
throw new ModuleLoadException("Name specified in configuration is not a role.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,8 +23,8 @@ class ConfDefinition {
|
||||||
public EntityName? ReportingChannel { get; }
|
public EntityName? ReportingChannel { get; }
|
||||||
public IReadOnlyList<string> Response { get; }
|
public IReadOnlyList<string> Response { get; }
|
||||||
public int BanPurgeDays { get; }
|
public int BanPurgeDays { get; }
|
||||||
public bool NotifyChannelOfRemoval { get; }
|
public bool NotifyChannel { get; }
|
||||||
public bool NotifyUserOfRemoval { get; }
|
public bool NotifyUser { get; }
|
||||||
|
|
||||||
public ConfDefinition(JObject def) {
|
public ConfDefinition(JObject def) {
|
||||||
Label = def[nameof(Label)]?.Value<string>()
|
Label = def[nameof(Label)]?.Value<string>()
|
||||||
|
@ -34,10 +34,12 @@ class ConfDefinition {
|
||||||
|
|
||||||
var rptch = def[nameof(ReportingChannel)]?.Value<string>();
|
var rptch = def[nameof(ReportingChannel)]?.Value<string>();
|
||||||
if (rptch != null) {
|
if (rptch != null) {
|
||||||
ReportingChannel = new EntityName(rptch);
|
try {
|
||||||
if (ReportingChannel.Type != EntityType.Channel)
|
ReportingChannel = new EntityName(rptch, EntityType.Channel);
|
||||||
|
} catch (FormatException) {
|
||||||
throw new ModuleLoadException($"'{nameof(ReportingChannel)}' is not defined as a channel{errpostfx}");
|
throw new ModuleLoadException($"'{nameof(ReportingChannel)}' is not defined as a channel{errpostfx}");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Regex loading
|
// Regex loading
|
||||||
var opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
|
var opts = RegexOptions.Compiled | RegexOptions.CultureInvariant;
|
||||||
|
@ -81,8 +83,8 @@ class ConfDefinition {
|
||||||
throw new ModuleLoadException($"'{nameof(Response)}' is not properly defined{errpostfx}");
|
throw new ModuleLoadException($"'{nameof(Response)}' is not properly defined{errpostfx}");
|
||||||
}
|
}
|
||||||
BanPurgeDays = def[nameof(BanPurgeDays)]?.Value<int>() ?? 0;
|
BanPurgeDays = def[nameof(BanPurgeDays)]?.Value<int>() ?? 0;
|
||||||
NotifyChannelOfRemoval = def[nameof(NotifyChannelOfRemoval)]?.Value<bool>() ?? true;
|
NotifyChannel = def[nameof(NotifyChannel)]?.Value<bool>() ?? true;
|
||||||
NotifyUserOfRemoval = def[nameof(NotifyUserOfRemoval)]?.Value<bool>() ?? true;
|
NotifyUser = def[nameof(NotifyUser)]?.Value<bool>() ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
@ -46,29 +46,15 @@ internal class RegexModerator : RegexbotModule {
|
||||||
var defs = GetGuildState<IEnumerable<ConfDefinition>>(ch.Guild.Id);
|
var defs = GetGuildState<IEnumerable<ConfDefinition>>(ch.Guild.Id);
|
||||||
if (defs == null) return;
|
if (defs == null) return;
|
||||||
|
|
||||||
// Send further processing to thread pool.
|
// Matching and response processing
|
||||||
// Match checking is a CPU-intensive task, thus very little checking is done here.
|
|
||||||
var msgProcessingTasks = new List<Task>();
|
|
||||||
foreach (var item in defs) {
|
foreach (var item in defs) {
|
||||||
// Need to check sender's moderator status here. Definition can't access mod list.
|
// Need to check sender's moderator status here. Definition can't access mod list.
|
||||||
var isMod = GetModerators(ch.Guild.Id).IsListMatch(msg, true);
|
var isMod = GetModerators(ch.Guild.Id).IsListMatch(msg, true);
|
||||||
|
|
||||||
var match = item.IsMatch(msg, isMod);
|
if (!item.IsMatch(msg, isMod)) continue;
|
||||||
msgProcessingTasks.Add(Task.Run(async () => await ProcessMessage(item, ch.Guild, msg, isMod)));
|
Log(ch.Guild, $"Rule '{item.Label}' triggered by {msg.Author}.");
|
||||||
}
|
var exec = new ResponseExecutor(item, Bot, msg, (string logLine) => Log(ch.Guild, logLine));
|
||||||
await Task.WhenAll(msgProcessingTasks);
|
await exec.Execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Does further message checking and response execution.
|
|
||||||
/// Invocations of this method are meant to be placed onto a thread separate from the caller.
|
|
||||||
/// </summary>
|
|
||||||
private async Task ProcessMessage(ConfDefinition def, SocketGuild g, SocketMessage msg, bool isMod) {
|
|
||||||
if (!def.IsMatch(msg, isMod)) return;
|
|
||||||
|
|
||||||
// TODO logging options for match result; handle here?
|
|
||||||
|
|
||||||
var executor = new ResponseExecutor(def, Bot, msg, (string logLine) => Log(g, logLine));
|
|
||||||
await executor.Execute();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,11 +3,14 @@ using RegexBot.Common;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace RegexBot.Modules.RegexModerator;
|
namespace RegexBot.Modules.RegexModerator;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Transient helper class which handles response interpreting and execution.
|
/// Transient helper class which handles response interpreting and execution.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
class ResponseExecutor {
|
class ResponseExecutor {
|
||||||
|
private const string ErrParamNeedNone = "This response type does not accept parameters.";
|
||||||
|
private const string ErrParamWrongAmount = "Incorrect number of parameters defined in the response.";
|
||||||
|
private const string ErrMissingUser = "The target user is no longer in the server.";
|
||||||
|
|
||||||
delegate Task<ResponseResult> ResponseHandler(string? parameter);
|
delegate Task<ResponseResult> ResponseHandler(string? parameter);
|
||||||
|
|
||||||
private readonly ConfDefinition _rule;
|
private readonly ConfDefinition _rule;
|
||||||
|
@ -20,6 +23,8 @@ class ResponseExecutor {
|
||||||
private readonly List<(string, ResponseResult)> _reports;
|
private readonly List<(string, ResponseResult)> _reports;
|
||||||
private Action<string> Log { get; }
|
private Action<string> Log { get; }
|
||||||
|
|
||||||
|
private string LogSource => $"{_rule.Label} ({nameof(RegexModerator)})";
|
||||||
|
|
||||||
public ResponseExecutor(ConfDefinition rule, RegexbotClient bot, SocketMessage msg, Action<string> logger) {
|
public ResponseExecutor(ConfDefinition rule, RegexbotClient bot, SocketMessage msg, Action<string> logger) {
|
||||||
_rule = rule;
|
_rule = rule;
|
||||||
_bot = bot;
|
_bot = bot;
|
||||||
|
@ -114,7 +119,7 @@ class ResponseExecutor {
|
||||||
)
|
)
|
||||||
.WithDescription(invokingLine)
|
.WithDescription(invokingLine)
|
||||||
.WithFooter(
|
.WithFooter(
|
||||||
text: $"Rule: {_rule.Label}",
|
text: LogSource,
|
||||||
iconUrl: _bot.DiscordClient.CurrentUser.GetAvatarUrl()
|
iconUrl: _bot.DiscordClient.CurrentUser.GetAvatarUrl()
|
||||||
)
|
)
|
||||||
.WithCurrentTimestamp()
|
.WithCurrentTimestamp()
|
||||||
|
@ -135,15 +140,15 @@ class ResponseExecutor {
|
||||||
private async Task<ResponseResult> CmdBanKick(RemovalType rt, string? parameter) {
|
private async Task<ResponseResult> CmdBanKick(RemovalType rt, string? parameter) {
|
||||||
BanKickResult result;
|
BanKickResult result;
|
||||||
if (rt == RemovalType.Ban) {
|
if (rt == RemovalType.Ban) {
|
||||||
result = await _bot.BanAsync(_guild, $"Rule '{_rule.Label}'", _user.Id,
|
result = await _bot.BanAsync(_guild, LogSource, _user.Id,
|
||||||
_rule.BanPurgeDays, parameter, _rule.NotifyUserOfRemoval);
|
_rule.BanPurgeDays, parameter, _rule.NotifyUser);
|
||||||
} else {
|
} else {
|
||||||
result = await _bot.KickAsync(_guild, $"Rule '{_rule.Label}'", _user.Id,
|
result = await _bot.KickAsync(_guild, LogSource, _user.Id,
|
||||||
parameter, _rule.NotifyUserOfRemoval);
|
parameter, _rule.NotifyUser);
|
||||||
}
|
}
|
||||||
if (result.ErrorForbidden) return FromError(Messages.ForbiddenGenericError);
|
if (result.ErrorForbidden) return FromError(Messages.ForbiddenGenericError);
|
||||||
if (result.ErrorNotFound) return FromError("The target user is no longer in the server.");
|
if (result.ErrorNotFound) return FromError(ErrMissingUser);
|
||||||
if (_rule.NotifyChannelOfRemoval) await _msg.Channel.SendMessageAsync(result.GetResultString(_bot));
|
if (_rule.NotifyChannel) await _msg.Channel.SendMessageAsync(result.GetResultString(_bot));
|
||||||
return FromSuccess(result.MessageSendSuccess ? null : "Unable to send notification DM.");
|
return FromSuccess(result.MessageSendSuccess ? null : "Unable to send notification DM.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -151,23 +156,22 @@ class ResponseExecutor {
|
||||||
private Task<ResponseResult> CmdRoleDel(string? parameter) => CmdRoleManipulation(parameter, false);
|
private Task<ResponseResult> CmdRoleDel(string? parameter) => CmdRoleManipulation(parameter, false);
|
||||||
private async Task<ResponseResult> CmdRoleManipulation(string? parameter, bool add) {
|
private async Task<ResponseResult> CmdRoleManipulation(string? parameter, bool add) {
|
||||||
// parameters: @_, &, reason?
|
// parameters: @_, &, reason?
|
||||||
// TODO add persistence option if/when implemented
|
if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount);
|
||||||
if (string.IsNullOrWhiteSpace(parameter)) return FromError("This response requires parameters.");
|
|
||||||
var param = parameter.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
var param = parameter.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
if (param.Length < 2) return FromError("Incorrect number of parameters.");
|
if (param.Length != 2) return FromError(ErrParamWrongAmount);
|
||||||
|
|
||||||
// Find targets
|
// Find targets
|
||||||
SocketGuildUser? tuser;
|
SocketGuildUser? tuser;
|
||||||
SocketRole? trole;
|
SocketRole? trole;
|
||||||
try {
|
try {
|
||||||
var userName = new EntityName(param[0]);
|
var userName = new EntityName(param[0], EntityType.User);
|
||||||
if (userName.Id.HasValue) tuser = _guild.GetUser(userName.Id.Value);
|
if (userName.Id.HasValue) tuser = _guild.GetUser(userName.Id.Value);
|
||||||
else {
|
else {
|
||||||
if (userName.Name == "_") tuser = _user;
|
if (userName.Name == "_") tuser = _user;
|
||||||
else tuser = userName.FindUserIn(_guild);
|
else tuser = userName.FindUserIn(_guild);
|
||||||
}
|
}
|
||||||
if (tuser == null) return FromError($"Unable to find user '{userName.Name}'.");
|
if (tuser == null) return FromError($"Unable to find user '{userName.Name}'.");
|
||||||
var roleName = new EntityName(param[1]);
|
var roleName = new EntityName(param[1], EntityType.Role);
|
||||||
if (roleName.Id.HasValue) trole = _guild.GetRole(roleName.Id.Value);
|
if (roleName.Id.HasValue) trole = _guild.GetRole(roleName.Id.Value);
|
||||||
else trole = roleName.FindRoleIn(_guild);
|
else trole = roleName.FindRoleIn(_guild);
|
||||||
if (trole == null) return FromError($"Unable to find role '{roleName.Name}'.");
|
if (trole == null) return FromError($"Unable to find role '{roleName.Name}'.");
|
||||||
|
@ -176,21 +180,17 @@ class ResponseExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do action
|
// Do action
|
||||||
var rq = new RequestOptions() { AuditLogReason = $"Rule '{_rule.Label}'" };
|
var rq = new RequestOptions() { AuditLogReason = LogSource };
|
||||||
if (param.Length == 3 && !string.IsNullOrWhiteSpace(param[2])) {
|
|
||||||
rq.AuditLogReason += " - " + param[2];
|
|
||||||
}
|
|
||||||
if (add) await tuser.AddRoleAsync(trole, rq);
|
if (add) await tuser.AddRoleAsync(trole, rq);
|
||||||
else await tuser.RemoveRoleAsync(trole, rq);
|
else await tuser.RemoveRoleAsync(trole, rq);
|
||||||
return FromSuccess($"{(add ? "Set" : "Unset")} {trole.Mention}.");
|
return FromSuccess($"{(add ? "Set" : "Unset")} {trole.Mention}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ResponseResult> CmdDelete(string? parameter) {
|
private async Task<ResponseResult> CmdDelete(string? parameter) {
|
||||||
// TODO detailed audit log deletion reason?
|
if (!string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamNeedNone);
|
||||||
if (parameter != null) return FromError("This response does not accept parameters.");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _msg.DeleteAsync(new RequestOptions { AuditLogReason = $"Rule {_rule.Label}" });
|
await _msg.DeleteAsync(new RequestOptions { AuditLogReason = LogSource });
|
||||||
return FromSuccess();
|
return FromSuccess();
|
||||||
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.NotFound) {
|
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.NotFound) {
|
||||||
return FromError("The message had already been deleted.");
|
return FromError("The message had already been deleted.");
|
||||||
|
@ -199,9 +199,9 @@ class ResponseExecutor {
|
||||||
|
|
||||||
private async Task<ResponseResult> CmdSay(string? parameter) {
|
private async Task<ResponseResult> CmdSay(string? parameter) {
|
||||||
// parameters: [#_/@_] message
|
// parameters: [#_/@_] message
|
||||||
if (string.IsNullOrWhiteSpace(parameter)) return FromError("This response requires parameters.");
|
if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount);
|
||||||
var param = parameter.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
var param = parameter.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
if (param.Length != 2) return FromError("Incorrect number of parameters.");
|
if (param.Length != 2) return FromError(ErrParamWrongAmount);
|
||||||
|
|
||||||
// Get target
|
// Get target
|
||||||
IMessageChannel? targetCh;
|
IMessageChannel? targetCh;
|
||||||
|
@ -233,17 +233,39 @@ class ResponseExecutor {
|
||||||
return FromSuccess($"Sent to {(isUser ? "user DM" : $"<#{targetCh.Id}>")}.");
|
return FromSuccess($"Sent to {(isUser ? "user DM" : $"<#{targetCh.Id}>")}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<ResponseResult> CmdNote(string? parameter) {
|
private async Task<ResponseResult> CmdNote(string? parameter) {
|
||||||
#warning Not implemented
|
if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount);
|
||||||
return Task.FromResult(FromError("not implemented"));
|
var log = await _bot.AddUserNoteAsync(_guild, _user.Id, LogSource, parameter);
|
||||||
|
return FromSuccess($"Note \\#{log.LogId} logged for {_user}.");
|
||||||
}
|
}
|
||||||
private Task<ResponseResult> CmdTimeout(string? parameter) {
|
|
||||||
#warning Not implemented
|
private async Task<ResponseResult> CmdWarn(string? parameter) {
|
||||||
return Task.FromResult(FromError("not implemented"));
|
if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount);
|
||||||
|
var (log, result) = await _bot.AddUserWarnAsync(_guild, _user.Id, LogSource, parameter);
|
||||||
|
var resultMsg = $"Warning \\#{log.LogId} logged for {_user}.";
|
||||||
|
if (result.Success) return FromSuccess(resultMsg);
|
||||||
|
else return FromError(resultMsg + " Failed to send DM.");
|
||||||
}
|
}
|
||||||
private Task<ResponseResult> CmdWarn(string? parameter) {
|
|
||||||
#warning Not implemented
|
private async Task<ResponseResult> CmdTimeout(string? parameter) {
|
||||||
return Task.FromResult(FromError("not implemented"));
|
// parameters: (time in minutes) [reason]
|
||||||
|
if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount);
|
||||||
|
var param = parameter.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (param.Length < 1) return FromError(ErrParamWrongAmount);
|
||||||
|
|
||||||
|
if (!int.TryParse(param[0], out var timemin)) {
|
||||||
|
return FromError($"Couldn't parse '{param[0]}' as amount of time in minutes.");
|
||||||
|
}
|
||||||
|
string? reason = null;
|
||||||
|
if (param.Length == 2) reason = param[1];
|
||||||
|
|
||||||
|
var result = await _bot.SetTimeoutAsync(_guild, LogSource, _user,
|
||||||
|
TimeSpan.FromMinutes(timemin), reason, _rule.NotifyUser);
|
||||||
|
if (result.ErrorForbidden) return FromError(Messages.ForbiddenGenericError);
|
||||||
|
if (result.ErrorNotFound) return FromError(ErrMissingUser);
|
||||||
|
if (result.Error != null) return FromError(result.Error.Message);
|
||||||
|
if (_rule.NotifyChannel) await _msg.Channel.SendMessageAsync(result.ToResultString());
|
||||||
|
return FromSuccess(result.Success ? null : "Unable to send notification DM.");
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|
|
@ -1,32 +1,40 @@
|
||||||
|
using RegexBot.Common;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
namespace RegexBot.Modules.VoiceRoleSync;
|
namespace RegexBot.Modules.VoiceRoleSync;
|
||||||
/// <summary>
|
|
||||||
/// Dictionary wrapper. Key = voice channel ID, Value = role.
|
|
||||||
/// </summary>
|
|
||||||
class ModuleConfig {
|
class ModuleConfig {
|
||||||
|
/// <summary>
|
||||||
|
/// Key = voice channel ID, Value = role ID.
|
||||||
|
/// </summary>
|
||||||
private readonly ReadOnlyDictionary<ulong, ulong> _values;
|
private readonly ReadOnlyDictionary<ulong, ulong> _values;
|
||||||
|
|
||||||
public int Count { get => _values.Count; }
|
public int Count { get => _values.Count; }
|
||||||
|
|
||||||
public ModuleConfig(JObject config) {
|
public ModuleConfig(JObject config, SocketGuild g) {
|
||||||
// Configuration format is expected to be an object that contains other objects.
|
// Configuration: Object with properties.
|
||||||
// The objects themselves should have their name be the voice channel,
|
// Property name is a role entity name
|
||||||
// and the value be the role to be applied.
|
// Value is a string or array of voice channel IDs.
|
||||||
|
|
||||||
// TODO Make it accept names; currently only accepts ulongs
|
|
||||||
|
|
||||||
var values = new Dictionary<ulong, ulong>();
|
var values = new Dictionary<ulong, ulong>();
|
||||||
|
|
||||||
foreach (var item in config.Properties()) {
|
foreach (var item in config.Properties()) {
|
||||||
if (!ulong.TryParse(item.Name, out var voice)) throw new ModuleLoadException($"{item.Name} is not a voice channel ID.");
|
EntityName name;
|
||||||
var valstr = item.Value.Value<string>();
|
try {
|
||||||
if (!ulong.TryParse(valstr, out var role)) throw new ModuleLoadException($"{valstr} is not a role ID.");
|
name = new EntityName(item.Name, EntityType.Role);
|
||||||
|
} catch (FormatException) {
|
||||||
values[voice] = role;
|
throw new ModuleLoadException($"'{item.Name}' is not specified as a role.");
|
||||||
}
|
}
|
||||||
|
var role = name.FindRoleIn(g);
|
||||||
|
if (role == null) throw new ModuleLoadException($"Unable to find role '{name}'.");
|
||||||
|
|
||||||
_values = new ReadOnlyDictionary<ulong, ulong>(values);
|
var channels = Utilities.LoadStringOrStringArray(item.Value);
|
||||||
|
if (channels.Count == 0) throw new ModuleLoadException($"One or more channels must be defined under '{name}'.");
|
||||||
|
foreach (var id in channels) {
|
||||||
|
if (!ulong.TryParse(id, out var channelId)) throw new ModuleLoadException("Voice channel IDs must be numeric.");
|
||||||
|
if (values.ContainsKey(channelId)) throw new ModuleLoadException($"'{channelId}' cannot be specified more than once.");
|
||||||
|
values.Add(channelId, role.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_values = new(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
public SocketRole? GetAssociatedRoleFor(SocketVoiceChannel voiceChannel) {
|
public SocketRole? GetAssociatedRoleFor(SocketVoiceChannel voiceChannel) {
|
||||||
|
@ -36,8 +44,9 @@ class ModuleConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<SocketRole> GetTrackedRoles(SocketGuild guild) {
|
public IEnumerable<SocketRole> GetTrackedRoles(SocketGuild guild) {
|
||||||
foreach (var pair in _values) {
|
var roles = _values.Select(v => v.Value).Distinct();
|
||||||
var r = guild.GetRole(pair.Value);
|
foreach (var id in roles) {
|
||||||
|
var r = guild.GetRole(id);
|
||||||
if (r != null) yield return r;
|
if (r != null) yield return r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,8 +5,6 @@ namespace RegexBot.Modules.VoiceRoleSync;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[RegexbotModule]
|
[RegexbotModule]
|
||||||
internal class VoiceRoleSync : RegexbotModule {
|
internal class VoiceRoleSync : RegexbotModule {
|
||||||
// TODO wishlist? specify multiple definitions - multiple channels associated with multiple roles.
|
|
||||||
|
|
||||||
public VoiceRoleSync(RegexbotClient bot) : base(bot) {
|
public VoiceRoleSync(RegexbotClient bot) : base(bot) {
|
||||||
DiscordClient.UserVoiceStateUpdated += Client_UserVoiceStateUpdated;
|
DiscordClient.UserVoiceStateUpdated += Client_UserVoiceStateUpdated;
|
||||||
}
|
}
|
||||||
|
@ -18,7 +16,8 @@ internal class VoiceRoleSync : RegexbotModule {
|
||||||
if (settings == null) return; // not enabled here
|
if (settings == null) return; // not enabled here
|
||||||
|
|
||||||
async Task RemoveAllAssociatedRoles()
|
async Task RemoveAllAssociatedRoles()
|
||||||
=> await user.RemoveRolesAsync(settings.GetTrackedRoles(user.Guild).Intersect(user.Roles));
|
=> await user.RemoveRolesAsync(settings.GetTrackedRoles(user.Guild).Intersect(user.Roles),
|
||||||
|
new Discord.RequestOptions() { AuditLogReason = nameof(VoiceRoleSync) + ": No longer in associated voice channel." });
|
||||||
|
|
||||||
if (after.VoiceChannel == null) {
|
if (after.VoiceChannel == null) {
|
||||||
// Not in any voice channel. Remove all roles being tracked by this instance. Clear.
|
// Not in any voice channel. Remove all roles being tracked by this instance. Clear.
|
||||||
|
@ -35,10 +34,10 @@ internal class VoiceRoleSync : RegexbotModule {
|
||||||
await RemoveAllAssociatedRoles();
|
await RemoveAllAssociatedRoles();
|
||||||
} else {
|
} else {
|
||||||
// In a tracked voice channel: Clear all except target, add target if needed.
|
// In a tracked voice channel: Clear all except target, add target if needed.
|
||||||
await user.RemoveRolesAsync(settings.GetTrackedRoles(user.Guild)
|
var toRemove = settings.GetTrackedRoles(user.Guild).Where(role => role.Id != targetRole.Id).Intersect(user.Roles);
|
||||||
.Where(role => role.Id != targetRole.Id)
|
if (toRemove.Any()) await user.RemoveRolesAsync(toRemove);
|
||||||
.Intersect(user.Roles));
|
if (!user.Roles.Contains(targetRole)) await user.AddRoleAsync(targetRole,
|
||||||
if (!user.Roles.Contains(targetRole)) await user.AddRoleAsync(targetRole);
|
new Discord.RequestOptions() { AuditLogReason = nameof(VoiceRoleSync) + ": Joined associated voice channel." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -49,7 +48,7 @@ internal class VoiceRoleSync : RegexbotModule {
|
||||||
if (config.Type != JTokenType.Object)
|
if (config.Type != JTokenType.Object)
|
||||||
throw new ModuleLoadException("Configuration for this section is invalid.");
|
throw new ModuleLoadException("Configuration for this section is invalid.");
|
||||||
|
|
||||||
var newconf = new ModuleConfig((JObject)config);
|
var newconf = new ModuleConfig((JObject)config, Bot.DiscordClient.GetGuild(guildID));
|
||||||
Log(DiscordClient.GetGuild(guildID), $"Configured with {newconf.Count} pairing(s).");
|
Log(DiscordClient.GetGuild(guildID), $"Configured with {newconf.Count} pairing(s).");
|
||||||
return Task.FromResult<object?>(newconf);
|
return Task.FromResult<object?>(newconf);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
<Authors>NoiTheCat</Authors>
|
<Authors>NoiTheCat</Authors>
|
||||||
<Description>Advanced and flexible Discord moderation bot.</Description>
|
<Description>Advanced and flexible Discord moderation bot.</Description>
|
||||||
<Version>3.0.0</Version>
|
<Version>3.1.0</Version>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
||||||
|
@ -21,7 +21,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CommandLineParser" Version="2.9.1" />
|
<PackageReference Include="CommandLineParser" Version="2.9.1" />
|
||||||
<PackageReference Include="Discord.Net" Version="3.7.2" />
|
<PackageReference Include="Discord.Net" Version="3.8.1" />
|
||||||
<PackageReference Include="EFCore.NamingConventions" Version="6.0.0" />
|
<PackageReference Include="EFCore.NamingConventions" Version="6.0.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.7" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.7" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.7">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.7">
|
||||||
|
@ -30,8 +30,8 @@
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.7" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.7" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||||
<PackageReference Include="Npgsql" Version="6.0.5" />
|
<PackageReference Include="Npgsql" Version="6.0.7" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.5" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.7" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@ public partial class RegexbotClient {
|
||||||
|
|
||||||
// Get all services started up
|
// Get all services started up
|
||||||
_svcLogging = new Services.Logging.LoggingService(this);
|
_svcLogging = new Services.Logging.LoggingService(this);
|
||||||
|
_svcSharedEvents = new Services.SharedEventService.SharedEventService(this);
|
||||||
_svcGuildState = new Services.ModuleState.ModuleStateService(this);
|
_svcGuildState = new Services.ModuleState.ModuleStateService(this);
|
||||||
_svcCommonFunctions = new Services.CommonFunctions.CommonFunctionsService(this);
|
_svcCommonFunctions = new Services.CommonFunctions.CommonFunctionsService(this);
|
||||||
_svcEntityCache = new Services.EntityCache.EntityCacheService(this);
|
_svcEntityCache = new Services.EntityCache.EntityCacheService(this);
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
using Discord.Net;
|
using Discord.Net;
|
||||||
using RegexBot.Common;
|
using RegexBot.Common;
|
||||||
|
|
||||||
// Instances of this class are created by CommonFunctionService and are meant to be sent to modules,
|
|
||||||
// therefore we put this in the root RegexBot namespace despite being specific to this service.
|
|
||||||
namespace RegexBot;
|
namespace RegexBot;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Contains information on various success/failure outcomes for a ban or kick operation.
|
/// Contains information on various success/failure outcomes for a ban or kick operation.
|
||||||
|
|
80
Services/CommonFunctions/CF_ModLogs.Hooks.cs
Normal file
80
Services/CommonFunctions/CF_ModLogs.Hooks.cs
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
#pragma warning disable CA1822 // "Mark members as static" - members should only be callable by code with access to this instance
|
||||||
|
using Discord.Net;
|
||||||
|
using RegexBot.Common;
|
||||||
|
using RegexBot.Data;
|
||||||
|
|
||||||
|
namespace RegexBot;
|
||||||
|
partial class RegexbotClient {
|
||||||
|
/// <summary>
|
||||||
|
/// Appends a note to the moderation log regarding the given user, containing the given message.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Unlike warnings, notes are private and intended for moderators only. Users are never notified and may
|
||||||
|
/// never be aware of notes associated with them. Otherwise, they function as any other entry in the log.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="guild">The guild which the target user is associated.</param>
|
||||||
|
/// <param name="targetUser">The snowflake ID of the target user.</param>
|
||||||
|
/// <param name="source">
|
||||||
|
/// The the entity which issued this log item.
|
||||||
|
/// If it was a user, this value preferably is in the <seealso cref="EntityName"/> format.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="message">The message to add to this entry.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// The resulting <see cref="ModLogEntry"/> from the creation of this note.
|
||||||
|
/// </returns>
|
||||||
|
public async Task<ModLogEntry> AddUserNoteAsync(SocketGuild guild, ulong targetUser, string source, string? message) {
|
||||||
|
var entry = new ModLogEntry() {
|
||||||
|
GuildId = (long)guild.Id,
|
||||||
|
UserId = (long)targetUser,
|
||||||
|
LogType = ModLogType.Note,
|
||||||
|
IssuedBy = source,
|
||||||
|
Message = message
|
||||||
|
};
|
||||||
|
using (var db = new BotDatabaseContext()) {
|
||||||
|
db.Add(entry);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
await PushSharedEventAsync(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Warns a user, adding an entry to the moderation log and also attempting to notify the user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guild">The guild which the target user is associated.</param>
|
||||||
|
/// <param name="targetUser">The snowflake ID of the target user.</param>
|
||||||
|
/// <param name="source">
|
||||||
|
/// The the entity which issued this log item.
|
||||||
|
/// If it was a user, this value preferably is in the <seealso cref="EntityName"/> format.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="message">The message to add to this entry.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A tuple containing the resulting <see cref="ModLogEntry"/> and <see cref="LogAppendResult"/>.
|
||||||
|
/// </returns>
|
||||||
|
public async Task<(ModLogEntry, LogAppendResult)> AddUserWarnAsync(SocketGuild guild, ulong targetUser, string source, string? message) {
|
||||||
|
var entry = new ModLogEntry() {
|
||||||
|
GuildId = (long)guild.Id,
|
||||||
|
UserId = (long)targetUser,
|
||||||
|
LogType = ModLogType.Warn,
|
||||||
|
IssuedBy = source,
|
||||||
|
Message = message
|
||||||
|
};
|
||||||
|
using (var db = new BotDatabaseContext()) {
|
||||||
|
db.Add(entry);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
await PushSharedEventAsync(entry);
|
||||||
|
|
||||||
|
// Attempt warning message
|
||||||
|
var userSearch = _svcEntityCache.QueryUserCache(targetUser.ToString());
|
||||||
|
var userDisp = userSearch != null
|
||||||
|
? $"**{userSearch.Username}#{userSearch.Discriminator}**"
|
||||||
|
: $"user with ID **{targetUser}**";
|
||||||
|
var targetGuildUser = guild.GetUser(targetUser);
|
||||||
|
if (targetGuildUser == null) return (entry, new LogAppendResult(
|
||||||
|
new HttpException(System.Net.HttpStatusCode.NotFound, null), entry.LogId, userDisp));
|
||||||
|
|
||||||
|
var sendStatus = await _svcCommonFunctions.SendUserWarningAsync(targetGuildUser, message);
|
||||||
|
return (entry, new LogAppendResult(sendStatus, entry.LogId, userDisp));
|
||||||
|
}
|
||||||
|
}
|
39
Services/CommonFunctions/CF_ModLogs.cs
Normal file
39
Services/CommonFunctions/CF_ModLogs.cs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
#pragma warning disable CA1822 // "Mark members as static" - members should only be callable by code with access to this instance
|
||||||
|
using Discord.Net;
|
||||||
|
using RegexBot.Data;
|
||||||
|
|
||||||
|
namespace RegexBot.Services.CommonFunctions;
|
||||||
|
internal partial class CommonFunctionsService : Service {
|
||||||
|
// Called by EF_Removals, this processes a removal into a log entry.
|
||||||
|
// A notification for this entry is then propagated.
|
||||||
|
private void ModLogsProcessRemoval(ulong guildId, ulong targetId, ModLogType remType, string source, string? logReason) {
|
||||||
|
var entry = new ModLogEntry() {
|
||||||
|
GuildId = (long)guildId,
|
||||||
|
UserId = (long)targetId,
|
||||||
|
LogType = remType,
|
||||||
|
IssuedBy = source,
|
||||||
|
Message = logReason
|
||||||
|
};
|
||||||
|
using (var db = new BotDatabaseContext()) {
|
||||||
|
db.Add(entry);
|
||||||
|
db.SaveChanges();
|
||||||
|
}
|
||||||
|
BotClient.PushSharedEventAsync(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<HttpException?> SendUserWarningAsync(SocketGuildUser target, string? reason) {
|
||||||
|
const string DMTemplate = "You have been issued a warning in {0}";
|
||||||
|
const string DMTemplateReason = " with the following message:\n{1}";
|
||||||
|
|
||||||
|
var outMessage = string.IsNullOrWhiteSpace(reason)
|
||||||
|
? string.Format(DMTemplate + ".", target.Guild.Name)
|
||||||
|
: string.Format(DMTemplate + DMTemplateReason, target.Guild.Name, reason);
|
||||||
|
try {
|
||||||
|
var dch = await target.CreateDMChannelAsync();
|
||||||
|
await dch.SendMessageAsync(outMessage);
|
||||||
|
} catch (HttpException ex) {
|
||||||
|
return ex;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,5 @@
|
||||||
using RegexBot.Services.CommonFunctions;
|
namespace RegexBot;
|
||||||
|
|
||||||
namespace RegexBot;
|
|
||||||
partial class RegexbotClient {
|
partial class RegexbotClient {
|
||||||
private readonly CommonFunctionsService _svcCommonFunctions;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to ban the given user from the specified guild. It is greatly preferred to call this method
|
/// Attempts to ban the given user from the specified guild. It is greatly preferred to call this method
|
||||||
/// instead of manually executing the equivalent method found in Discord.Net. It notifies other services
|
/// instead of manually executing the equivalent method found in Discord.Net. It notifies other services
|
48
Services/CommonFunctions/CF_Removals.cs
Normal file
48
Services/CommonFunctions/CF_Removals.cs
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
#pragma warning disable CA1822 // "Mark members as static" - members should only be callable by code with access to this instance
|
||||||
|
using Discord.Net;
|
||||||
|
|
||||||
|
namespace RegexBot.Services.CommonFunctions;
|
||||||
|
internal partial class CommonFunctionsService : Service {
|
||||||
|
// Hooked (indirectly)
|
||||||
|
internal async Task<BanKickResult> BanOrKickAsync(RemovalType t, SocketGuild guild, string source, ulong target,
|
||||||
|
int banPurgeDays, string? logReason, bool sendDmToTarget) {
|
||||||
|
if (t == RemovalType.None) throw new ArgumentException("Removal type must be 'ban' or 'kick'.");
|
||||||
|
var dmSuccess = true;
|
||||||
|
|
||||||
|
SocketGuildUser utarget = guild.GetUser(target);
|
||||||
|
// Can't kick without obtaining user object. Quit here.
|
||||||
|
if (t == RemovalType.Kick && utarget == null) return new BanKickResult(null, false, true, RemovalType.Kick, 0);
|
||||||
|
|
||||||
|
// Send DM notification
|
||||||
|
// Must be done before removal, or we risk not being able to send a notification afterwards
|
||||||
|
if (sendDmToTarget) {
|
||||||
|
if (utarget != null) dmSuccess = await BanKickSendNotificationAsync(utarget, t, logReason);
|
||||||
|
else dmSuccess = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the action
|
||||||
|
var auditReason = $"(By: {source}) {logReason}";
|
||||||
|
try {
|
||||||
|
if (t == RemovalType.Ban) await guild.AddBanAsync(target, banPurgeDays, auditReason);
|
||||||
|
else await utarget!.KickAsync(auditReason);
|
||||||
|
} catch (HttpException ex) {
|
||||||
|
return new BanKickResult(ex, dmSuccess, false, t, target);
|
||||||
|
}
|
||||||
|
ModLogsProcessRemoval(guild.Id, target, t == RemovalType.Ban ? ModLogType.Ban : ModLogType.Kick, source, logReason);
|
||||||
|
|
||||||
|
return new BanKickResult(null, dmSuccess, false, t, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> BanKickSendNotificationAsync(SocketGuildUser target, RemovalType action, string? reason) {
|
||||||
|
const string DMTemplate = "You have been {0} from {1}";
|
||||||
|
const string DMTemplateReason = " for the following reason:\n{2}";
|
||||||
|
|
||||||
|
var outMessage = string.IsNullOrWhiteSpace(reason)
|
||||||
|
? string.Format(DMTemplate + ".", action == RemovalType.Ban ? "banned" : "kicked", target.Guild.Name)
|
||||||
|
: string.Format(DMTemplate + DMTemplateReason, action == RemovalType.Ban ? "banned" : "kicked", target.Guild.Name, reason);
|
||||||
|
|
||||||
|
var dch = await target.CreateDMChannelAsync();
|
||||||
|
try { await dch.SendMessageAsync(outMessage); } catch (HttpException) { return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
18
Services/CommonFunctions/CF_Timeout.Hooks.cs
Normal file
18
Services/CommonFunctions/CF_Timeout.Hooks.cs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
namespace RegexBot;
|
||||||
|
partial class RegexbotClient {
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a timeout on a user while also adding an entry to the moderation log and attempting to notify the user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="guild">The guild which the target user is associated.</param>
|
||||||
|
/// <param name="source">
|
||||||
|
/// The the entity which issued this log item.
|
||||||
|
/// If it was a user, this value preferably is in the <seealso cref="Common.EntityName"/> format.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="target">The user to be issued a timeout.</param>
|
||||||
|
/// <param name="duration">The duration of the timeout.</param>
|
||||||
|
/// <param name="reason">Reason for the action. Sent to the Audit Log and user (if specified).</param>
|
||||||
|
/// <param name="sendNotificationDM">Specify whether to send a direct message to the target user informing them of the action.</param>
|
||||||
|
public Task<TimeoutSetResult> SetTimeoutAsync(SocketGuild guild, string source, SocketGuildUser target,
|
||||||
|
TimeSpan duration, string? reason, bool sendNotificationDM)
|
||||||
|
=> _svcCommonFunctions.SetTimeoutAsync(guild, source, target, duration, reason, sendNotificationDM);
|
||||||
|
}
|
66
Services/CommonFunctions/CF_Timeout.cs
Normal file
66
Services/CommonFunctions/CF_Timeout.cs
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
#pragma warning disable CA1822 // "Mark members as static" - members should only be callable by code with access to this instance
|
||||||
|
using Discord.Net;
|
||||||
|
using RegexBot.Data;
|
||||||
|
|
||||||
|
namespace RegexBot.Services.CommonFunctions;
|
||||||
|
internal partial class CommonFunctionsService : Service {
|
||||||
|
// Hooked
|
||||||
|
internal async Task<TimeoutSetResult> SetTimeoutAsync(SocketGuild guild, string source, SocketGuildUser target,
|
||||||
|
TimeSpan duration, string? reason, bool sendNotificationDM) {
|
||||||
|
if (duration < TimeSpan.FromMinutes(1))
|
||||||
|
return new TimeoutSetResult(new ArgumentOutOfRangeException(
|
||||||
|
nameof(duration), "Cannot set a timeout with a duration less than 60 seconds."), true, target);
|
||||||
|
if (duration > TimeSpan.FromDays(28))
|
||||||
|
return new TimeoutSetResult(new ArgumentOutOfRangeException(
|
||||||
|
nameof(duration), "Cannot set a timeout with a duration greater than 28 days."), true, target);
|
||||||
|
if (target.TimedOutUntil != null && DateTimeOffset.UtcNow < target.TimedOutUntil)
|
||||||
|
return new TimeoutSetResult(new InvalidOperationException(
|
||||||
|
"Cannot set a timeout. The user is already timed out."), true, target);
|
||||||
|
|
||||||
|
Discord.RequestOptions? audit = null;
|
||||||
|
if (reason != null) audit = new() { AuditLogReason = reason };
|
||||||
|
try {
|
||||||
|
await target.SetTimeOutAsync(duration, audit);
|
||||||
|
} catch (HttpException ex) {
|
||||||
|
return new TimeoutSetResult(ex, false, target);
|
||||||
|
}
|
||||||
|
var entry = new ModLogEntry() {
|
||||||
|
GuildId = (long)guild.Id,
|
||||||
|
UserId = (long)target.Id,
|
||||||
|
LogType = ModLogType.Timeout,
|
||||||
|
IssuedBy = source,
|
||||||
|
Message = $"Duration: {Math.Floor(duration.TotalMinutes)}min{(reason == null ? "." : " - " + reason)}"
|
||||||
|
};
|
||||||
|
using (var db = new BotDatabaseContext()) {
|
||||||
|
db.Add(entry);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
// TODO check if this log entry should be propagated now or if (to be implemented) will do it for us later
|
||||||
|
await BotClient.PushSharedEventAsync(entry); // Until then, we for sure propagate our own
|
||||||
|
|
||||||
|
bool dmSuccess;
|
||||||
|
// DM notification
|
||||||
|
if (sendNotificationDM) {
|
||||||
|
dmSuccess = await SendUserTimeoutNoticeAsync(target, duration, reason);
|
||||||
|
} else dmSuccess = true;
|
||||||
|
|
||||||
|
return new TimeoutSetResult(null, dmSuccess, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<bool> SendUserTimeoutNoticeAsync(SocketGuildUser target, TimeSpan duration, string? reason) {
|
||||||
|
// you have been issued a timeout in x.
|
||||||
|
// the timeout will expire on <t:...>
|
||||||
|
const string DMTemplate1 = "You have been issued a timeout in {0}";
|
||||||
|
const string DMTemplateReason = " for the following reason:\n{2}";
|
||||||
|
const string DMTemplate2 = "\nThe timeout will expire on <t:{1}:f> (<t:{1}:R>).";
|
||||||
|
|
||||||
|
var expireTime = (DateTimeOffset.UtcNow + duration).ToUnixTimeSeconds();
|
||||||
|
var outMessage = string.IsNullOrWhiteSpace(reason)
|
||||||
|
? string.Format($"{DMTemplate1}.{DMTemplate2}", target.Guild.Name, expireTime)
|
||||||
|
: string.Format($"{DMTemplate1}{DMTemplateReason}\n{DMTemplate2}", target.Guild.Name, expireTime, reason);
|
||||||
|
|
||||||
|
var dch = await target.CreateDMChannelAsync();
|
||||||
|
try { await dch.SendMessageAsync(outMessage); } catch (HttpException) { return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,64 +1,20 @@
|
||||||
using Discord.Net;
|
using RegexBot.Services.CommonFunctions;
|
||||||
|
|
||||||
namespace RegexBot.Services.CommonFunctions;
|
namespace RegexBot.Services.CommonFunctions {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Implements certain common actions that modules may want to perform. Using this service to perform those
|
/// Implements certain common actions that modules may want to perform. Using this service to perform those
|
||||||
/// functions may help enforce a sense of consistency across modules when performing common actions, and may
|
/// functions may help enforce a sense of consistency across modules when performing common actions, and may
|
||||||
/// inform services which provide any additional features the ability to respond to those actions ahead of time.
|
/// inform services which provide any additional features the ability to respond to those actions ahead of time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal class CommonFunctionsService : Service {
|
internal partial class CommonFunctionsService : Service {
|
||||||
|
// Note: Several classes within this service created by its hooks are meant to be sent to modules,
|
||||||
|
// therefore those public classes are placed into the root RegexBot namespace for the developer's convenience.
|
||||||
public CommonFunctionsService(RegexbotClient bot) : base(bot) { }
|
public CommonFunctionsService(RegexbotClient bot) : base(bot) { }
|
||||||
|
}
|
||||||
#region Guild member removal
|
|
||||||
// Hooked (indirectly)
|
|
||||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static")]
|
|
||||||
internal async Task<BanKickResult> BanOrKickAsync(RemovalType t,
|
|
||||||
SocketGuild guild,
|
|
||||||
string source,
|
|
||||||
ulong target,
|
|
||||||
int banPurgeDays,
|
|
||||||
string? logReason,
|
|
||||||
bool sendDmToTarget) {
|
|
||||||
if (t == RemovalType.None) throw new ArgumentException("Removal type must be 'ban' or 'kick'.");
|
|
||||||
var dmSuccess = true;
|
|
||||||
|
|
||||||
SocketGuildUser utarget = guild.GetUser(target);
|
|
||||||
// Can't kick without obtaining user object. Quit here.
|
|
||||||
if (t == RemovalType.Kick && utarget == null) return new BanKickResult(null, false, true, RemovalType.Kick, 0);
|
|
||||||
|
|
||||||
// TODO notify services here as soon as we get some who will want to listen to this (use source parameter)
|
|
||||||
|
|
||||||
// Send DM notification
|
|
||||||
if (sendDmToTarget) {
|
|
||||||
if (utarget != null) dmSuccess = await BanKickSendNotificationAsync(utarget, t, logReason);
|
|
||||||
else dmSuccess = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform the action
|
namespace RegexBot {
|
||||||
var auditReason = $"(By: {source}) {logReason}";
|
partial class RegexbotClient {
|
||||||
try {
|
private readonly CommonFunctionsService _svcCommonFunctions;
|
||||||
if (t == RemovalType.Ban) await guild.AddBanAsync(target, banPurgeDays, auditReason);
|
|
||||||
else await utarget!.KickAsync(auditReason);
|
|
||||||
// TODO for kick: Figure out a way to specify invoker properly in audit log (as in mee6, etc).
|
|
||||||
} catch (HttpException ex) {
|
|
||||||
return new BanKickResult(ex, dmSuccess, false, t, target);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new BanKickResult(null, dmSuccess, false, t, target);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<bool> BanKickSendNotificationAsync(SocketGuildUser target, RemovalType action, string? reason) {
|
|
||||||
const string DMTemplate = "You have been {0} from {1}";
|
|
||||||
const string DMTemplateReason = " for the following reason:\n{2}";
|
|
||||||
|
|
||||||
var outMessage = string.IsNullOrWhiteSpace(reason)
|
|
||||||
? string.Format(DMTemplate + ".", action == RemovalType.Ban ? "banned" : "kicked", target.Guild.Name)
|
|
||||||
: string.Format(DMTemplate + DMTemplateReason, action == RemovalType.Ban ? "banned" : "kicked", target.Guild.Name, reason);
|
|
||||||
var dch = await target.CreateDMChannelAsync();
|
|
||||||
|
|
||||||
try { await dch.SendMessageAsync(outMessage); } catch (HttpException) { return false; }
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
}
|
}
|
40
Services/CommonFunctions/IOperationResult.cs
Normal file
40
Services/CommonFunctions/IOperationResult.cs
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
namespace RegexBot;
|
||||||
|
/// <summary>
|
||||||
|
/// Contains information on success or failure outcomes for certain operations.
|
||||||
|
/// </summary>
|
||||||
|
public interface IOperationResult {
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether the operation was successful.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Be aware this value may return <see langword="true"/> while
|
||||||
|
/// <see cref="NotificationSuccess"/> returns <see langword="false"/>.
|
||||||
|
/// </remarks>
|
||||||
|
bool Success { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The exception thrown, if any, when attempting to perform the operation.
|
||||||
|
/// </summary>
|
||||||
|
Exception? Error { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates if the operation failed due to being unable to find the user.
|
||||||
|
/// </summary>
|
||||||
|
bool ErrorNotFound { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates if the operation failed due to a permissions issue.
|
||||||
|
/// </summary>
|
||||||
|
bool ErrorForbidden { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates if user DM notification for this event was successful.
|
||||||
|
/// Always returns <see langword="true"/> in cases where no notification was requested.
|
||||||
|
/// </summary>
|
||||||
|
bool NotificationSuccess { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a message representative of this result that may be posted as-is within a Discord channel.
|
||||||
|
/// </summary>
|
||||||
|
string ToResultString();
|
||||||
|
}
|
46
Services/CommonFunctions/LogAppendResult.cs
Normal file
46
Services/CommonFunctions/LogAppendResult.cs
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
using Discord.Net;
|
||||||
|
|
||||||
|
namespace RegexBot;
|
||||||
|
/// <summary>
|
||||||
|
/// Contains information on success/failure outcomes for a warn operation.
|
||||||
|
/// </summary>
|
||||||
|
public class LogAppendResult {
|
||||||
|
private readonly int _logId;
|
||||||
|
private readonly string _rptDisplayName;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the exception thrown, if any, when attempting to send the warning to the target.
|
||||||
|
/// </summary>
|
||||||
|
public HttpException? MessageSendError { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates if the operation failed due to being unable to find the user.
|
||||||
|
/// </summary>
|
||||||
|
public bool ErrorNotFound => MessageSendError?.HttpCode == System.Net.HttpStatusCode.NotFound;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates if the operation failed due to a permissions issue.
|
||||||
|
/// </summary>
|
||||||
|
public bool ErrorForbidden => MessageSendError?.HttpCode == System.Net.HttpStatusCode.Forbidden;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates if the operation completed successfully.
|
||||||
|
/// </summary>
|
||||||
|
public bool Success => MessageSendError == null;
|
||||||
|
|
||||||
|
internal LogAppendResult(HttpException? error, int logId, string reportDispName) {
|
||||||
|
_logId = logId;
|
||||||
|
MessageSendError = error;
|
||||||
|
_rptDisplayName = reportDispName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a message representative of this result that may be posted as-is
|
||||||
|
/// within a Discord channel.
|
||||||
|
/// </summary>
|
||||||
|
public string GetResultString() {
|
||||||
|
var msg = $":white_check_mark: Warning \\#{_logId} logged for {_rptDisplayName}.";
|
||||||
|
if (!Success) msg += "\n:warning: **User did not receive warning message.** Consider sending a message manually.";
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
}
|
31
Services/CommonFunctions/ModLogType.cs
Normal file
31
Services/CommonFunctions/ModLogType.cs
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
namespace RegexBot;
|
||||||
|
/// <summary>
|
||||||
|
/// Specifies the type of action or event represented by a
|
||||||
|
/// <see cref="Data.ModLogEntry"/> or <see cref="LogAppendResult"/>.
|
||||||
|
/// </summary>
|
||||||
|
public enum ModLogType {
|
||||||
|
/// <summary>
|
||||||
|
/// An unspecified logging type.
|
||||||
|
/// </summary>
|
||||||
|
Other,
|
||||||
|
/// <summary>
|
||||||
|
/// A note appended to a user's log for moderator reference.
|
||||||
|
/// </summary>
|
||||||
|
Note,
|
||||||
|
/// <summary>
|
||||||
|
/// A warning. Similar to a note, but with higher priority and presented to the user when issued.
|
||||||
|
/// </summary>
|
||||||
|
Warn,
|
||||||
|
/// <summary>
|
||||||
|
/// A timeout, preventing the user from speaking for some amount of time.
|
||||||
|
/// </summary>
|
||||||
|
Timeout,
|
||||||
|
/// <summary>
|
||||||
|
/// A forced removal from the server.
|
||||||
|
/// </summary>
|
||||||
|
Kick,
|
||||||
|
/// <summary>
|
||||||
|
/// A forced removal from the server, with the user additionally getting added to the ban list.
|
||||||
|
/// </summary>
|
||||||
|
Ban
|
||||||
|
}
|
47
Services/CommonFunctions/TimeoutSetResult.cs
Normal file
47
Services/CommonFunctions/TimeoutSetResult.cs
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
using Discord.Net;
|
||||||
|
using RegexBot.Common;
|
||||||
|
|
||||||
|
namespace RegexBot;
|
||||||
|
/// <summary>
|
||||||
|
/// Contains information on various success/failure outcomes for setting a timeout.
|
||||||
|
/// </summary>
|
||||||
|
public class TimeoutSetResult : IOperationResult {
|
||||||
|
private readonly SocketGuildUser? _target;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool Success => Error == null;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Exception? Error { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool ErrorNotFound => (_target == null) || ((Error as HttpException)?.HttpCode == System.Net.HttpStatusCode.NotFound);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool ErrorForbidden => (Error as HttpException)?.HttpCode == System.Net.HttpStatusCode.Forbidden;
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool NotificationSuccess { get; }
|
||||||
|
|
||||||
|
internal TimeoutSetResult(Exception? error, bool notificationSuccess, SocketGuildUser? target) {
|
||||||
|
Error = error;
|
||||||
|
NotificationSuccess = notificationSuccess;
|
||||||
|
_target = target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public string ToResultString() {
|
||||||
|
if (Success) {
|
||||||
|
var msg = $":white_check_mark: Timeout set for **{_target!.Username}#{_target.Discriminator}**.";
|
||||||
|
if (!NotificationSuccess) msg += "\n(User was unable to receive notification message.)";
|
||||||
|
return msg;
|
||||||
|
} else {
|
||||||
|
var msg = ":x: Failed to set timeout: ";
|
||||||
|
if (ErrorNotFound) msg += "The specified user could not be found.";
|
||||||
|
else if (ErrorForbidden) msg += Messages.ForbiddenGenericError;
|
||||||
|
else if (Error != null) msg += Error.Message;
|
||||||
|
else msg += "Unknown error.";
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,12 +6,13 @@ namespace RegexBot.Services.EntityCache;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
class EntityCacheService : Service {
|
class EntityCacheService : Service {
|
||||||
private readonly UserCachingSubservice _uc;
|
private readonly UserCachingSubservice _uc;
|
||||||
|
#pragma warning disable IDE0052
|
||||||
private readonly MessageCachingSubservice _mc;
|
private readonly MessageCachingSubservice _mc;
|
||||||
|
#pragma warning restore IDE0052
|
||||||
|
|
||||||
internal EntityCacheService(RegexbotClient bot) : base(bot) {
|
internal EntityCacheService(RegexbotClient bot) : base(bot) {
|
||||||
// Currently we only have UserCache. May add Channel and Server caches later.
|
|
||||||
_uc = new UserCachingSubservice(bot, Log);
|
_uc = new UserCachingSubservice(bot, Log);
|
||||||
_mc = new MessageCachingSubservice(bot, Log);
|
_mc = new MessageCachingSubservice(bot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hooked
|
// Hooked
|
||||||
|
@ -21,10 +22,4 @@ class EntityCacheService : Service {
|
||||||
// Hooked
|
// Hooked
|
||||||
internal CachedGuildUser? QueryGuildUserCache(ulong guildId, string search)
|
internal CachedGuildUser? QueryGuildUserCache(ulong guildId, string search)
|
||||||
=> _uc.DoGuildUserQuery(guildId, search);
|
=> _uc.DoGuildUserQuery(guildId, search);
|
||||||
|
|
||||||
// Hooked
|
|
||||||
internal event RegexbotClient.EcMessageUpdateHandler? OnCachePreUpdate {
|
|
||||||
add { lock (_mc) _mc.OnCachePreUpdate += value; }
|
|
||||||
remove { lock (_mc) _mc.OnCachePreUpdate -= value; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,25 +23,4 @@ partial class RegexbotClient {
|
||||||
/// <param name="search">Search string. May be a name with discriminator, a name, or an ID.</param>
|
/// <param name="search">Search string. May be a name with discriminator, a name, or an ID.</param>
|
||||||
/// <returns>A <see cref="CachedGuildUser"/> instance containing cached information, or null if no result.</returns>
|
/// <returns>A <see cref="CachedGuildUser"/> instance containing cached information, or null if no result.</returns>
|
||||||
public CachedGuildUser? EcQueryGuildUser(ulong guildId, string search) => _svcEntityCache.QueryGuildUserCache(guildId, search);
|
public CachedGuildUser? EcQueryGuildUser(ulong guildId, string search) => _svcEntityCache.QueryGuildUserCache(guildId, search);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fired after a message edit, when the message cache is about to be updated with the edited message.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// This event serves as an alternative to <seealso cref="BaseSocketClient.MessageUpdated"/>,
|
|
||||||
/// pulling the previous state of the message from the entity cache instead of the library's cache.
|
|
||||||
/// </remarks>
|
|
||||||
public event EcMessageUpdateHandler? EcOnMessageUpdate {
|
|
||||||
add { _svcEntityCache.OnCachePreUpdate += value; }
|
|
||||||
remove { _svcEntityCache.OnCachePreUpdate -= value; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Delegate used for the <seealso cref="EcOnMessageUpdate"/> event.
|
|
||||||
/// </summary>
|
|
||||||
/// <params>
|
|
||||||
/// <param name="oldMsg">The previous state of the message prior to being updated, as known by the entity cache.</param>
|
|
||||||
/// <param name="newMsg">The new, updated incoming message.</param>
|
|
||||||
/// </params>
|
|
||||||
public delegate Task EcMessageUpdateHandler(CachedGuildMessage? oldMsg, SocketMessage newMsg);
|
|
||||||
}
|
}
|
||||||
|
|
26
Services/EntityCache/MessageCacheUpdateEvent.cs
Normal file
26
Services/EntityCache/MessageCacheUpdateEvent.cs
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
using RegexBot.Data;
|
||||||
|
|
||||||
|
namespace RegexBot;
|
||||||
|
/// <summary>
|
||||||
|
/// Fired after a message edit, when the message cache is about to be updated with the edited message.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Processing this serves as an alternative to <seealso cref="BaseSocketClient.MessageUpdated"/>,
|
||||||
|
/// pulling the previous state of the message from the entity cache instead of the library's cache.
|
||||||
|
/// </remarks>
|
||||||
|
public class MessageCacheUpdateEvent : ISharedEvent {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the previous state of the message prior to being updated, as known by the entity cache.
|
||||||
|
/// </summary>
|
||||||
|
public CachedGuildMessage? OldMessage { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the new, updated incoming message.
|
||||||
|
/// </summary>
|
||||||
|
public SocketMessage NewMessage { get; }
|
||||||
|
|
||||||
|
internal MessageCacheUpdateEvent(CachedGuildMessage? old, SocketMessage @new) {
|
||||||
|
OldMessage = old;
|
||||||
|
NewMessage = @new;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,16 +1,12 @@
|
||||||
using Discord;
|
using Discord;
|
||||||
using RegexBot.Data;
|
using RegexBot.Data;
|
||||||
using static RegexBot.RegexbotClient;
|
|
||||||
|
|
||||||
namespace RegexBot.Services.EntityCache;
|
namespace RegexBot.Services.EntityCache;
|
||||||
class MessageCachingSubservice {
|
class MessageCachingSubservice {
|
||||||
// Hooked
|
private readonly RegexbotClient _bot;
|
||||||
public event EcMessageUpdateHandler? OnCachePreUpdate;
|
|
||||||
|
|
||||||
private readonly Action<string> _log;
|
internal MessageCachingSubservice(RegexbotClient bot) {
|
||||||
|
_bot = bot;
|
||||||
internal MessageCachingSubservice(RegexbotClient bot, Action<string> logMethod) {
|
|
||||||
_log = logMethod;
|
|
||||||
bot.DiscordClient.MessageReceived += DiscordClient_MessageReceived;
|
bot.DiscordClient.MessageReceived += DiscordClient_MessageReceived;
|
||||||
bot.DiscordClient.MessageUpdated += DiscordClient_MessageUpdated;
|
bot.DiscordClient.MessageUpdated += DiscordClient_MessageUpdated;
|
||||||
}
|
}
|
||||||
|
@ -34,7 +30,8 @@ class MessageCachingSubservice {
|
||||||
// Alternative for Discord.Net's MessageUpdated handler:
|
// Alternative for Discord.Net's MessageUpdated handler:
|
||||||
// Notify subscribers of message update using EC entry for the previous message state
|
// Notify subscribers of message update using EC entry for the previous message state
|
||||||
var oldMsg = CachedGuildMessage.Clone(cachedMsg);
|
var oldMsg = CachedGuildMessage.Clone(cachedMsg);
|
||||||
await Task.Factory.StartNew(async () => await RunPreUpdateHandlersAsync(oldMsg, arg));
|
var updEvent = new MessageCacheUpdateEvent(oldMsg, arg);
|
||||||
|
await _bot.PushSharedEventAsync(updEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cachedMsg == null) {
|
if (cachedMsg == null) {
|
||||||
|
@ -55,21 +52,4 @@ class MessageCachingSubservice {
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RunPreUpdateHandlersAsync(CachedGuildMessage? oldMsg, SocketMessage newMsg) {
|
|
||||||
Delegate[]? subscribers;
|
|
||||||
lock (this) {
|
|
||||||
subscribers = OnCachePreUpdate?.GetInvocationList();
|
|
||||||
if (subscribers == null || subscribers.Length == 0) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var handler in subscribers) {
|
|
||||||
try {
|
|
||||||
await (Task)handler.DynamicInvoke(oldMsg, newMsg)!;
|
|
||||||
} catch (Exception ex) {
|
|
||||||
_log($"Unhandled exception in {nameof(RegexbotClient.EcOnMessageUpdate)} handler '{handler.Method.Name}':\n"
|
|
||||||
+ ex.ToString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using Microsoft.EntityFrameworkCore;
|
#pragma warning disable CA1822 // "Mark members as static" - members should only be callable by code with access to this instance
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using RegexBot.Common;
|
using RegexBot.Common;
|
||||||
using RegexBot.Data;
|
using RegexBot.Data;
|
||||||
|
|
||||||
|
@ -8,7 +9,6 @@ namespace RegexBot.Services.EntityCache;
|
||||||
/// It is meant to work as a supplement to Discord.Net's own user caching capabilities. Its purpose is to
|
/// It is meant to work as a supplement to Discord.Net's own user caching capabilities. Its purpose is to
|
||||||
/// provide information on users which the library may not be aware about, such as users no longer in a guild.
|
/// provide information on users which the library may not be aware about, such as users no longer in a guild.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static")]
|
|
||||||
class UserCachingSubservice {
|
class UserCachingSubservice {
|
||||||
private readonly Action<string> _log;
|
private readonly Action<string> _log;
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ namespace RegexBot.Services.Logging;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
class LoggingService : Service {
|
class LoggingService : Service {
|
||||||
// NOTE: Service.Log's functionality is implemented here. DO NOT use within this class.
|
// NOTE: Service.Log's functionality is implemented here. DO NOT use within this class.
|
||||||
private readonly string? _logBasePath;
|
private readonly string _logBasePath;
|
||||||
|
|
||||||
internal LoggingService(RegexbotClient bot) : base(bot) {
|
internal LoggingService(RegexbotClient bot) : base(bot) {
|
||||||
_logBasePath = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location)
|
_logBasePath = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location)
|
||||||
|
@ -17,8 +17,7 @@ class LoggingService : Service {
|
||||||
if (!Directory.Exists(_logBasePath)) Directory.CreateDirectory(_logBasePath);
|
if (!Directory.Exists(_logBasePath)) Directory.CreateDirectory(_logBasePath);
|
||||||
Directory.GetFiles(_logBasePath);
|
Directory.GetFiles(_logBasePath);
|
||||||
} catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) {
|
} catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) {
|
||||||
_logBasePath = null;
|
throw new Exception("Cannot create or access logging directory.");
|
||||||
DoLog(Name, "Cannot create or access logging directory. File logging will be disabled.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bot.DiscordClient.Log += DiscordClient_Log;
|
bot.DiscordClient.Log += DiscordClient_Log;
|
||||||
|
@ -50,17 +49,15 @@ class LoggingService : Service {
|
||||||
// Hooked
|
// Hooked
|
||||||
internal void DoLog(string source, string? message) {
|
internal void DoLog(string source, string? message) {
|
||||||
message ??= "(null)";
|
message ??= "(null)";
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.Now;
|
||||||
var output = new StringBuilder();
|
var output = new StringBuilder();
|
||||||
var prefix = $"[{now:u}] [{source}] ";
|
var prefix = $"[{now:s}] [{source}] ";
|
||||||
foreach (var line in message.Split(new string[] { "\r\n", "\n" }, StringSplitOptions.None)) {
|
foreach (var line in message.Split(new string[] { "\r\n", "\n" }, StringSplitOptions.None)) {
|
||||||
output.Append(prefix).AppendLine(line);
|
output.Append(prefix).AppendLine(line);
|
||||||
}
|
}
|
||||||
var outstr = output.ToString();
|
var outstr = output.ToString();
|
||||||
Console.Write(outstr);
|
Console.Write(outstr);
|
||||||
if (_logBasePath != null) {
|
|
||||||
var filename = _logBasePath + Path.DirectorySeparatorChar + $"{now:yyyy-MM}.log";
|
var filename = _logBasePath + Path.DirectorySeparatorChar + $"{now:yyyy-MM}.log";
|
||||||
File.AppendAllText(filename, outstr, Encoding.UTF8);
|
File.AppendAllText(filename, outstr, Encoding.UTF8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -22,7 +22,8 @@ class ModuleStateService : Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RefreshGuildState(SocketGuild arg) {
|
private async Task RefreshGuildState(SocketGuild arg) {
|
||||||
if (await ProcessConfiguration(arg)) Log($"Configuration refreshed for '{arg.Name}'.");
|
if (await ProcessConfiguration(arg)) Log($"'{arg.Name}': Configuration refreshed.");
|
||||||
|
else Log($"'{arg.Name}': Configuration refresh failed. Retaining existing configuration and state, if any.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task RemoveGuildData(SocketGuild arg) {
|
private Task RemoveGuildData(SocketGuild arg) {
|
||||||
|
@ -52,14 +53,6 @@ class ModuleStateService : Service {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration is loaded from database, and appropriate sections dispatched to their
|
|
||||||
/// respective methods for further processing.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// This takes an all-or-nothing approach. Should there be a single issue in processing
|
|
||||||
/// configuration, all existing state data is kept.
|
|
||||||
/// </remarks>
|
|
||||||
private async Task<bool> ProcessConfiguration(SocketGuild guild) {
|
private async Task<bool> ProcessConfiguration(SocketGuild guild) {
|
||||||
var jstr = await LoadConfigFile(guild);
|
var jstr = await LoadConfigFile(guild);
|
||||||
JObject guildConf;
|
JObject guildConf;
|
||||||
|
@ -76,7 +69,7 @@ class ModuleStateService : Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load moderator list
|
// Load moderator list
|
||||||
var mods = new EntityList(guildConf["Moderators"]!, true);
|
var mods = new EntityList(guildConf["Moderators"]!);
|
||||||
|
|
||||||
// Create guild state objects for all existing modules
|
// Create guild state objects for all existing modules
|
||||||
var newStates = new Dictionary<Type, object?>();
|
var newStates = new Dictionary<Type, object?>();
|
||||||
|
|
34
Services/SharedEventService/Hooks.cs
Normal file
34
Services/SharedEventService/Hooks.cs
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
using RegexBot.Services.SharedEventService;
|
||||||
|
|
||||||
|
namespace RegexBot;
|
||||||
|
partial class RegexbotClient {
|
||||||
|
private readonly SharedEventService _svcSharedEvents;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delegate used for the <seealso cref="SharedEventReceived"/> event.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ev">The incoming event instance.</param>
|
||||||
|
public delegate Task IncomingSharedEventHandler(ISharedEvent ev);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an object instance implementing <seealso cref="ISharedEvent"/> to all modules and services
|
||||||
|
/// subscribed to the <seealso cref="SharedEventReceived"/> event.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This method is non-blocking. Event handlers are executed in their own thread.
|
||||||
|
/// </remarks>
|
||||||
|
public Task PushSharedEventAsync(ISharedEvent ev) => _svcSharedEvents.PushSharedEventAsync(ev);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// This event is fired after a module or internal service calls <see cref="PushSharedEventAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Subscribers to this event are handled on a "fire and forget" basis and may execute on a thread
|
||||||
|
/// separate from the main one handling Discord events. Ensure that the code executed by the handler
|
||||||
|
/// executes quickly, is thread-safe, and throws no exceptions.
|
||||||
|
/// </remarks>
|
||||||
|
public event IncomingSharedEventHandler? SharedEventReceived {
|
||||||
|
add { lock (_svcSharedEvents) _svcSharedEvents.Subscribers += value; }
|
||||||
|
remove { lock (_svcSharedEvents) _svcSharedEvents.Subscribers -= value; }
|
||||||
|
}
|
||||||
|
}
|
6
Services/SharedEventService/SharedEvent.cs
Normal file
6
Services/SharedEventService/SharedEvent.cs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
namespace RegexBot; // Note: Within RegexBot namespace, for ease of use by modules
|
||||||
|
/// <summary>
|
||||||
|
/// An empty interface which denotes that the implementing object instance may be passed through
|
||||||
|
/// the shared event service.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISharedEvent { }
|
48
Services/SharedEventService/SharedEventService.cs
Normal file
48
Services/SharedEventService/SharedEventService.cs
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
|
namespace RegexBot.Services.SharedEventService;
|
||||||
|
/// <summary>
|
||||||
|
/// Implements a queue which any service or module may send objects into,
|
||||||
|
/// which are then sent to subscribing services and/or modules. Allows for simple,
|
||||||
|
/// basic sharing of information between separate parts of the program.
|
||||||
|
/// </summary>
|
||||||
|
class SharedEventService : Service {
|
||||||
|
private readonly Channel<ISharedEvent> _items;
|
||||||
|
//private readonly Task _itemPropagationWorker;
|
||||||
|
|
||||||
|
internal SharedEventService(RegexbotClient bot) : base(bot) {
|
||||||
|
_items = Channel.CreateUnbounded<ISharedEvent>();
|
||||||
|
_ = Task.Factory.StartNew(ItemPropagator, CancellationToken.None,
|
||||||
|
TaskCreationOptions.LongRunning, TaskScheduler.Default);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hooked (lock this on self)
|
||||||
|
internal event RegexbotClient.IncomingSharedEventHandler? Subscribers;
|
||||||
|
|
||||||
|
internal async Task PushSharedEventAsync(ISharedEvent ev) {
|
||||||
|
await _items.Writer.WriteAsync(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ItemPropagator() {
|
||||||
|
while (true) {
|
||||||
|
var ev = await _items.Reader.ReadAsync();
|
||||||
|
|
||||||
|
Delegate[]? subscribed;
|
||||||
|
lock (this) {
|
||||||
|
subscribed = Subscribers?.GetInvocationList();
|
||||||
|
if (subscribed == null || subscribed.Length == 0) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var handler in subscribed) {
|
||||||
|
// Fire and forget!
|
||||||
|
_ = Task.Run(async () => {
|
||||||
|
try {
|
||||||
|
await (Task)handler.DynamicInvoke(ev)!;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Log("Unhandled exception in shared event handler:" + ex.ToString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue