Add Entity Framework, update services

Add EF; port EventLoggingService to it

Update CommonFunctions: new style, some tweaks

Update user caching subservice

Update GuildStateService

- File-based only. Removed incomplete database support.
- Removed hooks within client; ModuleBase has direct access now
- Removed checks based on time-based staleness
- Code and style updates on all affected files

Style and nullable updates

And some minor structural changes here and there

Rewrite LoggingService

- Remove database-backed instance log
- Make logging methods synchronous
- Change instance reporting to webhook-based

Update ModuleStateService and related code
This commit is contained in:
Noikoio 2022-03-28 22:03:01 -07:00 committed by Noi
parent a7de9132ed
commit 02f91947f3
36 changed files with 1445 additions and 2046 deletions

View file

@ -1,146 +1,123 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
namespace RegexBot.Common
{
namespace RegexBot.Common;
/// <summary>
/// Represents a commonly-used configuration structure: an array of strings consisting of <see cref="EntityName"/> values.
/// </summary>
public class EntityList : IEnumerable<EntityName> {
private readonly IReadOnlyCollection<EntityName> _innerList;
/// <summary>Gets an enumerable collection of all role names defined in this list.</summary>
public IEnumerable<EntityName> Roles
=> _innerList.Where(n => n.Type == EntityType.Role);
/// <summary>Gets an enumerable collection of all channel names defined in this list.</summary>
public IEnumerable<EntityName> Channels
=> _innerList.Where(n => n.Type == EntityType.Channel);
/// <summary>Gets an enumerable collection of all user names defined in this list.</summary>
public IEnumerable<EntityName> Users
=> _innerList.Where(n => n.Type == EntityType.User);
/// <summary>
/// Represents a commonly-used configuration structure: an array of strings consisting of <see cref="EntityName"/> values.
/// Creates a new EntityList instance with no data.
/// </summary>
public class EntityList : IEnumerable<EntityName>
{
private readonly IReadOnlyCollection<EntityName> _innerList;
public EntityList() : this(null, false) { }
/// <summary>Gets an enumerable collection of all role names defined in this list.</summary>
public IEnumerable<EntityName> Roles
=> _innerList.Where(n => n.Type == EntityType.Role);
/// <summary>Gets an enumerable collection of all channel names defined in this list.</summary>
public IEnumerable<EntityName> Channels
=> _innerList.Where(n => n.Type == EntityType.Channel);
/// <summary>Gets an enumerable collection of all user names defined in this list.</summary>
public IEnumerable<EntityName> Users
=> _innerList.Where(n => n.Type == EntityType.User);
/// <summary>
/// Creates a new EntityList instance with no data.
/// </summary>
public EntityList() : this(null, false) { }
/// <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.
/// </exception>
/// <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)
{
if (input == null)
{
_innerList = new List<EntityName>().AsReadOnly();
return;
}
if (input.Type != JTokenType.Array)
throw new ArgumentException("JToken input must be a JSON array.");
var inputArray = (JArray)input;
var list = new List<EntityName>();
foreach (var item in inputArray.Values<string>())
{
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();
}
/// <summary>
/// Checks if the parameters of the given <see cref="SocketMessage"/> matches with
/// any entity specified in this list.
/// </summary>
/// <param name="keepId">
/// Specifies if EntityName instances within this list should have their internal ID value
/// updated if found during the matching process.
/// </param>
/// <returns>
/// True if the message author exists in this list, or if the message's channel exists in this list,
/// or if the message author contains a role that exists in this list.
/// </returns>
public bool IsListMatch(SocketMessage msg, bool keepId)
{
var author = msg.Author as SocketGuildUser;
var authorRoles = author.Roles;
var channel = msg.Channel;
foreach (var entry in this)
{
if (entry.Type == EntityType.Role)
{
if (entry.Id.HasValue)
{
return authorRoles.Any(r => r.Id == entry.Id.Value);
}
else
{
foreach (var r in authorRoles)
{
if (!string.Equals(r.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
if (keepId) entry.SetId(r.Id);
return true;
}
}
}
else if (entry.Type == EntityType.Channel)
{
if (entry.Id.HasValue)
{
return entry.Id.Value == channel.Id;
}
else
{
if (!string.Equals(channel.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
if (keepId) entry.SetId(channel.Id);
return true;
}
}
else // User
{
if (entry.Id.HasValue)
{
return entry.Id.Value == author.Id;
}
else
{
if (!string.Equals(author.Username, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
if (keepId) entry.SetId(author.Id);
return true;
}
}
}
return false;
/// <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.
/// </exception>
/// <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) {
if (input == null) {
_innerList = new List<EntityName>().AsReadOnly();
return;
}
/// <summary>
/// Determines if this list contains no entries.
/// </summary>
public bool IsEmpty() => _innerList.Count == 0;
if (input.Type != JTokenType.Array)
throw new ArgumentException("JToken input must be a JSON array.");
var inputArray = (JArray)input;
public override string ToString() => $"Entity list contains {_innerList.Count} item(s).";
public IEnumerator<EntityName> GetEnumerator() => _innerList.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
var list = new List<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();
}
/// <summary>
/// Checks if the parameters of the given <see cref="SocketMessage"/> matches with
/// any entity specified in this list.
/// </summary>
/// <param name="keepId">
/// Specifies if EntityName instances within this list should have their internal ID value
/// updated if found during the matching process.
/// </param>
/// <returns>
/// True if the message author exists in this list, or if the message's channel exists in this list,
/// or if the message author contains a role that exists in this list.
/// </returns>
public bool IsListMatch(SocketMessage msg, bool keepId) {
var author = (SocketGuildUser)msg.Author;
var authorRoles = author.Roles;
var channel = msg.Channel;
foreach (var entry in this) {
if (entry.Type == EntityType.Role) {
if (entry.Id.HasValue) {
return authorRoles.Any(r => r.Id == entry.Id.Value);
} else {
foreach (var r in authorRoles) {
if (!string.Equals(r.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
if (keepId) entry.SetId(r.Id);
return true;
}
}
} else if (entry.Type == EntityType.Channel) {
if (entry.Id.HasValue) {
return entry.Id.Value == channel.Id;
} else {
if (!string.Equals(channel.Name, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
if (keepId) entry.SetId(channel.Id);
return true;
}
} else // User
{
if (entry.Id.HasValue) {
return entry.Id.Value == author.Id;
} else {
if (!string.Equals(author.Username, entry.Name, StringComparison.OrdinalIgnoreCase)) break;
if (keepId) entry.SetId(author.Id);
return true;
}
}
}
return false;
}
/// <summary>
/// Determines if this list contains no entries.
/// </summary>
public bool IsEmpty() => _innerList.Count == 0;
public override string ToString() => $"Entity list contains {_innerList.Count} item(s).";
public IEnumerator<EntityName> GetEnumerator() => _innerList.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

View file

@ -1,224 +1,182 @@
using Discord;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.Linq;
using Discord.WebSocket;
namespace RegexBot.Common
{
namespace RegexBot.Common;
/// <summary>
/// Helper class that holds an entity's name, ID, or both.
/// Meant to be used during configuration processing in cases where the configuration expects
/// an entity name to be defined in a certain way which may or may not include its snowflake ID.
/// </summary>
public class EntityName {
/// <summary>
/// The type of entity specified in an <see cref="EntityName"/>.
/// The entity's type, if specified in configuration.
/// </summary>
public enum EntityType
{
/// <summary>Default value. Is never referenced in regular usage.</summary>
Unspecified,
Role,
Channel,
User
public EntityType Type { get; private set; }
/// <summary>
/// Entity's unique ID value (snowflake). May be null if the value is not known.
/// </summary>
public ulong? Id { get; private set; }
/// <summary>
/// Entity's name as specified in configuration. May be null if it was not specified.
/// This value is not updated during runtime.
/// </summary>
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.
/// </summary>
/// <param name="input">Input string in EntityName format.</param>
/// <exception cref="ArgumentNullException">Input string is null or blank.</exception>
/// <exception cref="ArgumentException">Input string cannot be resolved to an entity type.</exception>
public EntityName(string input) {
if (string.IsNullOrWhiteSpace(input))
throw new ArgumentNullException(nameof(input), "Specified name is blank.");
// 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.");
input = input[1..]; // Remove prefix
// Input contains ID/Label separator?
int separator = input.IndexOf("::");
if (separator != -1) {
Name = input[(separator + 2)..];
if (ulong.TryParse(input.AsSpan(0, separator), out var parseOut)) {
// Got an ID.
Id = parseOut;
} else {
// It's not actually an ID. Assuming the entire string is a name.
Name = input;
Id = null;
}
} else {
// No separator. Input is either entirely an ID or entirely a Name.
if (ulong.TryParse(input, out var parseOut)) {
// ID without name.
Id = parseOut;
Name = null;
} else {
// Name without ID.
Name = input;
Id = null;
}
}
}
internal void SetId(ulong id) {
if (!Id.HasValue) Id = id;
}
/// <summary>
/// Helper class that holds an entity's name, ID, or both.
/// Meant to be used during configuration processing in cases where the configuration expects
/// an entity name to be defined in a certain way which may or may not include its snowflake ID.
/// Returns the appropriate prefix corresponding to an EntityType.
/// </summary>
public class EntityName
{
/// <summary>
/// The entity's type, if specified in configuration.
/// </summary>
public EntityType Type { get; private set; }
public static char Prefix(EntityType t) => t switch {
EntityType.Role => '&',
EntityType.Channel => '#',
EntityType.User => '@',
_ => '\0',
};
/// <summary>
/// Entity's unique ID value (snowflake). May be null if the value is not known.
/// </summary>
public ulong? Id { get; private set; }
/// <summary>
/// Returns a string representation of this item in proper EntityName format.
/// </summary>
public override string ToString() {
char pf = Prefix(Type);
/// <summary>
/// Entity's name as specified in configuration. May be null if it was not specified.
/// This value is not updated during runtime.
/// </summary>
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.
/// </summary>
/// <param name="input">Input string in EntityName format.</param>
/// <exception cref="ArgumentNullException">Input string is null or blank.</exception>
/// <exception cref="ArgumentException">Input string cannot be resolved to an entity type.</exception>
public EntityName(string input)
{
if (string.IsNullOrWhiteSpace(input))
throw new ArgumentNullException("Specified name is null or blank.");
// Check if type prefix was specified and extract it
Type = EntityType.Unspecified;
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 == EntityType.Unspecified)
throw new ArgumentException("Entity type unable to be inferred by given input.");
input = input.Substring(1); // Remove prefix
// Input contains ID/Label separator?
int separator = input.IndexOf("::");
if (separator != -1)
{
Name = input.Substring(separator + 2, input.Length - (separator + 2));
if (ulong.TryParse(input.Substring(0, separator), out var parseOut))
{
// Got an ID.
Id = parseOut;
}
else
{
// It's not actually an ID. Assuming the entire string is a name.
Name = input;
Id = null;
}
}
else
{
// No separator. Input is either entirely an ID or entirely a Name.
if (ulong.TryParse(input, out var parseOut))
{
// ID without name.
Id = parseOut;
Name = null;
}
else
{
// Name without ID.
Name = input;
Id = null;
}
}
}
internal void SetId(ulong id)
{
if (!Id.HasValue) Id = id;
}
/// <summary>
/// Returns the appropriate prefix corresponding to an EntityType.
/// </summary>
public static char Prefix(EntityType t)
{
switch (t)
{
case EntityType.Role: return '&';
case EntityType.Channel: return '#';
case EntityType.User: return '@';
default: return '\0';
}
}
/// <summary>
/// Returns a string representation of this item in proper EntityName format.
/// </summary>
public override string ToString()
{
char pf = Prefix(Type);
if (Id.HasValue && Name != null)
return $"{pf}{Id.Value}::{Name}";
else if (Id.HasValue)
return $"{pf}{Id}";
else
return $"{pf}{Name}";
}
#region Helper methods
/// <summary>
/// Attempts to find the corresponding role within the given guild.
/// </summary>
/// <param name="guild">The guild in which to search for the role.</param>
/// <param name="updateMissingID">
/// Specifies if this EntityName instance should keep the snowflake ID of the
/// corresponding role found in this guild, if it is not already known by this instance.
/// </param>
/// <returns></returns>
public SocketRole FindRoleIn(SocketGuild guild, bool updateMissingID = false)
{
if (this.Type != EntityType.Role)
throw new ArgumentException("This EntityName instance must correspond to a Role.");
bool dirty = false; // flag for updating ID if possible regardless of updateMissingId setting
if (this.Id.HasValue)
{
var role = guild.GetRole(Id.Value);
if (role != null) return role;
else dirty = true; // only set if ID already existed but is now invalid
}
var r = guild.Roles.FirstOrDefault(rq => string.Equals(rq.Name, this.Name, StringComparison.OrdinalIgnoreCase));
if (r != null && (updateMissingID || dirty)) this.Id = r.Id;
return r;
}
/// <summary>
/// Attempts to find the corresponding user within the given guild.
/// </summary>
/// <param name="guild">The guild in which to search for the user.</param>
/// <param name="updateMissingID">
/// Specifies if this EntityName instance should keep the snowflake ID of the
/// corresponding user found in this guild, if it is not already known by this instance.
/// </param>
public SocketGuildUser FindUserIn(SocketGuild guild, bool updateMissingID = false)
{
if (this.Type != EntityType.User)
throw new ArgumentException("This EntityName instance must correspond to a User.");
bool dirty = false; // flag for updating ID if possible regardless of updateMissingId setting
if (this.Id.HasValue)
{
var user = guild.GetUser(Id.Value);
if (user != null) return user;
else dirty = true; // only set if ID already existed but is now invalid
}
var u = guild.Users.FirstOrDefault(rq => string.Equals(rq.Username, this.Name, StringComparison.OrdinalIgnoreCase));
if (u != null && (updateMissingID || dirty)) this.Id = u.Id;
return u;
}
/// <summary>
/// Attempts to find the corresponding channel within the given guild.
/// </summary>
/// <param name="guild">The guild in which to search for the channel.</param>
/// <param name="updateMissingID">
/// Specifies if this EntityName instance should keep the snowflake ID of the
/// corresponding channel found in this guild, if it is not already known by this instance.
/// </param>
public SocketTextChannel FindChannelIn(SocketGuild guild, bool updateMissingID = false)
{
if (this.Type != EntityType.Channel)
throw new ArgumentException("This EntityName instance must correspond to a Channel.");
bool dirty = false; // flag for updating ID if possible regardless of updateMissingId setting
if (this.Id.HasValue)
{
var channel = guild.GetTextChannel(Id.Value);
if (channel != null) return channel;
else dirty = true; // only set if ID already existed but is now invalid
}
var c = guild.TextChannels.FirstOrDefault(rq => string.Equals(rq.Name, this.Name, StringComparison.OrdinalIgnoreCase));
if (c != null && (updateMissingID || dirty)) this.Id = c.Id;
return c;
}
#endregion
if (Id.HasValue && Name != null)
return $"{pf}{Id.Value}::{Name}";
else if (Id.HasValue)
return $"{pf}{Id}";
else
return $"{pf}{Name}";
}
#region Helper methods
/// <summary>
/// Attempts to find the corresponding role within the given guild.
/// </summary>
/// <param name="guild">The guild in which to search for the role.</param>
/// <param name="updateMissingID">
/// Specifies if this EntityName instance should keep the snowflake ID of the
/// corresponding role found in this guild, if it is not already known by this instance.
/// </param>
public SocketRole? FindRoleIn(SocketGuild guild, bool updateMissingID = false) {
if (Type != EntityType.Role)
throw new ArgumentException("This EntityName instance must correspond to a Role.");
bool dirty = false; // flag for updating ID if possible regardless of updateMissingId setting
if (Id.HasValue) {
var role = guild.GetRole(Id.Value);
if (role != null) return role;
else dirty = true; // only set if ID already existed but is now invalid
}
var r = guild.Roles.FirstOrDefault(rq => string.Equals(rq.Name, Name, StringComparison.OrdinalIgnoreCase));
if (r != null && (updateMissingID || dirty)) Id = r.Id;
return r;
}
/// <summary>
/// Attempts to find the corresponding user within the given guild.
/// </summary>
/// <param name="guild">The guild in which to search for the user.</param>
/// <param name="updateMissingID">
/// Specifies if this EntityName instance should keep the snowflake ID of the
/// corresponding user found in this guild, if it is not already known by this instance.
/// </param>
public SocketGuildUser? FindUserIn(SocketGuild guild, bool updateMissingID = false) {
if (Type != EntityType.User)
throw new ArgumentException("This EntityName instance must correspond to a User.");
bool dirty = false; // flag for updating ID if possible regardless of updateMissingId setting
if (Id.HasValue) {
var user = guild.GetUser(Id.Value);
if (user != null) return user;
else dirty = true; // only set if ID already existed but is now invalid
}
var u = guild.Users.FirstOrDefault(rq => string.Equals(rq.Username, Name, StringComparison.OrdinalIgnoreCase));
if (u != null && (updateMissingID || dirty)) Id = u.Id;
return u;
}
/// <summary>
/// Attempts to find the corresponding channel within the given guild.
/// </summary>
/// <param name="guild">The guild in which to search for the channel.</param>
/// <param name="updateMissingID">
/// Specifies if this EntityName instance should keep the snowflake ID of the
/// corresponding channel found in this guild, if it is not already known by this instance.
/// </param>
public SocketTextChannel? FindChannelIn(SocketGuild guild, bool updateMissingID = false) {
if (Type != EntityType.Channel)
throw new ArgumentException("This EntityName instance must correspond to a Channel.");
bool dirty = false; // flag for updating ID if possible regardless of updateMissingId setting
if (Id.HasValue) {
var channel = guild.GetTextChannel(Id.Value);
if (channel != null) return channel;
else dirty = true; // only set if ID already existed but is now invalid
}
var c = guild.TextChannels.FirstOrDefault(rq => string.Equals(rq.Name, Name, StringComparison.OrdinalIgnoreCase));
if (c != null && (updateMissingID || dirty)) Id = c.Id;
return c;
}
#endregion
}

View file

@ -0,0 +1,12 @@
namespace RegexBot.Common;
/// <summary>
/// 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,
Role,
Channel,
User
}

View file

@ -1,111 +1,97 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using System;
namespace RegexBot.Common
{
namespace RegexBot.Common;
/// <summary>
/// Represents commonly-used configuration regarding whitelist/blacklist filtering, including exemptions.
/// </summary>
public class FilterList {
public enum FilterMode { None, Whitelist, Blacklist }
public FilterMode Mode { get; }
public EntityList FilteredList { get; }
public EntityList FilterExemptions { get; }
/// <summary>
/// Represents commonly-used configuration regarding whitelist/blacklist filtering, including exemptions.
/// Sets up a FilterList instance with the given JSON configuration section.
/// </summary>
public class FilterList
{
public enum FilterMode { None, Whitelist, Blacklist }
public FilterMode Mode { get; }
public EntityList FilteredList { get; }
public EntityList FilterExemptions { get; }
/// <summary>
/// Sets up a FilterList instance with the given JSON configuration section.
/// </summary>
/// <param name="config">
/// JSON object in which to attempt to find the given whitelist, blacklist, and/or excemption keys.
/// </param>
/// <param name="whitelistKey">The key in which to search for the whitelist. Set as null to disable.</param>
/// <param name="blacklistKey">The key in which to search for the blacklist. Set as null to disable.</param>
/// <param name="exemptKey">The key in which to search for filter exemptions. Set as null to disable.</param>
/// <exception cref="FormatException">
/// Thrown if there is a problem with input. The exception message specifies the reason.
/// </exception>
public FilterList(
JObject config,
string whitelistKey = "whitelist",
string blacklistKey = "blacklist",
string exemptKey = "exempt")
{
if ((whitelistKey != null && config[whitelistKey] != null) &&
(blacklistKey != null && config[blacklistKey] != null))
{
throw new FormatException($"Cannot have both '{whitelistKey}' and '{blacklistKey}' defined at once.");
}
JToken valueSrc = null;
if (whitelistKey != null)
{
// Try getting a whitelist
valueSrc = config[whitelistKey];
Mode = FilterMode.Whitelist;
}
if (valueSrc != null && blacklistKey != null)
{
// Try getting a blacklist
valueSrc = config[blacklistKey];
Mode = FilterMode.Blacklist;
}
if (valueSrc == null)
{
// Got neither. Have an empty list.
Mode = FilterMode.None;
FilteredList = new EntityList();
FilterExemptions = new EntityList();
return;
}
// Verify that the specified array is actually an array.
if (valueSrc != null && valueSrc.Type != JTokenType.Array)
throw new ArgumentException("Given list must be a JSON array.");
FilteredList = new EntityList((JArray)valueSrc, true);
// Verify the same for the exemption list.
FilterExemptions = new EntityList();
if (exemptKey != null)
{
var exc = config[exemptKey];
if (exc != null && exc.Type == JTokenType.Array)
{
FilterExemptions = new EntityList(exc, true);
}
}
/// <param name="config">
/// JSON object in which to attempt to find the given whitelist, blacklist, and/or excemption keys.
/// </param>
/// <param name="whitelistKey">The key in which to search for the whitelist. Set as null to disable.</param>
/// <param name="blacklistKey">The key in which to search for the blacklist. Set as null to disable.</param>
/// <param name="exemptKey">The key in which to search for filter exemptions. Set as null to disable.</param>
/// <exception cref="FormatException">
/// Thrown if there is a problem with input. The exception message specifies the reason.
/// </exception>
public FilterList(
JObject config,
string whitelistKey = "whitelist",
string blacklistKey = "blacklist",
string exemptKey = "exempt") {
if ((whitelistKey != null && config[whitelistKey] != null) &&
(blacklistKey != null && config[blacklistKey] != null)) {
throw new FormatException($"Cannot have both '{whitelistKey}' and '{blacklistKey}' defined at once.");
}
/// <summary>
/// Determines if the parameters of the given message match up against the filtering
/// rules described within this instance.
/// </summary>
/// <param name="keepId">
/// See equivalent documentation for <see cref="EntityList.IsListMatch(SocketMessage, bool)"/>.
/// </param>
/// <returns>
/// True if the author or associated channel exists in and is not exempted by this instance.
/// </returns>
public bool IsFiltered(SocketMessage msg, bool keepId)
{
if (Mode == FilterMode.None) return false;
JToken? valueSrc = null;
if (whitelistKey != null) {
// Try getting a whitelist
valueSrc = config[whitelistKey];
Mode = FilterMode.Whitelist;
}
if (valueSrc != null && blacklistKey != null) {
// Try getting a blacklist
valueSrc = config[blacklistKey];
Mode = FilterMode.Blacklist;
}
bool inFilter = FilteredList.IsListMatch(msg, keepId);
if (Mode == FilterMode.Whitelist)
{
if (!inFilter) return true;
return FilterExemptions.IsListMatch(msg, keepId);
}
else if (Mode == FilterMode.Blacklist)
{
if (!inFilter) return false;
return !FilterExemptions.IsListMatch(msg, keepId);
}
if (valueSrc == null) {
// Got neither. Have an empty list.
Mode = FilterMode.None;
FilteredList = new EntityList();
FilterExemptions = new EntityList();
return;
}
throw new Exception("it is not possible for this to happen");
// Verify that the specified array is actually an array.
if (valueSrc != null && valueSrc.Type != JTokenType.Array)
throw new ArgumentException("Given list must be a JSON array.");
FilteredList = new EntityList((JArray)valueSrc!, true);
// Verify the same for the exemption list.
FilterExemptions = new EntityList();
if (exemptKey != null) {
var exc = config[exemptKey];
if (exc != null && exc.Type == JTokenType.Array) {
FilterExemptions = new EntityList(exc, true);
}
}
}
/// <summary>
/// Determines if the parameters of the given message match up against the filtering
/// rules described within this instance.
/// </summary>
/// <param name="keepId">
/// See equivalent documentation for <see cref="EntityList.IsListMatch(SocketMessage, bool)"/>.
/// </param>
/// <returns>
/// True if the author or associated channel exists in and is not exempted by this instance.
/// </returns>
public bool IsFiltered(SocketMessage msg, bool keepId) {
if (Mode == FilterMode.None) return false;
bool inFilter = FilteredList.IsListMatch(msg, keepId);
if (Mode == FilterMode.Whitelist) {
if (!inFilter) return true;
return FilterExemptions.IsListMatch(msg, keepId);
} else if (Mode == FilterMode.Blacklist) {
if (!inFilter) return false;
return !FilterExemptions.IsListMatch(msg, keepId);
}
throw new Exception("it is not possible for this to happen");
}
}

View file

@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore;
namespace RegexBot.Data;
public class BotDatabaseContext : DbContext {
private static string? _npgsqlConnectionString;
internal static string NpgsqlConnectionString {
#if DEBUG
get {
if (_npgsqlConnectionString != null) return _npgsqlConnectionString;
Console.WriteLine($"{nameof(RegexBot)} - {nameof(BotDatabaseContext)} note: Using hardcoded connection string!");
return _npgsqlConnectionString ?? "Host=localhost;Username=regexbot;Password=rb";
}
#else
get => _npgsqlConnectionString!;
#endif
set => _npgsqlConnectionString ??= value;
}
public DbSet<GuildLogLine> GuildLog { get; set; } = null!;
public DbSet<CachedUser> UserCache { get; set; } = null!;
public DbSet<CachedGuildUser> GuildUserCache { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseNpgsql(NpgsqlConnectionString)
.UseSnakeCaseNamingConvention();
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.Entity<GuildLogLine>(entity =>
entity.Property(e => e.Timestamp).HasDefaultValueSql("now()"));
modelBuilder.Entity<CachedUser>(entity => entity.Property(e => e.Discriminator).HasMaxLength(4).IsFixedLength());
modelBuilder.Entity<CachedGuildUser>(entity => entity.Navigation(e => e.User).AutoInclude());
}
}

View file

@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RegexBot.Data;
[Table("cache_guilduser")]
public class CachedGuildUser {
[Key]
public long UserId { get; set; }
[Key]
public long GuildId { get; set; }
public DateTimeOffset GULastUpdateTime { get; set; }
public DateTimeOffset FirstSeenTime { get; set; }
public string? Nickname { get; set; }
[ForeignKey(nameof(CachedUser.UserId))]
[InverseProperty(nameof(CachedUser.Guilds))]
public CachedUser User { get; set; } = null!;
}

View file

@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RegexBot.Data;
[Table("cache_user")]
public class CachedUser {
[Key]
public long UserId { get; set; }
public DateTimeOffset ULastUpdateTime { get; set; }
public string Username { get; set; } = null!;
public string Discriminator { get; set; } = null!;
public string? AvatarUrl { get; set; }
[InverseProperty(nameof(CachedGuildUser.User))]
public ICollection<CachedGuildUser> Guilds { get; set; } = null!;
}

View file

@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations.Schema;
namespace RegexBot.Data;
[Table("guild_log")]
[Index(nameof(GuildId))]
public class GuildLogLine {
public int Id { get; set; }
public long GuildId { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string Source { get; set; } = null!;
public string Message { get; set; } = null!;
}

View file

@ -1,117 +1,81 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using RegexBot;
using System;
using System.IO;
using System.Linq;
using System.Reflection;
namespace RegexBot {
namespace RegexBot;
/// <summary>
/// Contains essential instance configuration for this bot including Discord connection settings, service configuration,
/// and command-line options.
/// </summary>
class InstanceConfig {
/// <summary>
/// Contains instance configuration for this bot,
/// including Discord connection settings and service configuration.
/// Token used for Discord authentication.
/// </summary>
class InstanceConfig
{
const string JBotToken = "BotToken";
readonly string _botToken;
/// <summary>
/// Token used for Discord authentication.
/// </summary>
internal string BotToken => _botToken;
internal string BotToken { get; }
const string JAssemblies = "Assemblies";
readonly string[] _enabledAssemblies;
/// <summary>
/// List of assemblies to load, by file. Paths are always relative to the bot directory.
/// </summary>
internal string[] EnabledAssemblies => _enabledAssemblies;
/// <summary>
/// List of assemblies to load, by file. Paths are always relative to the bot directory.
/// </summary>
internal string[] Assemblies { get; }
const string JPgSqlConnectionString = "SqlConnectionString";
readonly string _pgSqlConnectionString;
/// <summary>
/// Connection string for accessing the PostgreSQL database.
/// </summary>
/// <remarks>
/// That's right, the user can specify the -entire- thing.
/// Should problems arise, this will be replaced by a full section within configuration.
/// </remarks>
internal string PostgresConnString => _pgSqlConnectionString;
/// <summary>
/// Connection string for accessing the PostgreSQL database.
/// </summary>
internal string PostgresConnString { get; }
const string JInstanceLogReportTarget = "LogTarget";
readonly ulong _ilReptGuild, _ilReptChannel;
/// <summary>
/// Guild and channel ID, respectively, for instance log reporting.
/// Specified as "(guild ID)/(channel ID)".
/// </summary>
internal (ulong, ulong) InstanceLogReportTarget => (_ilReptGuild, _ilReptChannel);
/// <summary>
/// Webhook URL for bot log reporting.
/// </summary>
internal string InstanceLogTarget { get; }
// TODO add fields for services to be configurable: DMRelay
// TODO add fields for services to be configurable: DMRelay
/// <summary>
/// Sets up instance configuration object from file and command line parameters.
/// </summary>
/// <param name="path">Path to file from which to load configuration. If null, uses default path.</param>
internal InstanceConfig(Options options)
{
string path = options.ConfigFile;
if (path == null) // default: config.json in working directory
{
path = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location)
+ "." + Path.DirectorySeparatorChar + "config.json";
}
/// <summary>
/// Sets up instance configuration object from file and command line parameters.
/// </summary>
internal InstanceConfig(string[] cmdline) {
var opts = Options.ParseOptions(cmdline);
JObject conf;
try
{
var conftxt = File.ReadAllText(path);
conf = JObject.Parse(conftxt);
}
catch (Exception ex)
{
string pfx;
if (ex is JsonException) pfx = "Unable to parse configuration: ";
else pfx = "Unable to access configuration: ";
throw new Exception(pfx + ex.Message, ex);
}
// Input validation - throw exception on errors. Exception messages printed as-is.
_botToken = conf[JBotToken]?.Value<string>();
if (string.IsNullOrEmpty(_botToken))
throw new Exception($"'{JBotToken}' is not properly specified in configuration.");
_pgSqlConnectionString = conf[JPgSqlConnectionString]?.Value<string>();
if (string.IsNullOrEmpty(_pgSqlConnectionString))
throw new Exception($"'{JPgSqlConnectionString}' is not properly specified in configuration.");
var asmList = conf[JAssemblies];
if (asmList == null || asmList.Type != JTokenType.Array)
{
throw new Exception($"'{JAssemblies}' is not properly specified in configuration.");
}
_enabledAssemblies = asmList.Values<string>().ToArray();
var ilInput = conf[JInstanceLogReportTarget]?.Value<string>();
if (!string.IsNullOrWhiteSpace(ilInput))
{
int idx = ilInput.IndexOf('/');
if (idx < 0) throw new Exception($"'{JInstanceLogReportTarget}' is not properly specified in configuration.");
try
{
_ilReptGuild = ulong.Parse(ilInput.Substring(0, idx));
_ilReptChannel = ulong.Parse(ilInput.Substring(idx + 1, ilInput.Length - (idx + 1)));
}
catch (FormatException)
{
throw new Exception($"'{JInstanceLogReportTarget}' is not properly specified in configuration.");
}
}
else
{
// Feature is disabled
_ilReptGuild = 0;
_ilReptChannel = 0;
}
string path = opts.ConfigFile;
if (path == null) { // default: config.json in working directory
path = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location)
+ "." + Path.DirectorySeparatorChar + "config.json";
}
JObject conf;
try {
var conftxt = File.ReadAllText(path);
conf = JObject.Parse(conftxt);
} catch (Exception ex) {
string pfx;
if (ex is JsonException) pfx = "Unable to parse configuration: ";
else pfx = "Unable to access configuration: ";
throw new Exception(pfx + ex.Message, ex);
}
#pragma warning disable CS8601 // Possible null reference assignment.
// Input validation - throw exception on errors. Exception messages printed as-is.
BotToken = conf[nameof(BotToken)]?.Value<string>();
if (string.IsNullOrEmpty(BotToken))
throw new Exception($"'{nameof(BotToken)}' is not properly specified in configuration.");
PostgresConnString = conf[nameof(PostgresConnString)]?.Value<string>();
if (string.IsNullOrEmpty(PostgresConnString))
throw new Exception($"'{nameof(PostgresConnString)}' is not properly specified in configuration.");
InstanceLogTarget = conf[nameof(InstanceLogTarget)]?.Value<string>();
if (string.IsNullOrEmpty(InstanceLogTarget))
throw new Exception($"'{nameof(InstanceLogTarget)}' is not properly specified in configuration.");
#pragma warning restore CS8601
var asmList = conf[nameof(Assemblies)];
if (asmList == null || asmList.Type != JTokenType.Array) {
throw new Exception($"'{nameof(Assemblies)}' is not properly specified in configuration.");
}
var asmListImport = new List<string>();
foreach (var line in asmList.Values<string>()) if (!string.IsNullOrEmpty(line)) asmListImport.Add(line);
Assemblies = asmListImport.ToArray();
}
}

View file

@ -1,154 +0,0 @@
using Discord.WebSocket;
using RegexBot.Common;
using Newtonsoft.Json.Linq;
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using static RegexBot.RegexbotClient;
namespace RegexBot
{
/// <summary>
/// Base class for a Kerobot module. A module implements a user-facing feature and is expected to directly handle
/// user input (both by means of configuration and incoming Discord events) and process it accordingly.
/// </summary>
/// <remarks>
/// Implementing classes should not rely on local variables to store runtime data regarding guilds.
/// Use <see cref="CreateGuildStateAsync(JToken)"/> and <see cref="GetGuildState{T}(ulong)"/>.
/// </remarks>
public abstract class ModuleBase
{
/// <summary>
/// Retrieves the Kerobot instance.
/// </summary>
public RegexbotClient BotClient { get; }
/// <summary>
/// Retrieves the Discord client instance.
/// </summary>
public DiscordSocketClient DiscordClient { get => BotClient.DiscordClient; }
/// <summary>
/// When a module is loaded, this constructor is called.
/// Services are available at this point. Do not attempt to communicate to Discord within the constructor.
/// </summary>
public ModuleBase(RegexbotClient bot) => BotClient = bot;
/// <summary>
/// Gets the module name.
/// This value is derived from the class's name. It is used in configuration and logging.
/// </summary>
public string Name => GetType().Name;
/// <summary>
/// Called when a guild becomes available. The implementing class should construct an object to hold
/// data specific to the corresponding guild for use during runtime.
/// </summary>
/// <param name="guildID">Corresponding guild ID for the state data being used. Can be useful when reloading.</param>
/// <param name="config">JSON token holding module configuration specific to this guild.</param>
/// <returns>
/// An object containing state and/or configuration information for the guild currently being processed.
/// </returns>
public abstract Task<object> CreateGuildStateAsync(ulong guildID, JToken config);
/// <summary>
/// Retrieves the state object that corresponds with the given guild.
/// </summary>
/// <typeparam name="T">The state object's type.</typeparam>
/// <param name="guildId">The guild ID for which to retrieve the state object.</param>
/// <returns>The state object cast in the given type, or Default(T) if none exists.</returns>
/// <exception cref="InvalidCastException">
/// Thrown if the stored state object cannot be cast as specified.
/// </exception>
[DebuggerStepThrough]
protected T GetGuildState<T>(ulong guildId) => BotClient.GetGuildState<T>(guildId, GetType());
/// <summary>
/// Appends a message to the global instance log. Use sparingly.
/// </summary>
/// <param name="report">
/// Specifies if the log message should be sent to the reporting channel.
/// Only messages of very high importance should use this option.
/// </param>
protected Task LogAsync(string message, bool report = false) => BotClient.InstanceLogAsync(report, Name, message);
/// <summary>
/// Appends a message to the log for the specified guild.
/// </summary>
protected Task LogAsync(ulong guild, string message) => BotClient.GuildLogAsync(guild, Name, message);
/// <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
/// that the action originated from the bot, and allows them to handle the action appropriately.
/// </summary>
/// <returns>A structure containing results of the ban operation.</returns>
/// <param name="guild">The guild in which to attempt the action.</param>
/// <param name="source">The user, module, or service which is requesting this action to be taken.</param>
/// <param name="targetUser">The user which to perform the action to.</param>
/// <param name="purgeDays">Number of days of prior post history to delete on ban. Must be between 0-7.</param>
/// <param name="reason">Reason for the action. Sent to the Audit Log and user (if specified).</param>
/// <param name="sendDMToTarget">Specify whether to send a direct message to the target user informing them of the action being taken.</param>
protected Task<BanKickResult> BanAsync(SocketGuild guild, string source, ulong targetUser, int purgeDays, string reason, bool sendDMToTarget)
=> BotClient.BanOrKickAsync(RemovalType.Ban, guild, source, targetUser, purgeDays, reason, sendDMToTarget);
/// <summary>
/// Similar to <see cref="BanAsync(SocketGuild, string, ulong, int, string, bool)"/>, but making use of an
/// EntityCache lookup to determine the target.
/// </summary>
/// <param name="targetSearch">The EntityCache search string.</param>
protected async Task<BanKickResult> BanAsync(SocketGuild guild, string source, string targetSearch, int purgeDays, string reason, bool sendDMToTarget)
{
var result = await BotClient.EcQueryUser(guild.Id, targetSearch);
if (result == null) return new BanKickResult(null, false, true, RemovalType.Ban, 0);
return await BanAsync(guild, source, result.UserID, purgeDays, reason, sendDMToTarget);
}
/// <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
/// that the action originated from the bot, and allows them to handle the action appropriately.
/// </summary>
/// <returns>A structure containing results of the ban operation.</returns>
/// <param name="guild">The guild in which to attempt the action.</param>
/// <param name="source">The user, if any, which requested the action to be taken.</param>
/// <param name="targetUser">The user which to perform the action to.</param>
/// <param name="reason">Reason for the action. Sent to the Audit Log and user (if specified).</param>
/// <param name="sendDMToTarget">Specify whether to send a direct message to the target user informing them of the action being taken.</param>
protected Task<BanKickResult> KickAsync(SocketGuild guild, string source, ulong targetUser, string reason, bool sendDMToTarget)
=> BotClient.BanOrKickAsync(RemovalType.Ban, guild, source, targetUser, 0, reason, sendDMToTarget);
/// <summary>
/// Similar to <see cref="KickAsync(SocketGuild, string, ulong, string, bool)"/>, but making use of an
/// EntityCache lookup to determine the target.
/// </summary>
/// <param name="targetSearch">The EntityCache search string.</param>
protected async Task<BanKickResult> KickAsync(SocketGuild guild, string source, string targetSearch, string reason, bool sendDMToTarget)
{
var result = await BotClient.EcQueryUser(guild.Id, targetSearch);
if (result == null) return new BanKickResult(null, false, true, RemovalType.Kick, 0);
return await KickAsync(guild, source, result.UserID, reason, sendDMToTarget);
}
/// <summary>
/// Returns the list of moderators defined in the current guild configuration.
/// </summary>
/// <returns>
/// An <see cref="EntityList"/> with corresponding moderator configuration data.
/// In case none exists, an empty list will be returned.
/// </returns>
protected EntityList GetModerators(ulong guild) => BotClient.GetModerators(guild);
}
/// <summary>
/// Represents errors that occur when a module attempts to create a new guild state object.
/// </summary>
public class ModuleLoadException : Exception
{
/// <summary>
/// Initializes this exception class with the specified error message.
/// </summary>
/// <param name="message"></param>
public ModuleLoadException(string message) : base(message) { }
}
}

View file

@ -0,0 +1,11 @@
namespace RegexBot;
/// <summary>
/// Represents errors that occur when a module attempts to create a new guild state object.
/// </summary>
public class ModuleLoadException : Exception {
/// <summary>
/// Initializes this exception class with the specified error message.
/// </summary>
public ModuleLoadException(string message) : base(message) { }
}

View file

@ -1,72 +1,56 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Collections.ObjectModel;
using System.Reflection;
namespace RegexBot
{
static class ModuleLoader
{
private const string LogName = nameof(ModuleLoader);
namespace RegexBot;
/// <summary>
/// Given the instance configuration, loads all appropriate types from file specified in it.
/// </summary>
internal static ReadOnlyCollection<ModuleBase> Load(InstanceConfig conf, RegexbotClient k)
{
var path = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location) + Path.DirectorySeparatorChar;
var modules = new List<ModuleBase>();
static class ModuleLoader {
private const string LogName = nameof(ModuleLoader);
foreach (var file in conf.EnabledAssemblies)
{
Assembly a = null;
try
{
a = Assembly.LoadFile(path + file);
}
catch (Exception ex)
{
Console.WriteLine("An error occurred when attempting to load a module assembly.");
Console.WriteLine($"File: {file}");
Console.WriteLine(ex.ToString());
Environment.Exit(2);
}
/// <summary>
/// Given the instance configuration, loads all appropriate types from file specified in it.
/// </summary>
internal static ReadOnlyCollection<RegexbotModule> Load(InstanceConfig conf, RegexbotClient k) {
var path = Path.GetDirectoryName(Assembly.GetEntryAssembly()!.Location) + Path.DirectorySeparatorChar;
var modules = new List<RegexbotModule>();
IEnumerable<ModuleBase> amods = null;
try
{
amods = LoadModulesFromAssembly(a, k);
}
catch (Exception ex)
{
Console.WriteLine("An error occurred when attempting to create a module instance.");
Console.WriteLine(ex.ToString());
Environment.Exit(2);
}
modules.AddRange(amods);
foreach (var file in conf.Assemblies) {
Assembly? a = null;
try {
a = Assembly.LoadFile(path + file);
} catch (Exception ex) {
Console.WriteLine("An error occurred when attempting to load a module assembly.");
Console.WriteLine($"File: {file}");
Console.WriteLine(ex.ToString());
Environment.Exit(2);
}
return modules.AsReadOnly();
}
static IEnumerable<ModuleBase> LoadModulesFromAssembly(Assembly asm, RegexbotClient k)
{
var eligibleTypes = from type in asm.GetTypes()
where !type.IsAssignableFrom(typeof(ModuleBase))
where type.GetCustomAttribute<RegexbotModuleAttribute>() != null
select type;
k.InstanceLogAsync(false, LogName, $"Scanning {asm.GetName().Name}");
var newmods = new List<ModuleBase>();
foreach (var t in eligibleTypes)
{
var mod = Activator.CreateInstance(t, k);
k.InstanceLogAsync(false, LogName,
$"---> Loading module {t.FullName}");
newmods.Add((ModuleBase)mod);
IEnumerable<RegexbotModule>? amods = null;
try {
amods = LoadModulesFromAssembly(a, k);
} catch (Exception ex) {
Console.WriteLine("An error occurred when attempting to create a module instance.");
Console.WriteLine(ex.ToString());
Environment.Exit(2);
}
return newmods;
modules.AddRange(amods);
}
return modules.AsReadOnly();
}
static IEnumerable<RegexbotModule> LoadModulesFromAssembly(Assembly asm, RegexbotClient k) {
var eligibleTypes = from type in asm.GetTypes()
where !type.IsAssignableFrom(typeof(RegexbotModule))
where type.GetCustomAttribute<RegexbotModuleAttribute>() != null
select type;
k._svcLogging.DoInstanceLog(false, LogName, $"Scanning {asm.GetName().Name}");
var newmods = new List<RegexbotModule>();
foreach (var t in eligibleTypes) {
var mod = Activator.CreateInstance(t, k)!;
k._svcLogging.DoInstanceLog(false, LogName,
$"---> Loading module {t.FullName}");
newmods.Add((RegexbotModule)mod);
}
return newmods;
}
}

View file

@ -1,34 +1,33 @@
using CommandLine;
using CommandLine.Text;
using System;
namespace RegexBot {
namespace RegexBot;
/// <summary>
/// Command line options
/// </summary>
class Options {
[Option('c', "config", Default = null,
HelpText = "Custom path to instance configuration. Defaults to config.json in bot directory.")]
public string ConfigFile { get; set; } = null!;
/// <summary>
/// Command line options
/// Command line arguments parsed here. Depending on inputs, the program can exit here.
/// </summary>
class Options {
[Option('c', "config", Default = null,
HelpText = "Custom path to instance configuration. Defaults to config.json in bot directory.")]
public string ConfigFile { get; set; }
public static Options ParseOptions(string[] args) {
// Parser will not write out to console by itself
var parser = new Parser(config => config.HelpWriter = null);
Options? opts = null;
/// <summary>
/// Command line arguments parsed here. Depending on inputs, the program can exit here.
/// </summary>
public static Options ParseOptions(string[] args) {
// Parser will not write out to console by itself
var parser = new Parser(config => config.HelpWriter = null);
Options opts = null;
var result = parser.ParseArguments<Options>(args);
result.WithParsed(p => opts = p);
result.WithNotParsed(p => {
// Taking some extra steps to modify the header to make it resemble our welcome message.
var ht = HelpText.AutoBuild(result);
ht.Heading += " - https://github.com/NoiTheCat/RegexBot";
Console.WriteLine(ht.ToString());
Environment.Exit(1);
});
return opts;
}
var result = parser.ParseArguments<Options>(args);
result.WithParsed(p => opts = p);
result.WithNotParsed(p => {
// Taking some extra steps to modify the header to make it resemble our welcome message.
var ht = HelpText.AutoBuild(result);
ht.Heading += " - https://github.com/NoiTheCat/RegexBot";
Console.WriteLine(ht.ToString());
Environment.Exit(1);
});
return opts!;
}
}

View file

@ -1,101 +1,69 @@
using Discord;
using Discord.WebSocket;
using RegexBot;
using System;
using System.Threading.Tasks;
namespace RegexBot {
namespace RegexBot;
class Program {
/// <summary>
/// Program startup class.
/// </summary>
class Program
{
/// <summary>
/// Timestamp specifying the date and time that the program began running.
/// </summary>
public static DateTimeOffset StartTime { get; private set; }
/// Timestamp specifying the date and time that the program began running.
/// </summary>
public static DateTimeOffset StartTime { get; private set; }
static RegexbotClient _main;
/// <summary>
/// Entry point. Loads options, initializes all components, then connects to Discord.
/// </summary>
static async Task Main(string[] args)
{
StartTime = DateTimeOffset.UtcNow;
Console.WriteLine("Bot start time: " + StartTime.ToString("u"));
static RegexbotClient _main = null!;
// Get instance configuration from file and parameters
var opts = Options.ParseOptions(args); // Program can exit here.
InstanceConfig cfg;
try
{
cfg = new InstanceConfig(opts);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
Environment.ExitCode = 1;
return;
}
static async Task Main(string[] args) {
StartTime = DateTimeOffset.UtcNow;
Console.WriteLine("Bot start time: " + StartTime.ToString("u"));
// Quick test of database configuration
try
{
using (var d = new Npgsql.NpgsqlConnection(cfg.PostgresConnString))
{
await d.OpenAsync();
d.Close();
}
}
catch (Exception ex)
{
Console.WriteLine("Could not establish a database connection! Check your settings and try again.");
Console.WriteLine($"Error: {ex.GetType().FullName}: {ex.Message}");
Environment.Exit(1);
}
// Configure Discord client
var client = new DiscordSocketClient(new DiscordSocketConfig()
{
DefaultRetryMode = RetryMode.AlwaysRetry,
MessageCacheSize = 0, // using our own
LogLevel = LogSeverity.Info
});
// Kerobot class initialization - will set up services and modules
_main = new RegexbotClient(cfg, client);
// Set up application close handler
Console.CancelKeyPress += Console_CancelKeyPress;
// TODO Set up unhandled exception handler
// send error notification to instance log channel, if possible
// And off we go.
await _main.DiscordClient.LoginAsync(TokenType.Bot, cfg.BotToken);
await _main.DiscordClient.StartAsync();
await Task.Delay(-1);
InstanceConfig cfg;
try {
cfg = new InstanceConfig(args); // Program may exit within here.
} catch (Exception ex) {
Console.WriteLine(ex.Message);
Environment.ExitCode = 1;
return;
}
private static void Console_CancelKeyPress(object sender, ConsoleCancelEventArgs e)
{
e.Cancel = true;
// Configure Discord client
var client = new DiscordSocketClient(new DiscordSocketConfig() {
DefaultRetryMode = RetryMode.Retry502 | RetryMode.RetryTimeouts,
MessageCacheSize = 0, // using our own
LogLevel = LogSeverity.Info,
GatewayIntents = GatewayIntents.All & ~GatewayIntents.GuildPresences,
LogGatewayIntentWarnings = false
});
_main.InstanceLogAsync(true, nameof(RegexBot), "Shutting down. Reason: Interrupt signal.");
// Kerobot class initialization - will set up services and modules
_main = new RegexbotClient(cfg, client);
// 5 seconds of leeway - any currently running tasks will need time to finish executing
var leeway = Task.Delay(5000);
// Set up application close handler
Console.CancelKeyPress += Console_CancelKeyPress;
// TODO periodic task service: stop processing, wait for all tasks to finish
// TODO notify services of shutdown
// TODO Set up unhandled exception handler
// send error notification to instance log channel, if possible
leeway.Wait();
bool success = _main.DiscordClient.StopAsync().Wait(1000);
if (!success) _main.InstanceLogAsync(false, nameof(RegexBot),
"Failed to disconnect cleanly from Discord. Will force shut down.").Wait();
Environment.Exit(0);
}
// And off we go.
await _main.DiscordClient.LoginAsync(TokenType.Bot, cfg.BotToken);
await _main.DiscordClient.StartAsync();
await Task.Delay(-1);
}
private static void Console_CancelKeyPress(object? sender, ConsoleCancelEventArgs e) {
e.Cancel = true;
_main.InstanceLogAsync(true, nameof(RegexBot), "Shutting down. Reason: Interrupt signal.");
// 5 seconds of leeway - any currently running tasks will need time to finish executing
var closeWait = Task.Delay(5000);
// TODO periodic task service: stop processing, wait for all tasks to finish
// TODO notify services of shutdown
closeWait.Wait();
bool success = _main.DiscordClient.StopAsync().Wait(1000);
if (!success) _main.InstanceLogAsync(false, nameof(RegexBot),
"Failed to disconnect cleanly from Discord. Will force shut down.").Wait();
Environment.Exit(0);
}
}

View file

@ -26,8 +26,15 @@
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.8.0" />
<PackageReference Include="Discord.Net" Version="3.4.1" />
<PackageReference Include="EFCore.NamingConventions" Version="6.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Npgsql" Version="6.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
</ItemGroup>
</Project>

View file

@ -1,84 +1,42 @@
using Discord.WebSocket;
using RegexBot.Services;
using Npgsql;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Reflection;
namespace RegexBot
{
namespace RegexBot;
/// <summary>
/// The RegexBot client instance.
/// </summary>
public partial class RegexbotClient {
/// <summary>
/// Kerobot main class, and the most accessible and useful class in the whole program.
/// Provides an interface for any part of the program to call into all existing services.
/// Gets application instance configuration.
/// </summary>
public partial class RegexbotClient
{
/// <summary>
/// Gets application instance configuration.
/// </summary>
internal InstanceConfig Config { get; }
internal InstanceConfig Config { get; }
/// <summary>
/// Gets the Discord client instance.
/// </summary>
public DiscordSocketClient DiscordClient { get; }
/// <summary>
/// Gets the Discord client instance.
/// </summary>
public DiscordSocketClient DiscordClient { get; }
/// <summary>
/// Gets all loaded services in an iterable form.
/// </summary>
internal IReadOnlyCollection<Service> Services { get; }
/// <summary>
/// Gets all loaded modules in an iterable form.
/// </summary>
internal IReadOnlyCollection<RegexbotModule> Modules { get; }
/// <summary>
/// Gets all loaded modules in an iterable form.
/// </summary>
internal IReadOnlyCollection<ModuleBase> Modules { get; }
internal RegexbotClient(InstanceConfig conf, DiscordSocketClient client) {
Config = conf;
DiscordClient = client;
internal RegexbotClient(InstanceConfig conf, DiscordSocketClient client)
{
Config = conf;
DiscordClient = client;
// Get all services started up
Services = InitializeServices();
// Get all services started up
_svcLogging = new Services.Logging.LoggingService(this);
_svcGuildState = new Services.ModuleState.ModuleStateService(this);
_svcCommonFunctions = new Services.CommonFunctions.CommonFunctionsService(this);
_svcEntityCache = new Services.EntityCache.EntityCacheService(this);
// Load externally defined functionality
Modules = ModuleLoader.Load(Config, this);
// Load externally defined functionality
Modules = ModuleLoader.Load(Config, this);
// Everything's ready to go. Print the welcome message here.
var ver = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
InstanceLogAsync(false, nameof(RegexBot),
$"This is RegexBot v{ver.ToString(3)}. https://github.com/NoiTheCat/RegexBot").Wait();
// We return to Program.cs at this point.
}
private IReadOnlyCollection<Service> InitializeServices()
{
var svcList = new List<Service>();
// Put services here as they become usable.
_svcLogging = new Services.EventLogging.EventLoggingService(this);
svcList.Add(_svcLogging);
_svcGuildState = new Services.GuildState.GuildStateService(this);
svcList.Add(_svcGuildState);
_svcCommonFunctions = new Services.CommonFunctions.CommonFunctionsService(this);
svcList.Add(_svcCommonFunctions);
_svcEntityCache = new Services.EntityCache.EntityCacheService(this);
svcList.Add(_svcEntityCache);
return svcList.AsReadOnly();
}
/// <summary>
/// Returns an open NpgsqlConnection instance.
/// </summary>
/// <param name="guild">
/// If manipulating guild-specific information, this parameter sets the database connection's search path.
/// </param>
internal async Task<NpgsqlConnection> GetOpenNpgsqlConnectionAsync()
{
var db = new NpgsqlConnection(Config.PostgresConnString);
await db.OpenAsync();
return db;
}
// Everything's ready to go. Print the welcome message here.
var ver = Assembly.GetExecutingAssembly().GetName().Version!.ToString(3);
_svcLogging.DoInstanceLog(false, nameof(RegexBot), $"{nameof(RegexBot)} v{ver}. https://github.com/NoiTheCat/RegexBot");
}
}

View file

@ -0,0 +1,86 @@
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
using RegexBot.Common;
using System.Diagnostics;
namespace RegexBot;
/// <summary>
/// Base class for a RegexBot module. A module implements a user-facing feature and is expected to directly handle
/// user input (both by means of configuration and incoming Discord events) and process it accordingly.
/// </summary>
/// <remarks>
/// Implementing classes should not rely on local variables to store runtime or state data for guilds.
/// Instead, use <see cref="CreateGuildStateAsync"/> and <see cref="GetGuildState"/>.
/// </remarks>
public abstract class RegexbotModule {
/// <summary>
/// Retrieves the bot instance.
/// </summary>
public RegexbotClient Bot { get; }
/// <summary>
/// Retrieves the Discord client instance.
/// </summary>
public DiscordSocketClient DiscordClient { get => Bot.DiscordClient; }
/// <summary>
/// Called when a module is being loaded.
/// At this point, all bot services are available, but Discord is not. Do not use <see cref="DiscordClient"/>.
/// </summary>
public RegexbotModule(RegexbotClient bot) => Bot = bot;
/// <summary>
/// Gets the name of this module.
/// </summary>
/// <remarks>If not overridden, this defaults to the class's name.</remarks>
public virtual string Name => GetType().Name;
/// <summary>
/// Called when a guild becomes available during initial load or configuration reload.
/// The implementing class should construct an instance to hold data specific to the corresponding guild for use during runtime.
/// </summary>
/// <param name="guildID">Corresponding guild ID for the state data being used. May be useful when reloading.</param>
/// <param name="config">JSON token holding module configuration specific to this guild.</param>
/// <returns>
/// An object instance containing state and/or configuration information for the guild currently being processed.
/// </returns>
public abstract Task<object?> CreateGuildStateAsync(ulong guildID, JToken config);
/// <summary>
/// Retrieves the state object that corresponds with the given guild.
/// </summary>
/// <typeparam name="T">The state instance's type.</typeparam>
/// <param name="guildId">The guild ID for which to retrieve the state object.</param>
/// <returns>The state instance cast in the given type, or Default(T) if none exists.</returns>
/// <exception cref="InvalidCastException">
/// Thrown if the instance cannot be cast as specified.
/// </exception>
[DebuggerStepThrough]
protected T? GetGuildState<T>(ulong guildId) => Bot._svcGuildState.DoGetStateObj<T>(guildId, GetType());
/// <summary>
/// Returns the list of moderators defined in the current guild configuration.
/// </summary>
/// <returns>
/// An <see cref="EntityList"/> with corresponding moderator configuration data.
/// In case none exists, an empty list will be returned.
/// </returns>
protected EntityList GetModerators(ulong guild) => Bot._svcGuildState.DoGetModlist(guild);
/// <summary>
/// Appends a message to the specified guild log.
/// </summary>
/// /// <param name="message">The log message to send. Multi-line messages are acceptable.</param>
protected void Log(ulong guild, string message) => Bot._svcLogging.DoGuildLog(guild, Name, message);
/// <summary>
/// Sends a message to the instance log.
/// </summary>
/// <param name="message">The log message to send. Multi-line messages are acceptable.</param>
/// <param name="report">
/// Specifies if the log message should be sent to the reporting channel.
/// Only messages of very high importance should use this option.
/// </param>
protected void PLog(string message, bool report = false) => Bot._svcLogging.DoInstanceLog(report, Name, message);
}

View file

@ -1,12 +1,9 @@
using System;
namespace RegexBot;
namespace RegexBot
{
/// <summary>
/// Specifies to the Kerobot module loader that the target class should be treated as a module instance.
/// When the program scans an assembly which has been specified in its instance configuration to be loaded,
/// the program searches for classes implementing <see cref="ModuleBase"/> that also contain this attribute.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class RegexbotModuleAttribute : Attribute { }
}
/// <summary>
/// Specifies to the Kerobot module loader that the target class should be treated as a module instance.
/// When the program scans an assembly which has been specified in its instance configuration to be loaded,
/// the program searches for classes implementing <see cref="RegexbotModule"/> that also contain this attribute.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class RegexbotModuleAttribute : Attribute { }

View file

@ -1,132 +1,112 @@
using Discord.Net;
using static RegexBot.RegexbotClient;
namespace RegexBot
{
// Instances of this are created by CommonFunctionService and by ModuleBase on behalf of CommonFunctionService,
// and are meant to be sent to modules. This class has therefore been put within the Kerobot namespace.
// 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.
/// </summary>
public class BanKickResult {
private readonly bool _userNotFound; // possible to receive this error by means other than exception
private readonly RemovalType _rptRemovalType;
private readonly ulong _rptTargetId;
/// <summary>
/// Contains information on various success/failure outcomes for a ban or kick operation.
/// Gets a value indicating whether the kick or ban succeeded.
/// </summary>
public class BanKickResult
{
private readonly bool _userNotFound; // possible to receive this error by means other than exception
private readonly RemovalType _rptRemovalType;
private readonly ulong _rptTargetId;
internal BanKickResult(HttpException error, bool notificationSuccess, bool errorNotFound,
RemovalType rtype, ulong rtarget)
{
OperationError = error;
MessageSendSuccess = notificationSuccess;
_userNotFound = errorNotFound;
_rptRemovalType = rtype;
_rptTargetId = rtarget;
}
/// <summary>
/// Gets a value indicating whether the kick or ban succeeded.
/// </summary>
public bool OperationSuccess {
get {
if (ErrorNotFound) return false;
if (OperationError != null) return false;
return true;
}
}
/// <summary>
/// The exception thrown, if any, when attempting to kick or ban the target.
/// </summary>
public HttpException OperationError { get; }
/// <summary>
/// Indicates if the operation failed due to being unable to find the user.
/// </summary>
/// <remarks>
/// This may return true even if <see cref="OperationError"/> returns null.
/// This type of error may appear in cases that do not involve an exception being thrown.
/// </remarks>
public bool ErrorNotFound
{
get
{
if (_userNotFound) return true;
if (OperationError != null) return OperationError.HttpCode == System.Net.HttpStatusCode.NotFound;
return false;
}
}
/// <summary>
/// Indicates if the operation failed due to a permissions issue.
/// </summary>
public bool ErrorForbidden
{
get
{
if (OperationSuccess) return false;
return OperationError.HttpCode == System.Net.HttpStatusCode.Forbidden;
}
}
/// <summary>
/// Gets a value indicating whether the user was able to receive the ban or kick message.
/// </summary>
/// <value>
/// <see langword="false"/> if an error was encountered when attempting to send the target a DM. Will always
/// return <see langword="true"/> otherwise, including cases in which no message was sent.
/// </value>
public bool MessageSendSuccess { get; }
/// <summary>
/// Returns a message representative of the ban/kick result that may be posted as-is
/// within the a Discord channel.
/// </summary>
public string GetResultString(RegexbotClient bot, ulong guildId)
{
string msg;
if (OperationSuccess) msg = ":white_check_mark: ";
else msg = ":x: Failed to ";
if (_rptRemovalType == RemovalType.Ban)
{
if (OperationSuccess) msg += "Banned";
else msg += "ban";
}
else if (_rptRemovalType == RemovalType.Kick)
{
if (OperationSuccess) msg += "Kicked";
else msg += "kick";
}
else
{
throw new System.InvalidOperationException("Cannot create a message for removal type of None.");
}
if (_rptTargetId != 0)
{
var user = bot.EcQueryUser(guildId, _rptTargetId.ToString()).GetAwaiter().GetResult();
if (user != null)
{
// TODO sanitize possible formatting characters in display name
msg += $" user **{user.Username}#{user.Discriminator}**";
}
}
if (OperationSuccess)
{
msg += ".";
if (!MessageSendSuccess) msg += "\n(User was unable to receive notification message.)";
}
else
{
if (ErrorNotFound) msg += ": The specified user could not be found.";
else if (ErrorForbidden) msg += ": I do not have the required permissions to perform that action.";
}
return msg;
public bool OperationSuccess {
get {
if (ErrorNotFound) return false;
if (OperationError != null) return false;
return true;
}
}
/// <summary>
/// The exception thrown, if any, when attempting to kick or ban the target.
/// </summary>
public HttpException? OperationError { get; }
/// <summary>
/// Indicates if the operation failed due to being unable to find the user.
/// </summary>
/// <remarks>
/// This may return true even if <see cref="OperationError"/> returns null.
/// This type of error may appear in cases that do not involve an exception being thrown.
/// </remarks>
public bool ErrorNotFound {
get {
if (_userNotFound) return true;
return OperationError?.HttpCode == System.Net.HttpStatusCode.NotFound;
}
}
/// <summary>
/// Indicates if the operation failed due to a permissions issue.
/// </summary>
public bool ErrorForbidden {
get {
if (OperationSuccess) return false;
return OperationError?.HttpCode == System.Net.HttpStatusCode.Forbidden;
}
}
/// <summary>
/// Gets a value indicating whether the user was able to receive the ban or kick message.
/// </summary>
/// <value>
/// <see langword="false"/> if an error was encountered when attempting to send the target a DM. Will always
/// return <see langword="true"/> otherwise, including cases in which no message was sent.
/// </value>
public bool MessageSendSuccess { get; }
internal BanKickResult(HttpException? error, bool notificationSuccess, bool errorNotFound,
RemovalType rtype, ulong rtarget) {
OperationError = error;
MessageSendSuccess = notificationSuccess;
_userNotFound = errorNotFound;
_rptRemovalType = rtype;
_rptTargetId = rtarget;
}
/// <summary>
/// Returns a message representative of the ban/kick result that may be posted as-is
/// within the a Discord channel.
/// </summary>
public string GetResultString(RegexbotClient bot, ulong guildId) {
string msg;
if (OperationSuccess) msg = ":white_check_mark: ";
else msg = ":x: Failed to ";
if (_rptRemovalType == RemovalType.Ban) {
if (OperationSuccess) msg += "Banned";
else msg += "ban";
} else if (_rptRemovalType == RemovalType.Kick) {
if (OperationSuccess) msg += "Kicked";
else msg += "kick";
} else {
throw new System.InvalidOperationException("Cannot create a message for removal type of None.");
}
if (_rptTargetId != 0) {
var user = bot.EcQueryUser(guildId, _rptTargetId.ToString()).GetAwaiter().GetResult();
if (user != null) {
// TODO sanitize possible formatting characters in display name
msg += $" user **{user.Username}#{user.Discriminator}**";
}
}
if (OperationSuccess) {
msg += ".";
if (!MessageSendSuccess) msg += "\n(User was unable to receive notification message.)";
} else {
if (ErrorNotFound) msg += ": The specified user could not be found.";
else if (ErrorForbidden) msg += ": I do not have the required permissions to perform that action.";
}
return msg;
}
}

View file

@ -1,76 +1,65 @@
using System;
using System.Threading.Tasks;
using Discord.Net;
using Discord.Net;
using Discord.WebSocket;
using static RegexBot.RegexbotClient;
namespace 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.
///
/// This is currently an experimental section. If it turns out to not be necessary, this service will be removed and
/// modules may resume executing common actions on their own.
/// </summary>
internal class CommonFunctionsService : Service {
public CommonFunctionsService(RegexbotClient bot) : base(bot) { }
#region Guild member removal
/// <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.
///
/// This is currently an experimental section. If it turns out to not be necessary, this service will be removed and
/// modules may resume executing common actions on their own.
/// Common processing for kicks and bans. Called by DoKickAsync and DoBanAsync.
/// </summary>
internal class CommonFunctionsService : Service
{
public CommonFunctionsService(RegexbotClient bot) : base(bot) { }
/// <param name="logReason">The reason to insert into the Audit Log.</param>
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'.");
if (string.IsNullOrWhiteSpace(logReason)) logReason = "Reason not specified.";
var dmSuccess = true;
#region Guild member removal
/// <summary>
/// Common processing for kicks and bans. Called by DoKickAsync and DoBanAsync.
/// </summary>
/// <param name="logReason">The reason to insert into the Audit Log.</param>
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'.");
if (string.IsNullOrWhiteSpace(logReason)) logReason = "Reason not specified.";
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);
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
// TODO notify services here as soon as we get some who will want to listen to this
// Send DM notification
if (sendDmToTarget)
{
if (utarget != null) dmSuccess = await BanKickSendNotificationAsync(utarget, t, logReason);
else dmSuccess = false;
}
// Perform the action
try
{
if (t == RemovalType.Ban) await guild.AddBanAsync(target, banPurgeDays);
else await utarget.KickAsync(logReason);
// TODO !! Insert ban reason! For kick also: Figure out a way to specify invoker.
}
catch (HttpException ex)
{
return new BanKickResult(ex, dmSuccess, false, t, target);
}
return new BanKickResult(null, dmSuccess, false, t, target);
// Send DM notification
if (sendDmToTarget) {
if (utarget != null) dmSuccess = await BanKickSendNotificationAsync(utarget, t, logReason);
else dmSuccess = false;
}
private async Task<bool> BanKickSendNotificationAsync(SocketGuildUser target, RemovalType action, string reason)
{
const string DMTemplate = "You have been {0} from {1} for the following reason:\n{2}";
var dch = await target.GetOrCreateDMChannelAsync(); // TODO can this throw an exception?
var output = string.Format(DMTemplate, action == RemovalType.Ban ? "banned" : "kicked", target.Guild.Name, reason);
try { await dch.SendMessageAsync(output); }
catch (HttpException) { return false; }
return true;
// Perform the action
try {
if (t == RemovalType.Ban) await guild.AddBanAsync(target, banPurgeDays);
else await utarget!.KickAsync(logReason);
// TODO !! Insert ban reason! For kick also: Figure out a way to specify invoker.
} catch (HttpException ex) {
return new BanKickResult(ex, dmSuccess, false, t, target);
}
#endregion
return new BanKickResult(null, dmSuccess, false, t, target);
}
private static async Task<bool> BanKickSendNotificationAsync(SocketGuildUser target, RemovalType action, string reason) {
const string DMTemplate = "You have been {0} from {1} for the following reason:\n{2}";
var dch = await target.CreateDMChannelAsync();
var output = string.Format(DMTemplate, action == RemovalType.Ban ? "banned" : "kicked", target.Guild.Name, reason);
try { await dch.SendMessageAsync(output); } catch (HttpException) { return false; }
return true;
}
#endregion
}

View file

@ -1,21 +1,63 @@
using Discord.WebSocket;
using System.Threading.Tasks;
using RegexBot.Services.CommonFunctions;
namespace RegexBot
{
partial class RegexbotClient
{
private CommonFunctionsService _svcCommonFunctions;
namespace RegexBot;
public enum RemovalType { None, Ban, Kick }
partial class RegexbotClient {
private CommonFunctionsService _svcCommonFunctions;
/// <summary>
/// See <see cref="ModuleBase.BanAsync(SocketGuild, string, ulong, int, string, string)"/>
/// and related methods.
/// </summary>
public Task<BanKickResult> BanOrKickAsync(RemovalType t, SocketGuild guild, string source,
ulong target, int banPurgeDays, string logReason, bool sendDMToTarget)
=> _svcCommonFunctions.BanOrKickAsync(t, guild, source, target, banPurgeDays, logReason, sendDMToTarget);
public enum RemovalType { None, Ban, Kick }
/// <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
/// that the action originated from the bot, and allows them to handle the action appropriately.
/// </summary>
/// <returns>A structure containing results of the ban operation.</returns>
/// <param name="guild">The guild in which to attempt the ban.</param>
/// <param name="source">The user, module, or service which is requesting this action to be taken.</param>
/// <param name="targetUser">The user which to perform the action to.</param>
/// <param name="purgeDays">Number of days of prior post history to delete on ban. Must be between 0-7.</param>
/// <param name="reason">Reason for the action. Sent to the Audit Log and user (if specified).</param>
/// <param name="sendDMToTarget">Specify whether to send a direct message to the target user informing them of the action.</param>
public Task<BanKickResult> BanAsync(SocketGuild guild, string source, ulong targetUser,
int purgeDays, string reason, bool sendDMToTarget)
=> _svcCommonFunctions.BanOrKickAsync(RemovalType.Ban, guild, source, targetUser, purgeDays, reason, sendDMToTarget);
/// <summary>
/// Similar to <see cref="BanAsync(SocketGuild, string, ulong, int, string, bool)"/>, but making use of an
/// EntityCache lookup to determine the target.
/// </summary>
/// <param name="targetSearch">The EntityCache search string.</param>
public async Task<BanKickResult> BanAsync(SocketGuild guild, string source, string targetSearch,
int purgeDays, string reason, bool sendDMToTarget) {
var result = await EcQueryUser(guild.Id, targetSearch);
if (result == null) return new BanKickResult(null, false, true, RemovalType.Ban, 0);
return await BanAsync(guild, source, result.UserID, purgeDays, reason, sendDMToTarget);
}
/// <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
/// that the action originated from the bot, and allows them to handle the action appropriately.
/// </summary>
/// <returns>A structure containing results of the ban operation.</returns>
/// <param name="guild">The guild in which to attempt the kick.</param>
/// <param name="source">The user, module, or service which is requesting this action to be taken.</param>
/// <param name="targetUser">The user which to perform the action to.</param>
/// <param name="reason">Reason for the action. Sent to the Audit Log and user (if specified).</param>
/// <param name="sendDMToTarget">Specify whether to send a direct message to the target user informing them of the action being taken.</param>
public Task<BanKickResult> KickAsync(SocketGuild guild, string source, ulong targetUser, string reason, bool sendDMToTarget)
=> _svcCommonFunctions.BanOrKickAsync(RemovalType.Ban, guild, source, targetUser, 0, reason, sendDMToTarget);
/// <summary>
/// Similar to <see cref="KickAsync(SocketGuild, string, ulong, string, bool)"/>, but making use of an
/// EntityCache lookup to determine the target.
/// </summary>
/// <param name="targetSearch">The EntityCache search string.</param>
public async Task<BanKickResult> KickAsync(SocketGuild guild, string source, string targetSearch, string reason, bool sendDMToTarget) {
var result = await EcQueryUser(guild.Id, targetSearch);
if (result == null) return new BanKickResult(null, false, true, RemovalType.Kick, 0);
return await KickAsync(guild, source, result.UserID, reason, sendDMToTarget);
}
}

View file

@ -1,67 +0,0 @@
using System;
using System.Data.Common;
namespace RegexBot // Publicly accessible class; placing in main namespace
{
/// <summary>
/// Representation of user information retrieved from Kerobot's UserCache.
/// </summary>
public class CachedUser
{
/// <summary>
/// The user's snowflake ID.
/// </summary>
public ulong UserID { get; }
/// <summary>
/// The corresponding guild's snowflake ID.
/// </summary>
public ulong GuildID { get; }
/// <summary>
/// The date in which this user was first recorded onto the database.
/// </summary>
public DateTimeOffset FirstSeenDate { get; }
/// <summary>
/// The date in which cache information for this user was last updated.
/// </summary>
public DateTimeOffset CacheDate { get; }
/// <summary>
/// The user's corresponding username, without discriminator.
/// </summary>
public string Username { get; }
/// <summary>
/// The user's corresponding discriminator value.
/// </summary>
public string Discriminator { get; }
/// <summary>
/// The user's nickname in the corresponding guild. May be null.
/// </summary>
public string Nickname { get; }
/// <summary>
/// A link to a high resolution version of the user's current avatar. May be null.
/// </summary>
public string AvatarUrl { get; }
internal CachedUser(DbDataReader row)
{
// Highly dependent on column order in the cache view defined in UserCacheService.
unchecked
{
UserID = (ulong)row.GetInt64(0);
GuildID = (ulong)row.GetInt64(1);
}
FirstSeenDate = row.GetDateTime(2).ToUniversalTime();
CacheDate = row.GetDateTime(3).ToUniversalTime();
Username = row.GetString(4);
Discriminator = row.GetString(5);
Nickname = row.IsDBNull(6) ? null : row.GetString(6);
AvatarUrl = row.IsDBNull(7) ? null : row.GetString(7);
}
}
}

View file

@ -1,25 +1,25 @@
using System.Threading.Tasks;
using RegexBot.Data;
namespace RegexBot.Services.EntityCache
{
/// <summary>
/// Provides and maintains a database-backed cache of entities. Portions of information collected by this
/// service may be used by modules, while other portions are useful only for external applications which may
/// require this information, such as an external web interface.
/// </summary>
class EntityCacheService : Service
{
private readonly UserCache _uc;
namespace RegexBot.Services.EntityCache;
internal EntityCacheService(RegexbotClient bot) : base(bot)
{
// Currently we only have UserCache. May add Channel and Server caches later.
_uc = new UserCache(bot);
}
/// <summary>
/// Provides and maintains a database-backed cache of entities. Portions of information collected by this
/// service may be used by modules, while other portions are useful only for external applications which may
/// require this information, such as an external web interface.
/// </summary>
class EntityCacheService : Service {
private readonly UserCachingSubservice _uc;
/// <summary>
/// See <see cref="RegexbotClient.EcQueryUser(ulong, string)"/>.
/// </summary>
internal Task<CachedUser> QueryUserCache(ulong guildId, string search) => _uc.Query(guildId, search);
internal EntityCacheService(RegexbotClient bot) : base(bot) {
// Currently we only have UserCache. May add Channel and Server caches later.
_uc = new UserCachingSubservice(bot);
}
// Hooked
internal static CachedUser? QueryUserCache(string search)
=> UserCachingSubservice.DoUserQuery(search);
// Hooked
internal static CachedGuildUser? QueryGuildUserCache(ulong guildId, string search)
=> UserCachingSubservice.DoGuildUserQuery(guildId, search);
}

View file

@ -1,20 +1,28 @@
using RegexBot.Services.EntityCache;
using System.Threading.Tasks;
#pragma warning disable CA1822
using RegexBot.Data;
using RegexBot.Services.EntityCache;
namespace RegexBot
{
partial class RegexbotClient
{
private EntityCacheService _svcEntityCache;
namespace RegexBot;
/// <summary>
/// Queries the Entity Cache for user information. The given search string may contain a user ID
/// or a username with optional discriminator. In case there are multiple results, the most recently
/// cached user will be returned.
/// </summary>
/// <param name="guildId">ID of the corresponding guild in which to search.</param>
/// <param name="search">Search string. May be a name with discriminator, a name, or an ID.</param>
/// <returns>A <see cref="CachedUser"/> instance containing cached information, or null if no result.</returns>
public Task<CachedUser> EcQueryUser(ulong guildId, string search) => _svcEntityCache.QueryUserCache(guildId, search);
}
partial class RegexbotClient {
private EntityCacheService _svcEntityCache;
/// <summary>
/// Queries the entity cache for user information. The given search string may contain a user ID
/// or a username with optional discriminator. In case there are multiple results, the most recently
/// cached user will be returned.
/// </summary>
/// <param name="search">Search string. May be a name with discriminator, a name, or an ID.</param>
/// <returns>A <see cref="CachedUser"/> instance containing cached information, or null if no result.</returns>
public CachedUser? EcQueryUser(string search) => EntityCacheService.QueryUserCache(search);
/// <summary>
/// Queries the entity cache for guild-specific user information. The given search string may contain a user ID,
/// nickname, or a username with optional discriminator. In case there are multiple results, the most recently
/// cached user will be returned.
/// </summary>
/// <param name="guildId">ID of the corresponding guild in which to search.</param>
/// <param name="search">Search string. May be a name with discriminator, a name, or an ID.</param>
/// <returns>A <see cref="CachedGuildUser"/> instance containing cached information, or null if no result.</returns>
public CachedGuildUser? EcQueryGuildUser(ulong guildId, string search) => EntityCacheService.QueryGuildUserCache(guildId, search);
}

View file

@ -1,209 +0,0 @@
using Discord.WebSocket;
using NpgsqlTypes;
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace RegexBot.Services.EntityCache
{
/// <summary>
/// Provides and maintains a database-backed cache of users.
/// It is meant to work as an addition 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>
class UserCache
{
private RegexbotClient _bot;
internal UserCache(RegexbotClient bot)
{
_bot = bot;
CreateDatabaseTablesAsync().Wait();
bot.DiscordClient.GuildMemberUpdated += DiscordClient_GuildMemberUpdated;
bot.DiscordClient.UserUpdated += DiscordClient_UserUpdated;
}
#region Database setup
public const string GlobalUserTable = "cache_userdata";
public const string GuildUserTable = "cache_guildmemberdata";
public const string UserView = "cache_guildusers"; // <- intended way to access data
private async Task CreateDatabaseTablesAsync()
{
using (var db = await _bot.GetOpenNpgsqlConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {GlobalUserTable} (" +
"user_id bigint primary key, " +
"cache_update_time timestamptz not null, " + // TODO auto update w/ trigger?
"username text not null, " +
"discriminator text not null, " +
"avatar_url text null" +
")";
await c.ExecuteNonQueryAsync();
}
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {GuildUserTable} (" +
$"user_id bigint references {GlobalUserTable}, " +
"guild_id bigint, " + // TODO foreign key reference?
"first_seen timestamptz not null default NOW(), " + // TODO also make immutable w/ trigger?
"cache_update_time timestamptz not null, " + // TODO auto update w/ trigger?
"nickname text null, " +
"primary key (user_id, guild_id)" +
")";
await c.ExecuteNonQueryAsync();
}
// note to self: https://stackoverflow.com/questions/9556474/how-do-i-automatically-update-a-timestamp-in-postgresql
using (var c = db.CreateCommand())
{
// NOTE: CachedUser constructor is highly dependent of the row order specified here.
// Any changes here must be reflected there.
c.CommandText = $"create or replace view {UserView} as " +
$"select {GlobalUserTable}.user_id, {GuildUserTable}.guild_id, {GuildUserTable}.first_seen, " +
$"{GuildUserTable}.cache_update_time, " +
$"{GlobalUserTable}.username, {GlobalUserTable}.discriminator, {GuildUserTable}.nickname, " +
$"{GlobalUserTable}.avatar_url " +
$"from {GlobalUserTable} join {GuildUserTable} on {GlobalUserTable}.user_id = {GuildUserTable}.user_id";
await c.ExecuteNonQueryAsync();
// TODO consolidate both tables' cache_update_time, show the greater value.
// Right now we will have just the guild table's data visible.
}
}
}
#endregion
#region Database updates
private async Task DiscordClient_UserUpdated(SocketUser old, SocketUser current)
{
using (var db = await _bot.GetOpenNpgsqlConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"insert into {GlobalUserTable} " +
"(user_id, cache_update_time, username, discriminator, avatar_url) values " +
"(@Uid, now(), @Uname, @Disc, @Aurl) " +
"on conflict (user_id) do update " +
"set cache_update_time = EXCLUDED.cache_update_time, username = EXCLUDED.username, " +
"discriminator = EXCLUDED.discriminator, avatar_url = EXCLUDED.avatar_url";
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)current.Id;
c.Parameters.Add("@Uname", NpgsqlDbType.Text).Value = current.Username;
c.Parameters.Add("@Disc", NpgsqlDbType.Text).Value = current.Discriminator;
var aurl = c.Parameters.Add("@Aurl", NpgsqlDbType.Text);
var aurlval = current.GetAvatarUrl(Discord.ImageFormat.Png, 1024);
if (aurlval != null) aurl.Value = aurlval;
else aurl.Value = DBNull.Value;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
}
private async Task DiscordClient_GuildMemberUpdated(SocketGuildUser old, SocketGuildUser current)
{
// Also update user data here, in case it's unknown (avoid foreign key constraint violation)
await DiscordClient_UserUpdated(old, current);
using (var db = await _bot.GetOpenNpgsqlConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"insert into {GuildUserTable} " +
"(user_id, guild_id, cache_update_time, nickname) values " +
"(@Uid, @Gid, now(), @Nname) " +
"on conflict (user_id, guild_id) do update " +
"set cache_update_time = EXCLUDED.cache_update_time, nickname = EXCLUDED.nickname";
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)current.Id;
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)current.Guild.Id;
var nname = c.Parameters.Add("@Nname", NpgsqlDbType.Text);
if (current.Nickname != null) nname.Value = current.Nickname;
else nname.Value = DBNull.Value;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
}
#endregion
#region Querying
private static readonly Regex DiscriminatorSearch = new Regex(@"(.+)#(\d{4}(?!\d))", RegexOptions.Compiled);
/// <summary>
/// See <see cref="RegexbotClient.EcQueryUser(ulong, string)"/>.
/// </summary>
internal async Task<CachedUser> Query(ulong guildID, string search)
{
// Is search just a number? Assume ID, pass it on to the correct place.
// If it fails, assume the number may be a username.
if (ulong.TryParse(search, out var searchid))
{
var idres = await InternalDoQuery(guildID, searchid, null, null);
if (idres != null) return idres;
}
// Split name/discriminator
string name, disc;
var split = DiscriminatorSearch.Match(search);
if (split.Success)
{
name = split.Groups[1].Value;
disc = split.Groups[2].Value;
}
else
{
name = search;
disc = null;
}
// Strip leading @ from username, if any
if (name.Length > 0 && name[0] == '@') name = name.Substring(1);
// Ready to query
return await InternalDoQuery(guildID, null, name, disc);
// TODO exception handling
}
private async Task<CachedUser> InternalDoQuery(ulong guildId, ulong? sID, string sName, string sDisc)
{
using (var db = await _bot.GetOpenNpgsqlConnectionAsync())
{
var c = db.CreateCommand();
c.CommandText = $"select * from {UserView} " +
"where guild_id = @Gid";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId;
if (sID.HasValue)
{
c.CommandText += " and user_id = @Uid";
c.Parameters.Add("@Uid", NpgsqlDbType.Bigint).Value = (long)sID.Value;
}
if (sName != null)
{
c.CommandText += " and username = @Uname";
c.Parameters.Add("@Uname", NpgsqlDbType.Text).Value = sName;
if (sDisc != null) // only search discriminator if name has been provided
{
c.CommandText += " and discriminator = @Udisc";
c.Parameters.Add("@Udisc", NpgsqlDbType.Text).Value = sDisc;
}
}
c.CommandText += " order by cache_update_time desc limit 1";
c.Prepare();
using (var r = await c.ExecuteReaderAsync())
{
if (await r.ReadAsync()) return new CachedUser(r);
return null;
}
}
}
#endregion
}
}

View file

@ -0,0 +1,135 @@
using Discord.WebSocket;
using RegexBot.Data;
using System.Text.RegularExpressions;
namespace RegexBot.Services.EntityCache;
/// <summary>
/// Provides and maintains a database-backed cache of users.
/// 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>
class UserCachingSubservice {
private static Regex DiscriminatorSearch { get; } = new(@"(.+)#(\d{4}(?!\d))", RegexOptions.Compiled);
internal UserCachingSubservice(RegexbotClient bot) {
bot.DiscordClient.GuildMemberUpdated += DiscordClient_GuildMemberUpdated;
bot.DiscordClient.UserUpdated += DiscordClient_UserUpdated;
}
private async Task DiscordClient_UserUpdated(SocketUser old, SocketUser current) {
using var db = new BotDatabaseContext();
UpdateUser(current, db);
await db.SaveChangesAsync();
}
private static void UpdateUser(SocketUser user, BotDatabaseContext db) {
CachedUser uinfo;
try {
uinfo = db.UserCache.Where(c => c.UserId == (long)user.Id).First();
} catch (InvalidOperationException) {
uinfo = new() { UserId = (long)user.Id };
db.UserCache.Add(uinfo);
}
uinfo.Username = user.Username;
uinfo.Discriminator = user.Discriminator;
uinfo.AvatarUrl = user.GetAvatarUrl(size: 512);
uinfo.ULastUpdateTime = DateTimeOffset.UtcNow;
}
private async Task DiscordClient_GuildMemberUpdated(Discord.Cacheable<SocketGuildUser, ulong> old, SocketGuildUser current) {
using var db = new BotDatabaseContext();
UpdateUser(current, db); // Update user data too (avoid potential foreign key constraint violation)
CachedGuildUser guinfo;
try {
guinfo = db.GuildUserCache.Where(c => c.GuildId == (long)current.Guild.Id && c.UserId == (long)current.Id).First();
} catch (InvalidOperationException) {
guinfo = new() { GuildId = (long)current.Guild.Id, UserId = (long)current.Id };
db.GuildUserCache.Add(guinfo);
}
guinfo.GULastUpdateTime = DateTimeOffset.UtcNow;
guinfo.Nickname = current.Nickname;
// TODO guild-specific avatar, other details?
await db.SaveChangesAsync();
}
// Hooked
internal static CachedUser? DoUserQuery(string search) {
static CachedUser? innerQuery(ulong? sID, (string name, string? disc)? nameSearch) {
var db = new BotDatabaseContext();
var query = db.UserCache.AsQueryable();
if (sID.HasValue)
query = query.Where(c => c.UserId == (long)sID.Value);
if (nameSearch != null) {
query = query.Where(c => c.Username.ToLower() == nameSearch.Value.name.ToLower());
if (nameSearch.Value.disc != null) query = query.Where(c => c.Discriminator == nameSearch.Value.disc);
}
query = query.OrderByDescending(e => e.ULastUpdateTime);
return query.SingleOrDefault();
}
// Is search just a number? Assume ID, pass it on to the correct place.
if (ulong.TryParse(search, out var searchid)) {
var idres = innerQuery(searchid, null);
if (idres != null) return idres;
}
// If the above fails, assume the number may be a string to search.
var namesplit = SplitNameAndDiscriminator(search);
return innerQuery(null, namesplit);
}
// Hooked
internal static CachedGuildUser? DoGuildUserQuery(ulong guildId, string search) {
static CachedGuildUser? innerQuery(ulong guildId, ulong? sID, (string name, string? disc)? nameSearch) {
var db = new BotDatabaseContext();
var query = db.GuildUserCache.Where(c => c.GuildId == (long)guildId);
if (sID.HasValue)
query = query.Where(c => c.UserId == (long)sID.Value);
if (nameSearch != null) {
query = query.Where(c => (c.Nickname != null && c.Nickname.ToLower() == nameSearch.Value.name.ToLower()) ||
c.User.Username.ToLower() == nameSearch.Value.name.ToLower());
if (nameSearch.Value.disc != null) query = query.Where(c => c.User.Discriminator == nameSearch.Value.disc);
}
query = query.OrderByDescending(e => e.GULastUpdateTime);
return query.SingleOrDefault();
}
// Is search just a number? Assume ID, pass it on to the correct place.
if (ulong.TryParse(search, out var searchid)) {
var idres = innerQuery(guildId, searchid, null);
if (idres != null) return idres;
}
// If the above fails, assume the number may be a string to search.
var namesplit = SplitNameAndDiscriminator(search);
return innerQuery(guildId, null, namesplit);
}
private static (string, string?) SplitNameAndDiscriminator(string input) {
string name;
string? disc = null;
var split = DiscriminatorSearch.Match(input);
if (split.Success) {
name = split.Groups[1].Value;
disc = split.Groups[2].Value;
} else {
name = input;
}
// Also strip leading '@' from search
if (name.Length > 0 && name[0] == '@') name = name[1..];
return (name, disc);
}
}

View file

@ -1,199 +0,0 @@
using System;
using System.Threading.Tasks;
using Discord;
using NpgsqlTypes;
namespace RegexBot.Services.EventLogging
{
/// <summary>
/// Implements logging. Logging is distinguished into two types: Instance and per-guild.
/// Instance logs are messages of varying importance to the bot operator. Guild logs are messages that can be seen
/// by moderators of a particular guild. All log messages are backed by database.
/// Instance logs are stored as guild ID 0.
/// </summary>
class EventLoggingService : Service
{
// Note: Service.Log's functionality is implemented here. Don't use it within this class.
// If necessary, use DoInstanceLogAsync instead.
internal EventLoggingService(RegexbotClient bot) : base(bot)
{
// Create logging table
CreateDatabaseTablesAsync().Wait();
// Discord.Net log handling (client logging option is specified in Program.cs)
bot.DiscordClient.Log += DiscordClient_Log;
// Ready message too
bot.DiscordClient.Ready +=
async delegate { await DoInstanceLogAsync(true, nameof(RegexBot), "Connected and ready."); };
}
/// <summary>
/// Discord.Net logging events handled here.
/// Only events with high importance are kept. Others are just printed to console.
/// </summary>
private async Task DiscordClient_Log(LogMessage arg)
{
bool important = arg.Severity != LogSeverity.Info;
string msg = $"[{Enum.GetName(typeof(LogSeverity), arg.Severity)}] {arg.Message}";
const string logSource = "Discord.Net";
if (arg.Exception != null) msg += "\n```\n" + arg.Exception.ToString() + "\n```";
if (important) await DoInstanceLogAsync(true, logSource, msg);
else FormatToConsole(DateTimeOffset.UtcNow, logSource, msg);
}
#region Database
const string TableLog = "program_log";
private async Task CreateDatabaseTablesAsync()
{
using (var db = await BotClient.GetOpenNpgsqlConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {TableLog} ("
+ "log_id serial primary key, "
+ "guild_id bigint not null, "
+ "log_timestamp timestamptz not null, "
+ "log_source text not null, "
+ "message text not null"
+ ")";
await c.ExecuteNonQueryAsync();
}
using (var c = db.CreateCommand())
{
c.CommandText = "create index if not exists " +
$"{TableLog}_guild_id_idx on {TableLog} (guild_id)";
await c.ExecuteNonQueryAsync();
}
}
}
private async Task TableInsertAsync(ulong guildId, DateTimeOffset timestamp, string source, string message)
{
using (var db = await BotClient.GetOpenNpgsqlConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"insert into {TableLog} (guild_id, log_timestamp, log_source, message) values"
+ "(@Gid, @Ts, @Src, @Msg)";
c.Parameters.Add("@Gid", NpgsqlDbType.Bigint).Value = (long)guildId;
c.Parameters.Add("@Ts", NpgsqlDbType.TimestampTz).Value = timestamp;
c.Parameters.Add("@Src", NpgsqlDbType.Text).Value = source;
c.Parameters.Add("@Msg", NpgsqlDbType.Text).Value = message;
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
}
#endregion
/// <summary>
/// All console writes originate here.
/// Takes incoming details of a log message. Formats the incoming information in a
/// consistent format before writing out the result to console.
/// </summary>
private void FormatToConsole(DateTimeOffset timestamp, string source, string message)
{
var prefix = $"[{timestamp:u}] [{source}] ";
foreach (var line in message.Split(new string[] { "\r\n", "\n" }, StringSplitOptions.None))
{
Console.WriteLine(prefix + line);
}
}
/// <summary>
/// See <see cref="RegexbotClient.InstanceLogAsync(bool, string, string)"/>
/// </summary>
public async Task DoInstanceLogAsync(bool report, string source, string message)
{
FormatToConsole(DateTimeOffset.UtcNow, source, message);
Exception insertException = null;
try
{
await TableInsertAsync(0, DateTimeOffset.UtcNow, source, message);
}
catch (Exception ex)
{
// Not good. Resorting to plain console write to report the error.
Console.WriteLine("!!! Error during recording to instance log: " + ex.Message);
Console.WriteLine(ex.StackTrace);
// Attempt to pass this error to the reporting channel.
insertException = ex;
}
// Report to logging channel if necessary and possible
// TODO replace with webhook?
var (g, c) = BotClient.Config.InstanceLogReportTarget;
if ((insertException != null || report) &&
g != 0 && c != 0 && BotClient.DiscordClient.ConnectionState == ConnectionState.Connected)
{
var ch = BotClient.DiscordClient.GetGuild(g)?.GetTextChannel(c);
if (ch == null) return; // not connected, or channel doesn't exist.
if (insertException != null)
{
// Attempt to report instance logging failure to the reporting channel
try
{
EmbedBuilder e = new EmbedBuilder()
{
Footer = new EmbedFooterBuilder() { Text = Name },
Timestamp = DateTimeOffset.UtcNow,
Description = "Error during recording to instance log: `" +
insertException.Message + "`\nCheck the console.",
Color = Color.DarkRed
};
await ch.SendMessageAsync("", embed: e.Build());
}
catch
{
return; // Give up
}
}
if (report)
{
try
{
EmbedBuilder e = new EmbedBuilder()
{
Footer = new EmbedFooterBuilder() { Text = source },
Timestamp = DateTimeOffset.UtcNow,
Description = message
};
await ch.SendMessageAsync("", embed: e.Build());
}
catch (Discord.Net.HttpException ex)
{
await DoInstanceLogAsync(false, Name, "Failed to send message to reporting channel: " + ex.Message);
}
}
}
}
/// <summary>
/// See <see cref="RegexbotClient.GuildLogAsync(ulong, string, string)"/>
/// </summary>
public async Task DoGuildLogAsync(ulong guild, string source, string message)
{
try
{
await TableInsertAsync(guild, DateTimeOffset.UtcNow, source, message);
#if DEBUG
FormatToConsole(DateTimeOffset.UtcNow, $"DEBUG {guild} - {source}", message);
#endif
}
catch (Exception ex)
{
// This is probably a terrible idea, but...
await DoInstanceLogAsync(true, this.Name, "Failed to store guild log item: " + ex.Message);
// Stack trace goes to console only.
FormatToConsole(DateTime.UtcNow, this.Name, ex.StackTrace);
}
}
}
}

View file

@ -1,28 +0,0 @@
using RegexBot.Services.EventLogging;
using System.Threading.Tasks;
namespace RegexBot
{
partial class RegexbotClient
{
EventLoggingService _svcLogging;
/// <summary>
/// Appends a log message to the instance log.
/// </summary>
/// <param name="report">Specifies if the message should be sent to the dedicated logging channel on Discord.</param>
/// <param name="source">Name of the subsystem from which the log message originated.</param>
/// <param name="message">The log message to append. Multi-line messages are acceptable.</param>
public Task InstanceLogAsync(bool report, string source, string message)
=> _svcLogging.DoInstanceLogAsync(report, source, message);
/// <summary>
/// Appends a log message to the guild-specific log.
/// </summary>
/// <param name="guild">The guild ID associated with this message.</param>
/// <param name="source">Name of the subsystem from which the log message originated.</param>
/// <param name="message">The log message to append. Multi-line messages are acceptable.</param>
public Task GuildLogAsync(ulong guild, string source, string message)
=> _svcLogging.DoGuildLogAsync(guild, source, message);
}
}

View file

@ -1,252 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using Discord.WebSocket;
using RegexBot.Common;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace RegexBot.Services.GuildState
{
/// <summary>
/// Implements per-module storage and retrieval of guild-specific state data.
/// This typically includes module configuration data.
/// </summary>
class GuildStateService : Service
{
private readonly object _storageLock = new object();
private readonly Dictionary<ulong, EntityList> _moderators;
private readonly Dictionary<ulong, Dictionary<Type, StateInfo>> _states;
const string GuildLogSource = "Configuration loader";
public GuildStateService(RegexbotClient bot) : base(bot)
{
_moderators = new Dictionary<ulong, EntityList>();
_states = new Dictionary<ulong, Dictionary<Type, StateInfo>>();
CreateDatabaseTablesAsync().Wait();
bot.DiscordClient.GuildAvailable += DiscordClient_GuildAvailable;
bot.DiscordClient.JoinedGuild += DiscordClient_JoinedGuild;
bot.DiscordClient.LeftGuild += DiscordClient_LeftGuild;
// TODO periodic task for refreshing stale configuration
}
private async Task DiscordClient_GuildAvailable(SocketGuild arg) => await InitializeGuild(arg);
private async Task DiscordClient_JoinedGuild(SocketGuild arg) => await InitializeGuild(arg);
/// <summary>
/// Unloads in-memory guild information.
/// </summary>
private Task DiscordClient_LeftGuild(SocketGuild arg)
{
// TODO what is GuildUnavailable? Should we listen for that too?
lock (_storageLock) _states.Remove(arg.Id);
return Task.CompletedTask;
}
/// <summary>
/// Initializes guild in-memory structures and attempts to load configuration.
/// </summary>
private async Task InitializeGuild(SocketGuild arg)
{
// We're only loading config here now.
bool success = await LoadGuildConfiguration(arg.Id);
if (!success)
{
await BotClient.GuildLogAsync(arg.Id, GuildLogSource,
"Configuration was not reloaded due to the previously stated error(s).");
}
else
{
await BotClient.InstanceLogAsync(false, GuildLogSource,
$"Configuration successfully refreshed for guild ID {arg.Id}.");
}
}
#region Data output
/// <summary>
/// See <see cref="ModuleBase.GetGuildState{T}(ulong)"/>.
/// </summary>
public T RetrieveGuildStateObject<T>(ulong guildId, Type t)
{
lock (_storageLock)
{
if (_states.TryGetValue(guildId, out var tl))
{
if (tl.TryGetValue(t, out var val))
{
// Leave handling of potential InvalidCastException to caller.
return (T)val.Data;
}
}
return default;
}
}
/// <summary>
/// See <see cref="ModuleBase.GetModerators(ulong)"/>.
/// </summary>
public EntityList RetrieveGuildModerators(ulong guildId)
{
lock (_storageLock)
{
if (_moderators.TryGetValue(guildId, out var mods)) return mods;
else return new EntityList();
}
}
#endregion
/// <summary>
/// Guild-specific configuration begins processing here.
/// 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, the old state data is kept.
/// </remarks>
private async Task<bool> LoadGuildConfiguration(ulong guildId)
{
var jstr = await RetrieveConfiguration(guildId);
int jstrHash = jstr.GetHashCode();
JObject guildConf;
try
{
var tok = JToken.Parse(jstr);
if (tok.Type == JTokenType.Object)
{
guildConf = (JObject)tok;
}
else
{
throw new InvalidCastException("The given configuration is not a JSON object.");
}
}
catch (Exception ex) when (ex is JsonReaderException || ex is InvalidCastException)
{
await BotClient.GuildLogAsync(guildId, GuildLogSource,
$"A problem exists within the guild configuration: {ex.Message}");
// Don't update currently loaded state.
return false;
}
// TODO Guild-specific service options? If implemented, this is where to load them.
// Load moderator list
var mods = new EntityList(guildConf["Moderators"], true);
// Create guild state objects for all existing modules
var newStates = new Dictionary<Type, StateInfo>();
foreach (var mod in BotClient.Modules)
{
var t = mod.GetType();
var tn = t.Name;
try
{
try
{
var state = await mod.CreateGuildStateAsync(guildId, guildConf[tn]); // can be null
newStates.Add(t, new StateInfo(state, jstrHash));
}
catch (Exception ex) when (!(ex is ModuleLoadException))
{
Log("Unhandled exception while initializing guild state for module:\n" +
$"Module: {tn} | " +
$"Guild: {guildId} ({BotClient.DiscordClient.GetGuild(guildId)?.Name ?? "unknown name"})\n" +
$"```\n{ex.ToString()}\n```", true).Wait();
BotClient.GuildLogAsync(guildId, GuildLogSource,
"An internal error occurred when attempting to load new configuration. " +
"The bot owner has been notified.").Wait();
return false;
}
}
catch (ModuleLoadException ex)
{
await BotClient.GuildLogAsync(guildId, GuildLogSource,
$"{tn} has encountered an issue with its configuration: {ex.Message}");
return false;
}
}
lock (_storageLock)
{
_moderators[guildId] = mods;
_states[guildId] = newStates;
}
return true;
}
#region Database
const string DBTableName = "guild_configuration";
/// <summary>
/// Creates the table structures for holding guild configuration.
/// </summary>
private async Task CreateDatabaseTablesAsync()
{
using (var db = await BotClient.GetOpenNpgsqlConnectionAsync())
{
using (var c = db.CreateCommand())
{
c.CommandText = $"create table if not exists {DBTableName} ("
+ $"rev_id SERIAL primary key, "
+ "guild_id bigint not null, "
+ "author bigint not null, "
+ "rev_date timestamptz not null default NOW(), "
+ "config_json text not null"
+ ")";
await c.ExecuteNonQueryAsync();
}
// Creating default configuration with revision ID 0.
// Config ID 0 is used when no other configurations can be loaded for a guild.
using (var c = db.CreateCommand())
{
c.CommandText = $"insert into {DBTableName} (rev_id, guild_id, author, config_json) "
+ "values (0, 0, 0, @Json) "
+ "on conflict (rev_id) do nothing";
c.Parameters.Add("@Json", NpgsqlTypes.NpgsqlDbType.Text).Value = GetDefaultConfiguration();
c.Prepare();
await c.ExecuteNonQueryAsync();
}
}
}
private async Task<string> RetrieveConfiguration(ulong guildId)
{
// Offline option: Per-guild configuration exists under `config/(guild ID).json`
var basePath = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location) +
Path.DirectorySeparatorChar + "config" + Path.DirectorySeparatorChar;
if (!Directory.Exists(basePath)) Directory.CreateDirectory(basePath);
var path = basePath + guildId + ".json";
if (File.Exists(path))
{
return await File.ReadAllTextAsync(path);
}
else
{
await File.WriteAllTextAsync(path, GetDefaultConfiguration());
await Log($"Created initial configuration file in config{Path.DirectorySeparatorChar}{guildId}.json");
return await RetrieveConfiguration(guildId);
}
}
#endregion
/// <summary>
/// Retrieves the default configuration loaded within the assembly.
/// </summary>
private string GetDefaultConfiguration()
{
const string ResourceName = $"{nameof(RegexBot)}.DefaultGuildConfig.json";
var a = System.Reflection.Assembly.GetExecutingAssembly();
using (var s = a.GetManifestResourceStream(ResourceName))
using (var r = new System.IO.StreamReader(s))
return r.ReadToEnd();
}
}
}

View file

@ -1,22 +0,0 @@
using RegexBot.Common;
using RegexBot.Services.GuildState;
using System;
namespace RegexBot
{
partial class RegexbotClient
{
private GuildStateService _svcGuildState;
/// <summary>
/// See <see cref="ModuleBase.GetGuildState{T}(ulong)"/>.
/// </summary>
internal T GetGuildState<T>(ulong guild, Type type)
=> _svcGuildState.RetrieveGuildStateObject<T>(guild, type);
/// <summary>
/// See <see cref="ModuleBase.GetModerators(ulong)"/>.
/// </summary>
internal EntityList GetModerators(ulong guild) => _svcGuildState.RetrieveGuildModerators(guild);
}
}

View file

@ -1,50 +0,0 @@
using Newtonsoft.Json.Linq;
using System;
namespace RegexBot.Services.GuildState
{
/// <summary>
/// Contains a guild state object and other useful metadata in regards to it.
/// </summary>
class StateInfo : IDisposable
{
static readonly TimeSpan TimeUntilStale = new TimeSpan(0, 15, 0);
/// <summary>
/// Module-provided data.
/// </summary>
public object Data { get; }
/// <summary>
/// Hash of the JToken used to generate the data. In certain casaes, it is used to check
/// if the configuration may be stale and needs to be reloaded.
/// </summary>
private readonly int _configHash;
private DateTimeOffset _lastStaleCheck;
public StateInfo(object data, int configHash)
{
Data = data;
_configHash = configHash;
_lastStaleCheck = DateTimeOffset.UtcNow;
}
public void Dispose()
{
if (Data is IDisposable dd) { dd.Dispose(); }
}
/// <summary>
/// Checks if the current data may be stale, based on the last staleness check or
/// if the underlying configuration has changed.
/// </summary>
public bool IsStale(JToken comparison)
{
if (DateTimeOffset.UtcNow - _lastStaleCheck > TimeUntilStale) return true;
if (comparison.GetHashCode() != _configHash) return true;
_lastStaleCheck = DateTimeOffset.UtcNow;
return false;
}
}
}

View file

@ -0,0 +1,8 @@
using RegexBot.Services.Logging;
namespace RegexBot;
partial class RegexbotClient {
// Access set to internal for ModuleBase and Service base class
internal readonly LoggingService _svcLogging;
}

View file

@ -0,0 +1,85 @@
using Discord;
using Discord.Webhook;
using RegexBot.Data;
namespace RegexBot.Services.Logging;
/// <summary>
/// Implements logging. Logging is distinguished into two types: Instance and per-guild.
/// For further information on log types, see documentation under <see cref="Data.BotDatabaseContext"/>.
/// </summary>
class LoggingService : Service {
// NOTE: Service.Log's functionality is implemented here. DO NOT use within this class.
private readonly DiscordWebhookClient _instLogWebhook;
internal LoggingService(RegexbotClient bot) : base(bot) {
_instLogWebhook = new DiscordWebhookClient(bot.Config.InstanceLogTarget);
// Discord.Net log handling (client logging option is specified in Program.cs)
bot.DiscordClient.Log += DiscordClient_Log;
// Let's also do the ready message
bot.DiscordClient.Ready +=
delegate { DoInstanceLog(true, nameof(RegexBot), "Connected and ready."); return Task.CompletedTask; };
}
/// <summary>
/// Discord.Net logging events handled here.
/// Only events with high importance are stored. Others are just printed to console.
/// </summary>
private Task DiscordClient_Log(LogMessage arg) {
bool important = arg.Severity != LogSeverity.Info;
string msg = $"[{Enum.GetName(typeof(LogSeverity), arg.Severity)}] {arg.Message}";
const string logSource = "Discord.Net";
if (arg.Exception != null) msg += "\n```\n" + arg.Exception.ToString() + "\n```";
if (important) DoInstanceLog(true, logSource, msg);
else ToConsole(logSource, msg);
return Task.CompletedTask;
}
private static void ToConsole(string source, string message) {
message ??= "(null)";
var prefix = $"[{DateTimeOffset.UtcNow:u}] [{source}] ";
foreach (var line in message.Split(new string[] { "\r\n", "\n" }, StringSplitOptions.None)) {
Console.WriteLine(prefix + line);
}
}
// Hooked
internal void DoInstanceLog(bool report, string source, string? message) {
message ??= "(null)";
ToConsole(source, message);
if (report) Task.Run(() => ReportInstanceWebhook(source, message));
}
private async Task ReportInstanceWebhook(string source, string message) {
try {
EmbedBuilder e = new() {
Footer = new EmbedFooterBuilder() { Text = source },
Timestamp = DateTimeOffset.UtcNow,
Description = message
};
await _instLogWebhook.SendMessageAsync(embeds: new[] { e.Build() });
} catch (Discord.Net.HttpException ex) {
DoInstanceLog(false, Name, "Failed to send message to reporting channel: " + ex.Message);
}
}
// Hooked
public void DoGuildLog(ulong guild, string source, string message) {
message ??= "(null)";
try {
using var db = new BotDatabaseContext();
db.Add(new GuildLogLine() { GuildId = (long)guild, Source = source, Message = message });
db.SaveChanges();
#if DEBUG
ToConsole($"DEBUG {guild} - {source}", message);
#endif
} catch (Exception ex) {
// Stack trace goes to console only.
DoInstanceLog(false, Name, "Error when storing guild log line: " + ex.ToString());
}
}
}

View file

@ -0,0 +1,8 @@
using RegexBot.Services.ModuleState;
namespace RegexBot;
partial class RegexbotClient {
// Access set to internal for ModuleBase
internal readonly ModuleStateService _svcGuildState;
}

View file

@ -0,0 +1,138 @@
using Discord.WebSocket;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using RegexBot.Common;
using System.Reflection;
namespace RegexBot.Services.ModuleState;
/// <summary>
/// Implements per-module storage and retrieval of guild-specific state data, most typically but not limited to configuration data.
/// To that end, this service handles loading and validation of per-guild configuration files.
/// </summary>
class ModuleStateService : Service {
private readonly object _storageLock = new();
private readonly Dictionary<ulong, EntityList> _moderators;
private readonly Dictionary<ulong, Dictionary<Type, object?>> _stateData;
const string GuildLogSource = "Configuration loader";
public ModuleStateService(RegexbotClient bot) : base(bot) {
_moderators = new();
_stateData = new();
bot.DiscordClient.GuildAvailable += RefreshGuildState;
bot.DiscordClient.JoinedGuild += RefreshGuildState;
bot.DiscordClient.LeftGuild += RemoveGuildData;
}
private async Task RefreshGuildState(SocketGuild arg) {
bool success = await ProcessConfiguration(arg.Id);
if (success) BotClient._svcLogging.DoInstanceLog(false, GuildLogSource, $"Configuration refreshed for guild ID {arg.Id}.");
else BotClient._svcLogging.DoGuildLog(arg.Id, GuildLogSource, "Configuration was not refreshed due to errors.");
}
private Task RemoveGuildData(SocketGuild arg) {
lock (_storageLock) {
_stateData.Remove(arg.Id);
_moderators.Remove(arg.Id);
}
return Task.CompletedTask;
}
// Hooked
public T? DoGetStateObj<T>(ulong guildId, Type t) {
lock (_storageLock) {
if (_stateData.ContainsKey(guildId) && _stateData[guildId].ContainsKey(t)) {
// Leave handling of potential InvalidCastException to caller.
return (T?)_stateData[guildId][t];
}
return default;
}
}
// Hooked
public EntityList DoGetModlist(ulong guildId) {
lock (_storageLock) {
if (_moderators.TryGetValue(guildId, out var mods)) return mods;
else return new EntityList();
}
}
/// <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(ulong guildId) {
var jstr = await LoadConfigFile(guildId);
JObject guildConf;
try {
var tok = JToken.Parse(jstr);
if (tok.Type == JTokenType.Object) {
guildConf = (JObject)tok;
} else {
throw new InvalidCastException("Configuration is not valid JSON.");
}
} catch (Exception ex) when (ex is JsonReaderException or InvalidCastException) {
BotClient._svcLogging.DoGuildLog(guildId, GuildLogSource, $"A problem exists within the guild configuration: {ex.Message}");
return false;
}
// TODO Guild-specific service options? If implemented, this is where to load them.
// Load moderator list
var mods = new EntityList(guildConf["Moderators"]!, true);
// Create guild state objects for all existing modules
var newStates = new Dictionary<Type, object?>();
foreach (var mod in BotClient.Modules) {
var t = mod.GetType();
var tn = t.Name;
try {
try {
var state = await mod.CreateGuildStateAsync(guildId, guildConf[tn]!);
newStates.Add(t, state);
} catch (Exception ex) when (ex is not ModuleLoadException) {
Log("Unhandled exception while initializing guild state for module:\n" +
$"Module: {tn} | " +
$"Guild: {guildId} ({BotClient.DiscordClient.GetGuild(guildId)?.Name ?? "unknown name"})\n" +
$"```\n{ex}\n```", true);
BotClient._svcLogging.DoGuildLog(guildId, GuildLogSource,
"An internal error occurred when attempting to load new configuration.");
return false;
}
} catch (ModuleLoadException ex) {
BotClient._svcLogging.DoGuildLog(guildId, GuildLogSource,
$"{tn} has encountered an issue with its configuration: {ex.Message}");
return false;
}
}
lock (_storageLock) {
_moderators[guildId] = mods;
_stateData[guildId] = newStates;
}
return true;
}
private async Task<string> LoadConfigFile(ulong guildId) {
// Per-guild configuration exists under `config/(guild ID).json`
var basePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly()!.Location) +
Path.DirectorySeparatorChar + "config" + Path.DirectorySeparatorChar;
if (!Directory.Exists(basePath)) Directory.CreateDirectory(basePath);
var path = basePath + guildId + ".json";
if (File.Exists(path)) {
return await File.ReadAllTextAsync(path);
} else {
// Write default configuration to new file
using var resStream = Assembly.GetExecutingAssembly().GetManifestResourceStream($"{nameof(RegexBot)}.DefaultGuildConfig.json");
using (var newFile = File.OpenWrite(path)) resStream!.CopyTo(newFile);
Log($"Created initial configuration file in config{Path.DirectorySeparatorChar}{guildId}.json");
return await LoadConfigFile(guildId);
}
}
}

View file

@ -1,28 +1,23 @@
using System.Threading.Tasks;
namespace RegexBot.Services;
/// <summary>
/// Base class for services.
/// </summary>
/// <remarks>
/// Services provide core and shared functionality for this program. Modules are expected to call into services
/// directly or indirectly in order to access bot features.
/// </remarks>
internal abstract class Service {
public RegexbotClient BotClient { get; }
public string Name => GetType().Name;
public Service(RegexbotClient bot) => BotClient = bot;
namespace RegexBot.Services
{
/// <summary>
/// Base class for Kerobot services.
/// Emits a log message.
/// </summary>
/// <remarks>
/// Services provide the core functionality of this program. Modules are expected to call into methods
/// provided by services for the times when processor-intensive or shared functionality needs to be utilized.
/// </remarks>
internal abstract class Service
{
public RegexbotClient BotClient { get; }
public string Name => this.GetType().Name;
public Service(RegexbotClient bot) => BotClient = bot;
/// <summary>
/// Creates a log message.
/// </summary>
/// <param name="message">Logging message contents.</param>
/// <param name="report">Determines if the log message should be sent to a reporting channel.</param>
/// <returns></returns>
protected Task Log(string message, bool report = false) => BotClient.InstanceLogAsync(report, Name, message);
}
/// <param name="message">The log message to send. Multi-line messages are acceptable.</param>
/// <param name="report">Specify if the log message should be sent to a reporting channel.</param>
protected void Log(string message, bool report = false) => BotClient._svcLogging.DoInstanceLog(report, Name, message);
}