Restructured the project structure back to the way it was, there's no reasonable way to split the modules

This commit is contained in:
Kwoth
2024-04-26 22:26:24 +00:00
parent 6c9c8bf63e
commit e0819f760c
768 changed files with 192 additions and 1047 deletions

View File

@@ -0,0 +1,426 @@
#nullable disable
using NadekoBot.Common.Configs;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db;
using ExecuteResult = Discord.Commands.ExecuteResult;
using PreconditionResult = Discord.Commands.PreconditionResult;
namespace NadekoBot.Services;
public class CommandHandler : INService, IReadyExecutor, ICommandHandler
{
private const int GLOBAL_COMMANDS_COOLDOWN = 750;
private const float ONE_THOUSANDTH = 1.0f / 1000;
public event Func<IUserMessage, CommandInfo, Task> CommandExecuted = delegate { return Task.CompletedTask; };
public event Func<CommandInfo, ITextChannel, string, Task> CommandErrored = delegate { return Task.CompletedTask; };
//userid/msg count
public ConcurrentDictionary<ulong, uint> UserMessagesSent { get; } = new();
public ConcurrentHashSet<ulong> UsersOnShortCooldown { get; } = new();
private readonly DiscordSocketClient _client;
private readonly CommandService _commandService;
private readonly BotConfigService _bss;
private readonly IBot _bot;
private readonly IBehaviorHandler _behaviorHandler;
private readonly IServiceProvider _services;
private readonly ConcurrentDictionary<ulong, string> _prefixes;
private readonly DbService _db;
// private readonly InteractionService _interactions;
public CommandHandler(
DiscordSocketClient client,
DbService db,
CommandService commandService,
BotConfigService bss,
IBot bot,
IBehaviorHandler behaviorHandler,
// InteractionService interactions,
IServiceProvider services)
{
_client = client;
_commandService = commandService;
_bss = bss;
_bot = bot;
_behaviorHandler = behaviorHandler;
_db = db;
_services = services;
// _interactions = interactions;
_prefixes = bot.AllGuildConfigs.Where(x => x.Prefix is not null)
.ToDictionary(x => x.GuildId, x => x.Prefix)
.ToConcurrent();
}
public async Task OnReadyAsync()
{
// clear users on short cooldown every GLOBAL_COMMANDS_COOLDOWN miliseconds
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(GLOBAL_COMMANDS_COOLDOWN));
while (await timer.WaitForNextTickAsync())
UsersOnShortCooldown.Clear();
}
public string GetPrefix(IGuild guild)
=> GetPrefix(guild?.Id);
public string GetPrefix(ulong? id = null)
{
if (id is null || !_prefixes.TryGetValue(id.Value, out var prefix))
return _bss.Data.Prefix;
return prefix;
}
public string SetDefaultPrefix(string prefix)
{
if (string.IsNullOrWhiteSpace(prefix))
throw new ArgumentNullException(nameof(prefix));
_bss.ModifyConfig(bs =>
{
bs.Prefix = prefix;
});
return prefix;
}
public string SetPrefix(IGuild guild, string prefix)
{
if (string.IsNullOrWhiteSpace(prefix))
throw new ArgumentNullException(nameof(prefix));
if (guild is null)
throw new ArgumentNullException(nameof(guild));
using (var uow = _db.GetDbContext())
{
var gc = uow.GuildConfigsForId(guild.Id, set => set);
gc.Prefix = prefix;
uow.SaveChanges();
}
_prefixes[guild.Id] = prefix;
return prefix;
}
public async Task ExecuteExternal(ulong? guildId, ulong channelId, string commandText)
{
if (guildId is not null)
{
var guild = _client.GetGuild(guildId.Value);
if (guild?.GetChannel(channelId) is not SocketTextChannel channel)
{
Log.Warning("Channel for external execution not found");
return;
}
try
{
IUserMessage msg = await channel.SendMessageAsync(commandText);
msg = (IUserMessage)await channel.GetMessageAsync(msg.Id);
await TryRunCommand(guild, channel, msg);
//msg.DeleteAfter(5);
}
catch { }
}
}
public Task StartHandling()
{
_client.MessageReceived += MessageReceivedHandler;
// _client.SlashCommandExecuted += SlashCommandExecuted;
return Task.CompletedTask;
}
// private async Task SlashCommandExecuted(SocketSlashCommand arg)
// {
// var ctx = new SocketInteractionContext<SocketSlashCommand>(_client, arg);
// await _interactions.ExecuteCommandAsync(ctx, _services);
// }
private Task LogSuccessfulExecution(IUserMessage usrMsg, ITextChannel channel, params int[] execPoints)
{
if (_bss.Data.ConsoleOutputType == ConsoleOutputType.Normal)
{
Log.Information("""
Command Executed after {ExecTime}s
User: {User}
Server: {Server}
Channel: {Channel}
Message: {Message}
""",
string.Join("/", execPoints.Select(x => (x * ONE_THOUSANDTH).ToString("F3"))),
usrMsg.Author + " [" + usrMsg.Author.Id + "]",
channel is null ? "PRIVATE" : channel.Guild.Name + " [" + channel.Guild.Id + "]",
channel is null ? "PRIVATE" : channel.Name + " [" + channel.Id + "]",
usrMsg.Content);
}
else
{
Log.Information("Succ | g:{GuildId} | c: {ChannelId} | u: {UserId} | msg: {Message}",
channel?.Guild.Id.ToString() ?? "-",
channel?.Id.ToString() ?? "-",
usrMsg.Author.Id,
usrMsg.Content.TrimTo(10));
}
return Task.CompletedTask;
}
private void LogErroredExecution(
string errorMessage,
IUserMessage usrMsg,
ITextChannel channel,
params int[] execPoints)
{
if (_bss.Data.ConsoleOutputType == ConsoleOutputType.Normal)
{
Log.Warning("""
Command Errored after {ExecTime}s
User: {User}
Server: {Guild}
Channel: {Channel}
Message: {Message}
Error: {ErrorMessage}
""",
string.Join("/", execPoints.Select(x => (x * ONE_THOUSANDTH).ToString("F3"))),
usrMsg.Author + " [" + usrMsg.Author.Id + "]",
channel is null ? "DM" : channel.Guild.Name + " [" + channel.Guild.Id + "]",
channel is null ? "DM" : channel.Name + " [" + channel.Id + "]",
usrMsg.Content,
errorMessage);
}
else
{
Log.Warning("""
Err | g:{GuildId} | c: {ChannelId} | u: {UserId} | msg: {Message}
Err: {ErrorMessage}
""",
channel?.Guild.Id.ToString() ?? "-",
channel?.Id.ToString() ?? "-",
usrMsg.Author.Id,
usrMsg.Content.TrimTo(10),
errorMessage);
}
}
private Task MessageReceivedHandler(SocketMessage msg)
{
//no bots, wait until bot connected and initialized
if (msg.Author.IsBot || !_bot.IsReady)
return Task.CompletedTask;
if (msg is not SocketUserMessage usrMsg)
return Task.CompletedTask;
Task.Run(async () =>
{
try
{
#if !GLOBAL_NADEKO
// track how many messages each user is sending
UserMessagesSent.AddOrUpdate(usrMsg.Author.Id, 1, (_, old) => ++old);
#endif
var channel = msg.Channel;
var guild = (msg.Channel as SocketTextChannel)?.Guild;
await TryRunCommand(guild, channel, usrMsg);
}
catch (Exception ex)
{
Log.Warning(ex, "Error in CommandHandler");
if (ex.InnerException is not null)
Log.Warning(ex.InnerException, "Inner Exception of the error in CommandHandler");
}
});
return Task.CompletedTask;
}
public async Task TryRunCommand(SocketGuild guild, ISocketMessageChannel channel, IUserMessage usrMsg)
{
var startTime = Environment.TickCount;
var blocked = await _behaviorHandler.RunExecOnMessageAsync(guild, usrMsg);
if (blocked)
return;
var blockTime = Environment.TickCount - startTime;
var messageContent = await _behaviorHandler.RunInputTransformersAsync(guild, usrMsg);
var prefix = GetPrefix(guild?.Id);
var isPrefixCommand = messageContent.StartsWith(".prefix", StringComparison.InvariantCultureIgnoreCase);
// execute the command and measure the time it took
if (isPrefixCommand || messageContent.StartsWith(prefix, StringComparison.InvariantCulture))
{
var context = new CommandContext(_client, usrMsg);
var (success, error, info) = await ExecuteCommandAsync(context,
messageContent,
isPrefixCommand ? 1 : prefix.Length,
_services,
MultiMatchHandling.Best);
startTime = Environment.TickCount - startTime;
// if a command is found
if (info is not null)
{
// if it successfully executed
if (success)
{
await LogSuccessfulExecution(usrMsg, channel as ITextChannel, blockTime, startTime);
await CommandExecuted(usrMsg, info);
await _behaviorHandler.RunPostCommandAsync(context, info.Module.GetTopLevelModule().Name, info);
return;
}
// if it errored
if (error is not null)
{
error = HumanizeError(error);
LogErroredExecution(error, usrMsg, channel as ITextChannel, blockTime, startTime);
if (guild is not null)
await CommandErrored(info, channel as ITextChannel, error);
return;
}
}
}
await _behaviorHandler.RunOnNoCommandAsync(guild, usrMsg);
}
private string HumanizeError(string error)
{
if (error.Contains("parse int", StringComparison.OrdinalIgnoreCase)
|| error.Contains("parse float"))
return "Invalid number specified. Make sure you're specifying parameters in the correct order.";
return error;
}
public Task<(bool Success, string Error, CommandInfo Info)> ExecuteCommandAsync(
ICommandContext context,
string input,
int argPos,
IServiceProvider serviceProvider,
MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception)
=> ExecuteCommand(context, input[argPos..], serviceProvider, multiMatchHandling);
public async Task<(bool Success, string Error, CommandInfo Info)> ExecuteCommand(
ICommandContext context,
string input,
IServiceProvider services,
MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception)
{
var searchResult = _commandService.Search(context, input);
if (!searchResult.IsSuccess)
return (false, null, null);
var commands = searchResult.Commands;
var preconditionResults = new Dictionary<CommandMatch, PreconditionResult>();
foreach (var match in commands)
preconditionResults[match] = await match.Command.CheckPreconditionsAsync(context, services);
var successfulPreconditions = preconditionResults.Where(x => x.Value.IsSuccess).ToArray();
if (successfulPreconditions.Length == 0)
{
//All preconditions failed, return the one from the highest priority command
var bestCandidate = preconditionResults.OrderByDescending(x => x.Key.Command.Priority)
.FirstOrDefault(x => !x.Value.IsSuccess);
return (false, bestCandidate.Value.ErrorReason, commands[0].Command);
}
var parseResultsDict = new Dictionary<CommandMatch, ParseResult>();
foreach (var pair in successfulPreconditions)
{
var parseResult = await pair.Key.ParseAsync(context, searchResult, pair.Value, services);
if (parseResult.Error == CommandError.MultipleMatches)
{
IReadOnlyList<TypeReaderValue> argList, paramList;
switch (multiMatchHandling)
{
case MultiMatchHandling.Best:
argList = parseResult.ArgValues
.Map(x => x.Values.MaxBy(y => y.Score));
paramList = parseResult.ParamValues
.Map(x => x.Values.MaxBy(y => y.Score));
parseResult = ParseResult.FromSuccess(argList, paramList);
break;
}
}
parseResultsDict[pair.Key] = parseResult;
}
// Calculates the 'score' of a command given a parse result
float CalculateScore(CommandMatch match, ParseResult parseResult)
{
float argValuesScore = 0, paramValuesScore = 0;
if (match.Command.Parameters.Count > 0)
{
var argValuesSum =
parseResult.ArgValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score)
?? 0;
var paramValuesSum =
parseResult.ParamValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score)
?? 0;
argValuesScore = argValuesSum / match.Command.Parameters.Count;
paramValuesScore = paramValuesSum / match.Command.Parameters.Count;
}
var totalArgsScore = (argValuesScore + paramValuesScore) / 2;
return match.Command.Priority + (totalArgsScore * 0.99f);
}
//Order the parse results by their score so that we choose the most likely result to execute
var parseResults = parseResultsDict.OrderByDescending(x => CalculateScore(x.Key, x.Value)).ToList();
var successfulParses = parseResults.Where(x => x.Value.IsSuccess).ToArray();
if (successfulParses.Length == 0)
{
//All parses failed, return the one from the highest priority command, using score as a tie breaker
var bestMatch = parseResults.FirstOrDefault(x => !x.Value.IsSuccess);
return (false, bestMatch.Value.ErrorReason, commands[0].Command);
}
var cmd = successfulParses[0].Key.Command;
// Bot will ignore commands which are ran more often than what specified by
// GlobalCommandsCooldown constant (miliseconds)
if (!UsersOnShortCooldown.Add(context.Message.Author.Id))
return (false, null, cmd);
//return SearchResult.FromError(CommandError.Exception, "You are on a global cooldown.");
var blocked = await _behaviorHandler.RunPreCommandAsync(context, cmd);
if (blocked)
return (false, null, cmd);
//If we get this far, at least one parse was successful. Execute the most likely overload.
var chosenOverload = successfulParses[0];
var execResult = (ExecuteResult)await chosenOverload.Key.ExecuteAsync(context, chosenOverload.Value, services);
if (execResult.Exception is not null
&& (execResult.Exception is not HttpException he
|| he.DiscordCode != DiscordErrorCode.InsufficientPermissions))
Log.Warning(execResult.Exception, "Command Error");
return (true, null, cmd);
}
}

