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:
Noi 2022-10-13 11:49:47 -07:00 committed by GitHub
commit d3414f7b04
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1625 additions and 369 deletions

View file

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

View file

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

View file

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

View file

@ -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();

View file

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

View file

@ -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()"));
}
}

View file

@ -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) {

View file

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

View file

@ -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)]

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

View 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" });
}
}
}

View file

@ -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
View 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!;
}

View file

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

View file

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

View file

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

View file

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

View 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);
}

View file

@ -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" +

View 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());
}
}

View 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());
}
}
}

View 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);
}
}
}

View file

@ -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.");
}

View file

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

View file

@ -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"));

View 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));
}
}

View file

@ -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);
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);
SetAttachmentsField(reportEmbed, newMsg.Attachments.Select(a => a.Filename));
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;
}
}

View file

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

View file

@ -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.");
}
}
}

View file

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

View file

@ -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();
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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);

View file

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

View 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));
}
}

View 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;
}
}

View file

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

View 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;
}
}

View 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);
}

View 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;
}
}

View file

@ -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) { }
}
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
}
namespace RegexBot {
partial class RegexbotClient {
private readonly CommonFunctionsService _svcCommonFunctions;
}
}

View 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();
}

View 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;
}
}

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

View 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;
}
}
}

View file

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

View file

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

View 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;
}
}

View file

@ -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());
}
}
}
}

View file

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

View file

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

View file

@ -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?>();

View 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; }
}
}

View 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 { }

View 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());
}
});
}
}
}
}