Lots more stuff

This commit is contained in:
Kwoth
2023-03-14 15:48:59 +01:00
parent 0af8048938
commit 7a60868632
315 changed files with 2482 additions and 2128 deletions

View File

@@ -0,0 +1,78 @@
#nullable disable
namespace NadekoBot;
public interface IBotCredentials
{
string Token { get; }
string GoogleApiKey { get; }
ICollection<ulong> OwnerIds { get; }
bool UsePrivilegedIntents { get; }
string RapidApiKey { get; }
IDbOptions Db { get; }
string OsuApiKey { get; }
int TotalShards { get; }
IPatreonSettings Patreon { get; }
string CleverbotApiKey { get; }
string Gpt3ApiKey { get; }
IRestartConfig RestartCommand { get; }
IVotesSettings Votes { get; }
string BotListToken { get; }
string RedisOptions { get; }
string LocationIqApiKey { get; }
string TimezoneDbApiKey { get; }
string CoinmarketcapApiKey { get; }
string TrovoClientId { get; }
string CoordinatorUrl { get; set; }
string TwitchClientId { get; set; }
string TwitchClientSecret { get; set; }
IGoogleApiConfig Google { get; set; }
BotCacheImplemenation BotCache { get; set; }
}
public interface IVotesSettings
{
string TopggServiceUrl { get; set; }
string TopggKey { get; set; }
string DiscordsServiceUrl { get; set; }
string DiscordsKey { get; set; }
}
public interface IPatreonSettings
{
public string ClientId { get; set; }
public string AccessToken { get; set; }
public string RefreshToken { get; set; }
public string ClientSecret { get; set; }
public string CampaignId { get; set; }
}
public interface IRestartConfig
{
string Cmd { get; set; }
string Args { get; set; }
}
public class RestartConfig : IRestartConfig
{
public string Cmd { get; set; }
public string Args { get; set; }
}
public enum BotCacheImplemenation
{
Memory,
Redis
}
public interface IDbOptions
{
string Type { get; set; }
string ConnectionString { get; set; }
}
public interface IGoogleApiConfig
{
string SearchId { get; init; }
string ImageSearchId { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace NadekoBot;
public interface IBotCredsProvider
{
public void Reload();
public IBotCredentials GetCreds();
public void ModifyCredsFile(Action<IBotCredentials> func);
}

View File

@@ -0,0 +1,17 @@
#nullable disable
using System.Globalization;
using NadekoBot.Services;
namespace NadekoBot.Common;
/// <summary>
/// Defines methods to retrieve and reload bot strings
/// </summary>
public interface IBotStrings
{
string GetText(string key, ulong? guildId = null, params object[] data);
string GetText(string key, CultureInfo locale, params object[] data);
void Reload();
ICommandStrings GetCommandStrings(string commandName, ulong? guildId = null);
ICommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo);
}

View File

@@ -0,0 +1,17 @@
#nullable disable
using System.Globalization;
namespace NadekoBot.Common;
public static class BotStringsExtensions
{
// this one is for pipe fun, see PipeExtensions.cs
public static string GetText(this IBotStrings strings, in LocStr str, in ulong guildId)
=> strings.GetText(str.Key, guildId, str.Params);
public static string GetText(this IBotStrings strings, in LocStr str, ulong? guildId = null)
=> strings.GetText(str.Key, guildId, str.Params);
public static string GetText(this IBotStrings strings, in LocStr str, CultureInfo culture)
=> strings.GetText(str.Key, culture, str.Params);
}

View File

@@ -0,0 +1,28 @@
#nullable disable
namespace NadekoBot.Services;
/// <summary>
/// Implemented by classes which provide localized strings in their own ways
/// </summary>
public interface IBotStringsProvider
{
/// <summary>
/// Gets localized string
/// </summary>
/// <param name="localeName">Language name</param>
/// <param name="key">String key</param>
/// <returns>Localized string</returns>
string GetText(string localeName, string key);
/// <summary>
/// Reloads string cache
/// </summary>
void Reload();
/// <summary>
/// Gets command arg examples and description
/// </summary>
/// <param name="localeName">Language name</param>
/// <param name="commandName">Command name</param>
ICommandStrings GetCommandStrings(string localeName, string commandName);
}

View File

@@ -0,0 +1,8 @@
#nullable disable
namespace NadekoBot.Services;
public interface ICommandStrings
{
string Desc { get; set; }
string[] Args { get; set; }
}

View File

@@ -0,0 +1,16 @@
#nullable disable
namespace NadekoBot.Services;
/// <summary>
/// Basic interface used for classes implementing strings loading mechanism
/// </summary>
public interface IStringsSource
{
/// <summary>
/// Gets all response strings
/// </summary>
/// <returns>Dictionary(localename, Dictionary(key, response))</returns>
Dictionary<string, Dictionary<string, string>> GetResponseStrings();
Dictionary<string, Dictionary<string, ICommandStrings>> GetCommandStrings();
}

View File

@@ -0,0 +1,13 @@
namespace NadekoBot.Common;
public readonly struct LocStr
{
public readonly string Key;
public readonly object[] Params;
public LocStr(string key, params object[] data)
{
Key = key;
Params = data;
}
}

View File

@@ -0,0 +1,204 @@
#nullable disable
using Cloneable;
using NadekoBot.Common.Yml;
using SixLabors.ImageSharp.PixelFormats;
using System.Globalization;
using Microsoft.EntityFrameworkCore;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
namespace NadekoBot.Common.Configs;
[Cloneable]
public sealed partial class BotConfig : ICloneable<BotConfig>
{
[Comment("""DO NOT CHANGE""")]
public int Version { get; set; } = 5;
[Comment("""
Most commands, when executed, have a small colored line
next to the response. The color depends whether the command
is completed, errored or in progress (pending)
Color settings below are for the color of those lines.
To get color's hex, you can go here https://htmlcolorcodes.com/
and copy the hex code fo your selected color (marked as #)
""")]
public ColorConfig Color { get; set; }
[Comment("Default bot language. It has to be in the list of supported languages (.langli)")]
public CultureInfo DefaultLocale { get; set; }
[Comment("""
Style in which executed commands will show up in the console.
Allowed values: Simple, Normal, None
""")]
public ConsoleOutputType ConsoleOutputType { get; set; }
[Comment("""Whether the bot will check for new releases every hour""")]
public bool CheckForUpdates { get; set; } = true;
[Comment("""Do you want any messages sent by users in Bot's DM to be forwarded to the owner(s)?""")]
public bool ForwardMessages { get; set; }
[Comment("""
Do you want the message to be forwarded only to the first owner specified in the list of owners (in creds.yml),
or all owners? (this might cause the bot to lag if there's a lot of owners specified)
""")]
public bool ForwardToAllOwners { get; set; }
[Comment("""
Any messages sent by users in Bot's DM to be forwarded to the specified channel.
This option will only work when ForwardToAllOwners is set to false
""")]
public ulong? ForwardToChannel { get; set; }
[Comment("""
When a user DMs the bot with a message which is not a command
they will receive this message. Leave empty for no response. The string which will be sent whenever someone DMs the bot.
Supports embeds. How it looks: https://puu.sh/B0BLV.png
""")]
[YamlMember(ScalarStyle = ScalarStyle.Literal)]
public string DmHelpText { get; set; }
[Comment("""
Only users who send a DM to the bot containing one of the specified words will get a DmHelpText response.
Case insensitive.
Leave empty to reply with DmHelpText to every DM.
""")]
public List<string> DmHelpTextKeywords { get; set; }
[Comment("""This is the response for the .h command""")]
[YamlMember(ScalarStyle = ScalarStyle.Literal)]
public string HelpText { get; set; }
[Comment("""List of modules and commands completely blocked on the bot""")]
public BlockedConfig Blocked { get; set; }
[Comment("""Which string will be used to recognize the commands""")]
public string Prefix { get; set; }
[Comment("""
Toggles whether your bot will group greet/bye messages into a single message every 5 seconds.
1st user who joins will get greeted immediately
If more users join within the next 5 seconds, they will be greeted in groups of 5.
This will cause %user.mention% and other placeholders to be replaced with multiple users.
Keep in mind this might break some of your embeds - for example if you have %user.avatar% in the thumbnail,
it will become invalid, as it will resolve to a list of avatars of grouped users.
note: This setting is primarily used if you're afraid of raids, or you're running medium/large bots where some
servers might get hundreds of people join at once. This is used to prevent the bot from getting ratelimited,
and (slightly) reduce the greet spam in those servers.
""")]
public bool GroupGreets { get; set; }
[Comment("""
Whether the bot will rotate through all specified statuses.
This setting can be changed via .ropl command.
See RotatingStatuses submodule in Administration.
""")]
public bool RotateStatuses { get; set; }
public BotConfig()
{
var color = new ColorConfig();
Color = color;
DefaultLocale = new("en-US");
ConsoleOutputType = ConsoleOutputType.Normal;
ForwardMessages = false;
ForwardToAllOwners = false;
DmHelpText = """{"description": "Type `%prefix%h` for help."}""";
HelpText = """
{
"title": "To invite me to your server, use this link",
"description": "https://discordapp.com/oauth2/authorize?client_id={0}&scope=bot&permissions=66186303",
"color": 53380,
"thumbnail": "https://i.imgur.com/nKYyqMK.png",
"fields": [
{
"name": "Useful help commands",
"value": "`%bot.prefix%modules` Lists all bot modules.
`%prefix%h CommandName` Shows some help about a specific command.
`%prefix%commands ModuleName` Lists all commands in a module.",
"inline": false
},
{
"name": "List of all Commands",
"value": "https://nadeko.bot/commands",
"inline": false
},
{
"name": "Nadeko Support Server",
"value": "https://discord.nadeko.bot/ ",
"inline": true
}
]
}
""";
var blocked = new BlockedConfig();
Blocked = blocked;
Prefix = ".";
RotateStatuses = false;
GroupGreets = false;
DmHelpTextKeywords = new()
{
"help",
"commands",
"cmds",
"module",
"can you do"
};
}
// [Comment(@"Whether the prefix will be a suffix, or prefix.
// For example, if your prefix is ! you will run a command called 'cash' by typing either
// '!cash @Someone' if your prefixIsSuffix: false or
// 'cash @Someone!' if your prefixIsSuffix: true")]
// public bool PrefixIsSuffix { get; set; }
// public string Prefixed(string text) => PrefixIsSuffix
// ? text + Prefix
// : Prefix + text;
public string Prefixed(string text)
=> Prefix + text;
}
[Cloneable]
public sealed partial class BlockedConfig
{
public HashSet<string> Commands { get; set; }
public HashSet<string> Modules { get; set; }
public BlockedConfig()
{
Modules = new();
Commands = new();
}
}
[Cloneable]
public partial class ColorConfig
{
[Comment("""Color used for embed responses when command successfully executes""")]
public Rgba32 Ok { get; set; }
[Comment("""Color used for embed responses when command has an error""")]
public Rgba32 Error { get; set; }
[Comment("""Color used for embed responses while command is doing work or is in progress""")]
public Rgba32 Pending { get; set; }
public ColorConfig()
{
Ok = Rgba32.ParseHex("00e584");
Error = Rgba32.ParseHex("ee281f");
Pending = Rgba32.ParseHex("faa61a");
}
}
public enum ConsoleOutputType
{
Normal = 0,
Simple = 1,
None = 2
}

View File

@@ -0,0 +1,18 @@
namespace NadekoBot.Common.Configs;
/// <summary>
/// Base interface for available config serializers
/// </summary>
public interface IConfigSeria
{
/// <summary>
/// Serialize the object to string
/// </summary>
public string Serialize<T>(T obj)
where T : notnull;
/// <summary>
/// Deserialize string data into an object of the specified type
/// </summary>
public T Deserialize<T>(string data);
}

View File

@@ -0,0 +1,78 @@
#nullable disable
using LinqToDB.Common;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Services.Database;
namespace NadekoBot.Services;
public class DbService
{
private readonly IBotCredsProvider _creds;
// these are props because creds can change at runtime
private string DbType => _creds.GetCreds().Db.Type.ToLowerInvariant().Trim();
private string ConnString => _creds.GetCreds().Db.ConnectionString;
public DbService(IBotCredsProvider creds)
{
LinqToDBForEFTools.Initialize();
Configuration.Linq.DisableQueryCache = true;
_creds = creds;
}
public async Task SetupAsync()
{
var dbType = DbType;
var connString = ConnString;
await using var context = CreateRawDbContext(dbType, connString);
// make sure sqlite db is in wal journal mode
if (context is SqliteContext)
{
await context.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL");
}
await context.Database.MigrateAsync();
}
private static NadekoContext CreateRawDbContext(string dbType, string connString)
{
switch (dbType)
{
case "postgresql":
case "postgres":
case "pgsql":
return new PostgreSqlContext(connString);
case "mysql":
return new MysqlContext(connString);
case "sqlite":
return new SqliteContext(connString);
default:
throw new NotSupportedException($"The database provide type of '{dbType}' is not supported.");
}
}
private NadekoContext GetDbContextInternal()
{
var dbType = DbType;
var connString = ConnString;
var context = CreateRawDbContext(dbType, connString);
if (context is SqliteContext)
{
var conn = context.Database.GetDbConnection();
conn.Open();
using var com = conn.CreateCommand();
com.CommandText = "PRAGMA synchronous=OFF";
com.ExecuteNonQuery();
}
return context;
}
public NadekoContext GetDbContext()
=> GetDbContextInternal();
}

View File

@@ -0,0 +1,12 @@
using NadekoBot.Db;
using NadekoBot.Db.Models;
// todo fix these namespaces. It should only be Nadeko.Bot.Db
using NadekoBot.Services.Database;
namespace NadekoBot.Extensions;
public static class DbExtensions
{
public static DiscordUser GetOrCreateUser(this NadekoContext ctx, IUser original, Func<IQueryable<DiscordUser>, IQueryable<DiscordUser>> includes = null)
=> ctx.GetOrCreateUser(original.Id, original.Username, original.Discriminator, original.AvatarId, includes);
}

View File

@@ -0,0 +1,98 @@
using SixLabors.Fonts;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Drawing;
using SixLabors.ImageSharp.Drawing.Processing;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using Color = Discord.Color;
namespace NadekoBot.Extensions;
public static class ImagesharpExtensions
{
// https://github.com/SixLabors/Samples/blob/master/ImageSharp/AvatarWithRoundedCorner/Program.cs
public static IImageProcessingContext ApplyRoundedCorners(this IImageProcessingContext ctx, float cornerRadius)
{
var size = ctx.GetCurrentSize();
var corners = BuildCorners(size.Width, size.Height, cornerRadius);
ctx.SetGraphicsOptions(new GraphicsOptions
{
Antialias = true,
// enforces that any part of this shape that has color is punched out of the background
AlphaCompositionMode = PixelAlphaCompositionMode.DestOut
});
foreach (var c in corners)
ctx = ctx.Fill(SixLabors.ImageSharp.Color.Red, c);
return ctx;
}
private static IPathCollection BuildCorners(int imageWidth, int imageHeight, float cornerRadius)
{
// first create a square
var rect = new RectangularPolygon(-0.5f, -0.5f, cornerRadius, cornerRadius);
// then cut out of the square a circle so we are left with a corner
var cornerTopLeft = rect.Clip(new EllipsePolygon(cornerRadius - 0.5f, cornerRadius - 0.5f, cornerRadius));
// corner is now a corner shape positions top left
//lets make 3 more positioned correctly, we can do that by translating the original around the center of the image
var rightPos = imageWidth - cornerTopLeft.Bounds.Width + 1;
var bottomPos = imageHeight - cornerTopLeft.Bounds.Height + 1;
// move it across the width of the image - the width of the shape
var cornerTopRight = cornerTopLeft.RotateDegree(90).Translate(rightPos, 0);
var cornerBottomLeft = cornerTopLeft.RotateDegree(-90).Translate(0, bottomPos);
var cornerBottomRight = cornerTopLeft.RotateDegree(180).Translate(rightPos, bottomPos);
return new PathCollection(cornerTopLeft, cornerBottomLeft, cornerTopRight, cornerBottomRight);
}
public static Color ToDiscordColor(this Rgba32 color)
=> new(color.R, color.G, color.B);
public static MemoryStream ToStream(this Image<Rgba32> img, IImageFormat? format = null)
{
var imageStream = new MemoryStream();
if (format?.Name == "GIF")
img.SaveAsGif(imageStream);
else
{
img.SaveAsPng(imageStream,
new()
{
ColorType = PngColorType.RgbWithAlpha,
CompressionLevel = PngCompressionLevel.DefaultCompression
});
}
imageStream.Position = 0;
return imageStream;
}
public static async Task<MemoryStream> ToStreamAsync(this Image<Rgba32> img, IImageFormat? format = null)
{
var imageStream = new MemoryStream();
if (format?.Name == "GIF")
{
await img.SaveAsGifAsync(imageStream);
}
else
{
await img.SaveAsPngAsync(imageStream,
new PngEncoder()
{
ColorType = PngColorType.RgbWithAlpha,
CompressionLevel = PngCompressionLevel.DefaultCompression
});
}
imageStream.Position = 0;
return imageStream;
}
}

View File

@@ -0,0 +1,31 @@
// // global using System.Collections.Concurrent;
// global using NonBlocking;
//
// // packages
// global using Serilog;
global using Humanizer;
//
// // nadekobot
// global using NadekoBot;
// global using NadekoBot.Services;
global using Nadeko.Common; // new project
// global using NadekoBot.Common; // old + nadekobot specific things
// global using NadekoBot.Common.Attributes;
global using NadekoBot.Extensions;
// global using Nadeko.Snake;
// discord
global using Discord;
global using Discord.Commands;
global using Discord.Net;
global using Discord.WebSocket;
// aliases
global using GuildPerm = Discord.GuildPermission;
global using ChannelPerm = Discord.ChannelPermission;
global using BotPermAttribute = Discord.Commands.RequireBotPermissionAttribute;
global using LeftoverAttribute = Discord.Commands.RemainderAttribute;
// global using TypeReaderResult = NadekoBot.Common.TypeReaders.TypeReaderResult;
// non-essential
global using JetBrains.Annotations;

View File

@@ -0,0 +1,8 @@
#nullable disable
namespace NadekoBot.Common;
public interface ICloneable<T>
where T : new()
{
public T Clone();
}

View File

@@ -0,0 +1,35 @@
using NadekoBot.Services.Database.Models;
namespace NadekoBot.Common;
public interface ILogCommandService
{
void AddDeleteIgnore(ulong xId);
Task LogServer(ulong guildId, ulong channelId, bool actionValue);
bool LogIgnore(ulong guildId, ulong itemId, IgnoredItemType itemType);
LogSetting? GetGuildLogSettings(ulong guildId);
bool Log(ulong guildId, ulong? channelId, LogType type);
}
public enum LogType
{
Other,
MessageUpdated,
MessageDeleted,
UserJoined,
UserLeft,
UserBanned,
UserUnbanned,
UserUpdated,
ChannelCreated,
ChannelDestroyed,
ChannelUpdated,
UserPresence,
VoicePresence,
VoicePresenceTts,
UserMuted,
UserWarned,
ThreadDeleted,
ThreadCreated
}

View File

@@ -0,0 +1,8 @@
namespace NadekoBot;
public interface INadekoInteractionService
{
public NadekoInteraction Create<T>(
ulong userId,
SimpleInteraction<T> inter);
}

View File

@@ -0,0 +1,82 @@
namespace NadekoBot;
public sealed class NadekoInteraction
{
private readonly ulong _authorId;
private readonly ButtonBuilder _button;
private readonly Func<SocketMessageComponent, Task> _onClick;
private readonly bool _onlyAuthor;
public DiscordSocketClient Client { get; }
private readonly TaskCompletionSource<bool> _interactionCompletedSource;
private IUserMessage message = null!;
public NadekoInteraction(DiscordSocketClient client,
ulong authorId,
ButtonBuilder button,
Func<SocketMessageComponent, Task> onClick,
bool onlyAuthor)
{
_authorId = authorId;
_button = button;
_onClick = onClick;
_onlyAuthor = onlyAuthor;
_interactionCompletedSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
Client = client;
}
public async Task RunAsync(IUserMessage msg)
{
message = msg;
Client.InteractionCreated += OnInteraction;
await Task.WhenAny(Task.Delay(15_000), _interactionCompletedSource.Task);
Client.InteractionCreated -= OnInteraction;
await msg.ModifyAsync(m => m.Components = new ComponentBuilder().Build());
}
private Task OnInteraction(SocketInteraction arg)
{
if (arg is not SocketMessageComponent smc)
return Task.CompletedTask;
if (smc.Message.Id != message.Id)
return Task.CompletedTask;
if (_onlyAuthor && smc.User.Id != _authorId)
return Task.CompletedTask;
if (smc.Data.CustomId != _button.CustomId)
return Task.CompletedTask;
_ = Task.Run(async () =>
{
await ExecuteOnActionAsync(smc);
// this should only be a thing on single-response buttons
_interactionCompletedSource.TrySetResult(true);
if (!smc.HasResponded)
{
await smc.DeferAsync();
}
});
return Task.CompletedTask;
}
public MessageComponent CreateComponent()
{
var comp = new ComponentBuilder()
.WithButton(_button);
return comp.Build();
}
public Task ExecuteOnActionAsync(SocketMessageComponent smc)
=> _onClick(smc);
}

View File

@@ -0,0 +1,8 @@
namespace NadekoBot;
/// <summary>
/// Represents essential interacation data
/// </summary>
/// <param name="Emote">Emote which will show on a button</param>
/// <param name="CustomId">Custom interaction id</param>
public record NadekoInteractionData(IEmote Emote, string CustomId, string? Text = null);

View File

@@ -0,0 +1,22 @@
using NadekoBot.Services;
namespace NadekoBot;
public class NadekoInteractionService : INadekoInteractionService, INService
{
private readonly DiscordSocketClient _client;
public NadekoInteractionService(DiscordSocketClient client)
{
_client = client;
}
public NadekoInteraction Create<T>(
ulong userId,
SimpleInteraction<T> inter)
=> new NadekoInteraction(_client,
userId,
inter.Button,
inter.TriggerAsync,
onlyAuthor: true);
}

View File

@@ -0,0 +1,20 @@
namespace NadekoBot;
public class SimpleInteraction<T>
{
public ButtonBuilder Button { get; }
private readonly Func<SocketMessageComponent, T, Task> _onClick;
private readonly T? _state;
public SimpleInteraction(ButtonBuilder button, Func<SocketMessageComponent, T?, Task> onClick, T? state = default)
{
Button = button;
_onClick = onClick;
_state = state;
}
public async Task TriggerAsync(SocketMessageComponent smc)
{
await _onClick(smc, _state!);
}
}

View File

@@ -0,0 +1,24 @@
using System.Globalization;
namespace Nadeko.Medusa;
public interface IMedusaLoaderService
{
Task<MedusaLoadResult> LoadMedusaAsync(string medusaName);
Task<MedusaUnloadResult> UnloadMedusaAsync(string medusaName);
string GetCommandDescription(string medusaName, string commandName, CultureInfo culture);
string[] GetCommandExampleArgs(string medusaName, string commandName, CultureInfo culture);
Task ReloadStrings();
IReadOnlyCollection<string> GetAllMedusae();
IReadOnlyCollection<MedusaStats> GetLoadedMedusae(CultureInfo? cultureInfo = null);
}
public sealed record MedusaStats(string Name,
string? Description,
IReadOnlyCollection<SnekStats> Sneks);
public sealed record SnekStats(string Name,
string? Prefix,
IReadOnlyCollection<SnekCommandStats> Commands);
public sealed record SnekCommandStats(string Name);

View File

@@ -0,0 +1,10 @@
namespace Nadeko.Medusa;
public enum MedusaLoadResult
{
Success,
NotFound,
AlreadyLoaded,
Empty,
UnknownError,
}

View File

@@ -0,0 +1,9 @@
namespace Nadeko.Medusa;
public enum MedusaUnloadResult
{
Success,
NotLoaded,
PossiblyUnable,
NotFound,
}

View File

@@ -0,0 +1,8 @@
namespace NadekoBot.Common;
public enum MsgType
{
Ok,
Pending,
Error
}

View File

@@ -0,0 +1,19 @@
namespace NadekoBot.Common.ModuleBehaviors;
/// <summary>
/// Executed if no command was found for this message
/// </summary>
public interface IExecNoCommand
{
/// <summary>
/// Executed at the end of the lifecycle if no command was found
/// <see cref="IExecOnMessage"/> →
/// <see cref="IInputTransformer"/> →
/// <see cref="IExecPreCommand"/> →
/// [<see cref="IExecPostCommand"/> | *<see cref="IExecNoCommand"/>*]
/// </summary>
/// <param name="guild"></param>
/// <param name="msg"></param>
/// <returns>A task representing completion</returns>
Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg);
}

View File

@@ -0,0 +1,21 @@
namespace NadekoBot.Common.ModuleBehaviors;
/// <summary>
/// Implemented by modules to handle non-bot messages received
/// </summary>
public interface IExecOnMessage
{
int Priority { get; }
/// <summary>
/// Ran after a non-bot message was received
/// *<see cref="IExecOnMessage"/>* →
/// <see cref="IInputTransformer"/> →
/// <see cref="IExecPreCommand"/> →
/// [<see cref="IExecPostCommand"/> | <see cref="IExecNoCommand"/>]
/// </summary>
/// <param name="guild">Guild where the message was sent</param>
/// <param name="msg">The message that was received</param>
/// <returns>Whether further processing of this message should be blocked</returns>
Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage msg);
}

View File

@@ -0,0 +1,22 @@
namespace NadekoBot.Common.ModuleBehaviors;
/// <summary>
/// This interface's method is executed after the command successfully finished execution.
/// ***There is no support for this method in NadekoBot services.***
/// It is only meant to be used in medusa system
/// </summary>
public interface IExecPostCommand
{
/// <summary>
/// Executed after a command was successfully executed
/// <see cref="IExecOnMessage"/> →
/// <see cref="IInputTransformer"/> →
/// <see cref="IExecPreCommand"/> →
/// [*<see cref="IExecPostCommand"/>* | <see cref="IExecNoCommand"/>]
/// </summary>
/// <param name="ctx">Command context</param>
/// <param name="moduleName">Module name</param>
/// <param name="commandName">Command name</param>
/// <returns>A task representing completion</returns>
ValueTask ExecPostCommandAsync(ICommandContext ctx, string moduleName, string commandName);
}

View File

@@ -0,0 +1,25 @@
namespace NadekoBot.Common.ModuleBehaviors;
/// <summary>
/// This interface's method is executed after a command was found but before it was executed.
/// Able to block further processing of a command
/// </summary>
public interface IExecPreCommand
{
public int Priority { get; }
/// <summary>
/// <para>
/// Ran after a command was found but before execution.
/// </para>
/// <see cref="IExecOnMessage"/> →
/// <see cref="IInputTransformer"/> →
/// *<see cref="IExecPreCommand"/>* →
/// [<see cref="IExecPostCommand"/> | <see cref="IExecNoCommand"/>]
/// </summary>
/// <param name="context">Command context</param>
/// <param name="moduleName">Name of the module</param>
/// <param name="command">Command info</param>
/// <returns>Whether further processing of the command is blocked</returns>
Task<bool> ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command);
}

View File

@@ -0,0 +1,25 @@
namespace NadekoBot.Common.ModuleBehaviors;
/// <summary>
/// Implemented by services which may transform input before a command is searched for
/// </summary>
public interface IInputTransformer
{
/// <summary>
/// Ran after a non-bot message was received
/// <see cref="IExecOnMessage"/> ->
/// *<see cref="IInputTransformer"/>* ->
/// <see cref="IExecPreCommand"/> ->
/// [<see cref="IExecPostCommand"/> OR <see cref="IExecNoCommand"/>]
/// </summary>
/// <param name="guild">Guild</param>
/// <param name="channel">Channel in which the message was sent</param>
/// <param name="user">User who sent the message</param>
/// <param name="input">Content of the message</param>
/// <returns>New input, if any, otherwise null</returns>
Task<string?> TransformInput(
IGuild guild,
IMessageChannel channel,
IUser user,
string input);
}

View File

@@ -0,0 +1,13 @@
namespace NadekoBot.Common.ModuleBehaviors;
/// <summary>
/// All services which need to execute something after
/// the bot is ready should implement this interface
/// </summary>
public interface IReadyExecutor
{
/// <summary>
/// Executed when bot is ready
/// </summary>
public Task OnReadyAsync();
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RunAnalyzersDuringBuild>false</RunAnalyzersDuringBuild>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Cloneable" Version="1.3.0" />
<PackageReference Include="Discord.Net" Version="3.104.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Nadeko.Bot.Db\Nadeko.Bot.Db.csproj" />
<ProjectReference Include="..\Nadeko.Common\Nadeko.Common.csproj" />
<PackageReference Include="JetBrains.Annotations" Version="2022.3.1" />
<PackageReference Include="SixLabors.Fonts" Version="1.0.0-beta17" />
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="1.0.0-beta14" />
<PackageReference Include="SixLabors.Shapes" Version="1.0.0-beta0009" />
<PackageReference Include="Humanizer" Version="2.14.1">
<PrivateAssets>all</PrivateAssets>
<Publish>True</Publish>
</PackageReference>
<ProjectReference Include="..\Nadeko.Medusa\Nadeko.Medusa.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,142 @@
#nullable disable
using System.Globalization;
using NadekoBot.Services;
// ReSharper disable InconsistentNaming
namespace NadekoBot.Common;
[UsedImplicitly(ImplicitUseTargetFlags.Default
| ImplicitUseTargetFlags.WithInheritors
| ImplicitUseTargetFlags.WithMembers)]
public abstract class NadekoModule : ModuleBase
{
protected CultureInfo Culture { get; set; }
// Injected by Discord.net
public IBotStrings Strings { get; set; }
public ICommandHandler _cmdHandler { get; set; }
public ILocalization _localization { get; set; }
public IEmbedBuilderService _eb { get; set; }
public INadekoInteractionService _inter { get; set; }
protected string prefix
=> _cmdHandler.GetPrefix(ctx.Guild);
protected ICommandContext ctx
=> Context;
protected override void BeforeExecute(CommandInfo command)
=> Culture = _localization.GetCultureInfo(ctx.Guild?.Id);
protected string GetText(in LocStr data)
=> Strings.GetText(data, Culture);
public Task<IUserMessage> SendErrorAsync(
string title,
string error,
string url = null,
string footer = null,
NadekoInteraction inter = null)
=> ctx.Channel.SendErrorAsync(_eb, title, error, url, footer);
public Task<IUserMessage> SendConfirmAsync(
string title,
string text,
string url = null,
string footer = null)
=> ctx.Channel.SendConfirmAsync(_eb, title, text, url, footer);
//
public Task<IUserMessage> SendErrorAsync(string text, NadekoInteraction inter = null)
=> ctx.Channel.SendAsync(_eb, text, MsgType.Error, inter);
public Task<IUserMessage> SendConfirmAsync(string text, NadekoInteraction inter = null)
=> ctx.Channel.SendAsync(_eb, text, MsgType.Ok, inter);
public Task<IUserMessage> SendPendingAsync(string text, NadekoInteraction inter = null)
=> ctx.Channel.SendAsync(_eb, text, MsgType.Pending, inter);
// localized normal
public Task<IUserMessage> ErrorLocalizedAsync(LocStr str, NadekoInteraction inter = null)
=> SendErrorAsync(GetText(str), inter);
public Task<IUserMessage> PendingLocalizedAsync(LocStr str, NadekoInteraction inter = null)
=> SendPendingAsync(GetText(str), inter);
public Task<IUserMessage> ConfirmLocalizedAsync(LocStr str, NadekoInteraction inter = null)
=> SendConfirmAsync(GetText(str), inter);
// localized replies
public Task<IUserMessage> ReplyErrorLocalizedAsync(LocStr str, NadekoInteraction inter = null)
=> SendErrorAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}", inter);
public Task<IUserMessage> ReplyPendingLocalizedAsync(LocStr str, NadekoInteraction inter = null)
=> SendPendingAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}", inter);
public Task<IUserMessage> ReplyConfirmLocalizedAsync(LocStr str, NadekoInteraction inter = null)
=> SendConfirmAsync($"{Format.Bold(ctx.User.ToString())} {GetText(str)}", inter);
public async Task<bool> PromptUserConfirmAsync(IEmbedBuilder embed)
{
embed.WithPendingColor().WithFooter("yes/no");
var msg = await ctx.Channel.EmbedAsync(embed);
try
{
var input = await GetUserInputAsync(ctx.User.Id, ctx.Channel.Id);
input = input?.ToUpperInvariant();
if (input != "YES" && input != "Y")
return false;
return true;
}
finally
{
_ = Task.Run(() => msg.DeleteAsync());
}
}
// TypeConverter typeConverter = TypeDescriptor.GetConverter(propType); ?
public async Task<string> GetUserInputAsync(ulong userId, ulong channelId)
{
var userInputTask = new TaskCompletionSource<string>();
var dsc = (DiscordSocketClient)ctx.Client;
try
{
dsc.MessageReceived += MessageReceived;
if (await Task.WhenAny(userInputTask.Task, Task.Delay(10000)) != userInputTask.Task)
return null;
return await userInputTask.Task;
}
finally
{
dsc.MessageReceived -= MessageReceived;
}
Task MessageReceived(SocketMessage arg)
{
_ = Task.Run(() =>
{
if (arg is not SocketUserMessage userMsg
|| userMsg.Channel is not ITextChannel
|| userMsg.Author.Id != userId
|| userMsg.Channel.Id != channelId)
return Task.CompletedTask;
if (userInputTask.TrySetResult(arg.Content))
userMsg.DeleteAfter(1);
return Task.CompletedTask;
});
return Task.CompletedTask;
}
}
}
public abstract class NadekoModule<TService> : NadekoModule
{
public TService _service { get; set; }
}