View File

@@ -0,0 +1,116 @@
#nullable disable
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using NadekoBot.Db;
using NadekoBot.Db.Models;
using NadekoBot.Services.Currency;
namespace NadekoBot.Services;
public sealed class CurrencyService : ICurrencyService, INService
{
private readonly DbService _db;
private readonly ITxTracker _txTracker;
public CurrencyService(DbService db, ITxTracker txTracker)
{
_db = db;
_txTracker = txTracker;
}
public Task<IWallet> GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default)
{
if (type == CurrencyType.Default)
return Task.FromResult<IWallet>(new DefaultWallet(userId, _db));
throw new ArgumentOutOfRangeException(nameof(type));
}
public async Task AddBulkAsync(
IReadOnlyCollection<ulong> userIds,
long amount,
TxData txData,
CurrencyType type = CurrencyType.Default)
{
if (type == CurrencyType.Default)
{
foreach (var userId in userIds)
{
var wallet = await GetWalletAsync(userId);
await wallet.Add(amount, txData);
}
return;
}
throw new ArgumentOutOfRangeException(nameof(type));
}
public async Task RemoveBulkAsync(
IReadOnlyCollection<ulong> userIds,
long amount,
TxData txData,
CurrencyType type = CurrencyType.Default)
{
if (type == CurrencyType.Default)
{
await using var ctx = _db.GetDbContext();
await ctx
.GetTable<DiscordUser>()
.Where(x => userIds.Contains(x.UserId))
.UpdateAsync(du => new()
{
CurrencyAmount = du.CurrencyAmount >= amount
? du.CurrencyAmount - amount
: 0
});
await ctx.SaveChangesAsync();
return;
}
throw new ArgumentOutOfRangeException(nameof(type));
}
public async Task AddAsync(
ulong userId,
long amount,
TxData txData)
{
var wallet = await GetWalletAsync(userId);
await wallet.Add(amount, txData);
await _txTracker.TrackAdd(amount, txData);
}
public async Task AddAsync(
IUser user,
long amount,
TxData txData)
=> await AddAsync(user.Id, amount, txData);
public async Task<bool> RemoveAsync(
ulong userId,
long amount,
TxData txData)
{
if (amount == 0)
return true;
var wallet = await GetWalletAsync(userId);
var result = await wallet.Take(amount, txData);
if (result)
await _txTracker.TrackRemove(amount, txData);
return result;
}
public async Task<bool> RemoveAsync(
IUser user,
long amount,
TxData txData)
=> await RemoveAsync(user.Id, amount, txData);
public async Task<IReadOnlyList<DiscordUser>> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9)
{
await using var uow = _db.GetDbContext();
return await uow.Set<DiscordUser>().GetTopRichest(ignoreId, page, perPage);
}
}

View File

@@ -0,0 +1,45 @@
using NadekoBot.Services.Currency;
namespace NadekoBot.Services;
public static class CurrencyServiceExtensions
{
public static async Task<long> GetBalanceAsync(this ICurrencyService cs, ulong userId)
{
var wallet = await cs.GetWalletAsync(userId);
return await wallet.GetBalance();
}
// FUTURE should be a transaction
public static async Task<bool> TransferAsync(
this ICurrencyService cs,
IEmbedBuilderService ebs,
IUser from,
IUser to,
long amount,
string? note,
string formattedAmount)
{
var fromWallet = await cs.GetWalletAsync(from.Id);
var toWallet = await cs.GetWalletAsync(to.Id);
var extra = new TxData("gift", from.ToString()!, note, from.Id);
if (await fromWallet.Transfer(amount, toWallet, extra))
{
try
{
await to.SendConfirmAsync(ebs,
string.IsNullOrWhiteSpace(note)
? $"Received {formattedAmount} from {from} "
: $"Received {formattedAmount} from {from}: {note}");
}
catch
{
//ignored
}
return true;
}
return false;
}
}

View File

@@ -0,0 +1,115 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using NadekoBot.Db.Models;
using Nadeko.Bot.Db.Models;
namespace NadekoBot.Services.Currency;
public class DefaultWallet : IWallet
{
private readonly DbService _db;
public ulong UserId { get; }
public DefaultWallet(ulong userId, DbService db)
{
UserId = userId;
_db = db;
}
public async Task<long> GetBalance()
{
await using var ctx = _db.GetDbContext();
var userId = UserId;
return await ctx
.GetTable<DiscordUser>()
.Where(x => x.UserId == userId)
.Select(x => x.CurrencyAmount)
.FirstOrDefaultAsync();
}
public async Task<bool> Take(long amount, TxData? txData)
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount), "Amount to take must be non negative.");
await using var ctx = _db.GetDbContext();
var userId = UserId;
var changed = await ctx
.GetTable<DiscordUser>()
.Where(x => x.UserId == userId && x.CurrencyAmount >= amount)
.UpdateAsync(x => new()
{
CurrencyAmount = x.CurrencyAmount - amount
});
if (changed == 0)
return false;
if (txData is not null)
{
await ctx
.GetTable<CurrencyTransaction>()
.InsertAsync(() => new()
{
Amount = -amount,
Note = txData.Note,
UserId = userId,
Type = txData.Type,
Extra = txData.Extra,
OtherId = txData.OtherId,
DateAdded = DateTime.UtcNow
});
}
return true;
}
public async Task Add(long amount, TxData? txData)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be greater than 0.");
await using var ctx = _db.GetDbContext();
var userId = UserId;
await using (var tran = await ctx.Database.BeginTransactionAsync())
{
var changed = await ctx
.GetTable<DiscordUser>()
.Where(x => x.UserId == userId)
.UpdateAsync(x => new()
{
CurrencyAmount = x.CurrencyAmount + amount
});
if (changed == 0)
{
await ctx
.GetTable<DiscordUser>()
.Value(x => x.UserId, userId)
.Value(x => x.Username, "Unknown")
.Value(x => x.Discriminator, "????")
.Value(x => x.CurrencyAmount, amount)
.InsertAsync();
}
await tran.CommitAsync();
}
if (txData is not null)
{
await ctx.GetTable<CurrencyTransaction>()
.InsertAsync(() => new()
{
Amount = amount,
UserId = userId,
Note = txData.Note,
Type = txData.Type,
Extra = txData.Extra,
OtherId = txData.OtherId,
DateAdded = DateTime.UtcNow
});
}
}
}

View File

@@ -0,0 +1,110 @@
using LinqToDB;
using LinqToDB.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Services.Currency;
using Nadeko.Bot.Db.Models;
namespace NadekoBot.Services;
public sealed class GamblingTxTracker : ITxTracker, INService, IReadyExecutor
{
private static readonly IReadOnlySet<string> _gamblingTypes = new HashSet<string>(new[]
{
"lula",
"betroll",
"betflip",
"blackjack",
"betdraw",
"slot",
});
private ConcurrentDictionary<string, (decimal Bet, decimal PaidOut)> _stats = new();
private readonly DbService _db;
public GamblingTxTracker(DbService db)
{
_db = db;
}
public async Task OnReadyAsync()
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
while (await timer.WaitForNextTickAsync())
{
await using var ctx = _db.GetDbContext();
await using var trans = await ctx.Database.BeginTransactionAsync();
try
{
var keys = _stats.Keys;
foreach (var key in keys)
{
if (_stats.TryRemove(key, out var stat))
{
await ctx.GetTable<GamblingStats>()
.InsertOrUpdateAsync(() => new()
{
Feature = key,
Bet = stat.Bet,
PaidOut = stat.PaidOut,
DateAdded = DateTime.UtcNow
}, old => new()
{
Bet = old.Bet + stat.Bet,
PaidOut = old.PaidOut + stat.PaidOut,
}, () => new()
{
Feature = key
});
}
}
}
catch (Exception ex)
{
Log.Error(ex, "An error occurred in gambling tx tracker");
}
finally
{
await trans.CommitAsync();
}
}
}
public Task TrackAdd(long amount, TxData? txData)
{
if (txData is null)
return Task.CompletedTask;
if (_gamblingTypes.Contains(txData.Type))
{
_stats.AddOrUpdate(txData.Type,
_ => (0, amount),
(_, old) => (old.Bet, old.PaidOut + amount));
}
return Task.CompletedTask;
}
public Task TrackRemove(long amount, TxData? txData)
{
if (txData is null)
return Task.CompletedTask;
if (_gamblingTypes.Contains(txData.Type))
{
_stats.AddOrUpdate(txData.Type,
_ => (amount, 0),
(_, old) => (old.Bet + amount, old.PaidOut));
}
return Task.CompletedTask;
}
public async Task<IReadOnlyCollection<GamblingStats>> GetAllAsync()
{
await using var ctx = _db.GetDbContext();
return await ctx.Set<GamblingStats>()
.ToListAsyncEF();
}
}

View File

@@ -0,0 +1,17 @@
#nullable disable
namespace NadekoBot.Services;
public interface IBehaviorHandler
{
Task<bool> AddAsync(ICustomBehavior behavior);
Task AddRangeAsync(IEnumerable<ICustomBehavior> behavior);
Task<bool> RemoveAsync(ICustomBehavior behavior);
Task RemoveRangeAsync(IEnumerable<ICustomBehavior> behs);
Task<bool> RunExecOnMessageAsync(SocketGuild guild, IUserMessage usrMsg);
Task<string> RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg);
Task<bool> RunPreCommandAsync(ICommandContext context, CommandInfo cmd);
ValueTask RunPostCommandAsync(ICommandContext ctx, string moduleName, CommandInfo cmd);
Task RunOnNoCommandAsync(SocketGuild guild, IUserMessage usrMsg);
void Initialize();
}

View File

@@ -0,0 +1,12 @@
namespace NadekoBot.Services;
public interface ICommandHandler
{
string GetPrefix(IGuild ctxGuild);
string GetPrefix(ulong? id = null);
string SetDefaultPrefix(string toSet);
string SetPrefix(IGuild ctxGuild, string toSet);
ConcurrentDictionary<ulong, uint> UserMessagesSent { get; }
Task TryRunCommand(SocketGuild guild, ISocketMessageChannel channel, IUserMessage usrMsg);
}

View File

@@ -0,0 +1,20 @@
#nullable disable
namespace NadekoBot.Services;
public interface ICoordinator
{
bool RestartBot();
void Die(bool graceful);
bool RestartShard(int shardId);
IList<ShardStatus> GetAllShardStatuses();
int GetGuildCount();
Task Reload();
}
public class ShardStatus
{
public ConnectionState ConnectionState { get; set; }
public DateTime LastUpdate { get; set; }
public int ShardId { get; set; }
public int GuildCount { get; set; }
}

View File

@@ -0,0 +1,13 @@
using NadekoBot.Common.ModuleBehaviors;
namespace NadekoBot.Services;
public interface ICustomBehavior
: IExecOnMessage,
IInputTransformer,
IExecPreCommand,
IExecNoCommand,
IExecPostCommand
{
}

View File

@@ -0,0 +1,81 @@
#nullable disable
using NadekoBot.Common.Configs;
namespace NadekoBot.Services;
public interface IEmbedBuilderService
{
IEmbedBuilder Create(ICommandContext ctx = null);
IEmbedBuilder Create(EmbedBuilder eb);
}
public class EmbedBuilderService : IEmbedBuilderService, INService
{
private readonly BotConfigService _botConfigService;
public EmbedBuilderService(BotConfigService botConfigService)
=> _botConfigService = botConfigService;
public IEmbedBuilder Create(ICommandContext ctx = null)
=> new DiscordEmbedBuilderWrapper(_botConfigService.Data);
public IEmbedBuilder Create(EmbedBuilder embed)
=> new DiscordEmbedBuilderWrapper(_botConfigService.Data, embed);
}
public sealed class DiscordEmbedBuilderWrapper : IEmbedBuilder
{
private readonly BotConfig _botConfig;
private EmbedBuilder embed;
public DiscordEmbedBuilderWrapper(in BotConfig botConfig, EmbedBuilder embed = null)
{
_botConfig = botConfig;
this.embed = embed ?? new EmbedBuilder();
}
public IEmbedBuilder WithDescription(string desc)
=> Wrap(embed.WithDescription(desc));
public IEmbedBuilder WithTitle(string title)
=> Wrap(embed.WithTitle(title));
public IEmbedBuilder AddField(string title, object value, bool isInline = false)
=> Wrap(embed.AddField(title, value, isInline));
public IEmbedBuilder WithFooter(string text, string iconUrl = null)
=> Wrap(embed.WithFooter(text, iconUrl));
public IEmbedBuilder WithAuthor(string name, string iconUrl = null, string url = null)
=> Wrap(embed.WithAuthor(name, iconUrl, url));
public IEmbedBuilder WithUrl(string url)
=> Wrap(embed.WithUrl(url));
public IEmbedBuilder WithImageUrl(string url)
=> Wrap(embed.WithImageUrl(url));
public IEmbedBuilder WithThumbnailUrl(string url)
=> Wrap(embed.WithThumbnailUrl(url));
public IEmbedBuilder WithColor(EmbedColor color)
=> color switch
{
EmbedColor.Ok => Wrap(embed.WithColor(_botConfig.Color.Ok.ToDiscordColor())),
EmbedColor.Pending => Wrap(embed.WithColor(_botConfig.Color.Pending.ToDiscordColor())),
EmbedColor.Error => Wrap(embed.WithColor(_botConfig.Color.Error.ToDiscordColor())),
_ => throw new ArgumentOutOfRangeException(nameof(color), "Unsupported EmbedColor type")
};
public IEmbedBuilder WithDiscordColor(Color color)
=> Wrap(embed.WithColor(color));
public Embed Build()
=> embed.Build();
private IEmbedBuilder Wrap(EmbedBuilder eb)
{
embed = eb;
return this;
}
}

