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:
parent
a7de9132ed
commit
02f91947f3
36 changed files with 1445 additions and 2046 deletions
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
12
RegexBot/Common/EntityType.cs
Normal file
12
RegexBot/Common/EntityType.cs
Normal 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
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
35
RegexBot/Data/BotDatabaseContext.cs
Normal file
35
RegexBot/Data/BotDatabaseContext.cs
Normal 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());
|
||||
}
|
||||
}
|
19
RegexBot/Data/CachedGuildUser.cs
Normal file
19
RegexBot/Data/CachedGuildUser.cs
Normal 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!;
|
||||
}
|
17
RegexBot/Data/CachedUser.cs
Normal file
17
RegexBot/Data/CachedUser.cs
Normal 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!;
|
||||
}
|
14
RegexBot/Data/GuildLogLine.cs
Normal file
14
RegexBot/Data/GuildLogLine.cs
Normal 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!;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) { }
|
||||
}
|
||||
}
|
11
RegexBot/ModuleLoadException.cs
Normal file
11
RegexBot/ModuleLoadException.cs
Normal 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) { }
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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!;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
86
RegexBot/RegexbotModule.cs
Normal file
86
RegexBot/RegexbotModule.cs
Normal 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);
|
||||
}
|
|
@ -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 { }
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
135
RegexBot/Services/EntityCache/UserCachingSubservice.cs
Normal file
135
RegexBot/Services/EntityCache/UserCachingSubservice.cs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
8
RegexBot/Services/Logging/Hooks.cs
Normal file
8
RegexBot/Services/Logging/Hooks.cs
Normal 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;
|
||||
}
|
85
RegexBot/Services/Logging/LoggingService.cs
Normal file
85
RegexBot/Services/Logging/LoggingService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
8
RegexBot/Services/ModuleState/Hooks.cs
Normal file
8
RegexBot/Services/ModuleState/Hooks.cs
Normal file
|
@ -0,0 +1,8 @@
|
|||
using RegexBot.Services.ModuleState;
|
||||
|
||||
namespace RegexBot;
|
||||
|
||||
partial class RegexbotClient {
|
||||
// Access set to internal for ModuleBase
|
||||
internal readonly ModuleStateService _svcGuildState;
|
||||
}
|
138
RegexBot/Services/ModuleState/ModuleStateService.cs
Normal file
138
RegexBot/Services/ModuleState/ModuleStateService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue