mirror of
https://gitlab.com/Kwoth/nadekobot.git
synced 2025-09-10 17:28:27 -04:00
Lots more stuff
This commit is contained in:
78
src/Nadeko.Bot.Common/Abstractions/creds/IBotCredentials.cs
Normal file
78
src/Nadeko.Bot.Common/Abstractions/creds/IBotCredentials.cs
Normal 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; }
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
namespace NadekoBot;
|
||||
|
||||
public interface IBotCredsProvider
|
||||
{
|
||||
public void Reload();
|
||||
public IBotCredentials GetCreds();
|
||||
public void ModifyCredsFile(Action<IBotCredentials> func);
|
||||
}
|
17
src/Nadeko.Bot.Common/Abstractions/strings/IBotStrings.cs
Normal file
17
src/Nadeko.Bot.Common/Abstractions/strings/IBotStrings.cs
Normal 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);
|
||||
}
|
@@ -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);
|
||||
}
|
@@ -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);
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public interface ICommandStrings
|
||||
{
|
||||
string Desc { get; set; }
|
||||
string[] Args { get; set; }
|
||||
}
|
16
src/Nadeko.Bot.Common/Abstractions/strings/IStringsSource.cs
Normal file
16
src/Nadeko.Bot.Common/Abstractions/strings/IStringsSource.cs
Normal 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();
|
||||
}
|
13
src/Nadeko.Bot.Common/Abstractions/strings/LocStr.cs
Normal file
13
src/Nadeko.Bot.Common/Abstractions/strings/LocStr.cs
Normal 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;
|
||||
}
|
||||
}
|
204
src/Nadeko.Bot.Common/Configs/BotConfig.cs
Normal file
204
src/Nadeko.Bot.Common/Configs/BotConfig.cs
Normal 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
|
||||
}
|
18
src/Nadeko.Bot.Common/Configs/IConfigSeria.cs
Normal file
18
src/Nadeko.Bot.Common/Configs/IConfigSeria.cs
Normal 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);
|
||||
}
|
78
src/Nadeko.Bot.Common/DbService.cs
Normal file
78
src/Nadeko.Bot.Common/DbService.cs
Normal 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();
|
||||
}
|
12
src/Nadeko.Bot.Common/Extensions/DbExtensions.cs
Normal file
12
src/Nadeko.Bot.Common/Extensions/DbExtensions.cs
Normal 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);
|
||||
}
|
98
src/Nadeko.Bot.Common/Extensions/ImagesharpExtensions.cs
Normal file
98
src/Nadeko.Bot.Common/Extensions/ImagesharpExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
31
src/Nadeko.Bot.Common/GlobalUsings.cs
Normal file
31
src/Nadeko.Bot.Common/GlobalUsings.cs
Normal 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;
|
8
src/Nadeko.Bot.Common/ICloneable.cs
Normal file
8
src/Nadeko.Bot.Common/ICloneable.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Common;
|
||||
|
||||
public interface ICloneable<T>
|
||||
where T : new()
|
||||
{
|
||||
public T Clone();
|
||||
}
|
35
src/Nadeko.Bot.Common/ILogCommandService.cs
Normal file
35
src/Nadeko.Bot.Common/ILogCommandService.cs
Normal 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
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
namespace NadekoBot;
|
||||
|
||||
public interface INadekoInteractionService
|
||||
{
|
||||
public NadekoInteraction Create<T>(
|
||||
ulong userId,
|
||||
SimpleInteraction<T> inter);
|
||||
}
|
82
src/Nadeko.Bot.Common/Interaction/NadekoInteraction.cs
Normal file
82
src/Nadeko.Bot.Common/Interaction/NadekoInteraction.cs
Normal 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);
|
||||
}
|
@@ -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);
|
@@ -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);
|
||||
}
|
20
src/Nadeko.Bot.Common/Interaction/SimpleInteraction.cs
Normal file
20
src/Nadeko.Bot.Common/Interaction/SimpleInteraction.cs
Normal 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!);
|
||||
}
|
||||
}
|
24
src/Nadeko.Bot.Common/Medusa/IMedusaLoaderService.cs
Normal file
24
src/Nadeko.Bot.Common/Medusa/IMedusaLoaderService.cs
Normal 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);
|
10
src/Nadeko.Bot.Common/Medusa/MedusaLoadResult.cs
Normal file
10
src/Nadeko.Bot.Common/Medusa/MedusaLoadResult.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Nadeko.Medusa;
|
||||
|
||||
public enum MedusaLoadResult
|
||||
{
|
||||
Success,
|
||||
NotFound,
|
||||
AlreadyLoaded,
|
||||
Empty,
|
||||
UnknownError,
|
||||
}
|
9
src/Nadeko.Bot.Common/Medusa/MedusaUnloadResult.cs
Normal file
9
src/Nadeko.Bot.Common/Medusa/MedusaUnloadResult.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Nadeko.Medusa;
|
||||
|
||||
public enum MedusaUnloadResult
|
||||
{
|
||||
Success,
|
||||
NotLoaded,
|
||||
PossiblyUnable,
|
||||
NotFound,
|
||||
}
|
8
src/Nadeko.Bot.Common/MessageType.cs
Normal file
8
src/Nadeko.Bot.Common/MessageType.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace NadekoBot.Common;
|
||||
|
||||
public enum MsgType
|
||||
{
|
||||
Ok,
|
||||
Pending,
|
||||
Error
|
||||
}
|
19
src/Nadeko.Bot.Common/ModuleBehaviors/IExecNoCommand.cs
Normal file
19
src/Nadeko.Bot.Common/ModuleBehaviors/IExecNoCommand.cs
Normal 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);
|
||||
}
|
21
src/Nadeko.Bot.Common/ModuleBehaviors/IExecOnMessage.cs
Normal file
21
src/Nadeko.Bot.Common/ModuleBehaviors/IExecOnMessage.cs
Normal 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);
|
||||
}
|
22
src/Nadeko.Bot.Common/ModuleBehaviors/IExecPostCommand.cs
Normal file
22
src/Nadeko.Bot.Common/ModuleBehaviors/IExecPostCommand.cs
Normal 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);
|
||||
}
|
25
src/Nadeko.Bot.Common/ModuleBehaviors/IExecPreCommand.cs
Normal file
25
src/Nadeko.Bot.Common/ModuleBehaviors/IExecPreCommand.cs
Normal 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);
|
||||
}
|
25
src/Nadeko.Bot.Common/ModuleBehaviors/IInputTransformer.cs
Normal file
25
src/Nadeko.Bot.Common/ModuleBehaviors/IInputTransformer.cs
Normal 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);
|
||||
}
|
13
src/Nadeko.Bot.Common/ModuleBehaviors/IReadyExecutor.cs
Normal file
13
src/Nadeko.Bot.Common/ModuleBehaviors/IReadyExecutor.cs
Normal 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();
|
||||
}
|
29
src/Nadeko.Bot.Common/Nadeko.Bot.Common.csproj
Normal file
29
src/Nadeko.Bot.Common/Nadeko.Bot.Common.csproj
Normal 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>
|
142
src/Nadeko.Bot.Common/NadekoModule.cs
Normal file
142
src/Nadeko.Bot.Common/NadekoModule.cs
Normal 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; }
|
||||
}
|
164
src/Nadeko.Bot.Common/Replacements/ReplacementBuilder.cs
Normal file
164
src/Nadeko.Bot.Common/Replacements/ReplacementBuilder.cs
Normal 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;
|
||||
}
|
||||
}
|
94
src/Nadeko.Bot.Common/Replacements/Replacer.cs
Normal file
94
src/Nadeko.Bot.Common/Replacements/Replacer.cs
Normal 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;
|
||||
}
|
||||
}
|
17
src/Nadeko.Bot.Common/Services/IBehaviourExecutor.cs
Normal file
17
src/Nadeko.Bot.Common/Services/IBehaviourExecutor.cs
Normal 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();
|
||||
}
|
6
src/Nadeko.Bot.Common/Services/ICommandHandler.cs
Normal file
6
src/Nadeko.Bot.Common/Services/ICommandHandler.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public interface ICommandHandler
|
||||
{
|
||||
string GetPrefix(IGuild ctxGuild);
|
||||
}
|
20
src/Nadeko.Bot.Common/Services/ICoordinator.cs
Normal file
20
src/Nadeko.Bot.Common/Services/ICoordinator.cs
Normal 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; }
|
||||
}
|
13
src/Nadeko.Bot.Common/Services/ICustomBehavior.cs
Normal file
13
src/Nadeko.Bot.Common/Services/ICustomBehavior.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using NadekoBot.Common.ModuleBehaviors;
|
||||
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public interface ICustomBehavior
|
||||
: IExecOnMessage,
|
||||
IInputTransformer,
|
||||
IExecPreCommand,
|
||||
IExecNoCommand,
|
||||
IExecPostCommand
|
||||
{
|
||||
|
||||
}
|
81
src/Nadeko.Bot.Common/Services/IEmbedBuilderService.cs
Normal file
81
src/Nadeko.Bot.Common/Services/IEmbedBuilderService.cs
Normal 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;
|
||||
}
|
||||
}
|
19
src/Nadeko.Bot.Common/Services/IGoogleApiService.cs
Normal file
19
src/Nadeko.Bot.Common/Services/IGoogleApiService.cs
Normal 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);
|
||||
}
|
19
src/Nadeko.Bot.Common/Services/ILocalization.cs
Normal file
19
src/Nadeko.Bot.Common/Services/ILocalization.cs
Normal 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);
|
||||
}
|
9
src/Nadeko.Bot.Common/Services/INService.cs
Normal file
9
src/Nadeko.Bot.Common/Services/INService.cs
Normal 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
|
||||
{
|
||||
}
|
56
src/Nadeko.Bot.Common/Services/IStatsService.cs
Normal file
56
src/Nadeko.Bot.Common/Services/IStatsService.cs
Normal 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();
|
||||
}
|
66
src/Nadeko.Bot.Common/Settings/BotConfigService.cs
Normal file
66
src/Nadeko.Bot.Common/Settings/BotConfigService.cs
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
50
src/Nadeko.Bot.Common/Settings/ConfigParsers.cs
Normal file
50
src/Nadeko.Bot.Common/Settings/ConfigParsers.cs
Normal 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");
|
||||
}
|
203
src/Nadeko.Bot.Common/Settings/ConfigServiceBase.cs
Normal file
203
src/Nadeko.Bot.Common/Settings/ConfigServiceBase.cs
Normal 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();
|
||||
}
|
||||
}
|
7
src/Nadeko.Bot.Common/Settings/IConfigMigrator.cs
Normal file
7
src/Nadeko.Bot.Common/Settings/IConfigMigrator.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
#nullable disable
|
||||
namespace NadekoBot.Services;
|
||||
|
||||
public interface IConfigMigrator
|
||||
{
|
||||
public void EnsureMigrated();
|
||||
}
|
46
src/Nadeko.Bot.Common/Settings/IConfigService.cs
Normal file
46
src/Nadeko.Bot.Common/Settings/IConfigService.cs
Normal 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);
|
||||
}
|
8
src/Nadeko.Bot.Common/Settings/SettingParser.cs
Normal file
8
src/Nadeko.Bot.Common/Settings/SettingParser.cs
Normal 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);
|
182
src/Nadeko.Bot.Common/SmartText/SmartEmbedText.cs
Normal file
182
src/Nadeko.Bot.Common/SmartText/SmartEmbedText.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
31
src/Nadeko.Bot.Common/SmartText/SmartEmbedTextArray.cs
Normal file
31
src/Nadeko.Bot.Common/SmartText/SmartEmbedTextArray.cs
Normal 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();
|
||||
}
|
||||
}
|
19
src/Nadeko.Bot.Common/SmartText/SmartPlainText.cs
Normal file
19
src/Nadeko.Bot.Common/SmartText/SmartPlainText.cs
Normal 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;
|
||||
}
|
85
src/Nadeko.Bot.Common/SmartText/SmartText.cs
Normal file
85
src/Nadeko.Bot.Common/SmartText/SmartText.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
14
src/Nadeko.Bot.Common/SmartText/SmartTextEmbedAuthor.cs
Normal file
14
src/Nadeko.Bot.Common/SmartText/SmartTextEmbedAuthor.cs
Normal 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; }
|
||||
}
|
9
src/Nadeko.Bot.Common/SmartText/SmartTextEmbedField.cs
Normal file
9
src/Nadeko.Bot.Common/SmartText/SmartTextEmbedField.cs
Normal 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; }
|
||||
}
|
12
src/Nadeko.Bot.Common/SmartText/SmartTextEmbedFooter.cs
Normal file
12
src/Nadeko.Bot.Common/SmartText/SmartTextEmbedFooter.cs
Normal 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; }
|
||||
}
|
@@ -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);
|
||||
}
|
198
src/Nadeko.Bot.Common/_Extensions/Extensions.cs
Normal file
198
src/Nadeko.Bot.Common/_Extensions/Extensions.cs
Normal 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 >= 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();
|
||||
}
|
||||
}
|
332
src/Nadeko.Bot.Common/_Extensions/IMessageChannelExtensions.cs
Normal file
332
src/Nadeko.Bot.Common/_Extensions/IMessageChannelExtensions.cs
Normal 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);
|
||||
}
|
18
src/Nadeko.Bot.Common/_Extensions/LinkedListExtensions.cs
Normal file
18
src/Nadeko.Bot.Common/_Extensions/LinkedListExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
7
src/Nadeko.Bot.Common/_Extensions/NumberExtensions.cs
Normal file
7
src/Nadeko.Bot.Common/_Extensions/NumberExtensions.cs
Normal 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);
|
||||
}
|
23
src/Nadeko.Bot.Common/_Extensions/ReflectionExtensions.cs
Normal file
23
src/Nadeko.Bot.Common/_Extensions/ReflectionExtensions.cs
Normal 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);
|
||||
}
|
||||
}
|
57
src/Nadeko.Bot.Common/_Extensions/Rgba32Extensions.cs
Normal file
57
src/Nadeko.Bot.Common/_Extensions/Rgba32Extensions.cs
Normal 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;
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
40
src/Nadeko.Bot.Common/_Extensions/UserExtensions.cs
Normal file
40
src/Nadeko.Bot.Common/_Extensions/UserExtensions.cs
Normal 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");
|
||||
}
|
Reference in New Issue
Block a user