View File

@@ -0,0 +1,19 @@
#nullable disable
namespace NadekoBot.Services;
public interface IGoogleApiService
{
IReadOnlyDictionary<string, string> Languages { get; }
Task<IEnumerable<string>> GetVideoLinksByKeywordAsync(string keywords, int count = 1);
Task<IEnumerable<(string Name, string Id, string Url)>> GetVideoInfosByKeywordAsync(string keywords, int count = 1);
Task<IEnumerable<string>> GetPlaylistIdsByKeywordsAsync(string keywords, int count = 1);
Task<IEnumerable<string>> GetRelatedVideosAsync(string id, int count = 1, string user = null);
Task<IEnumerable<string>> GetPlaylistTracksAsync(string playlistId, int count = 50);
Task<IReadOnlyDictionary<string, TimeSpan>> GetVideoDurationsAsync(IEnumerable<string> videoIds);
Task<string> Translate(string sourceText, string sourceLanguage, string targetLanguage);
Task<string> ShortenUrl(string url);
Task<string> ShortenUrl(Uri url);
}

View File

@@ -0,0 +1,13 @@
#nullable disable
using NadekoBot.Common.Pokemon;
using NadekoBot.Modules.Games.Common.Trivia;
namespace NadekoBot.Services;
public interface ILocalDataCache
{
Task<IReadOnlyDictionary<string, SearchPokemon>> GetPokemonsAsync();
Task<IReadOnlyDictionary<string, SearchPokemonAbility>> GetPokemonAbilitiesAsync();
Task<TriviaQuestionModel[]> GetTriviaQuestionsAsync();
Task<IReadOnlyDictionary<int, string>> GetPokemonMapAsync();
}

View File

@@ -0,0 +1,19 @@
#nullable disable
using System.Globalization;
namespace NadekoBot.Services;
public interface ILocalization
{
CultureInfo DefaultCultureInfo { get; }
IDictionary<ulong, CultureInfo> GuildCultureInfos { get; }
CultureInfo GetCultureInfo(IGuild guild);
CultureInfo GetCultureInfo(ulong? guildId);
void RemoveGuildCulture(IGuild guild);
void RemoveGuildCulture(ulong guildId);
void ResetDefaultCulture();
void SetDefaultCulture(CultureInfo ci);
void SetGuildCulture(IGuild guild, CultureInfo ci);
void SetGuildCulture(ulong guildId, CultureInfo ci);
}

View File

@@ -0,0 +1,9 @@
#nullable disable
namespace NadekoBot.Services;
/// <summary>
/// All services must implement this interface in order to be auto-discovered by the DI system
/// </summary>
public interface INService
{
}

View File

@@ -0,0 +1,15 @@
#nullable disable
using Nadeko.Bot.Db.Models;
namespace NadekoBot.Modules.Utility.Services;
public interface IRemindService
{
Task AddReminderAsync(ulong userId,
ulong targetId,
ulong? guildId,
bool isPrivate,
DateTime time,
string message,
ReminderType reminderType);
}

View File