View File

@@ -0,0 +1,164 @@
#nullable disable
using NadekoBot.Modules.Administration.Services;
using System.Text.RegularExpressions;
namespace NadekoBot.Common;
public class ReplacementBuilder
{
private static readonly Regex _rngRegex = new("%rng(?:(?<from>(?:-)?\\d+)-(?<to>(?:-)?\\d+))?%",
RegexOptions.Compiled);
private readonly ConcurrentDictionary<Regex, Func<Match, string>> _regex = new();
private readonly ConcurrentDictionary<string, Func<string>> _reps = new();
public ReplacementBuilder()
=> WithRngRegex();
public ReplacementBuilder WithDefault(
IUser usr,
IMessageChannel ch,
SocketGuild g,
DiscordSocketClient client)
=> WithUser(usr).WithChannel(ch).WithServer(client, g).WithClient(client);
public ReplacementBuilder WithDefault(ICommandContext ctx)
=> WithDefault(ctx.User, ctx.Channel, ctx.Guild as SocketGuild, (DiscordSocketClient)ctx.Client);
public ReplacementBuilder WithMention(DiscordSocketClient client)
{
_reps.TryAdd("%bot.mention%", () => client.CurrentUser.Mention);
return this;
}
public ReplacementBuilder WithClient(DiscordSocketClient client)
{
WithMention(client);
_reps.TryAdd("%bot.status%", () => client.Status.ToString());
_reps.TryAdd("%bot.latency%", () => client.Latency.ToString());
_reps.TryAdd("%bot.name%", () => client.CurrentUser.Username);
_reps.TryAdd("%bot.fullname%", () => client.CurrentUser.ToString());
_reps.TryAdd("%bot.time%",
() => DateTime.Now.ToString("HH:mm " + TimeZoneInfo.Local.StandardName.GetInitials()));
_reps.TryAdd("%bot.discrim%", () => client.CurrentUser.Discriminator);
_reps.TryAdd("%bot.id%", () => client.CurrentUser.Id.ToString());
_reps.TryAdd("%bot.avatar%", () => client.CurrentUser.RealAvatarUrl().ToString());
WithStats(client);
return this;
}
public ReplacementBuilder WithServer(DiscordSocketClient client, SocketGuild g)
{
_reps.TryAdd("%server%", () => g is null ? "DM" : g.Name);
_reps.TryAdd("%server.id%", () => g is null ? "DM" : g.Id.ToString());
_reps.TryAdd("%server.name%", () => g is null ? "DM" : g.Name);
_reps.TryAdd("%server.icon%", () => g is null ? null : g.IconUrl);
_reps.TryAdd("%server.members%", () => g is { } sg ? sg.MemberCount.ToString() : "?");
_reps.TryAdd("%server.boosters%", () => g.PremiumSubscriptionCount.ToString());
_reps.TryAdd("%server.boost_level%", () => ((int)g.PremiumTier).ToString());
_reps.TryAdd("%server.time%",
() =>
{
var to = TimeZoneInfo.Local;
if (g is not null)
{
if (GuildTimezoneService.AllServices.TryGetValue(client.CurrentUser.Id, out var tz))
to = tz.GetTimeZoneOrDefault(g.Id) ?? TimeZoneInfo.Local;
}
return TimeZoneInfo.ConvertTime(DateTime.UtcNow, TimeZoneInfo.Utc, to).ToString("HH:mm ")
+ to.StandardName.GetInitials();
});
return this;
}
public ReplacementBuilder WithChannel(IMessageChannel ch)
{
_reps.TryAdd("%channel%", () => ch.Name);
_reps.TryAdd("%channel.mention%", () => (ch as ITextChannel)?.Mention ?? "#" + ch.Name);
_reps.TryAdd("%channel.name%", () => ch.Name);
_reps.TryAdd("%channel.id%", () => ch.Id.ToString());
_reps.TryAdd("%channel.created%", () => ch.CreatedAt.ToString("HH:mm dd.MM.yyyy"));
_reps.TryAdd("%channel.nsfw%", () => (ch as ITextChannel)?.IsNsfw.ToString() ?? "-");
_reps.TryAdd("%channel.topic%", () => (ch as ITextChannel)?.Topic ?? "-");
return this;
}
public ReplacementBuilder WithUser(IUser user)
{
WithManyUsers(new[] { user });
return this;
}
public ReplacementBuilder WithManyUsers(IEnumerable<IUser> users)
{
_reps.TryAdd("%user%", () => string.Join(" ", users.Select(user => user.Mention)));
_reps.TryAdd("%user.mention%", () => string.Join(" ", users.Select(user => user.Mention)));
_reps.TryAdd("%user.fullname%", () => string.Join(" ", users.Select(user => user.ToString())));
_reps.TryAdd("%user.name%", () => string.Join(" ", users.Select(user => user.Username)));
_reps.TryAdd("%user.discrim%", () => string.Join(" ", users.Select(user => user.Discriminator)));
_reps.TryAdd("%user.avatar%", () => string.Join(" ", users.Select(user => user.RealAvatarUrl().ToString())));
_reps.TryAdd("%user.id%", () => string.Join(" ", users.Select(user => user.Id.ToString())));
_reps.TryAdd("%user.created_time%",
() => string.Join(" ", users.Select(user => user.CreatedAt.ToString("HH:mm"))));
_reps.TryAdd("%user.created_date%",
() => string.Join(" ", users.Select(user => user.CreatedAt.ToString("dd.MM.yyyy"))));
_reps.TryAdd("%user.joined_time%",
() => string.Join(" ", users.Select(user => (user as IGuildUser)?.JoinedAt?.ToString("HH:mm") ?? "-")));
_reps.TryAdd("%user.joined_date%",
() => string.Join(" ",
users.Select(user => (user as IGuildUser)?.JoinedAt?.ToString("dd.MM.yyyy") ?? "-")));
return this;
}
private ReplacementBuilder WithStats(DiscordSocketClient c)
{
_reps.TryAdd("%shard.servercount%", () => c.Guilds.Count.ToString());
_reps.TryAdd("%shard.usercount%", () => c.Guilds.Sum(g => g.MemberCount).ToString());
_reps.TryAdd("%shard.id%", () => c.ShardId.ToString());
return this;
}
public ReplacementBuilder WithRngRegex()
{
var rng = new NadekoRandom();
_regex.TryAdd(_rngRegex,
match =>
{
if (!int.TryParse(match.Groups["from"].ToString(), out var from))
from = 0;
if (!int.TryParse(match.Groups["to"].ToString(), out var to))
to = 0;
if (from == 0 && to == 0)
return rng.Next(0, 11).ToString();
if (from >= to)
return string.Empty;
return rng.Next(from, to + 1).ToString();
});
return this;
}
public ReplacementBuilder WithOverride(string key, Func<string> output)
{
_reps.AddOrUpdate(key, output, delegate { return output; });
return this;
}
public Replacer Build()
=> new(_reps.Select(x => (x.Key, x.Value)).ToArray(), _regex.Select(x => (x.Key, x.Value)).ToArray());
public ReplacementBuilder WithProviders(IEnumerable<IPlaceholderProvider> phProviders)
{
foreach (var provider in phProviders)
foreach (var ovr in provider.GetPlaceholders())
_reps.TryAdd(ovr.Name, ovr.Func);
return this;
}
}

View File

@@ -0,0 +1,94 @@
#nullable disable
using System.Text.RegularExpressions;
using Nadeko.Common;
namespace NadekoBot.Common;
public class Replacer
{
private readonly IEnumerable<(Regex Regex, Func<Match, string> Replacement)> _regex;
private readonly IEnumerable<(string Key, Func<string> Text)> _replacements;
public Replacer(IEnumerable<(string, Func<string>)> replacements, IEnumerable<(Regex, Func<Match, string>)> regex)
{
_replacements = replacements;
_regex = regex;
}
public string Replace(string input)
{
if (string.IsNullOrWhiteSpace(input))
return input;
foreach (var (key, text) in _replacements)
{
if (input.Contains(key))
input = input.Replace(key, text(), StringComparison.InvariantCulture);
}
foreach (var item in _regex)
input = item.Regex.Replace(input, m => item.Replacement(m));
return input;
}
public SmartText Replace(SmartText data)
=> data switch
{
SmartEmbedText embedData => Replace(embedData) with
{
PlainText = Replace(embedData.PlainText),
Color = embedData.Color
},
SmartPlainText plain => Replace(plain),
SmartEmbedTextArray arr => Replace(arr),
_ => throw new ArgumentOutOfRangeException(nameof(data), "Unsupported argument type")
};
private SmartEmbedTextArray Replace(SmartEmbedTextArray embedArr)
=> new()
{
Embeds = embedArr.Embeds.Map(e => Replace(e) with
{
Color = e.Color
}),
Content = Replace(embedArr.Content)
};
private SmartPlainText Replace(SmartPlainText plain)
=> Replace(plain.Text);
private T Replace<T>(T embedData) where T: SmartEmbedTextBase, new()
{
var newEmbedData = new T
{
Description = Replace(embedData.Description),
Title = Replace(embedData.Title),
Thumbnail = Replace(embedData.Thumbnail),
Image = Replace(embedData.Image),
Url = Replace(embedData.Url),
Author = embedData.Author is null
? null
: new()
{
Name = Replace(embedData.Author.Name),
IconUrl = Replace(embedData.Author.IconUrl)
},
Fields = embedData.Fields?.Map(f => new SmartTextEmbedField
{
Name = Replace(f.Name),
Value = Replace(f.Value),
Inline = f.Inline
}),
Footer = embedData.Footer is null
? null
: new()
{
Text = Replace(embedData.Footer.Text),
IconUrl = Replace(embedData.Footer.IconUrl)
}
};
return newEmbedData;
}
}

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,6 @@
namespace NadekoBot.Services;
public interface ICommandHandler
{
string GetPrefix(IGuild ctxGuild);
}

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,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,56 @@
#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 Discord framework used by the bot.
/// </summary>
string Library { 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();
}

View File

@@ -0,0 +1,66 @@
#nullable disable
using NadekoBot.Common.Configs;
using SixLabors.ImageSharp.PixelFormats;
namespace NadekoBot.Services;
/// <summary>
/// Settings service for bot-wide configuration.
/// </summary>
public sealed class BotConfigService : ConfigServiceBase<BotConfig>
{
private const string FILE_PATH = "data/bot.yml";
private static readonly TypedKey<BotConfig> _changeKey = new("config.bot.updated");
public override string Name { get; } = "bot";
public BotConfigService(IConfigSeria serializer, IPubSub pubSub)
: base(FILE_PATH, serializer, pubSub, _changeKey)
{
AddParsedProp("color.ok", bs => bs.Color.Ok, Rgba32.TryParseHex, ConfigPrinters.Color);
AddParsedProp("color.error", bs => bs.Color.Error, Rgba32.TryParseHex, ConfigPrinters.Color);
AddParsedProp("color.pending", bs => bs.Color.Pending, Rgba32.TryParseHex, ConfigPrinters.Color);
AddParsedProp("help.text", bs => bs.HelpText, ConfigParsers.String, ConfigPrinters.ToString);
AddParsedProp("help.dmtext", bs => bs.DmHelpText, ConfigParsers.String, ConfigPrinters.ToString);
AddParsedProp("console.type", bs => bs.ConsoleOutputType, Enum.TryParse, ConfigPrinters.ToString);
AddParsedProp("locale", bs => bs.DefaultLocale, ConfigParsers.Culture, ConfigPrinters.Culture);
AddParsedProp("prefix", bs => bs.Prefix, ConfigParsers.String, ConfigPrinters.ToString);
AddParsedProp("checkforupdates", bs => bs.CheckForUpdates, bool.TryParse, ConfigPrinters.ToString);
Migrate();
}
private void Migrate()
{
if (data.Version < 2)
ModifyConfig(c => c.Version = 2);
if (data.Version < 3)
{
ModifyConfig(c =>
{
c.Version = 3;
c.Blocked.Modules = c.Blocked.Modules?.Select(static x
=> string.Equals(x,
"ActualCustomReactions",
StringComparison.InvariantCultureIgnoreCase)
? "ACTUALEXPRESSIONS"
: x)
.Distinct()
.ToHashSet();
});
}
if (data.Version < 4)
ModifyConfig(c =>
{
c.Version = 4;
c.CheckForUpdates = true;
});
if(data.Version < 5)
ModifyConfig(c =>
{
c.Version = 5;
});
}
}

View File

@@ -0,0 +1,50 @@
#nullable disable
using SixLabors.ImageSharp.PixelFormats;
using System.Globalization;
namespace NadekoBot.Services;
/// <summary>
/// Custom setting value parsers for types which don't have them by default
/// </summary>
public static class ConfigParsers
{
/// <summary>
/// Default string parser. Passes input to output and returns true.
/// </summary>
public static bool String(string input, out string output)
{
output = input;
return true;
}
public static bool Culture(string input, out CultureInfo output)
{
try
{
output = new(input);
return true;
}
catch
{
output = null;
return false;
}
}
public static bool InsensitiveEnum<T>(string input, out T output)
where T: struct
=> Enum.TryParse(input, true, out output);
}
public static class ConfigPrinters
{
public static string ToString<TAny>(TAny input)
=> input.ToString();
public static string Culture(CultureInfo culture)
=> culture.Name;
public static string Color(Rgba32 color)
=> ((uint)((color.B << 0) | (color.G << 8) | (color.R << 16))).ToString("X6");
}

View File

@@ -0,0 +1,203 @@
using NadekoBot.Common.Configs;
using NadekoBot.Common.Yml;
using System.Linq.Expressions;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using NadekoBot.Common;
namespace NadekoBot.Services;
/// <summary>
/// Base service for all settings services
/// </summary>
/// <typeparam name="TSettings">Type of the settings</typeparam>
public abstract class ConfigServiceBase<TSettings> : IConfigService
where TSettings : ICloneable<TSettings>, new()
{
// FUTURE config arrays are not copied - they're not protected from mutations
public TSettings Data
=> data.Clone();
public abstract string Name { get; }
protected readonly string _filePath;
protected readonly IConfigSeria _serializer;
protected readonly IPubSub _pubSub;
private readonly TypedKey<TSettings> _changeKey;
protected TSettings data;
private readonly Dictionary<string, Func<TSettings, string, bool>> _propSetters = new();
private readonly Dictionary<string, Func<object>> _propSelectors = new();
private readonly Dictionary<string, Func<object, string>> _propPrinters = new();
private readonly Dictionary<string, string?> _propComments = new();
/// <summary>
/// Initialized an instance of <see cref="ConfigServiceBase{TSettings}" />
/// </summary>
/// <param name="filePath">Path to the file where the settings are serialized/deserialized to and from</param>
/// <param name="serializer">Serializer which will be used</param>
/// <param name="pubSub">Pubsub implementation for signaling when settings are updated</param>
/// <param name="changeKey">Key used to signal changed event</param>
protected ConfigServiceBase(
string filePath,
IConfigSeria serializer,
IPubSub pubSub,
TypedKey<TSettings> changeKey)
{
_filePath = filePath;
_serializer = serializer;
_pubSub = pubSub;
_changeKey = changeKey;
data = new();
Load();
_pubSub.Sub(_changeKey, OnChangePublished);
}
private void PublishChange()
=> _pubSub.Pub(_changeKey, data);
private ValueTask OnChangePublished(TSettings newData)
{
data = newData;
OnStateUpdate();
return default;
}
/// <summary>
/// Loads data from disk. If file doesn't exist, it will be created with default values
/// </summary>
protected void Load()
{
// if file is deleted, regenerate it with default values
if (!File.Exists(_filePath))
{
data = new();
Save();
}
data = _serializer.Deserialize<TSettings>(File.ReadAllText(_filePath));
}
/// <summary>
/// Loads new data and publishes the new state
/// </summary>
public void Reload()
{
Load();
_pubSub.Pub(_changeKey, data);
}
/// <summary>
/// Doesn't do anything by default. This method will be executed after
/// <see cref="data" /> is reloaded from <see cref="_filePath" /> or new data is recieved
/// from the publish event
/// </summary>
protected virtual void OnStateUpdate()
{
}
private void Save()
{
var strData = _serializer.Serialize(data);
File.WriteAllText(_filePath, strData);
}
protected void AddParsedProp<TProp>(
string key,
Expression<Func<TSettings, TProp>> selector,
SettingParser<TProp> parser,
Func<TProp, string> printer,
Func<TProp, bool>? checker = null)
{
checker ??= _ => true;
key = key.ToLowerInvariant();
_propPrinters[key] = obj => printer((TProp)obj);
_propSelectors[key] = () => selector.Compile()(data)!;
_propSetters[key] = Magic(selector, parser, checker);
_propComments[key] = ((MemberExpression)selector.Body).Member.GetCustomAttribute<CommentAttribute>()?.Comment;
}
private Func<TSettings, string, bool> Magic<TProp>(
Expression<Func<TSettings, TProp>> selector,
SettingParser<TProp> parser,
Func<TProp, bool> checker)
=> (target, input) =>
{
if (!parser(input, out var value))
return false;
if (!checker(value))
return false;
object targetObject = target;
var expr = (MemberExpression)selector.Body;
var prop = (PropertyInfo)expr.Member;
var expressions = new List<MemberExpression>();
while (true)
{
expr = expr.Expression as MemberExpression;
if (expr is null)
break;
expressions.Add(expr);
}
foreach (var memberExpression in expressions.AsEnumerable().Reverse())
{
var localProp = (PropertyInfo)memberExpression.Member;
targetObject = localProp.GetValue(targetObject)!;
}
prop.SetValue(targetObject, value, null);
return true;
};
public IReadOnlyList<string> GetSettableProps()
=> _propSetters.Keys.ToList();
public string? GetSetting(string prop)
{
prop = prop.ToLowerInvariant();
if (!_propSelectors.TryGetValue(prop, out var selector) || !_propPrinters.TryGetValue(prop, out var printer))
return null;
return printer(selector());
}
public string? GetComment(string prop)
{
if (_propComments.TryGetValue(prop, out var comment))
return comment;
return null;
}
private bool SetProperty(TSettings target, string key, string value)
=> _propSetters.TryGetValue(key.ToLowerInvariant(), out var magic) && magic(target, value);
public bool SetSetting(string prop, string newValue)
{
var success = true;
ModifyConfig(bs =>
{
success = SetProperty(bs, prop, newValue);
});
if (success)
PublishChange();
return success;
}
public void ModifyConfig(Action<TSettings> action)
{
var copy = Data;
action(copy);
data = copy;
Save();
PublishChange();
}
}

View File

@@ -0,0 +1,7 @@
#nullable disable
namespace NadekoBot.Services;
public interface IConfigMigrator
{
public void EnsureMigrated();
}

View File

@@ -0,0 +1,46 @@
#nullable disable
namespace NadekoBot.Services;
/// <summary>
/// Interface that all services which deal with configs should implement
/// </summary>
public interface IConfigService
{
/// <summary>
/// Name of the config
/// </summary>
public string Name { get; }
/// <summary>
/// Loads new data and publishes the new state
/// </summary>
void Reload();
/// <summary>
/// Gets the list of props you can set
/// </summary>
/// <returns>List of props</returns>
IReadOnlyList<string> GetSettableProps();
/// <summary>
/// Gets the value of the specified property
/// </summary>
/// <param name="prop">Prop name</param>
/// <returns>Value of the prop</returns>
string GetSetting(string prop);
/// <summary>
/// Gets the value of the specified property
/// </summary>
/// <param name="prop">Prop name</param>
/// <returns>Value of the prop</returns>
string GetComment(string prop);
/// <summary>
/// Sets the value of the specified property
/// </summary>
/// <param name="prop">Property to set</param>
/// <param name="newValue">Value to set the property to</param>
/// <returns>Success</returns>
bool SetSetting(string prop, string newValue);
}

View File

@@ -0,0 +1,8 @@
#nullable disable
namespace NadekoBot.Services;
/// <summary>
/// Delegate which describes a parser which can convert string input into given data type
/// </summary>
/// <typeparam name="TData">Data type to convert string to</typeparam>
public delegate bool SettingParser<TData>(string input, out TData output);

View File

@@ -0,0 +1,182 @@
#nullable disable warnings
using SixLabors.ImageSharp.PixelFormats;
namespace NadekoBot;
public sealed record SmartEmbedArrayElementText : SmartEmbedTextBase
{
public string Color { get; init; } = string.Empty;
public SmartEmbedArrayElementText() : base()
{
}
public SmartEmbedArrayElementText(IEmbed eb) : base(eb)
{
}
protected override EmbedBuilder GetEmbedInternal()
{
var embed = base.GetEmbedInternal();
if (Rgba32.TryParseHex(Color, out var color))
return embed.WithColor(color.ToDiscordColor());
return embed;
}
}
public sealed record SmartEmbedText : SmartEmbedTextBase
{
public string PlainText { get; init; }
public uint Color { get; init; } = 7458112;
public SmartEmbedText()
{
}
private SmartEmbedText(IEmbed eb, string? plainText = null)
: base(eb)
=> (PlainText, Color) = (plainText, eb.Color?.RawValue ?? 0);
public static SmartEmbedText FromEmbed(IEmbed eb, string? plainText = null)
=> new(eb, plainText);
protected override EmbedBuilder GetEmbedInternal()
{
var embed = base.GetEmbedInternal();
return embed.WithColor(Color);
}
}
public abstract record SmartEmbedTextBase : SmartText
{
public string Title { get; init; }
public string Description { get; init; }
public string Url { get; init; }
public string Thumbnail { get; init; }
public string Image { get; init; }
public SmartTextEmbedAuthor Author { get; init; }
public SmartTextEmbedFooter Footer { get; init; }
public SmartTextEmbedField[] Fields { get; init; }
public bool IsValid
=> !string.IsNullOrWhiteSpace(Title)
|| !string.IsNullOrWhiteSpace(Description)
|| !string.IsNullOrWhiteSpace(Url)
|| !string.IsNullOrWhiteSpace(Thumbnail)
|| !string.IsNullOrWhiteSpace(Image)
|| (Footer is not null
&& (!string.IsNullOrWhiteSpace(Footer.Text) || !string.IsNullOrWhiteSpace(Footer.IconUrl)))
|| Fields is { Length: > 0 };
protected SmartEmbedTextBase()
{
}
protected SmartEmbedTextBase(IEmbed eb)
{
Title = eb.Title;
Description = eb.Description;
Url = eb.Url;
Thumbnail = eb.Thumbnail?.Url;
Image = eb.Image?.Url;
Author = eb.Author is { } ea
? new()
{
Name = ea.Name,
Url = ea.Url,
IconUrl = ea.IconUrl
}
: null;
Footer = eb.Footer is { } ef
? new()
{
Text = ef.Text,
IconUrl = ef.IconUrl
}
: null;
if (eb.Fields.Length > 0)
{
Fields = eb.Fields.Select(field
=> new SmartTextEmbedField
{
Inline = field.Inline,
Name = field.Name,
Value = field.Value
})
.ToArray();
}
}
public EmbedBuilder GetEmbed()
=> GetEmbedInternal();
protected virtual EmbedBuilder GetEmbedInternal()
{
var embed = new EmbedBuilder();
if (!string.IsNullOrWhiteSpace(Title))
embed.WithTitle(Title);
if (!string.IsNullOrWhiteSpace(Description))
embed.WithDescription(Description);
if (Url is not null && Uri.IsWellFormedUriString(Url, UriKind.Absolute))
embed.WithUrl(Url);
if (Footer is not null)
{
embed.WithFooter(efb =>
{
efb.WithText(Footer.Text);
if (Uri.IsWellFormedUriString(Footer.IconUrl, UriKind.Absolute))
efb.WithIconUrl(Footer.IconUrl);
});
}
if (Thumbnail is not null && Uri.IsWellFormedUriString(Thumbnail, UriKind.Absolute))
embed.WithThumbnailUrl(Thumbnail);
if (Image is not null && Uri.IsWellFormedUriString(Image, UriKind.Absolute))
embed.WithImageUrl(Image);
if (Author is not null && !string.IsNullOrWhiteSpace(Author.Name))
{
if (!Uri.IsWellFormedUriString(Author.IconUrl, UriKind.Absolute))
Author.IconUrl = null;
if (!Uri.IsWellFormedUriString(Author.Url, UriKind.Absolute))
Author.Url = null;
embed.WithAuthor(Author.Name, Author.IconUrl, Author.Url);
}
if (Fields is not null)
{
foreach (var f in Fields)
{
if (!string.IsNullOrWhiteSpace(f.Name) && !string.IsNullOrWhiteSpace(f.Value))
embed.AddField(f.Name, f.Value, f.Inline);
}
}
return embed;
}
public void NormalizeFields()
{
if (Fields is { Length: > 0 })
{
foreach (var f in Fields)
{
f.Name = f.Name.TrimTo(256);
f.Value = f.Value.TrimTo(1024);
}
}
}
}

View File

@@ -0,0 +1,31 @@
#nullable disable
namespace NadekoBot;
public sealed record SmartEmbedTextArray : SmartText
{
public string Content { get; set; }
public SmartEmbedArrayElementText[] Embeds { get; set; }
public bool IsValid
=> Embeds?.All(x => x.IsValid) ?? false;
public EmbedBuilder[] GetEmbedBuilders()
{
if (Embeds is null)
return Array.Empty<EmbedBuilder>();
return Embeds
.Where(x => x.IsValid)
.Select(em => em.GetEmbed())
.ToArray();
}
public void NormalizeFields()
{
if (Embeds is null)
return;
foreach(var eb in Embeds)
eb.NormalizeFields();
}
}

View File

@@ -0,0 +1,19 @@
#nullable disable
namespace NadekoBot;
public sealed record SmartPlainText : SmartText
{
public string Text { get; init; }
public SmartPlainText(string text)
=> Text = text;
public static implicit operator SmartPlainText(string input)
=> new(input);
public static implicit operator string(SmartPlainText input)
=> input.Text;
public override string ToString()
=> Text;
}

View File

@@ -0,0 +1,85 @@
#nullable disable
using Newtonsoft.Json.Linq;
namespace NadekoBot;
public abstract record SmartText
{
public bool IsEmbed
=> this is SmartEmbedText;
public bool IsPlainText
=> this is SmartPlainText;
public bool IsEmbedArray
=> this is SmartEmbedTextArray;
public static SmartText operator +(SmartText text, string input)
=> text switch
{
SmartEmbedText set => set with
{
PlainText = set.PlainText + input
},
SmartPlainText spt => new SmartPlainText(spt.Text + input),
SmartEmbedTextArray arr => arr with
{
Content = arr.Content + input
},
_ => throw new ArgumentOutOfRangeException(nameof(text))
};
public static SmartText operator +(string input, SmartText text)
=> text switch
{
SmartEmbedText set => set with
{
PlainText = input + set.PlainText
},
SmartPlainText spt => new SmartPlainText(input + spt.Text),
SmartEmbedTextArray arr => arr with
{
Content = input + arr.Content
},
_ => throw new ArgumentOutOfRangeException(nameof(text))
};
public static SmartText CreateFrom(string input)
{
if (string.IsNullOrWhiteSpace(input))
return new SmartPlainText(input);
try
{
var doc = JObject.Parse(input);
var root = doc.Root;
if (root.Type == JTokenType.Object)
{
if (((JObject)root).TryGetValue("embeds", out _))
{
var arr = root.ToObject<SmartEmbedTextArray>();
if (arr is null)
return new SmartPlainText(input);
arr!.NormalizeFields();
return arr;
}
var obj = root.ToObject<SmartEmbedText>();
if (obj is null || !(obj.IsValid || !string.IsNullOrWhiteSpace(obj.PlainText)))
return new SmartPlainText(input);
obj.NormalizeFields();
return obj;
}
return new SmartPlainText(input);
}
catch
{
return new SmartPlainText(input);
}
}
}

View File

@@ -0,0 +1,14 @@
#nullable disable
using Newtonsoft.Json;
namespace NadekoBot;
public class SmartTextEmbedAuthor
{
public string Name { get; set; }
[JsonProperty("icon_url")]
public string IconUrl { get; set; }
public string Url { get; set; }
}

View File

@@ -0,0 +1,9 @@
#nullable disable
namespace NadekoBot;
public class SmartTextEmbedField
{
public string Name { get; set; }
public string Value { get; set; }
public bool Inline { get; set; }
}

View File

