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>
|
||||
/// Creates a new EntityList instance with no data.
|
||||
/// </summary>
|
||||
public EntityList() : this(null, false) { }
|
||||
public EntityList() : this(null) { }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new EntityList instance using the given JSON token as input.
|
||||
/// </summary>
|
||||
/// <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="ArgumentNullException">
|
||||
/// 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">
|
||||
/// When enforceTypes is set, this is thrown if an EntityName results in having its Type be Unspecified.
|
||||
/// </exception>
|
||||
public EntityList(JToken? input, bool enforceTypes) {
|
||||
public EntityList(JToken? input) {
|
||||
if (input == null) {
|
||||
_innerList = new List<EntityName>().AsReadOnly();
|
||||
return;
|
||||
|
@ -50,8 +49,6 @@ public class EntityList : IEnumerable<EntityName> {
|
|||
foreach (var item in inputArray.Values<string>()) {
|
||||
if (string.IsNullOrWhiteSpace(item)) continue;
|
||||
var itemName = new EntityName(item);
|
||||
if (enforceTypes && itemName.Type == EntityType.Unspecified)
|
||||
throw new FormatException($"The following value is not prefixed: {item}");
|
||||
list.Add(itemName);
|
||||
}
|
||||
_innerList = list.AsReadOnly();
|
||||
|
@ -82,7 +79,7 @@ public class EntityList : IEnumerable<EntityName> {
|
|||
} else {
|
||||
foreach (var r in authorRoles) {
|
||||
if (!string.Equals(r.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
|
||||
if (keepId) entry.SetId(r.Id);
|
||||
if (keepId) entry.Id = r.Id;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@ -91,7 +88,7 @@ public class EntityList : IEnumerable<EntityName> {
|
|||
if (entry.Id.Value == channel.Id) return true;
|
||||
} else {
|
||||
if (!string.Equals(channel.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
|
||||
if (keepId) entry.SetId(channel.Id);
|
||||
if (keepId) entry.Id = channel.Id;
|
||||
return true;
|
||||
}
|
||||
} else { // User
|
||||
|
@ -99,7 +96,7 @@ public class EntityList : IEnumerable<EntityName> {
|
|||
if (entry.Id.Value == author.Id) return true;
|
||||
} else {
|
||||
if (!string.Equals(author.Username, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
|
||||
if (keepId) entry.SetId(author.Id);
|
||||
if (keepId) entry.Id = author.Id;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,23 +6,28 @@
|
|||
/// </summary>
|
||||
public class EntityName {
|
||||
/// <summary>
|
||||
/// The entity's type, if specified in configuration.
|
||||
/// The entity's type, as specified in configuration.
|
||||
/// </summary>
|
||||
public EntityType Type { get; private set; }
|
||||
|
||||
private ulong? _id;
|
||||
/// <summary>
|
||||
/// Entity's unique ID value (snowflake). May be null if the value is not known.
|
||||
/// </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>
|
||||
/// Entity's name as specified in configuration. May be null if it was not specified.
|
||||
/// This value is not updated during runtime.
|
||||
/// </summary>
|
||||
/// <remarks>This value is not updated during runtime.</remarks>
|
||||
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>
|
||||
/// Creates a new object instance from the given input string.
|
||||
/// 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) {
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
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;
|
||||
else if (input[0] == '#') Type = EntityType.Channel;
|
||||
else if (input[0] == '@') Type = EntityType.User;
|
||||
}
|
||||
if (Type == default)
|
||||
throw new ArgumentException("Entity type unable to be inferred by given input.");
|
||||
if (input[0] == '&') Type = EntityType.Role;
|
||||
else if (input[0] == '#') Type = EntityType.Channel;
|
||||
else if (input[0] == '@') Type = EntityType.User;
|
||||
else throw new ArgumentException("Entity type unable to be inferred by given input.");
|
||||
|
||||
input = input[1..]; // Remove prefix
|
||||
|
||||
|
@ -72,14 +73,23 @@ public class EntityName {
|
|||
}
|
||||
}
|
||||
|
||||
internal void SetId(ulong id) {
|
||||
if (!Id.HasValue) Id = id;
|
||||
/// <summary>
|
||||
/// 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>
|
||||
/// Returns the appropriate prefix corresponding to an EntityType.
|
||||
/// </summary>
|
||||
public static char Prefix(EntityType t) => t switch {
|
||||
public static char GetPrefix(EntityType t) => t switch {
|
||||
EntityType.Role => '&',
|
||||
EntityType.Channel => '#',
|
||||
EntityType.User => '@',
|
||||
|
@ -90,7 +100,7 @@ public class EntityName {
|
|||
/// Returns a string representation of this item in proper EntityName format.
|
||||
/// </summary>
|
||||
public override string ToString() {
|
||||
var pf = Prefix(Type);
|
||||
var pf = GetPrefix(Type);
|
||||
|
||||
if (Id.HasValue && Name != null)
|
||||
return $"{pf}{Id.Value}::{Name}";
|
||||
|
@ -101,6 +111,7 @@ public class EntityName {
|
|||
}
|
||||
|
||||
#region Helper methods
|
||||
// TODO convert all to extension methods
|
||||
/// <summary>
|
||||
/// Attempts to find the corresponding role within the given guild.
|
||||
/// </summary>
|
||||
|
|
|
@ -3,8 +3,6 @@
|
|||
/// The type of entity specified in an <see cref="EntityName"/>.
|
||||
/// </summary>
|
||||
public enum EntityType {
|
||||
/// <summary>Default value. Is never referenced in regular usage.</summary>
|
||||
Unspecified,
|
||||
/// <summary>
|
||||
/// Userd when the <see cref="EntityName"/> represents a role.
|
||||
/// </summary>
|
||||
|
|
|
@ -75,7 +75,7 @@ public class FilterList {
|
|||
|
||||
if (incoming.Type != JTokenType.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.
|
||||
if (exemptKey != null) {
|
||||
|
@ -85,7 +85,7 @@ public class FilterList {
|
|||
} else if (incomingEx.Type != JTokenType.Array) {
|
||||
throw new FormatException("Filtering exemption list must be a JSON array.");
|
||||
} else {
|
||||
FilterExemptions = new EntityList(incomingEx, true);
|
||||
FilterExemptions = new EntityList(incomingEx);
|
||||
}
|
||||
} else {
|
||||
FilterExemptions = new EntityList();
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using Discord;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace RegexBot.Common;
|
||||
|
@ -64,4 +65,55 @@ public static class Utilities {
|
|||
}
|
||||
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
|
||||
var conf = new InstanceConfig();
|
||||
_connectionString = new NpgsqlConnectionStringBuilder() {
|
||||
#if DEBUG
|
||||
IncludeErrorDetail = true,
|
||||
#endif
|
||||
Host = conf.SqlHost ?? "localhost", // default to localhost
|
||||
Database = conf.SqlDatabase,
|
||||
Username = conf.SqlUsername,
|
||||
|
@ -34,6 +37,11 @@ public class BotDatabaseContext : DbContext {
|
|||
/// </summary>
|
||||
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 />
|
||||
protected sealed override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
=> optionsBuilder
|
||||
|
@ -43,10 +51,17 @@ public class BotDatabaseContext : DbContext {
|
|||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||
modelBuilder.Entity<CachedUser>(entity => entity.Property(e => e.Discriminator).HasMaxLength(4).IsFixedLength());
|
||||
modelBuilder.Entity<CachedGuildUser>(entity => {
|
||||
entity.HasKey(e => new { e.UserId, e.GuildId });
|
||||
entity.Property(e => e.FirstSeenTime).HasDefaultValueSql("now()");
|
||||
modelBuilder.Entity<CachedGuildUser>(e => {
|
||||
e.HasKey(p => new { p.GuildId, p.UserId });
|
||||
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>
|
||||
public string? Content { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// If included in the query, references the associated <seealso cref="CachedUser"/> for this entry.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="CachedGuildUser.User" />
|
||||
[ForeignKey(nameof(AuthorId))]
|
||||
[InverseProperty(nameof(CachedUser.GuildMessages))]
|
||||
public CachedUser Author { get; set; } = null!;
|
||||
// TODO set up composite foreign key. will require rewriting some parts in modules...
|
||||
|
||||
// Used by MessageCachingSubservice
|
||||
internal static CachedGuildMessage? Clone(CachedGuildMessage? original) {
|
||||
|
|
|
@ -6,14 +6,14 @@ namespace RegexBot.Data;
|
|||
/// </summary>
|
||||
[Table("cache_usersinguild")]
|
||||
public class CachedGuildUser {
|
||||
/// <inheritdoc cref="CachedUser.UserId"/>
|
||||
public long UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the associated guild's snowflake ID.
|
||||
/// </summary>
|
||||
public long GuildId { get; set; }
|
||||
|
||||
/// <inheritdoc cref="CachedUser.UserId"/>
|
||||
public long UserId { get; set; }
|
||||
|
||||
/// <inheritdoc cref="CachedUser.ULastUpdateTime"/>
|
||||
public DateTimeOffset GULastUpdateTime { get; set; }
|
||||
|
||||
|
@ -33,4 +33,9 @@ public class CachedGuildUser {
|
|||
[ForeignKey(nameof(UserId))]
|
||||
[InverseProperty(nameof(CachedUser.Guilds))]
|
||||
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")]
|
||||
public class CachedUser {
|
||||
/// <summary>
|
||||
/// Gets the user's snowflake ID.
|
||||
/// Gets the associated user's snowflake ID.
|
||||
/// </summary>
|
||||
[Key]
|
||||
[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("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 =>
|
||||
|
@ -71,14 +72,14 @@ namespace RegexBot.Data.Migrations
|
|||
|
||||
modelBuilder.Entity("RegexBot.Data.CachedGuildUser", b =>
|
||||
{
|
||||
b.Property<long>("UserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
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")
|
||||
|
@ -93,9 +94,12 @@ namespace RegexBot.Data.Migrations
|
|||
.HasColumnType("text")
|
||||
.HasColumnName("nickname");
|
||||
|
||||
b.HasKey("UserId", "GuildId")
|
||||
b.HasKey("GuildId", "UserId")
|
||||
.HasName("pk_cache_usersinguild");
|
||||
|
||||
b.HasIndex("UserId")
|
||||
.HasDatabaseName("ix_cache_usersinguild_user_id");
|
||||
|
||||
b.ToTable("cache_usersinguild", (string)null);
|
||||
});
|
||||
|
||||
|
@ -131,6 +135,51 @@ namespace RegexBot.Data.Migrations
|
|||
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")
|
||||
|
@ -155,6 +204,23 @@ namespace RegexBot.Data.Migrations
|
|||
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");
|
||||
|
|
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;
|
||||
|
||||
namespace RegexBot;
|
||||
|
||||
/// <summary>
|
||||
/// Contains essential instance configuration for this bot including Discord connection settings, service configuration,
|
||||
/// and command-line options.
|
||||
|
@ -67,17 +66,10 @@ class InstanceConfig {
|
|||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command line options
|
||||
/// </summary>
|
||||
class CommandLineParameters {
|
||||
[Option('c', "config", Default = null,
|
||||
HelpText = "Custom path to instance configuration. Defaults to instance.json in bot directory.")]
|
||||
public string ConfigFile { get; set; } = null!;
|
||||
[Option('c', "config", Default = 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) {
|
||||
CommandLineParameters? result = null;
|
||||
|
||||
|
|
|
@ -33,26 +33,23 @@ internal class AutoResponder : RegexbotModule {
|
|||
var definitions = GetGuildState<IEnumerable<Definition>>(ch.Guild.Id);
|
||||
if (definitions == null) return; // No configuration in this guild; do no further processing
|
||||
|
||||
var tasks = new List<Task>();
|
||||
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) {
|
||||
private async Task ProcessMessageAsync(SocketMessage msg, Definition def, SocketTextChannel ch) {
|
||||
if (!def.Match(msg)) return;
|
||||
|
||||
Log(ch.Guild, $"Definition '{def.Label}' triggered by {msg.Author}.");
|
||||
if (def.Command == null) {
|
||||
await msg.Channel.SendMessageAsync(def.GetResponse());
|
||||
} else {
|
||||
var ch = (SocketGuildChannel)msg.Channel;
|
||||
var cmdline = def.Command.Split(new char[] { ' ' }, 2);
|
||||
|
||||
var ps = new ProcessStartInfo() {
|
||||
FileName = cmdline[0],
|
||||
Arguments = (cmdline.Length == 2 ? cmdline[1] : ""),
|
||||
Arguments = cmdline.Length == 2 ? cmdline[1] : "",
|
||||
UseShellExecute = false, // ???
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
using RegexBot.Common;
|
||||
|
||||
namespace RegexBot.Modules.EntryRole;
|
||||
|
||||
/// <summary>
|
||||
/// Contains configuration data as well as per-guild timers for those awaiting role assignment.
|
||||
/// </summary>
|
||||
|
@ -27,13 +26,10 @@ class GuildData {
|
|||
public GuildData(JObject conf, Dictionary<ulong, DateTimeOffset> _waitingList) {
|
||||
WaitingList = _waitingList;
|
||||
|
||||
var cfgRole = conf["Role"]?.Value<string>();
|
||||
if (string.IsNullOrWhiteSpace(cfgRole))
|
||||
throw new ModuleLoadException("Role value not specified.");
|
||||
try {
|
||||
TargetRole = new EntityName(cfgRole);
|
||||
} catch (ArgumentException) {
|
||||
throw new ModuleLoadException("Role config value was not properly specified to be a role.");
|
||||
TargetRole = new EntityName(conf["Role"]?.Value<string>()!, EntityType.Role);
|
||||
} catch (Exception) {
|
||||
throw new ModuleLoadException("'Role' was not properly specified.");
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
@ -13,14 +13,9 @@ class Ban : BanKick {
|
|||
ulong targetId, CachedGuildUser? targetQuery, SocketUser? targetUser) {
|
||||
// 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);
|
||||
if (result.OperationSuccess) {
|
||||
if (SuccessMessage != null) {
|
||||
// TODO customization
|
||||
await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}");
|
||||
} else {
|
||||
// TODO custom fail message?
|
||||
await msg.Channel.SendMessageAsync(SuccessMessage);
|
||||
}
|
||||
if (result.OperationSuccess && SuccessMessage != null) {
|
||||
// TODO string replacement, formatting, etc
|
||||
await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}");
|
||||
} else {
|
||||
await msg.Channel.SendMessageAsync(result.GetResultString(Module.Bot));
|
||||
}
|
||||
|
@ -39,13 +34,12 @@ class Kick : BanKick {
|
|||
}
|
||||
|
||||
var result = await Module.Bot.KickAsync(g, msg.Author.ToString(), targetId, reason, SendNotify);
|
||||
if (result.OperationSuccess) {
|
||||
if (SuccessMessage != null) {
|
||||
// TODO string replacement, formatting, etc
|
||||
await msg.Channel.SendMessageAsync($"{SuccessMessage}\n{result.GetResultString(Module.Bot)}");
|
||||
}
|
||||
if (result.OperationSuccess && SuccessMessage != null) {
|
||||
// TODO string replacement, formatting, etc
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -60,7 +54,7 @@ abstract class BanKick : CommandConfig {
|
|||
// "PurgeDays" - integer; Number of days of target's post history to delete, if banning.
|
||||
// Must be between 0-7 inclusive. Defaults to 0.
|
||||
// "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) {
|
||||
ForceReason = config[nameof(ForceReason)]?.Value<bool>() ?? false;
|
||||
PurgeDays = config[nameof(PurgeDays)]?.Value<int>() ?? 0;
|
||||
|
@ -68,7 +62,7 @@ abstract class BanKick : CommandConfig {
|
|||
SendNotify = config[nameof(SendNotify)]?.Value<bool>() ?? true;
|
||||
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"
|
||||
+ (ban ? " and prevents the user from rejoining" : "") + ". "
|
||||
+ (ForceReason ? "L" : "Optionally l") + "ogs the reason for the "
|
||||
|
@ -80,7 +74,7 @@ abstract class BanKick : CommandConfig {
|
|||
private readonly string _usage;
|
||||
protected override string DefaultUsageMsg => _usage;
|
||||
|
||||
// Usage: (command) (mention) (reason)
|
||||
// Usage: (command) (user) (reason)
|
||||
public override async Task Invoke(SocketGuild g, SocketMessage msg) {
|
||||
var line = msg.Content.Split(new char[] { ' ' }, 3, StringSplitOptions.RemoveEmptyEntries);
|
||||
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.
|
||||
// "successmsg" - string; Messages to display on command success. Overrides default.
|
||||
protected RoleManipulation(ModCommands module, JObject config) : base(module, config) {
|
||||
var rolestr = config[nameof(Role)]?.Value<string>();
|
||||
if (string.IsNullOrWhiteSpace(rolestr)) throw new ModuleLoadException($"'{nameof(Role)}' must be provided.");
|
||||
Role = new EntityName(rolestr);
|
||||
if (Role.Type != EntityType.Role) throw new ModuleLoadException($"The value in '{nameof(Role)}' is not a role.");
|
||||
try {
|
||||
Role = new EntityName(config[nameof(Role)]?.Value<string>()!, EntityType.Role);
|
||||
} catch (ArgumentNullException) {
|
||||
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>();
|
||||
|
||||
_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)) {
|
||||
try {
|
||||
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) {
|
||||
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}). " +
|
||||
"Check the console for details.");
|
||||
}
|
||||
|
|
|
@ -33,10 +33,17 @@ class ModuleConfig {
|
|||
{ "kick", typeof(Kick) },
|
||||
{ "say", typeof(Say) },
|
||||
{ "unban", typeof(Unban) },
|
||||
{ "note", typeof(Note) },
|
||||
{ "addnote", typeof(Note) },
|
||||
{ "warn", typeof(Warn) },
|
||||
{ "timeout", typeof(Commands.Timeout) },
|
||||
{ "untimeout", typeof(Untimeout)},
|
||||
{ "addrole", typeof(RoleAdd) },
|
||||
{ "roleadd", typeof(RoleAdd) },
|
||||
{ "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?
|
||||
|
||||
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;
|
||||
bot.EcOnMessageUpdate += HandleUpdate;
|
||||
bot.SharedEventReceived += HandleReceivedSharedEvent;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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) {
|
||||
var result = new StringBuilder();
|
||||
//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;
|
||||
if (argChannel.Value is not SocketTextChannel channel) return;
|
||||
var conf = GetGuildState<ModuleConfig>(channel.Guild.Id);
|
||||
if ((conf?.LogMessageDeletions ?? false) == false) return;
|
||||
var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true);
|
||||
if (reportChannel == null) return;
|
||||
if ((conf?.LogMessageDeletions ?? false) == false) return;
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -53,22 +53,14 @@ internal partial class ModLogs {
|
|||
IconUrl = cachedMsg.Author.AvatarUrl ?? GetDefaultAvatarUrl(cachedMsg.Author.Discriminator)
|
||||
};
|
||||
}
|
||||
var attach = CheckAttachments(cachedMsg.AttachmentNames);
|
||||
if (attach != null) reportEmbed.AddField(attach);
|
||||
SetAttachmentsField(reportEmbed, cachedMsg.AttachmentNames);
|
||||
} else {
|
||||
reportEmbed.Description = NotCached;
|
||||
}
|
||||
|
||||
var contextStr = new StringBuilder();
|
||||
contextStr.AppendLine($"User: {(cachedMsg != null ? $"<@!{cachedMsg.AuthorId}>" : "Unknown")}");
|
||||
contextStr.AppendLine($"Channel: <#{channel.Id}> (#{channel.Name})");
|
||||
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()
|
||||
});
|
||||
var editLine = $"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(argMsg.Id))}";
|
||||
if (cachedMsg?.EditedAt != null) editLine += $"\nLast edit: {MakeTimestamp(cachedMsg.EditedAt.Value)}";
|
||||
SetContextField(reportEmbed, (ulong?)cachedMsg?.AuthorId, channel, editLine, argMsg.Id);
|
||||
|
||||
await reportChannel.SendMessageAsync(embed: reportEmbed.Build());
|
||||
}
|
||||
|
@ -77,11 +69,11 @@ internal partial class ModLogs {
|
|||
const int MaxPreviewLength = 500;
|
||||
var channel = (SocketTextChannel)newMsg.Channel;
|
||||
var conf = GetGuildState<ModuleConfig>(channel.Guild.Id);
|
||||
|
||||
var reportChannel = conf?.ReportingChannel?.FindChannelIn(channel.Guild, true);
|
||||
if (reportChannel == null) return;
|
||||
if ((conf?.LogMessageEdits ?? false) == false) return;
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -122,25 +114,39 @@ internal partial class ModLogs {
|
|||
}
|
||||
reportEmbed.AddField(newField);
|
||||
|
||||
var attach = CheckAttachments(newMsg.Attachments.Select(a => a.Filename));
|
||||
if (attach != null) reportEmbed.AddField(attach);
|
||||
SetAttachmentsField(reportEmbed, newMsg.Attachments.Select(a => a.Filename));
|
||||
|
||||
var contextStr = new StringBuilder();
|
||||
contextStr.AppendLine($"User: <@!{newMsg.Author.Id}>");
|
||||
contextStr.AppendLine($"Channel: <#{channel.Id}> (#{channel.Name})");
|
||||
if ((oldMsg?.EditedAt) == null) contextStr.AppendLine($"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(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);
|
||||
string editLine;
|
||||
if ((oldMsg?.EditedAt) == null) editLine = $"Posted: {MakeTimestamp(SnowflakeUtils.FromSnowflake(newMsg.Id))}";
|
||||
else editLine = $"Previous edit: {MakeTimestamp(oldMsg.EditedAt.Value)}";
|
||||
SetContextField(reportEmbed, newMsg.Author.Id, channel, editLine, newMsg.Id);
|
||||
|
||||
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()) {
|
||||
var field = new EmbedFieldBuilder { Name = "Attachments" };
|
||||
var attachNames = new StringBuilder();
|
||||
|
@ -148,8 +154,7 @@ internal partial class ModLogs {
|
|||
attachNames.AppendLine($"`{name}`");
|
||||
}
|
||||
field.Value = attachNames.ToString().TrimEnd();
|
||||
return field;
|
||||
e.AddField(field);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -6,16 +6,19 @@ class ModuleConfig {
|
|||
|
||||
public bool LogMessageDeletions { get; }
|
||||
public bool LogMessageEdits { get; }
|
||||
public bool LogModLogs { get; }
|
||||
|
||||
public ModuleConfig(JObject config) {
|
||||
const string RptChError = $"'{nameof(ReportingChannel)}' must be set to a valid channel name.";
|
||||
var rptch = config[nameof(ReportingChannel)]?.Value<string>();
|
||||
if (string.IsNullOrWhiteSpace(rptch)) throw new ModuleLoadException(RptChError);
|
||||
ReportingChannel = new EntityName(rptch);
|
||||
if (ReportingChannel.Type != EntityType.Channel) throw new ModuleLoadException(RptChError);
|
||||
try {
|
||||
ReportingChannel = new EntityName(config[nameof(ReportingChannel)]?.Value<string>()!, EntityType.Channel);
|
||||
} catch (Exception) {
|
||||
throw new ModuleLoadException(RptChError);
|
||||
}
|
||||
|
||||
// Individual logging settings - all default to false
|
||||
LogMessageDeletions = config[nameof(LogMessageDeletions)]?.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 ModuleConfig(JObject conf) {
|
||||
var cfgRole = conf[nameof(Role)]?.Value<string>();
|
||||
if (string.IsNullOrWhiteSpace(cfgRole))
|
||||
throw new ModuleLoadException("Role was not specified.");
|
||||
Role = new EntityName(cfgRole);
|
||||
if (Role.Type != EntityType.Role)
|
||||
throw new ModuleLoadException("Name specified in configuration is not a role.");
|
||||
try {
|
||||
Role = new EntityName(conf[nameof(Role)]?.Value<string>()!, EntityType.Role);
|
||||
} catch (ArgumentException) {
|
||||
throw new ModuleLoadException("Role was not properly specified.");
|
||||
} catch (FormatException) {
|
||||
throw new ModuleLoadException("Name specified in configuration is not a role.");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,8 +23,8 @@ class ConfDefinition {
|
|||
public EntityName? ReportingChannel { get; }
|
||||
public IReadOnlyList<string> Response { get; }
|
||||
public int BanPurgeDays { get; }
|
||||
public bool NotifyChannelOfRemoval { get; }
|
||||
public bool NotifyUserOfRemoval { get; }
|
||||
public bool NotifyChannel { get; }
|
||||
public bool NotifyUser { get; }
|
||||
|
||||
public ConfDefinition(JObject def) {
|
||||
Label = def[nameof(Label)]?.Value<string>()
|
||||
|
@ -34,9 +34,11 @@ class ConfDefinition {
|
|||
|
||||
var rptch = def[nameof(ReportingChannel)]?.Value<string>();
|
||||
if (rptch != null) {
|
||||
ReportingChannel = new EntityName(rptch);
|
||||
if (ReportingChannel.Type != EntityType.Channel)
|
||||
try {
|
||||
ReportingChannel = new EntityName(rptch, EntityType.Channel);
|
||||
} catch (FormatException) {
|
||||
throw new ModuleLoadException($"'{nameof(ReportingChannel)}' is not defined as a channel{errpostfx}");
|
||||
}
|
||||
}
|
||||
|
||||
// Regex loading
|
||||
|
@ -81,8 +83,8 @@ class ConfDefinition {
|
|||
throw new ModuleLoadException($"'{nameof(Response)}' is not properly defined{errpostfx}");
|
||||
}
|
||||
BanPurgeDays = def[nameof(BanPurgeDays)]?.Value<int>() ?? 0;
|
||||
NotifyChannelOfRemoval = def[nameof(NotifyChannelOfRemoval)]?.Value<bool>() ?? true;
|
||||
NotifyUserOfRemoval = def[nameof(NotifyUserOfRemoval)]?.Value<bool>() ?? true;
|
||||
NotifyChannel = def[nameof(NotifyChannel)]?.Value<bool>() ?? true;
|
||||
NotifyUser = def[nameof(NotifyUser)]?.Value<bool>() ?? true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
@ -46,29 +46,15 @@ internal class RegexModerator : RegexbotModule {
|
|||
var defs = GetGuildState<IEnumerable<ConfDefinition>>(ch.Guild.Id);
|
||||
if (defs == null) return;
|
||||
|
||||
// Send further processing to thread pool.
|
||||
// Match checking is a CPU-intensive task, thus very little checking is done here.
|
||||
var msgProcessingTasks = new List<Task>();
|
||||
// Matching and response processing
|
||||
foreach (var item in defs) {
|
||||
// Need to check sender's moderator status here. Definition can't access mod list.
|
||||
var isMod = GetModerators(ch.Guild.Id).IsListMatch(msg, true);
|
||||
|
||||
var match = item.IsMatch(msg, isMod);
|
||||
msgProcessingTasks.Add(Task.Run(async () => await ProcessMessage(item, ch.Guild, msg, isMod)));
|
||||
if (!item.IsMatch(msg, isMod)) continue;
|
||||
Log(ch.Guild, $"Rule '{item.Label}' triggered by {msg.Author}.");
|
||||
var exec = new ResponseExecutor(item, Bot, msg, (string logLine) => Log(ch.Guild, logLine));
|
||||
await exec.Execute();
|
||||
}
|
||||
await Task.WhenAll(msgProcessingTasks);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
namespace RegexBot.Modules.RegexModerator;
|
||||
|
||||
/// <summary>
|
||||
/// Transient helper class which handles response interpreting and execution.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
private readonly ConfDefinition _rule;
|
||||
|
@ -20,6 +23,8 @@ class ResponseExecutor {
|
|||
private readonly List<(string, ResponseResult)> _reports;
|
||||
private Action<string> Log { get; }
|
||||
|
||||
private string LogSource => $"{_rule.Label} ({nameof(RegexModerator)})";
|
||||
|
||||
public ResponseExecutor(ConfDefinition rule, RegexbotClient bot, SocketMessage msg, Action<string> logger) {
|
||||
_rule = rule;
|
||||
_bot = bot;
|
||||
|
@ -114,7 +119,7 @@ class ResponseExecutor {
|
|||
)
|
||||
.WithDescription(invokingLine)
|
||||
.WithFooter(
|
||||
text: $"Rule: {_rule.Label}",
|
||||
text: LogSource,
|
||||
iconUrl: _bot.DiscordClient.CurrentUser.GetAvatarUrl()
|
||||
)
|
||||
.WithCurrentTimestamp()
|
||||
|
@ -135,15 +140,15 @@ class ResponseExecutor {
|
|||
private async Task<ResponseResult> CmdBanKick(RemovalType rt, string? parameter) {
|
||||
BanKickResult result;
|
||||
if (rt == RemovalType.Ban) {
|
||||
result = await _bot.BanAsync(_guild, $"Rule '{_rule.Label}'", _user.Id,
|
||||
_rule.BanPurgeDays, parameter, _rule.NotifyUserOfRemoval);
|
||||
result = await _bot.BanAsync(_guild, LogSource, _user.Id,
|
||||
_rule.BanPurgeDays, parameter, _rule.NotifyUser);
|
||||
} else {
|
||||
result = await _bot.KickAsync(_guild, $"Rule '{_rule.Label}'", _user.Id,
|
||||
parameter, _rule.NotifyUserOfRemoval);
|
||||
result = await _bot.KickAsync(_guild, LogSource, _user.Id,
|
||||
parameter, _rule.NotifyUser);
|
||||
}
|
||||
if (result.ErrorForbidden) return FromError(Messages.ForbiddenGenericError);
|
||||
if (result.ErrorNotFound) return FromError("The target user is no longer in the server.");
|
||||
if (_rule.NotifyChannelOfRemoval) await _msg.Channel.SendMessageAsync(result.GetResultString(_bot));
|
||||
if (result.ErrorNotFound) return FromError(ErrMissingUser);
|
||||
if (_rule.NotifyChannel) await _msg.Channel.SendMessageAsync(result.GetResultString(_bot));
|
||||
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 async Task<ResponseResult> CmdRoleManipulation(string? parameter, bool add) {
|
||||
// parameters: @_, &, reason?
|
||||
// TODO add persistence option if/when implemented
|
||||
if (string.IsNullOrWhiteSpace(parameter)) return FromError("This response requires parameters.");
|
||||
if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount);
|
||||
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
|
||||
SocketGuildUser? tuser;
|
||||
SocketRole? trole;
|
||||
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);
|
||||
else {
|
||||
if (userName.Name == "_") tuser = _user;
|
||||
else tuser = userName.FindUserIn(_guild);
|
||||
}
|
||||
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);
|
||||
else trole = roleName.FindRoleIn(_guild);
|
||||
if (trole == null) return FromError($"Unable to find role '{roleName.Name}'.");
|
||||
|
@ -176,21 +180,17 @@ class ResponseExecutor {
|
|||
}
|
||||
|
||||
// Do action
|
||||
var rq = new RequestOptions() { AuditLogReason = $"Rule '{_rule.Label}'" };
|
||||
if (param.Length == 3 && !string.IsNullOrWhiteSpace(param[2])) {
|
||||
rq.AuditLogReason += " - " + param[2];
|
||||
}
|
||||
var rq = new RequestOptions() { AuditLogReason = LogSource };
|
||||
if (add) await tuser.AddRoleAsync(trole, rq);
|
||||
else await tuser.RemoveRoleAsync(trole, rq);
|
||||
return FromSuccess($"{(add ? "Set" : "Unset")} {trole.Mention}.");
|
||||
}
|
||||
|
||||
private async Task<ResponseResult> CmdDelete(string? parameter) {
|
||||
// TODO detailed audit log deletion reason?
|
||||
if (parameter != null) return FromError("This response does not accept parameters.");
|
||||
if (!string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamNeedNone);
|
||||
|
||||
try {
|
||||
await _msg.DeleteAsync(new RequestOptions { AuditLogReason = $"Rule {_rule.Label}" });
|
||||
await _msg.DeleteAsync(new RequestOptions { AuditLogReason = LogSource });
|
||||
return FromSuccess();
|
||||
} catch (Discord.Net.HttpException ex) when (ex.HttpCode == System.Net.HttpStatusCode.NotFound) {
|
||||
return FromError("The message had already been deleted.");
|
||||
|
@ -199,9 +199,9 @@ class ResponseExecutor {
|
|||
|
||||
private async Task<ResponseResult> CmdSay(string? parameter) {
|
||||
// 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);
|
||||
if (param.Length != 2) return FromError("Incorrect number of parameters.");
|
||||
if (param.Length != 2) return FromError(ErrParamWrongAmount);
|
||||
|
||||
// Get target
|
||||
IMessageChannel? targetCh;
|
||||
|
@ -233,17 +233,39 @@ class ResponseExecutor {
|
|||
return FromSuccess($"Sent to {(isUser ? "user DM" : $"<#{targetCh.Id}>")}.");
|
||||
}
|
||||
|
||||
private Task<ResponseResult> CmdNote(string? parameter) {
|
||||
#warning Not implemented
|
||||
return Task.FromResult(FromError("not implemented"));
|
||||
private async Task<ResponseResult> CmdNote(string? parameter) {
|
||||
if (string.IsNullOrWhiteSpace(parameter)) return FromError(ErrParamWrongAmount);
|
||||
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
|
||||
return Task.FromResult(FromError("not implemented"));
|
||||
|
||||
private async Task<ResponseResult> CmdWarn(string? parameter) {
|
||||
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
|
||||
return Task.FromResult(FromError("not implemented"));
|
||||
|
||||
private async Task<ResponseResult> CmdTimeout(string? parameter) {
|
||||
// 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
|
||||
|
||||
|
|
|
@ -1,32 +1,40 @@
|
|||
using RegexBot.Common;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace RegexBot.Modules.VoiceRoleSync;
|
||||
/// <summary>
|
||||
/// Dictionary wrapper. Key = voice channel ID, Value = role.
|
||||
/// </summary>
|
||||
class ModuleConfig {
|
||||
/// <summary>
|
||||
/// Key = voice channel ID, Value = role ID.
|
||||
/// </summary>
|
||||
private readonly ReadOnlyDictionary<ulong, ulong> _values;
|
||||
|
||||
public int Count { get => _values.Count; }
|
||||
|
||||
public ModuleConfig(JObject config) {
|
||||
// Configuration format is expected to be an object that contains other objects.
|
||||
// The objects themselves should have their name be the voice channel,
|
||||
// and the value be the role to be applied.
|
||||
|
||||
// TODO Make it accept names; currently only accepts ulongs
|
||||
|
||||
public ModuleConfig(JObject config, SocketGuild g) {
|
||||
// Configuration: Object with properties.
|
||||
// Property name is a role entity name
|
||||
// Value is a string or array of voice channel IDs.
|
||||
var values = new Dictionary<ulong, ulong>();
|
||||
|
||||
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.");
|
||||
var valstr = item.Value.Value<string>();
|
||||
if (!ulong.TryParse(valstr, out var role)) throw new ModuleLoadException($"{valstr} is not a role ID.");
|
||||
EntityName name;
|
||||
try {
|
||||
name = new EntityName(item.Name, EntityType.Role);
|
||||
} catch (FormatException) {
|
||||
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[voice] = role;
|
||||
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 ReadOnlyDictionary<ulong, ulong>(values);
|
||||
_values = new(values);
|
||||
}
|
||||
|
||||
public SocketRole? GetAssociatedRoleFor(SocketVoiceChannel voiceChannel) {
|
||||
|
@ -36,8 +44,9 @@ class ModuleConfig {
|
|||
}
|
||||
|
||||
public IEnumerable<SocketRole> GetTrackedRoles(SocketGuild guild) {
|
||||
foreach (var pair in _values) {
|
||||
var r = guild.GetRole(pair.Value);
|
||||
var roles = _values.Select(v => v.Value).Distinct();
|
||||
foreach (var id in roles) {
|
||||
var r = guild.GetRole(id);
|
||||
if (r != null) yield return r;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,8 +5,6 @@ namespace RegexBot.Modules.VoiceRoleSync;
|
|||
/// </summary>
|
||||
[RegexbotModule]
|
||||
internal class VoiceRoleSync : RegexbotModule {
|
||||
// TODO wishlist? specify multiple definitions - multiple channels associated with multiple roles.
|
||||
|
||||
public VoiceRoleSync(RegexbotClient bot) : base(bot) {
|
||||
DiscordClient.UserVoiceStateUpdated += Client_UserVoiceStateUpdated;
|
||||
}
|
||||
|
@ -18,7 +16,8 @@ internal class VoiceRoleSync : RegexbotModule {
|
|||
if (settings == null) return; // not enabled here
|
||||
|
||||
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) {
|
||||
// Not in any voice channel. Remove all roles being tracked by this instance. Clear.
|
||||
|
@ -35,10 +34,10 @@ internal class VoiceRoleSync : RegexbotModule {
|
|||
await RemoveAllAssociatedRoles();
|
||||
} else {
|
||||
// In a tracked voice channel: Clear all except target, add target if needed.
|
||||
await user.RemoveRolesAsync(settings.GetTrackedRoles(user.Guild)
|
||||
.Where(role => role.Id != targetRole.Id)
|
||||
.Intersect(user.Roles));
|
||||
if (!user.Roles.Contains(targetRole)) await user.AddRoleAsync(targetRole);
|
||||
var toRemove = settings.GetTrackedRoles(user.Guild).Where(role => role.Id != targetRole.Id).Intersect(user.Roles);
|
||||
if (toRemove.Any()) await user.RemoveRolesAsync(toRemove);
|
||||
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)
|
||||
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).");
|
||||
return Task.FromResult<object?>(newconf);
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<TargetFramework>net6.0</TargetFramework>
|
||||
<Authors>NoiTheCat</Authors>
|
||||
<Description>Advanced and flexible Discord moderation bot.</Description>
|
||||
<Version>3.0.0</Version>
|
||||
<Version>3.1.0</Version>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
||||
|
@ -21,7 +21,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<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="Microsoft.EntityFrameworkCore" Version="6.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.7">
|
||||
|
@ -30,8 +30,8 @@
|
|||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.7" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="Npgsql" Version="6.0.5" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.5" />
|
||||
<PackageReference Include="Npgsql" Version="6.0.7" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.7" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ public partial class RegexbotClient {
|
|||
|
||||
// Get all services started up
|
||||
_svcLogging = new Services.Logging.LoggingService(this);
|
||||
_svcSharedEvents = new Services.SharedEventService.SharedEventService(this);
|
||||
_svcGuildState = new Services.ModuleState.ModuleStateService(this);
|
||||
_svcCommonFunctions = new Services.CommonFunctions.CommonFunctionsService(this);
|
||||
_svcEntityCache = new Services.EntityCache.EntityCacheService(this);
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
using Discord.Net;
|
||||
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;
|
||||
/// <summary>
|
||||
/// 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 {
|
||||
private readonly CommonFunctionsService _svcCommonFunctions;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
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;
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// inform services which provide any additional features the ability to respond to those actions ahead of time.
|
||||
/// </summary>
|
||||
internal class CommonFunctionsService : Service {
|
||||
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
|
||||
var auditReason = $"(By: {source}) {logReason}";
|
||||
try {
|
||||
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);
|
||||
namespace RegexBot.Services.CommonFunctions {
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// inform services which provide any additional features the ability to respond to those actions ahead of time.
|
||||
/// </summary>
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
|
||||
namespace RegexBot {
|
||||
partial class RegexbotClient {
|
||||
private readonly CommonFunctionsService _svcCommonFunctions;
|
||||
}
|
||||
|
||||
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>
|
||||
class EntityCacheService : Service {
|
||||
private readonly UserCachingSubservice _uc;
|
||||
#pragma warning disable IDE0052
|
||||
private readonly MessageCachingSubservice _mc;
|
||||
#pragma warning restore IDE0052
|
||||
|
||||
internal EntityCacheService(RegexbotClient bot) : base(bot) {
|
||||
// Currently we only have UserCache. May add Channel and Server caches later.
|
||||
_uc = new UserCachingSubservice(bot, Log);
|
||||
_mc = new MessageCachingSubservice(bot, Log);
|
||||
_mc = new MessageCachingSubservice(bot);
|
||||
}
|
||||
|
||||
// Hooked
|
||||
|
@ -21,10 +22,4 @@ class EntityCacheService : Service {
|
|||
// Hooked
|
||||
internal CachedGuildUser? QueryGuildUserCache(ulong guildId, string 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>
|
||||
/// <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);
|
||||
|
||||
/// <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 RegexBot.Data;
|
||||
using static RegexBot.RegexbotClient;
|
||||
|
||||
namespace RegexBot.Services.EntityCache;
|
||||
class MessageCachingSubservice {
|
||||
// Hooked
|
||||
public event EcMessageUpdateHandler? OnCachePreUpdate;
|
||||
private readonly RegexbotClient _bot;
|
||||
|
||||
private readonly Action<string> _log;
|
||||
|
||||
internal MessageCachingSubservice(RegexbotClient bot, Action<string> logMethod) {
|
||||
_log = logMethod;
|
||||
internal MessageCachingSubservice(RegexbotClient bot) {
|
||||
_bot = bot;
|
||||
bot.DiscordClient.MessageReceived += DiscordClient_MessageReceived;
|
||||
bot.DiscordClient.MessageUpdated += DiscordClient_MessageUpdated;
|
||||
}
|
||||
|
@ -34,7 +30,8 @@ class MessageCachingSubservice {
|
|||
// Alternative for Discord.Net's MessageUpdated handler:
|
||||
// Notify subscribers of message update using EC entry for the previous message state
|
||||
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) {
|
||||
|
@ -55,21 +52,4 @@ class MessageCachingSubservice {
|
|||
}
|
||||
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.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
|
||||
/// provide information on users which the library may not be aware about, such as users no longer in a guild.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static")]
|
||||
class UserCachingSubservice {
|
||||
private readonly Action<string> _log;
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ namespace RegexBot.Services.Logging;
|
|||
/// </summary>
|
||||
class LoggingService : Service {
|
||||
// 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) {
|
||||
_logBasePath = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location)
|
||||
|
@ -17,8 +17,7 @@ class LoggingService : Service {
|
|||
if (!Directory.Exists(_logBasePath)) Directory.CreateDirectory(_logBasePath);
|
||||
Directory.GetFiles(_logBasePath);
|
||||
} catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) {
|
||||
_logBasePath = null;
|
||||
DoLog(Name, "Cannot create or access logging directory. File logging will be disabled.");
|
||||
throw new Exception("Cannot create or access logging directory.");
|
||||
}
|
||||
|
||||
bot.DiscordClient.Log += DiscordClient_Log;
|
||||
|
@ -50,17 +49,15 @@ class LoggingService : Service {
|
|||
// Hooked
|
||||
internal void DoLog(string source, string? message) {
|
||||
message ??= "(null)";
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var now = DateTimeOffset.Now;
|
||||
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)) {
|
||||
output.Append(prefix).AppendLine(line);
|
||||
}
|
||||
var outstr = output.ToString();
|
||||
Console.Write(outstr);
|
||||
if (_logBasePath != null) {
|
||||
var filename = _logBasePath + Path.DirectorySeparatorChar + $"{now:yyyy-MM}.log";
|
||||
File.AppendAllText(filename, outstr, Encoding.UTF8);
|
||||
}
|
||||
var filename = _logBasePath + Path.DirectorySeparatorChar + $"{now:yyyy-MM}.log";
|
||||
File.AppendAllText(filename, outstr, Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,8 @@ class ModuleStateService : Service {
|
|||
}
|
||||
|
||||
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) {
|
||||
|
@ -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) {
|
||||
var jstr = await LoadConfigFile(guild);
|
||||
JObject guildConf;
|
||||
|
@ -76,7 +69,7 @@ class ModuleStateService : Service {
|
|||
}
|
||||
|
||||
// Load moderator list
|
||||
var mods = new EntityList(guildConf["Moderators"]!, true);
|
||||
var mods = new EntityList(guildConf["Moderators"]!);
|
||||
|
||||
// Create guild state objects for all existing modules
|
||||
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