@@ -0,0 +1,70 @@
#nullable disable
namespace NadekoBot.Services;
public interface IStatsService
{
/// <summary>
/// The author of the bot.
/// </summary>
string Author { get; }
/// <summary>
/// The total amount of commands ran since startup.
/// </summary>
long CommandsRan { get; }
/// <summary>
/// The amount of messages seen by the bot since startup.
/// </summary>
long MessageCounter { get; }
/// <summary>
/// The rate of messages the bot sees every second.
/// </summary>
double MessagesPerSecond { get; }
/// <summary>
/// The total amount of text channels the bot can see.
/// </summary>
long TextChannels { get; }
/// <summary>
/// The total amount of voice channels the bot can see.
/// </summary>
long VoiceChannels { get; }
/// <summary>
/// Gets for how long the bot has been up since startup.
/// </summary>
TimeSpan GetUptime();
/// <summary>
/// Gets a formatted string of how long the bot has been up since startup.
/// </summary>
/// <param name="separator">The formatting separator.</param>
string GetUptimeString(string separator = ", ");
/// <summary>
/// Gets total amount of private memory currently in use by the bot, in Megabytes.
/// </summary>
double GetPrivateMemoryMegabytes();
GuildInfo GetGuildInfo(string name);
GuildInfo GetGuildInfo(ulong id);
}
public record struct GuildInfo
{
public required string Name { get; init; }
public required string IconUrl { get; init; }
public required string Owner { get; init; }
public required ulong OwnerId { get; init; }
public required ulong Id { get; init; }
public required int TextChannels { get; init; }
public required int VoiceChannels { get; init; }
public required DateTime CreatedAt { get; init; }
public required IReadOnlyList<string> Features { get; init; }
public required IReadOnlyList<Emote> Emojis { get; init; }
public required IReadOnlyList<IRole> Roles { get; init; }
public int MemberCount { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace NadekoBot.Common;
public interface ITimezoneService
{
TimeZoneInfo GetTimeZoneOrUtc(ulong? guildId);
}

View File

@@ -0,0 +1,302 @@
#nullable disable
using Microsoft.Extensions.DependencyInjection;
using NadekoBot.Common.ModuleBehaviors;
namespace NadekoBot.Services;
// should be renamed to handler as it's not only executing
public sealed class BehaviorHandler : IBehaviorHandler
{
private readonly IServiceProvider _services;
private IReadOnlyCollection<IExecNoCommand> noCommandExecs;
private IReadOnlyCollection<IExecPreCommand> preCommandExecs;
private IReadOnlyCollection<IExecOnMessage> onMessageExecs;
private IReadOnlyCollection<IInputTransformer> inputTransformers;
private readonly SemaphoreSlim _customLock = new(1, 1);
private readonly List<ICustomBehavior> _customExecs = new();
public BehaviorHandler(IServiceProvider services)
{
_services = services;
}
public void Initialize()
{
noCommandExecs = _services.GetServices<IExecNoCommand>().ToArray();
preCommandExecs = _services.GetServices<IExecPreCommand>().OrderByDescending(x => x.Priority).ToArray();
onMessageExecs = _services.GetServices<IExecOnMessage>().OrderByDescending(x => x.Priority).ToArray();
inputTransformers = _services.GetServices<IInputTransformer>().ToArray();
}
#region Add/Remove
public async Task AddRangeAsync(IEnumerable<ICustomBehavior> execs)
{
await _customLock.WaitAsync();
try
{
foreach (var exe in execs)
{
if (_customExecs.Contains(exe))
continue;
_customExecs.Add(exe);
}
}
finally
{
_customLock.Release();
}
}
public async Task<bool> AddAsync(ICustomBehavior behavior)
{
await _customLock.WaitAsync();
try
{
if (_customExecs.Contains(behavior))
return false;
_customExecs.Add(behavior);
return true;
}
finally
{
_customLock.Release();
}
}
public async Task<bool> RemoveAsync(ICustomBehavior behavior)
{
await _customLock.WaitAsync();
try
{
return _customExecs.Remove(behavior);
}
finally
{
_customLock.Release();
}
}
public async Task RemoveRangeAsync(IEnumerable<ICustomBehavior> behs)
{
await _customLock.WaitAsync();
try
{
foreach(var beh in behs)
_customExecs.Remove(beh);
}
finally
{
_customLock.Release();
}
}
#endregion
#region Running
public async Task<bool> RunExecOnMessageAsync(SocketGuild guild, IUserMessage usrMsg)
{
async Task<bool> Exec<T>(IReadOnlyCollection<T> execs)
where T : IExecOnMessage
{
foreach (var exec in execs)
{
try
{
if (await exec.ExecOnMessageAsync(guild, usrMsg))
{
Log.Information("{TypeName} blocked message g:{GuildId} u:{UserId} c:{ChannelId} msg:{Message}",
GetExecName(exec),
guild?.Id,
usrMsg.Author.Id,
usrMsg.Channel.Id,
usrMsg.Content?.TrimTo(10));
return true;
}
}
catch (Exception ex)
{
Log.Error(ex,
"An error occurred in {TypeName} late blocker: {ErrorMessage}",
GetExecName(exec),
ex.Message);
}
}
return false;
}
if (await Exec(onMessageExecs))
{
return true;
}
await _customLock.WaitAsync();
try
{
if (await Exec(_customExecs))
return true;
}
finally
{
_customLock.Release();
}
return false;
}
private string GetExecName(IBehavior exec)
=> exec.Name;
public async Task<bool> RunPreCommandAsync(ICommandContext ctx, CommandInfo cmd)
{
async Task<bool> Exec<T>(IReadOnlyCollection<T> execs) where T: IExecPreCommand
{
foreach (var exec in execs)
{
try
{
if (await exec.ExecPreCommandAsync(ctx, cmd.Module.GetTopLevelModule().Name, cmd))
{
Log.Information("{TypeName} Pre-Command blocked [{User}] Command: [{Command}]",
GetExecName(exec),
ctx.User,
cmd.Aliases[0]);
return true;
}
}
catch (Exception ex)
{
Log.Error(ex,
"An error occurred in {TypeName} PreCommand: {ErrorMessage}",
GetExecName(exec),
ex.Message);
}
}
return false;
}
if (await Exec(preCommandExecs))
return true;
await _customLock.WaitAsync();
try
{
if (await Exec(_customExecs))
return true;
}
finally
{
_customLock.Release();
}
return false;
}
public async Task RunOnNoCommandAsync(SocketGuild guild, IUserMessage usrMsg)
{
async Task Exec<T>(IReadOnlyCollection<T> execs) where T : IExecNoCommand
{
foreach (var exec in execs)
{
try
{
await exec.ExecOnNoCommandAsync(guild, usrMsg);
}
catch (Exception ex)
{
Log.Error(ex,
"An error occurred in {TypeName} OnNoCommand: {ErrorMessage}",
GetExecName(exec),
ex.Message);
}
}
}
await Exec(noCommandExecs);
await _customLock.WaitAsync();
try
{
await Exec(_customExecs);
}
finally
{
_customLock.Release();
}
}
public async Task<string> RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg)
{
async Task<string> Exec<T>(IReadOnlyCollection<T> execs, string content)
where T : IInputTransformer
{
foreach (var exec in execs)
{
try
{
var newContent = await exec.TransformInput(guild, usrMsg.Channel, usrMsg.Author, content);
if (newContent is not null)
{
Log.Information("{ExecName} transformed content {OldContent} -> {NewContent}",
GetExecName(exec),
content,
newContent);
return newContent;
}
}
catch (Exception ex)
{
Log.Warning(ex, "An error occured during InputTransform handling: {ErrorMessage}", ex.Message);
}
}
return null;
}
var newContent = await Exec(inputTransformers, usrMsg.Content);
if (newContent is not null)
return newContent;
await _customLock.WaitAsync();
try
{
newContent = await Exec(_customExecs, usrMsg.Content);
if (newContent is not null)
return newContent;
}
finally
{
_customLock.Release();
}
return usrMsg.Content;
}
public async ValueTask RunPostCommandAsync(ICommandContext ctx, string moduleName, CommandInfo cmd)
{
foreach (var exec in _customExecs)
{
try
{
await exec.ExecPostCommandAsync(ctx, moduleName, cmd.Name);
}
catch (Exception ex)
{
Log.Warning(ex,
"An error occured during PostCommand handling in {ExecName}: {ErrorMessage}",
GetExecName(exec),
ex.Message);
}
}
}
#endregion
}

View File

@@ -0,0 +1,143 @@
#nullable disable
using LinqToDB;
using LinqToDB.Data;
using LinqToDB.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using NadekoBot.Db;
using NadekoBot.Db.Models;
using Nadeko.Bot.Db.Models;
namespace NadekoBot.Modules.Permissions.Services;
public sealed class BlacklistService : IExecOnMessage
{
public int Priority
=> int.MaxValue;
private readonly DbService _db;
private readonly IPubSub _pubSub;
private readonly IBotCredentials _creds;
private IReadOnlyList<BlacklistEntry> blacklist;
private readonly TypedKey<BlacklistEntry[]> _blPubKey = new("blacklist.reload");
public BlacklistService(DbService db, IPubSub pubSub, IBotCredentials creds)
{
_db = db;
_pubSub = pubSub;
_creds = creds;
Reload(false);
_pubSub.Sub(_blPubKey, OnReload);
}
private ValueTask OnReload(BlacklistEntry[] newBlacklist)
{
blacklist = newBlacklist;
return default;
}
public Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg)
{
foreach (var bl in blacklist)
{
if (guild is not null && bl.Type == BlacklistType.Server && bl.ItemId == guild.Id)
{
Log.Information("Blocked input from blacklisted guild: {GuildName} [{GuildId}]", guild.Name, guild.Id);
return Task.FromResult(true);
}
if (bl.Type == BlacklistType.Channel && bl.ItemId == usrMsg.Channel.Id)
{
Log.Information("Blocked input from blacklisted channel: {ChannelName} [{ChannelId}]",
usrMsg.Channel.Name,
usrMsg.Channel.Id);
return Task.FromResult(true);
}
if (bl.Type == BlacklistType.User && bl.ItemId == usrMsg.Author.Id)
{
Log.Information("Blocked input from blacklisted user: {UserName} [{UserId}]",
usrMsg.Author.ToString(),
usrMsg.Author.Id);
return Task.FromResult(true);
}
}
return Task.FromResult(false);
}
public IReadOnlyList<BlacklistEntry> GetBlacklist()
=> blacklist;
public void Reload(bool publish = true)
{
using var uow = _db.GetDbContext();
var toPublish = uow.GetTable<BlacklistEntry>().ToArray();
blacklist = toPublish;
if (publish)
_pubSub.Pub(_blPubKey, toPublish);
}
public async Task Blacklist(BlacklistType type, ulong id)
{
if (_creds.OwnerIds.Contains(id))
return;
await using var uow = _db.GetDbContext();
await uow
.GetTable<BlacklistEntry>()
.InsertAsync(() => new()
{
ItemId = id,
Type = type,
});
if (type == BlacklistType.User)
{
await uow.GetTable<DiscordUser>()
.Where(x => x.UserId == id)
.UpdateAsync(_ => new()
{
CurrencyAmount = 0
});
}
Reload();
}
public async Task UnBlacklist(BlacklistType type, ulong id)
{
await using var uow = _db.GetDbContext();
await uow.GetTable<BlacklistEntry>()
.Where(bi => bi.ItemId == id && bi.Type == type)
.DeleteAsync();
Reload();
}
public async Task BlacklistUsers(IReadOnlyCollection<ulong> toBlacklist)
{
await using var uow = _db.GetDbContext();
var bc = uow.GetTable<BlacklistEntry>();
await bc.BulkCopyAsync(toBlacklist.Select(uid => new BlacklistEntry
{
ItemId = uid,
Type = BlacklistType.User
}));
var blList = toBlacklist.ToList();
await uow.GetTable<DiscordUser>()
.Where(x => blList.Contains(x.UserId))
.UpdateAsync(_ => new()
{
CurrencyAmount = 0
});
Reload();
}
}

View File