@@ -0,0 +1,12 @@
#nullable disable
using Newtonsoft.Json;
namespace NadekoBot;
public class SmartTextEmbedFooter
{
public string Text { get; set; }
[JsonProperty("icon_url")]
public string IconUrl { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace NadekoBot.Extensions;
public static class BotCredentialsExtensions
{
public static bool IsOwner(this IBotCredentials creds, IUser user)
=> creds.IsOwner(user.Id);
public static bool IsOwner(this IBotCredentials creds, ulong userId)
=> creds.OwnerIds.Contains(userId);
}

View File

@@ -0,0 +1,198 @@
using Humanizer.Localisation;
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using System.Text.RegularExpressions;
using Nadeko.Medusa;
using NadekoBot.Common;
using Serilog;
namespace NadekoBot.Extensions;
public static class Extensions
{
private static readonly Regex _urlRegex =
new(@"^(https?|ftp)://(?<path>[^\s/$.?#].[^\s]*)$", RegexOptions.Compiled);
public static IEmbedBuilder WithAuthor(this IEmbedBuilder eb, IUser author)
=> eb.WithAuthor(author.ToString()!, author.RealAvatarUrl().ToString());
public static Task EditAsync(this IUserMessage msg, SmartText text)
=> text switch
{
SmartEmbedText set => msg.ModifyAsync(x =>
{
x.Embed = set.IsValid ? set.GetEmbed().Build() : null;
x.Content = set.PlainText?.SanitizeMentions() ?? "";
}),
SmartEmbedTextArray set => msg.ModifyAsync(x =>
{
x.Embeds = set.GetEmbedBuilders().Map(eb => eb.Build());
x.Content = set.Content?.SanitizeMentions() ?? "";
}),
SmartPlainText spt => msg.ModifyAsync(x =>
{
x.Content = spt.Text.SanitizeMentions();
x.Embed = null;
}),
_ => throw new ArgumentOutOfRangeException(nameof(text))
};
public static ulong[] GetGuildIds(this DiscordSocketClient client)
=> client.Guilds
.Map(x => x.Id);
/// <summary>
/// Generates a string in the format HHH:mm if timespan is &gt;= 2m.
/// Generates a string in the format 00:mm:ss if timespan is less than 2m.
/// </summary>
/// <param name="span">Timespan to convert to string</param>
/// <returns>Formatted duration string</returns>
public static string ToPrettyStringHm(this TimeSpan span)
=> span.Humanize(2, minUnit: TimeUnit.Second);
public static bool TryGetUrlPath(this string input, out string path)
{
var match = _urlRegex.Match(input);
if (match.Success)
{
path = match.Groups["path"].Value;
return true;
}
path = string.Empty;
return false;
}
public static IEmote ToIEmote(this string emojiStr)
=> Emote.TryParse(emojiStr, out var maybeEmote) ? maybeEmote : new Emoji(emojiStr);
/// <summary>
/// First 10 characters of teh bot token.
/// </summary>
public static string RedisKey(this IBotCredentials bc)
=> bc.Token[..10];
public static bool IsAuthor(this IMessage msg, IDiscordClient client)
=> msg.Author?.Id == client.CurrentUser.Id;
public static string RealSummary(
this CommandInfo cmd,
IBotStrings strings,
IMedusaLoaderService medusae,
CultureInfo culture,
string prefix)
{
string description;
if (cmd.Remarks?.StartsWith("medusa///") ?? false)
{
// command method name is kept in Summary
// medusa///<medusa-name-here> is kept in remarks
// this way I can find the name of the medusa, and then name of the command for which
// the description should be loaded
var medusaName = cmd.Remarks.Split("///")[1];
description = medusae.GetCommandDescription(medusaName, cmd.Summary, culture);
}
else
{
description = strings.GetCommandStrings(cmd.Summary, culture).Desc;
}
return string.Format(description, prefix);
}
public static string[] RealRemarksArr(
this CommandInfo cmd,
IBotStrings strings,
IMedusaLoaderService medusae,
CultureInfo culture,
string prefix)
{
string[] args;
if (cmd.Remarks?.StartsWith("medusa///") ?? false)
{
// command method name is kept in Summary
// medusa///<medusa-name-here> is kept in remarks
// this way I can find the name of the medusa,
// and command for which data should be loaded
var medusaName = cmd.Remarks.Split("///")[1];
args = medusae.GetCommandExampleArgs(medusaName, cmd.Summary, culture);
}
else
{
args = strings.GetCommandStrings(cmd.Summary, culture).Args;
}
return args.Map(arg => GetFullUsage(cmd.Aliases.First(), arg, prefix));
}
private static string GetFullUsage(string commandName, string args, string prefix)
=> $"{prefix}{commandName} {string.Format(args, prefix)}".TrimEnd();
public static IEmbedBuilder AddPaginatedFooter(this IEmbedBuilder embed, int curPage, int? lastPage)
{
if (lastPage is not null)
return embed.WithFooter($"{curPage + 1} / {lastPage + 1}");
return embed.WithFooter(curPage.ToString());
}
public static IEmbedBuilder WithOkColor(this IEmbedBuilder eb)
=> eb.WithColor(EmbedColor.Ok);
public static IEmbedBuilder WithPendingColor(this IEmbedBuilder eb)
=> eb.WithColor(EmbedColor.Pending);
public static IEmbedBuilder WithErrorColor(this IEmbedBuilder eb)
=> eb.WithColor(EmbedColor.Error);
public static IMessage DeleteAfter(this IUserMessage msg, float seconds, ILogCommandService? logService = null)
{
Task.Run(async () =>
{
await Task.Delay((int)(seconds * 1000));
if (logService is not null)
logService.AddDeleteIgnore(msg.Id);
try { await msg.DeleteAsync(); }
catch { }
});
return msg;
}
public static ModuleInfo GetTopLevelModule(this ModuleInfo module)
{
while (module.Parent is not null)
module = module.Parent;
return module;
}
public static string GetGroupName(this ModuleInfo module)
=> module.Name.Replace("Commands", "", StringComparison.InvariantCulture);
public static async Task<IEnumerable<IGuildUser>> GetMembersAsync(this IRole role)
{
var users = await role.Guild.GetUsersAsync(CacheMode.CacheOnly);
return users.Where(u => u.RoleIds.Contains(role.Id));
}
public static string ToJson<T>(this T any, JsonSerializerOptions? options = null)
=> JsonSerializer.Serialize(any, options);
public static Stream ToStream(this IEnumerable<byte> bytes, bool canWrite = false)
{
var ms = new MemoryStream(bytes as byte[] ?? bytes.ToArray(), canWrite);
ms.Seek(0, SeekOrigin.Begin);
return ms;
}
public static IEnumerable<IRole> GetRoles(this IGuildUser user)
=> user.RoleIds.Select(r => user.Guild.GetRole(r)).Where(r => r is not null);
public static void Lap(this Stopwatch sw, string checkpoint)
{
Log.Information("Checkpoint {CheckPoint}: {Time}ms", checkpoint, sw.Elapsed.TotalMilliseconds);
sw.Restart();
}
}

View File

@@ -0,0 +1,332 @@
using NadekoBot.Common;
using NadekoBot.Services;
using Serilog;
namespace NadekoBot.Extensions;
public static class MessageChannelExtensions
{
// main overload that all other send methods reduce to
public static Task<IUserMessage> SendAsync(
this IMessageChannel channel,
string? plainText,
Embed? embed = null,
IReadOnlyCollection<Embed>? embeds = null,
bool sanitizeAll = false,
MessageComponent? components = null)
{
plainText = sanitizeAll
? plainText?.SanitizeAllMentions() ?? ""
: plainText?.SanitizeMentions() ?? "";
return channel.SendMessageAsync(plainText,
embed: embed,
embeds: embeds is null
? null
: embeds as Embed[] ?? embeds.ToArray(),
components: components);
}
public static async Task<IUserMessage> SendAsync(
this IMessageChannel channel,
string? plainText,
NadekoInteraction? inter,
Embed? embed = null,
IReadOnlyCollection<Embed>? embeds = null,
bool sanitizeAll = false)
{
var msg = await channel.SendAsync(plainText,
embed,
embeds,
sanitizeAll,
inter?.CreateComponent());
if (inter is not null)
await inter.RunAsync(msg);
return msg;
}
public static Task<IUserMessage> SendAsync(
this IMessageChannel channel,
SmartText text,
bool sanitizeAll = false)
=> text switch
{
SmartEmbedText set => channel.SendAsync(set.PlainText,
set.IsValid ? set.GetEmbed().Build() : null,
sanitizeAll: sanitizeAll),
SmartPlainText st => channel.SendAsync(st.Text,
default(Embed),
sanitizeAll: sanitizeAll),
SmartEmbedTextArray arr => channel.SendAsync(arr.Content,
embeds: arr.GetEmbedBuilders().Map(e => e.Build())),
_ => throw new ArgumentOutOfRangeException(nameof(text))
};
public static Task<IUserMessage> EmbedAsync(
this IMessageChannel ch,
IEmbedBuilder? embed,
string plainText = "",
IReadOnlyCollection<IEmbedBuilder>? embeds = null,
NadekoInteraction? inter = null)
=> ch.SendAsync(plainText,
inter,
embed: embed?.Build(),
embeds: embeds?.Map(x => x.Build()));
public static Task<IUserMessage> SendAsync(
this IMessageChannel ch,
IEmbedBuilderService eb,
string text,
MsgType type,
NadekoInteraction? inter = null)
{
var builder = eb.Create().WithDescription(text);
builder = (type switch
{
MsgType.Error => builder.WithErrorColor(),
MsgType.Ok => builder.WithOkColor(),
MsgType.Pending => builder.WithPendingColor(),
_ => throw new ArgumentOutOfRangeException(nameof(type))
});
return ch.EmbedAsync(builder, inter: inter);
}
// regular send overloads
public static Task<IUserMessage> SendErrorAsync(this IMessageChannel ch, IEmbedBuilderService eb, string text)
=> ch.SendAsync(eb, text, MsgType.Error);
public static Task<IUserMessage> SendConfirmAsync(this IMessageChannel ch, IEmbedBuilderService eb, string text)
=> ch.SendAsync(eb, text, MsgType.Ok);
public static Task<IUserMessage> SendAsync(
this IMessageChannel ch,
IEmbedBuilderService eb,
MsgType type,
string? title,
string text,
string? url = null,
string? footer = null)
{
var embed = eb.Create()
.WithDescription(text)
.WithTitle(title);
if (url is not null && Uri.IsWellFormedUriString(url, UriKind.Absolute))
embed.WithUrl(url);
if (!string.IsNullOrWhiteSpace(footer))
embed.WithFooter(footer);
embed = type switch
{
MsgType.Error => embed.WithErrorColor(),
MsgType.Ok => embed.WithOkColor(),
MsgType.Pending => embed.WithPendingColor(),
_ => throw new ArgumentOutOfRangeException(nameof(type))
};
return ch.EmbedAsync(embed);
}
// embed title and optional footer overloads
public static Task<IUserMessage> SendConfirmAsync(
this IMessageChannel ch,
IEmbedBuilderService eb,
string? title,
string text,
string? url = null,
string? footer = null)
=> ch.SendAsync(eb, MsgType.Ok, title, text, url, footer);
public static Task<IUserMessage> SendErrorAsync(
this IMessageChannel ch,
IEmbedBuilderService eb,
string title,
string text,
string? url = null,
string? footer = null)
=> ch.SendAsync(eb, MsgType.Error, title, text, url, footer);
public static Task SendPaginatedConfirmAsync(
this ICommandContext ctx,
int currentPage,
Func<int, IEmbedBuilder> pageFunc,
int totalElements,
int itemsPerPage,
bool addPaginatedFooter = true)
=> ctx.SendPaginatedConfirmAsync(currentPage,
x => Task.FromResult(pageFunc(x)),
totalElements,
itemsPerPage,
addPaginatedFooter);
private const string BUTTON_LEFT = "BUTTON_LEFT";
private const string BUTTON_RIGHT = "BUTTON_RIGHT";
private static readonly IEmote _arrowLeft = Emote.Parse("<:x:969658061805465651>");
private static readonly IEmote _arrowRight = Emote.Parse("<:x:969658062220701746>");
public static Task SendPaginatedConfirmAsync(
this ICommandContext ctx,
int currentPage,
Func<int, Task<IEmbedBuilder>> pageFunc,
int totalElements,
int itemsPerPage,
bool addPaginatedFooter = true)
=> ctx.SendPaginatedConfirmAsync(currentPage,
pageFunc,
default(Func<int, ValueTask<SimpleInteraction<object>?>>),
totalElements,
itemsPerPage,
addPaginatedFooter);
public static async Task SendPaginatedConfirmAsync<T>(
this ICommandContext ctx,
int currentPage,
Func<int, Task<IEmbedBuilder>> pageFunc,
Func<int, ValueTask<SimpleInteraction<T>?>>? interFactory,
int totalElements,
int itemsPerPage,
bool addPaginatedFooter = true)
{
var lastPage = (totalElements - 1) / itemsPerPage;
var embed = await pageFunc(currentPage);
if (addPaginatedFooter)
embed.AddPaginatedFooter(currentPage, lastPage);
SimpleInteraction<T>? maybeInter = null;
async Task<ComponentBuilder> GetComponentBuilder()
{
var cb = new ComponentBuilder();
cb.WithButton(new ButtonBuilder()
.WithStyle(ButtonStyle.Primary)
.WithCustomId(BUTTON_LEFT)
.WithDisabled(lastPage == 0)
.WithEmote(_arrowLeft)
.WithDisabled(currentPage <= 0));
if (interFactory is not null)
{
maybeInter = await interFactory(currentPage);
if (maybeInter is not null)
cb.WithButton(maybeInter.Button);
}
cb.WithButton(new ButtonBuilder()
.WithStyle(ButtonStyle.Primary)
.WithCustomId(BUTTON_RIGHT)
.WithDisabled(lastPage == 0 || currentPage >= lastPage)
.WithEmote(_arrowRight));
return cb;
}
async Task UpdatePageAsync(SocketMessageComponent smc)
{
var toSend = await pageFunc(currentPage);
if (addPaginatedFooter)
toSend.AddPaginatedFooter(currentPage, lastPage);
var component = (await GetComponentBuilder()).Build();
await smc.ModifyOriginalResponseAsync(x =>
{
x.Embed = toSend.Build();
x.Components = component;
});
}
var component = (await GetComponentBuilder()).Build();
var msg = await ctx.Channel.SendAsync(null, embed: embed.Build(), components: component);
async Task OnInteractionAsync(SocketInteraction si)
{
try
{
if (si is not SocketMessageComponent smc)
return;
if (smc.Message.Id != msg.Id)
return;
await si.DeferAsync();
if (smc.User.Id != ctx.User.Id)
return;
if (smc.Data.CustomId == BUTTON_LEFT)
{
if (currentPage == 0)
return;
--currentPage;
_ = UpdatePageAsync(smc);
}
else if (smc.Data.CustomId == BUTTON_RIGHT)
{
if (currentPage >= lastPage)
return;
++currentPage;
_ = UpdatePageAsync(smc);
}
else if (maybeInter is { } inter && inter.Button.CustomId == smc.Data.CustomId)
{
await inter.TriggerAsync(smc);
_ = UpdatePageAsync(smc);
}
}
catch (Exception ex)
{
Log.Error(ex, "Error in pagination: {ErrorMessage}", ex.Message);
}
}
if (lastPage == 0 && interFactory is null)
return;
var client = (DiscordSocketClient)ctx.Client;
client.InteractionCreated += OnInteractionAsync;
await Task.Delay(30_000);
client.InteractionCreated -= OnInteractionAsync;
await msg.ModifyAsync(mp => mp.Components = new ComponentBuilder().Build());
}
private static readonly Emoji _okEmoji = new Emoji("✅");
private static readonly Emoji _warnEmoji = new Emoji("⚠️");
private static readonly Emoji _errorEmoji = new Emoji("❌");
public static Task ReactAsync(this ICommandContext ctx, MsgType type)
{
var emoji = type switch
{
MsgType.Error => _errorEmoji,
MsgType.Pending => _warnEmoji,
MsgType.Ok => _okEmoji,
_ => throw new ArgumentOutOfRangeException(nameof(type)),
};
return ctx.Message.AddReactionAsync(emoji);
}
public static Task OkAsync(this ICommandContext ctx)
=> ctx.ReactAsync(MsgType.Ok);
public static Task ErrorAsync(this ICommandContext ctx)
=> ctx.ReactAsync(MsgType.Error);
public static Task WarningAsync(this ICommandContext ctx)
=> ctx.ReactAsync(MsgType.Pending);
}

View File

@@ -0,0 +1,18 @@
namespace NadekoBot.Extensions;
public static class LinkedListExtensions
{
public static LinkedListNode<T>? FindNode<T>(this LinkedList<T> list, Func<T, bool> predicate)
{
var node = list.First;
while (node is not null)
{
if (predicate(node.Value))
return node;
node = node.Next;
}
return null;
}
}

View File

@@ -0,0 +1,7 @@
namespace NadekoBot.Extensions;
public static class NumberExtensions
{
public static DateTimeOffset ToUnixTimestamp(this double number)
=> new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero).AddSeconds(number);
}

View File

@@ -0,0 +1,23 @@
namespace NadekoBot.Extensions;
public static class ReflectionExtensions
{
public static bool IsAssignableToGenericType(this Type givenType, Type genericType)
{
var interfaceTypes = givenType.GetInterfaces();
foreach (var it in interfaceTypes)
{
if (it.IsGenericType && it.GetGenericTypeDefinition() == genericType)
return true;
}
if (givenType.IsGenericType && givenType.GetGenericTypeDefinition() == genericType)
return true;
Type baseType = givenType.BaseType;
if (baseType == null) return false;
return IsAssignableToGenericType(baseType, genericType);
}
}

View File

@@ -0,0 +1,57 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Gif;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace NadekoBot.Extensions;
public static class Rgba32Extensions
{
public static Image<Rgba32> Merge(this IEnumerable<Image<Rgba32>> images)
=> images.Merge(out _);
public static Image<Rgba32> Merge(this IEnumerable<Image<Rgba32>> images, out IImageFormat format)
{
format = PngFormat.Instance;
void DrawFrame(IList<Image<Rgba32>> imgArray, Image<Rgba32> imgFrame, int frameNumber)
{
var xOffset = 0;
for (var i = 0; i < imgArray.Count; i++)
{
using var frame = imgArray[i].Frames.CloneFrame(frameNumber % imgArray[i].Frames.Count);
var offset = xOffset;
imgFrame.Mutate(x => x.DrawImage(frame, new(offset, 0), new GraphicsOptions()));
xOffset += imgArray[i].Bounds().Width;
}
}
var imgs = images.ToList();
var frames = imgs.Max(x => x.Frames.Count);
var width = imgs.Sum(img => img.Width);
var height = imgs.Max(img => img.Height);
var canvas = new Image<Rgba32>(width, height);
if (frames == 1)
{
DrawFrame(imgs, canvas, 0);
return canvas;
}
format = GifFormat.Instance;
for (var j = 0; j < frames; j++)
{
using var imgFrame = new Image<Rgba32>(width, height);
DrawFrame(imgs, imgFrame, j);
var frameToAdd = imgFrame.Frames[0];
frameToAdd.Metadata.GetGifMetadata().DisposalMethod = GifDisposalMethod.RestoreToBackground;
canvas.Frames.AddFrame(frameToAdd);
}
canvas.Frames.RemoveFrame(0);
return canvas;
}
}

View File

@@ -0,0 +1,100 @@
using Nadeko.Common;
using NadekoBot.Common;
using NadekoBot.Services;
namespace NadekoBot.Extensions;
public static class SocketMessageComponentExtensions
{
public static Task RespondAsync(
this SocketMessageComponent smc,
string? plainText,
Embed? embed = null,
IReadOnlyCollection<Embed>? embeds = null,
bool sanitizeAll = false,
MessageComponent? components = null,
bool ephemeral = true)
{
plainText = sanitizeAll
? plainText?.SanitizeAllMentions() ?? ""
: plainText?.SanitizeMentions() ?? "";
return smc.RespondAsync(plainText,
embed: embed,
embeds: embeds is null
? null
: embeds as Embed[] ?? embeds.ToArray(),
components: components,
ephemeral: ephemeral);
}
public static Task RespondAsync(
this SocketMessageComponent smc,
SmartText text,
bool sanitizeAll = false,
bool ephemeral = true)
=> text switch
{
SmartEmbedText set => smc.RespondAsync(set.PlainText,
set.IsValid ? set.GetEmbed().Build() : null,
sanitizeAll: sanitizeAll,
ephemeral: ephemeral),
SmartPlainText st => smc.RespondAsync(st.Text,
default(Embed),
sanitizeAll: sanitizeAll,
ephemeral: ephemeral),
SmartEmbedTextArray arr => smc.RespondAsync(arr.Content,
embeds: arr.GetEmbedBuilders().Map(e => e.Build()),
ephemeral: ephemeral),
_ => throw new ArgumentOutOfRangeException(nameof(text))
};
public static Task EmbedAsync(
this SocketMessageComponent smc,
IEmbedBuilder? embed,
string plainText = "",
IReadOnlyCollection<IEmbedBuilder>? embeds = null,
NadekoInteraction? inter = null,
bool ephemeral = false)
=> smc.RespondAsync(plainText,
embed: embed?.Build(),
embeds: embeds?.Map(x => x.Build()),
ephemeral: ephemeral);
public static Task RespondAsync(
this SocketMessageComponent ch,
IEmbedBuilderService eb,
string text,
MsgType type,
bool ephemeral = false,
NadekoInteraction? inter = null)
{
var builder = eb.Create().WithDescription(text);
builder = (type switch
{
MsgType.Error => builder.WithErrorColor(),
MsgType.Ok => builder.WithOkColor(),
MsgType.Pending => builder.WithPendingColor(),
_ => throw new ArgumentOutOfRangeException(nameof(type))
});
return ch.EmbedAsync(builder, inter: inter, ephemeral: ephemeral);
}
// embed title and optional footer overloads
public static Task RespondErrorAsync(
this SocketMessageComponent smc,
IEmbedBuilderService eb,
string text,
bool ephemeral = false)
=> smc.RespondAsync(eb, text, MsgType.Error, ephemeral);
public static Task RespondConfirmAsync(
this SocketMessageComponent smc,
IEmbedBuilderService eb,
string text,
bool ephemeral = false)
=> smc.RespondAsync(eb, text, MsgType.Ok, ephemeral);
}

View File

@@ -0,0 +1,40 @@
using NadekoBot.Db.Models;
using NadekoBot.Services;
namespace NadekoBot.Extensions;
public static class UserExtensions
{
public static async Task<IUserMessage> EmbedAsync(this IUser user, IEmbedBuilder embed, string msg = "")
{
var ch = await user.CreateDMChannelAsync();
return await ch.EmbedAsync(embed, msg);
}
public static async Task<IUserMessage> SendAsync(this IUser user, SmartText text, bool sanitizeAll = false)
{
var ch = await user.CreateDMChannelAsync();
return await ch.SendAsync(text, sanitizeAll);
}
public static async Task<IUserMessage> SendConfirmAsync(this IUser user, IEmbedBuilderService eb, string text)
=> await user.SendMessageAsync("", embed: eb.Create().WithOkColor().WithDescription(text).Build());
public static async Task<IUserMessage> SendErrorAsync(this IUser user, IEmbedBuilderService eb, string error)
=> await user.SendMessageAsync("", embed: eb.Create().WithErrorColor().WithDescription(error).Build());
public static async Task<IUserMessage> SendPendingAsync(this IUser user, IEmbedBuilderService eb, string message)
=> await user.SendMessageAsync("", embed: eb.Create().WithPendingColor().WithDescription(message).Build());
// This method is used by everything that fetches the avatar from a user
public static Uri RealAvatarUrl(this IUser usr, ushort size = 256)
=> usr.AvatarId is null ? new(usr.GetDefaultAvatarUrl()) : new Uri(usr.GetAvatarUrl(ImageFormat.Auto, size));
// This method is only used for the xp card
public static Uri RealAvatarUrl(this DiscordUser usr)
=> usr.AvatarId is null
? new(CDN.GetDefaultUserAvatarUrl(ushort.Parse(usr.Discriminator)))
: new Uri(usr.AvatarId.StartsWith("a_", StringComparison.InvariantCulture)
? $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.gif"
: $"{DiscordConfig.CDNUrl}avatars/{usr.UserId}/{usr.AvatarId}.png");
}