@@ -0,0 +1,172 @@
using CommandLine;
using Nadeko.Common.Medusa;
namespace NadekoBot.Common;
public sealed class CommandsUtilityService : ICommandsUtilityService, INService
{
private readonly CommandHandler _ch;
private readonly IBotStrings _strings;
private readonly DiscordPermOverrideService _dpos;
private readonly IEmbedBuilderService _eb;
private readonly ILocalization _loc;
private readonly IMedusaLoaderService _medusae;
public CommandsUtilityService(
CommandHandler ch,
IBotStrings strings,
DiscordPermOverrideService dpos,
IEmbedBuilderService eb,
ILocalization loc,
IMedusaLoaderService medusae)
{
_ch = ch;
_strings = strings;
_dpos = dpos;
_eb = eb;
_loc = loc;
_medusae = medusae;
}
public IEmbedBuilder GetCommandHelp(CommandInfo com, IGuild guild)
{
var prefix = _ch.GetPrefix(guild);
var str = $"**`{prefix + com.Aliases.First()}`**";
var alias = com.Aliases.Skip(1).FirstOrDefault();
if (alias is not null)
str += $" **/ `{prefix + alias}`**";
var culture = _loc.GetCultureInfo(guild);
var em = _eb.Create()
.AddField(str, $"{com.RealSummary(_strings, _medusae, culture, prefix)}", true);
_dpos.TryGetOverrides(guild?.Id ?? 0, com.Name, out var overrides);
var reqs = GetCommandRequirements(com, (GuildPermission?)overrides);
if (reqs.Any())
em.AddField(GetText(strs.requires, guild), string.Join("\n", reqs));
Extensions.Extensions.WithOkColor(em.AddField(_strings.GetText(strs.usage),
string.Join("\n", com.RealRemarksArr(_strings, _medusae, culture, prefix).Map(arg => Format.Code(arg))))
.WithFooter(GetText(strs.module(com.Module.GetTopLevelModule().Name), guild)));
var opt = GetNadekoOptionType(com.Attributes);
if (opt is not null)
{
var hs = GetCommandOptionHelp(opt);
if (!string.IsNullOrWhiteSpace(hs))
em.AddField(GetText(strs.options, guild), hs);
}
return em;
}
public static string GetCommandOptionHelp(Type opt)
{
var strs = GetCommandOptionHelpList(opt);
return string.Join("\n", strs);
}
public static List<string> GetCommandOptionHelpList(Type opt)
{
var strs = opt.GetProperties()
.Select(x => x.GetCustomAttributes(true).FirstOrDefault(a => a is OptionAttribute))
.Where(x => x is not null)
.Cast<OptionAttribute>()
.Select(x =>
{
var toReturn = $"`--{x.LongName}`";
if (!string.IsNullOrWhiteSpace(x.ShortName))
toReturn += $" (`-{x.ShortName}`)";
toReturn += $" {x.HelpText} ";
return toReturn;
})
.ToList();
return strs;
}
public static Type? GetNadekoOptionType(IEnumerable<Attribute> attributes)
=> attributes
.Select(a => a.GetType())
.Where(a => a.IsGenericType
&& a.GetGenericTypeDefinition() == typeof(NadekoOptionsAttribute<>))
.Select(a => a.GenericTypeArguments[0])
.FirstOrDefault();
public static string[] GetCommandRequirements(CommandInfo cmd, GuildPerm? overrides = null)
{
var toReturn = new List<string>();
if (cmd.Preconditions.Any(x => x is OwnerOnlyAttribute))
toReturn.Add("Bot Owner Only");
if (cmd.Preconditions.Any(x => x is NoPublicBotAttribute)
|| cmd.Module
.Preconditions
.Any(x => x is NoPublicBotAttribute)
|| cmd.Module.GetTopLevelModule()
.Preconditions
.Any(x => x is NoPublicBotAttribute))
toReturn.Add("No Public Bot");
if (cmd.Preconditions
.Any(x => x is OnlyPublicBotAttribute)
|| cmd.Module
.Preconditions
.Any(x => x is OnlyPublicBotAttribute)
|| cmd.Module.GetTopLevelModule()
.Preconditions
.Any(x => x is OnlyPublicBotAttribute))
toReturn.Add("Only Public Bot");
var userPermString = cmd.Preconditions
.Where(ca => ca is UserPermAttribute)
.Cast<UserPermAttribute>()
.Select(userPerm =>
{
if (userPerm.ChannelPermission is { } cPerm)
return GetPreconditionString(cPerm);
if (userPerm.GuildPermission is { } gPerm)
return GetPreconditionString(gPerm);
return string.Empty;
})
.Where(x => !string.IsNullOrWhiteSpace(x))
.Join('\n');
if (overrides is null)
{
if (!string.IsNullOrWhiteSpace(userPermString))
toReturn.Add(userPermString);
}
else
{
if (!string.IsNullOrWhiteSpace(userPermString))
toReturn.Add(Format.Strikethrough(userPermString));
toReturn.Add(GetPreconditionString(overrides.Value));
}
return toReturn.ToArray();
}
public static string GetPreconditionString(ChannelPerm perm)
=> (perm + " Channel Permission").Replace("Guild", "Server");
public static string GetPreconditionString(GuildPerm perm)
=> (perm + " Server Permission").Replace("Guild", "Server");
public string GetText(LocStr str, IGuild? guild)
=> _strings.GetText(str, guild?.Id);
}
public interface ICommandsUtilityService
{
IEmbedBuilder GetCommandHelp(CommandInfo com, IGuild guild);
}

View File

@@ -0,0 +1,136 @@
#nullable disable
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common.ModuleBehaviors;
using Nadeko.Bot.Db.Models;
namespace NadekoBot.Services;
public class DiscordPermOverrideService : INService, IExecPreCommand, IDiscordPermOverrideService
{
public int Priority { get; } = int.MaxValue;
private readonly DbService _db;
private readonly IServiceProvider _services;
private readonly ConcurrentDictionary<(ulong, string), DiscordPermOverride> _overrides;
public DiscordPermOverrideService(DbService db, IServiceProvider services)
{
_db = db;
_services = services;
using var uow = _db.GetDbContext();
_overrides = uow.Set<DiscordPermOverride>()
.AsNoTracking()
.AsEnumerable()
.ToDictionary(o => (o.GuildId ?? 0, o.Command), o => o)
.ToConcurrent();
}
public bool TryGetOverrides(ulong guildId, string commandName, out Nadeko.Bot.Db.GuildPerm? perm)
{
commandName = commandName.ToLowerInvariant();
if (_overrides.TryGetValue((guildId, commandName), out var dpo))
{
perm = dpo.Perm;
return true;
}
perm = null;
return false;
}
public Task<PreconditionResult> ExecuteOverrides(
ICommandContext ctx,
CommandInfo command,
GuildPerm perms,
IServiceProvider services)
{
var rupa = new RequireUserPermissionAttribute(perms);
return rupa.CheckPermissionsAsync(ctx, command, services);
}
public async Task AddOverride(ulong guildId, string commandName, GuildPerm perm)
{
commandName = commandName.ToLowerInvariant();
await using var uow = _db.GetDbContext();
var over = await uow.Set<DiscordPermOverride>()
.AsQueryable()
.FirstOrDefaultAsync(x => x.GuildId == guildId && commandName == x.Command);
if (over is null)
{
uow.Set<DiscordPermOverride>()
.Add(over = new()
{
Command = commandName,
Perm = (Nadeko.Bot.Db.GuildPerm)perm,
GuildId = guildId
});
}
else
over.Perm = (Nadeko.Bot.Db.GuildPerm)perm;
_overrides[(guildId, commandName)] = over;
await uow.SaveChangesAsync();
}
public async Task ClearAllOverrides(ulong guildId)
{
await using var uow = _db.GetDbContext();
var overrides = await uow.Set<DiscordPermOverride>()
.AsQueryable()
.AsNoTracking()
.Where(x => x.GuildId == guildId)
.ToListAsync();
uow.RemoveRange(overrides);
await uow.SaveChangesAsync();
foreach (var over in overrides)
_overrides.TryRemove((guildId, over.Command), out _);
}
public async Task RemoveOverride(ulong guildId, string commandName)
{
commandName = commandName.ToLowerInvariant();
await using var uow = _db.GetDbContext();
var over = await uow.Set<DiscordPermOverride>()
.AsQueryable()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.GuildId == guildId && x.Command == commandName);
if (over is null)
return;
uow.Remove(over);
await uow.SaveChangesAsync();
_overrides.TryRemove((guildId, commandName), out _);
}
public async Task<List<DiscordPermOverride>> GetAllOverrides(ulong guildId)
{
await using var uow = _db.GetDbContext();
return await uow.Set<DiscordPermOverride>()
.AsQueryable()
.AsNoTracking()
.Where(x => x.GuildId == guildId)
.ToListAsync();
}
public async Task<bool> ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command)
{
if (TryGetOverrides(context.Guild?.Id ?? 0, command.Name, out var perm) && perm is not null)
{
var result =
await new RequireUserPermissionAttribute((GuildPermission)perm).CheckPermissionsAsync(context,
command,
_services);
return !result.IsSuccess;
}
return false;
}
}

View File

@@ -0,0 +1,60 @@
#nullable disable
using SixLabors.Fonts;
namespace NadekoBot.Services;
public class FontProvider : INService
{
public FontFamily DottyFont { get; }
public FontFamily UniSans { get; }
public FontFamily NotoSans { get; }
//public FontFamily Emojis { get; }
/// <summary>
/// Font used for .rip command
/// </summary>
public Font RipFont { get; }
public List<FontFamily> FallBackFonts { get; }
private readonly FontCollection _fonts;
public FontProvider()
{
_fonts = new();
NotoSans = _fonts.Add("data/fonts/NotoSans-Bold.ttf");
UniSans = _fonts.Add("data/fonts/Uni Sans.ttf");
FallBackFonts = new();
//FallBackFonts.Add(_fonts.Install("data/fonts/OpenSansEmoji.ttf"));
// try loading some emoji and jap fonts on windows as fallback fonts
if (Environment.OSVersion.Platform == PlatformID.Win32NT)
{
try
{
var fontsfolder = Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
FallBackFonts.Add(_fonts.Add(Path.Combine(fontsfolder, "seguiemj.ttf")));
FallBackFonts.AddRange(_fonts.AddCollection(Path.Combine(fontsfolder, "msgothic.ttc")));
FallBackFonts.AddRange(_fonts.AddCollection(Path.Combine(fontsfolder, "segoe.ttc")));
}
catch { }
}
// any fonts present in data/fonts should be added as fallback fonts
// this will allow support for special characters when drawing text
foreach (var font in Directory.GetFiles(@"data/fonts"))
{
if (font.EndsWith(".ttf"))
FallBackFonts.Add(_fonts.Add(font));
else if (font.EndsWith(".ttc"))
FallBackFonts.AddRange(_fonts.AddCollection(font));
}
RipFont = NotoSans.CreateFont(20, FontStyle.Bold);
DottyFont = FallBackFonts.First(x => x.Name == "dotty");
}
}

View File

@@ -0,0 +1,17 @@
namespace NadekoBot.Services;
public interface IImageCache
{
Task<byte[]?> GetHeadsImageAsync();
Task<byte[]?> GetTailsImageAsync();
Task<byte[]?> GetCurrencyImageAsync();
Task<byte[]?> GetXpBackgroundImageAsync();
Task<byte[]?> GetRategirlBgAsync();
Task<byte[]?> GetRategirlDotAsync();
Task<byte[]?> GetDiceAsync(int num);
Task<byte[]?> GetSlotEmojiAsync(int number);
Task<byte[]?> GetSlotBgAsync();
Task<byte[]?> GetRipBgAsync();
Task<byte[]?> GetRipOverlayAsync();
Task<byte[]?> GetImageDataAsync(Uri url);
}

View File

@@ -0,0 +1,19 @@
using NadekoBot.Common.Configs;
namespace NadekoBot.Services;
public sealed class ImagesConfig : ConfigServiceBase<ImageUrls>
{
private const string PATH = "data/images.yml";
private static readonly TypedKey<ImageUrls> _changeKey =
new("config.images.updated");
public override string Name
=> "images";
public ImagesConfig(IConfigSeria serializer, IPubSub pubSub)
: base(PATH, serializer, pubSub, _changeKey)
{
}
}

View File

@@ -0,0 +1,11 @@
#nullable disable
namespace NadekoBot.Services;
public static class RedisImageExtensions
{
private const string OLD_CDN_URL = "nadeko-pictures.nyc3.digitaloceanspaces.com";
private const string NEW_CDN_URL = "cdn.nadeko.bot";
public static Uri ToNewCdn(this Uri uri)
=> new(uri.ToString().Replace(OLD_CDN_URL, NEW_CDN_URL));
}

View File

@@ -0,0 +1,58 @@
#nullable disable
using System.Diagnostics;
namespace NadekoBot.Services;
public class SingleProcessCoordinator : ICoordinator
{
private readonly IBotCredentials _creds;
private readonly DiscordSocketClient _client;
public SingleProcessCoordinator(IBotCredentials creds, DiscordSocketClient client)
{
_creds = creds;
_client = client;
}
public bool RestartBot()
{
if (string.IsNullOrWhiteSpace(_creds.RestartCommand?.Cmd)
|| string.IsNullOrWhiteSpace(_creds.RestartCommand?.Args))
{
Log.Error("You must set RestartCommand.Cmd and RestartCommand.Args in creds.yml");
return false;
}
Process.Start(_creds.RestartCommand.Cmd, _creds.RestartCommand.Args);
_ = Task.Run(async () =>
{
await Task.Delay(2000);
Die();
});
return true;
}
public void Die(bool graceful = false)
=> Environment.Exit(5);
public bool RestartShard(int shardId)
=> RestartBot();
public IList<ShardStatus> GetAllShardStatuses()
=> new[]
{
new ShardStatus
{
ConnectionState = _client.ConnectionState,
GuildCount = _client.Guilds.Count,
LastUpdate = DateTime.UtcNow,
ShardId = _client.ShardId
}
};
public int GetGuildCount()
=> _client.Guilds.Count;
public Task Reload()
=> Task.CompletedTask;
}

View File

@@ -0,0 +1,18 @@
#nullable disable
using System.Collections;
namespace NadekoBot.Services;
public class StartingGuildsService : IEnumerable<ulong>, INService
{
private readonly IReadOnlyList<ulong> _guilds;
public StartingGuildsService(DiscordSocketClient client)
=> _guilds = client.Guilds.Select(x => x.Id).ToList();
public IEnumerator<ulong> GetEnumerator()
=> _guilds.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator()
=> _guilds.GetEnumerator();
}

View File

@@ -0,0 +1,212 @@
#nullable disable
using Humanizer.Localisation;
using NadekoBot.Common.ModuleBehaviors;
using System.Diagnostics;
namespace NadekoBot.Services;
public sealed class StatsService : IStatsService, IReadyExecutor, INService
{
public const string BOT_VERSION = "5.0.0-alpha2";
public string Author
=> "Kwoth#2452";
public double MessagesPerSecond
=> MessageCounter / GetUptime().TotalSeconds;
public long TextChannels
=> Interlocked.Read(ref textChannels);
public long VoiceChannels
=> Interlocked.Read(ref voiceChannels);
public long MessageCounter
=> Interlocked.Read(ref messageCounter);
public long CommandsRan
=> Interlocked.Read(ref commandsRan);
private readonly Process _currentProcess = Process.GetCurrentProcess();
private readonly DiscordSocketClient _client;
private readonly IBotCredentials _creds;
private readonly DateTime _started;
private long textChannels;
private long voiceChannels;
private long messageCounter;
private long commandsRan;
private readonly IHttpClientFactory _httpFactory;
public StatsService(
DiscordSocketClient client,
CommandHandler cmdHandler,
IBotCredentials creds,
IHttpClientFactory factory)
{
_client = client;
_creds = creds;
_httpFactory = factory;
_started = DateTime.UtcNow;
_client.MessageReceived += _ => Task.FromResult(Interlocked.Increment(ref messageCounter));
cmdHandler.CommandExecuted += (_, _) => Task.FromResult(Interlocked.Increment(ref commandsRan));
_client.ChannelCreated += c =>
{
_ = Task.Run(() =>
{
if (c is ITextChannel)
Interlocked.Increment(ref textChannels);
else if (c is IVoiceChannel)
Interlocked.Increment(ref voiceChannels);
});
return Task.CompletedTask;
};
_client.ChannelDestroyed += c =>
{
_ = Task.Run(() =>
{
if (c is ITextChannel)
Interlocked.Decrement(ref textChannels);
else if (c is IVoiceChannel)
Interlocked.Decrement(ref voiceChannels);
});
return Task.CompletedTask;
};
_client.GuildAvailable += g =>
{
_ = Task.Run(() =>
{
var tc = g.Channels.Count(cx => cx is ITextChannel);
var vc = g.Channels.Count - tc;
Interlocked.Add(ref textChannels, tc);
Interlocked.Add(ref voiceChannels, vc);
});
return Task.CompletedTask;
};
_client.JoinedGuild += g =>
{
_ = Task.Run(() =>
{
var tc = g.Channels.Count(cx => cx is ITextChannel);
var vc = g.Channels.Count - tc;
Interlocked.Add(ref textChannels, tc);
Interlocked.Add(ref voiceChannels, vc);
});
return Task.CompletedTask;
};
_client.GuildUnavailable += g =>
{
_ = Task.Run(() =>
{
var tc = g.Channels.Count(cx => cx is ITextChannel);
var vc = g.Channels.Count - tc;
Interlocked.Add(ref textChannels, -tc);
Interlocked.Add(ref voiceChannels, -vc);
});
return Task.CompletedTask;
};
_client.LeftGuild += g =>
{
_ = Task.Run(() =>
{
var tc = g.Channels.Count(cx => cx is ITextChannel);
var vc = g.Channels.Count - tc;
Interlocked.Add(ref textChannels, -tc);
Interlocked.Add(ref voiceChannels, -vc);
});
return Task.CompletedTask;
};
}
private void InitializeChannelCount()
{
var guilds = _client.Guilds;
textChannels = guilds.Sum(static g => g.Channels.Count(static cx => cx is ITextChannel));
voiceChannels = guilds.Sum(static g => g.Channels.Count(static cx => cx is IVoiceChannel));
}
public async Task OnReadyAsync()
{
InitializeChannelCount();
using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
do
{
if (string.IsNullOrWhiteSpace(_creds.BotListToken))
continue;
try
{
using var http = _httpFactory.CreateClient();
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "shard_count", _creds.TotalShards.ToString() },
{ "shard_id", _client.ShardId.ToString() },
{ "server_count", _client.Guilds.Count().ToString() }
});
content.Headers.Clear();
content.Headers.Add("Content-Type", "application/x-www-form-urlencoded");
http.DefaultRequestHeaders.Add("Authorization", _creds.BotListToken);
using var res = await http.PostAsync(
new Uri($"https://discordbots.org/api/bots/{_client.CurrentUser.Id}/stats"),
content);
}
catch (Exception ex)
{
Log.Error(ex, "Error in botlist post");
}
} while (await timer.WaitForNextTickAsync());
}
public TimeSpan GetUptime()
=> DateTime.UtcNow - _started;
public string GetUptimeString(string separator = ", ")
{
var time = GetUptime();
return time.Humanize(3, maxUnit: TimeUnit.Day, minUnit: TimeUnit.Minute);
}
public double GetPrivateMemoryMegabytes()
{
_currentProcess.Refresh();
return _currentProcess.PrivateMemorySize64 / 1.Megabytes().Bytes;
}
public GuildInfo GetGuildInfo(string name)
=> throw new NotImplementedException();
public GuildInfo GetGuildInfo(ulong id)
{
var g = _client.GetGuild(id);
return new GuildInfo()
{
Id = g.Id,
IconUrl = g.IconUrl,
Name = g.Name,
Owner = g.Owner.Username,
OwnerId = g.OwnerId,
CreatedAt = g.CreatedAt.UtcDateTime,
VoiceChannels = g.VoiceChannels.Count,
TextChannels = g.TextChannels.Count,
Features = g.Features.Value.ToString().Split(","),
Emojis = g.Emotes.ToArray(),
Roles = g.Roles.OrderByDescending(x => x.Position).ToArray(),
MemberCount = g.MemberCount,
};
}
}

View File

@@ -0,0 +1,77 @@
#nullable disable
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
namespace NadekoBot.Services;
public class YtdlOperation
{
private readonly string _baseArgString;
private readonly bool _isYtDlp;
public YtdlOperation(string baseArgString, bool isYtDlp = false)
{
_baseArgString = baseArgString;
_isYtDlp = isYtDlp;
}
private Process CreateProcess(string[] args)
{
var newArgs = args.Map(arg => (object)arg.Replace("\"", ""));
return new()
{
StartInfo = new()
{
FileName = _isYtDlp ? "yt-dlp" : "youtube-dl",
Arguments = string.Format(_baseArgString, newArgs),
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
CreateNoWindow = true
}
};
}
public async Task<string> GetDataAsync(params string[] args)
{
try
{
using var process = CreateProcess(args);
Log.Debug("Executing {FileName} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
process.Start();
var str = await process.StandardOutput.ReadToEndAsync();
var err = await process.StandardError.ReadToEndAsync();
if (!string.IsNullOrEmpty(err))
Log.Warning("YTDL warning: {YtdlWarning}", err);
return str;
}
catch (Win32Exception)
{
Log.Error("youtube-dl is likely not installed. " + "Please install it before running the command again");
return default;
}
catch (Exception ex)
{
Log.Error(ex, "Exception running youtube-dl: {ErrorMessage}", ex.Message);
return default;
}
}
public async IAsyncEnumerable<string> EnumerateDataAsync(params string[] args)
{
using var process = CreateProcess(args);
Log.Debug("Executing {FileName} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
process.Start();
string line;
while ((line = await process.StandardOutput.ReadLineAsync()) is not null)
yield return line;
}
}

View File

@@ -0,0 +1,101 @@
#nullable disable
using System.Globalization;
namespace NadekoBot.Services;
public class BotStrings : IBotStrings
{
/// <summary>
/// Used as failsafe in case response key doesn't exist in the selected or default language.
/// </summary>
private readonly CultureInfo _usCultureInfo = new("en-US");
private readonly ILocalization _localization;
private readonly IBotStringsProvider _stringsProvider;
public BotStrings(ILocalization loc, IBotStringsProvider stringsProvider)
{
_localization = loc;
_stringsProvider = stringsProvider;
}
private string GetString(string key, CultureInfo cultureInfo)
=> _stringsProvider.GetText(cultureInfo.Name, key);
public string GetText(string key, ulong? guildId = null, params object[] data)
=> GetText(key, _localization.GetCultureInfo(guildId), data);
public string GetText(string key, CultureInfo cultureInfo)
{
var text = GetString(key, cultureInfo);
if (string.IsNullOrWhiteSpace(text))
{
Log.Warning("'{Key}' key is missing from '{LanguageName}' response strings. You may ignore this message",
key,
cultureInfo.Name);
text = GetString(key, _usCultureInfo) ?? $"Error: dkey {key} not found!";
if (string.IsNullOrWhiteSpace(text))
{
return
"I can't tell you if the command is executed, because there was an error printing out the response."
+ $" Key '{key}' is missing from resources. You may ignore this message.";
}
}
return text;
}
public string GetText(string key, CultureInfo cultureInfo, params object[] data)
{
try
{
return string.Format(GetText(key, cultureInfo), data);
}
catch (FormatException)
{
Log.Warning(
" Key '{Key}' is not properly formatted in '{LanguageName}' response strings. Please report this",
key,
cultureInfo.Name);
if (cultureInfo.Name != _usCultureInfo.Name)
return GetText(key, _usCultureInfo, data);
return
"I can't tell you if the command is executed, because there was an error printing out the response.\n"
+ $"Key '{key}' is not properly formatted. Please report this.";
}
}
public CommandStrings GetCommandStrings(string commandName, ulong? guildId = null)
=> GetCommandStrings(commandName, _localization.GetCultureInfo(guildId));
public CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo)
{
var cmdStrings = _stringsProvider.GetCommandStrings(cultureInfo.Name, commandName);
if (cmdStrings is null)
{
if (cultureInfo.Name == _usCultureInfo.Name)
{
Log.Warning("'{CommandName}' doesn't exist in 'en-US' command strings. Please report this",
commandName);
return new CommandStrings()
{
Args = new[] { "" },
Desc = "?"
};
}
// Log.Warning(@"'{CommandName}' command strings don't exist in '{LanguageName}' culture.
// This message is safe to ignore, however you can ask in Nadeko support server how you can contribute command translations",
// commandName, cultureInfo.Name);
return GetCommandStrings(commandName, _usCultureInfo);
}
return cmdStrings;
}
public void Reload()
=> _stringsProvider.Reload();
}

View File

@@ -0,0 +1,73 @@
#nullable disable
using Newtonsoft.Json;
using YamlDotNet.Serialization;
namespace NadekoBot.Services;
/// <summary>
/// Loads strings from the local default filepath <see cref="_responsesPath" />
/// </summary>
public class LocalFileStringsSource : IStringsSource
{
private readonly string _responsesPath = "data/strings/responses";
private readonly string _commandsPath = "data/strings/commands";
public LocalFileStringsSource(
string responsesPath = "data/strings/responses",
string commandsPath = "data/strings/commands")
{
_responsesPath = responsesPath;
_commandsPath = commandsPath;
}
public Dictionary<string, Dictionary<string, string>> GetResponseStrings()
{
var outputDict = new Dictionary<string, Dictionary<string, string>>();
foreach (var file in Directory.GetFiles(_responsesPath))
{
try
{
var langDict = JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(file));
var localeName = GetLocaleName(file);
outputDict[localeName] = langDict;
}
catch (Exception ex)
{
Log.Error(ex, "Error loading {FileName} response strings: {ErrorMessage}", file, ex.Message);
}
}
return outputDict;
}
public Dictionary<string, Dictionary<string, CommandStrings>> GetCommandStrings()
{
var deserializer = new DeserializerBuilder().Build();
var outputDict = new Dictionary<string, Dictionary<string, CommandStrings>>();
foreach (var file in Directory.GetFiles(_commandsPath))
{
try
{
var text = File.ReadAllText(file);
var langDict = deserializer.Deserialize<Dictionary<string, CommandStrings>>(text);
var localeName = GetLocaleName(file);
outputDict[localeName] = langDict;
}
catch (Exception ex)
{
Log.Error(ex, "Error loading {FileName} command strings: {ErrorMessage}", file, ex.Message);
}
}
return outputDict;
}
private static string GetLocaleName(string fileName)
{
fileName = Path.GetFileName(fileName);
var dotIndex = fileName.IndexOf('.') + 1;
var secondDotIndex = fileName.LastIndexOf('.');
return fileName.Substring(dotIndex, secondDotIndex - dotIndex);
}
}

View File

@@ -0,0 +1,38 @@
#nullable disable
namespace NadekoBot.Services;
public class MemoryBotStringsProvider : IBotStringsProvider
{
private readonly IStringsSource _source;
private IReadOnlyDictionary<string, Dictionary<string, string>> responseStrings;
private IReadOnlyDictionary<string, Dictionary<string, CommandStrings>> commandStrings;
public MemoryBotStringsProvider(IStringsSource source)
{
_source = source;
Reload();
}
public string GetText(string localeName, string key)
{
if (responseStrings.TryGetValue(localeName, out var langStrings) && langStrings.TryGetValue(key, out var text))
return text;
return null;
}
public void Reload()
{
responseStrings = _source.GetResponseStrings();
commandStrings = _source.GetCommandStrings();
}
public CommandStrings GetCommandStrings(string localeName, string commandName)
{
if (commandStrings.TryGetValue(localeName, out var langStrings)
&& langStrings.TryGetValue(commandName, out var strings))
return strings;
return null;
}
}