diff --git a/src/Nadeko.Bot.Common/ICurrencyProvider.cs b/src/Nadeko.Bot.Common/ICurrencyProvider.cs
index 5e916b2c8..7947c075f 100644
--- a/src/Nadeko.Bot.Common/ICurrencyProvider.cs
+++ b/src/Nadeko.Bot.Common/ICurrencyProvider.cs
@@ -1,7 +1,7 @@
using System.Globalization;
using System.Numerics;
-namespace Nadeko.Bot.Common;
+namespace NadekoBot.Common;
public interface ICurrencyProvider
{
diff --git a/src/Nadeko.Bot.Common/IPermissionChecker.cs b/src/Nadeko.Bot.Common/IPermissionChecker.cs
index 76a21a4e1..e60c3c4c2 100644
--- a/src/Nadeko.Bot.Common/IPermissionChecker.cs
+++ b/src/Nadeko.Bot.Common/IPermissionChecker.cs
@@ -2,7 +2,7 @@
using OneOf;
using OneOf.Types;
-namespace Nadeko.Bot.Common;
+namespace NadekoBot.Common;
public interface IPermissionChecker
{
diff --git a/src/Nadeko.Bot.Common/Nadeko.Bot.Common.csproj b/src/Nadeko.Bot.Common/Nadeko.Bot.Common.csproj
index cbf6974ae..dbafa63fd 100644
--- a/src/Nadeko.Bot.Common/Nadeko.Bot.Common.csproj
+++ b/src/Nadeko.Bot.Common/Nadeko.Bot.Common.csproj
@@ -39,4 +39,5 @@
responses.en-US.json
+
diff --git a/src/Nadeko.Bot.Common/NadekoModule.cs b/src/Nadeko.Bot.Common/NadekoModule.cs
index d026fd756..411558980 100644
--- a/src/Nadeko.Bot.Common/NadekoModule.cs
+++ b/src/Nadeko.Bot.Common/NadekoModule.cs
@@ -1,5 +1,6 @@
#nullable disable
using System.Globalization;
+using NadekoBot.Common;
// ReSharper disable InconsistentNaming
@@ -18,6 +19,7 @@ public abstract class NadekoModule : ModuleBase
public ILocalization _localization { get; set; }
public IEmbedBuilderService _eb { get; set; }
public INadekoInteractionService _inter { get; set; }
+ public IReplacementService repSvc { get; set; }
protected string prefix
=> _cmdHandler.GetPrefix(ctx.Guild);
diff --git a/src/Nadeko.Bot.Common/Replacements/IReplacementPatternStore.cs b/src/Nadeko.Bot.Common/Replacements/IReplacementPatternStore.cs
new file mode 100644
index 000000000..ef6faae82
--- /dev/null
+++ b/src/Nadeko.Bot.Common/Replacements/IReplacementPatternStore.cs
@@ -0,0 +1,20 @@
+using System.Text.RegularExpressions;
+
+namespace NadekoBot.Common;
+
+public interface IReplacementPatternStore : INService
+{
+ IReadOnlyDictionary Replacements { get; }
+ IReadOnlyDictionary RegexReplacements { get; }
+
+ ValueTask Register(string token, Func> repFactory);
+ ValueTask Register(string token, Func> repFactory);
+ ValueTask Register(string token, Func> repFactory);
+
+ ValueTask Register(string token, Func repFactory);
+ ValueTask Register(string token, Func repFactory);
+ ValueTask Register(string token, Func repFactory);
+
+ ValueTask Register(Regex regex, Func repFactory);
+ ValueTask Register(Regex regex, Func repFactory);
+}
\ No newline at end of file
diff --git a/src/Nadeko.Bot.Common/Replacements/IReplacementService.cs b/src/Nadeko.Bot.Common/Replacements/IReplacementService.cs
new file mode 100644
index 000000000..110ce836a
--- /dev/null
+++ b/src/Nadeko.Bot.Common/Replacements/IReplacementService.cs
@@ -0,0 +1,7 @@
+namespace NadekoBot.Common;
+
+public interface IReplacementService
+{
+ ValueTask ReplaceAsync(string input, ReplacementContext repCtx);
+ ValueTask ReplaceAsync(SmartText input, ReplacementContext repCtx);
+}
\ No newline at end of file
diff --git a/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementContext.cs b/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementContext.cs
new file mode 100644
index 000000000..e0bdf7b09
--- /dev/null
+++ b/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementContext.cs
@@ -0,0 +1,69 @@
+using System.Text.RegularExpressions;
+
+namespace NadekoBot.Common;
+
+public sealed class ReplacementContext
+{
+ public DiscordSocketClient? Client { get; }
+ public IGuild? Guild { get; }
+ public IMessageChannel? Channel { get; }
+ public IUser[]? Users { get; }
+
+ private readonly List _overrides = new();
+ private readonly HashSet _tokens = new();
+
+ public IReadOnlyList Overrides
+ => _overrides.AsReadOnly();
+
+ private readonly List _regexOverrides = new();
+ private readonly HashSet _regexPatterns = new();
+
+ public IReadOnlyList RegexOverrides
+ => _regexOverrides.AsReadOnly();
+
+ public ReplacementContext(ICommandContext cmdContext) : this(cmdContext.Client as DiscordSocketClient,
+ cmdContext.Guild,
+ cmdContext.Channel,
+ cmdContext.User)
+ {
+ }
+
+ public ReplacementContext(
+ DiscordSocketClient? client = null,
+ IGuild? guild = null,
+ IMessageChannel? channel = null,
+ params IUser[]? users)
+ {
+ Client = client;
+ Guild = guild;
+ Channel = channel;
+ Users = users;
+ }
+
+ public ReplacementContext WithOverride(string key, Func> repFactory)
+ {
+ if (_tokens.Add(key))
+ {
+ _overrides.Add(new(key, repFactory));
+ }
+
+ return this;
+ }
+
+ public ReplacementContext WithOverride(string key, Func repFactory)
+ => WithOverride(key, () => new ValueTask(repFactory()));
+
+
+ public ReplacementContext WithOverride(Regex regex, Func> repFactory)
+ {
+ if (_regexPatterns.Add(regex.ToString()))
+ {
+ _regexOverrides.Add(new(regex, repFactory));
+ }
+
+ return this;
+ }
+
+ public ReplacementContext WithOverride(Regex regex, Func repFactory)
+ => WithOverride(regex, (Match m) => new ValueTask(repFactory(m)));
+}
\ No newline at end of file
diff --git a/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementInfo.cs b/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementInfo.cs
new file mode 100644
index 000000000..b50665fe1
--- /dev/null
+++ b/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementInfo.cs
@@ -0,0 +1,57 @@
+using System.Reflection;
+using System.Text.RegularExpressions;
+
+namespace NadekoBot.Common;
+
+public sealed class ReplacementInfo
+{
+ private readonly Delegate _del;
+ public IReadOnlyCollection InputTypes { get; }
+ public string Token { get; }
+
+ private static readonly Func> _falllbackFunc = static () => default;
+
+ public ReplacementInfo(string token, Delegate del)
+ {
+ _del = del;
+ InputTypes = del.GetMethodInfo().GetParameters().Select(x => x.ParameterType).ToArray().AsReadOnly();
+ Token = token;
+ }
+
+ public async Task GetValueAsync(params object?[]? objs)
+ => await (ValueTask)(_del.DynamicInvoke(objs) ?? _falllbackFunc);
+
+ public override int GetHashCode()
+ => Token.GetHashCode();
+
+ public override bool Equals(object? obj)
+ => obj is ReplacementInfo ri && ri.Token == Token;
+}
+
+public sealed class RegexReplacementInfo
+{
+ private readonly Delegate _del;
+ public IReadOnlyCollection InputTypes { get; }
+
+ public Regex Regex { get; }
+ public string Pattern { get; }
+
+ private static readonly Func> _falllbackFunc = static _ => default;
+
+ public RegexReplacementInfo(Regex regex, Delegate del)
+ {
+ _del = del;
+ InputTypes = del.GetMethodInfo().GetParameters().Select(x => x.ParameterType).ToArray().AsReadOnly();
+ Regex = regex;
+ Pattern = Regex.ToString();
+ }
+
+ public async Task GetValueAsync(Match m, params object?[]? objs)
+ => await ((Func>)(_del.DynamicInvoke(objs) ?? _falllbackFunc))(m);
+
+ public override int GetHashCode()
+ => Regex.GetHashCode();
+
+ public override bool Equals(object? obj)
+ => obj is RegexReplacementInfo ri && ri.Pattern == Pattern;
+}
\ No newline at end of file
diff --git a/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementPatternStore.cs b/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementPatternStore.cs
new file mode 100644
index 000000000..44bb10b24
--- /dev/null
+++ b/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementPatternStore.cs
@@ -0,0 +1,130 @@
+using System.Text.RegularExpressions;
+using OneOf;
+
+namespace NadekoBot.Common;
+
+public sealed partial class ReplacementPatternStore : IReplacementPatternStore, INService
+{
+ private readonly ConcurrentDictionary> _guids = new();
+
+ private readonly ConcurrentDictionary _defaultReplacements = new();
+ private readonly ConcurrentDictionary _regexReplacements = new();
+
+ public IReadOnlyDictionary Replacements
+ => _defaultReplacements.AsReadOnly();
+
+ public IReadOnlyDictionary RegexReplacements
+ => _regexReplacements.AsReadOnly();
+
+ public ReplacementPatternStore()
+ {
+ WithClient();
+ WithChannel();
+ WithServer();
+ WithUsers();
+ WithDefault();
+ WithRegex();
+ }
+
+ // private async ValueTask InternalReplace(string input, ReplacementContexta repCtx)
+ // {
+ // // multiple executions vs single execution per replacement
+ // var minIndex = -1;
+ // var index = -1;
+ // foreach (var rep in _replacements)
+ // {
+ // while ((index = input.IndexOf(rep.Key, StringComparison.InvariantCulture)) != -1 && index > minIndex)
+ // {
+ // var valueToInsert = await rep.Value(repCtx);
+ // input = input[..index] + valueToInsert +input[(index + rep.Key.Length)..];
+ // minIndex = (index + valueToInsert.Length);
+ // }
+ // }
+ //
+ // return input;
+ // }
+
+ private ValueTask InternalRegister(string token, Delegate repFactory)
+ {
+ if (!token.StartsWith('%') || !token.EndsWith('%'))
+ {
+ Log.Warning(
+ """
+ Invalid replacement token: {Token}
+ Tokens have to start and end with a '%', ex: %mytoken%
+ """,
+ token);
+ return new(default(Guid?));
+ }
+
+ if (_defaultReplacements.TryAdd(token, new ReplacementInfo(token, repFactory)))
+ {
+ var guid = Guid.NewGuid();
+ _guids[guid] = token;
+ return new(guid);
+ }
+
+ return new(default(Guid?));
+ }
+
+ public ValueTask Register(string token, Func> repFactory)
+ => InternalRegister(token, repFactory);
+
+ public ValueTask Register(string token, Func> repFactory)
+ => InternalRegister(token, repFactory);
+
+ public ValueTask Register(string token, Func> repFactory)
+ => InternalRegister(token, repFactory);
+
+ public ValueTask Register(string token, Func repFactory)
+ => InternalRegister(token, () => new ValueTask(repFactory()));
+
+ public ValueTask Register(string token, Func repFactory)
+ => InternalRegister(token, (T1 a) => new ValueTask(repFactory(a)));
+
+ public ValueTask Register(string token, Func repFactory)
+ => InternalRegister(token, (T1 a, T2 b) => new ValueTask(repFactory(a, b)));
+
+
+ private ValueTask InternalRegexRegister(Regex regex, Delegate repFactory)
+ {
+ var regexPattern = regex.ToString();
+ if (!regexPattern.StartsWith('%') || !regexPattern.EndsWith('%'))
+ {
+ Log.Warning(
+ """
+ Invalid replacement pattern: {Token}
+ Tokens have to start and end with a '%', ex: %mytoken%
+ """,
+ regex);
+ return new(default(Guid?));
+ }
+
+ if (_regexReplacements.TryAdd(regexPattern, new RegexReplacementInfo(regex, repFactory)))
+ {
+ var guid = Guid.NewGuid();
+ _guids[guid] = regex;
+ return new(guid);
+ }
+
+ return new(default(Guid?));
+ }
+
+ public ValueTask Register(Regex regex, Func repFactory)
+ => InternalRegexRegister(regex, () => (Match m) => new ValueTask(repFactory(m)));
+
+ public ValueTask Register(Regex regex, Func repFactory)
+ => InternalRegexRegister(regex, (T1 a) => (Match m) => new ValueTask(repFactory(m, a)));
+
+ public bool Unregister(Guid guid)
+ {
+ if (_guids.TryRemove(guid, out var pattern))
+ {
+ return pattern.Match(
+ token => _defaultReplacements.TryRemove(token, out _),
+ regex => _regexReplacements.TryRemove(regex.ToString(), out _));
+ }
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementRegistrator.default.cs b/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementRegistrator.default.cs
new file mode 100644
index 000000000..306617901
--- /dev/null
+++ b/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementRegistrator.default.cs
@@ -0,0 +1,113 @@
+using System.Text.RegularExpressions;
+
+namespace NadekoBot.Common;
+
+public sealed partial class ReplacementPatternStore
+{
+ private static readonly Regex _rngRegex = new(@"%rng(?:(?(?:-)?\d+)-(?(?:-)?\d+))?%",
+ RegexOptions.Compiled);
+
+
+ private void WithDefault()
+ {
+ Register("%bot.time%",
+ static ()
+ => DateTime.Now.ToString("HH:mm " + TimeZoneInfo.Local.StandardName.GetInitials()));
+ }
+
+ private void WithClient()
+ {
+ Register("%bot.status%", static (DiscordSocketClient client) => client.Status.ToString());
+ Register("%bot.latency%", static (DiscordSocketClient client) => client.Latency.ToString());
+ Register("%bot.name%", static (DiscordSocketClient client) => client.CurrentUser.Username);
+ Register("%bot.fullname%", static (DiscordSocketClient client) => client.CurrentUser.ToString());
+ Register("%bot.discrim%", static (DiscordSocketClient client) => client.CurrentUser.Discriminator);
+ Register("%bot.id%", static (DiscordSocketClient client) => client.CurrentUser.Id.ToString());
+ Register("%bot.avatar%",
+ static (DiscordSocketClient client) => client.CurrentUser.RealAvatarUrl().ToString());
+
+ Register("%bot.mention%", static (DiscordSocketClient client) => client.CurrentUser.Mention);
+
+ Register("%shard.servercount%", static (DiscordSocketClient c) => c.Guilds.Count.ToString());
+ Register("%shard.usercount%",
+ static (DiscordSocketClient c) => c.Guilds.Sum(g => g.MemberCount).ToString());
+ Register("%shard.id%", static (DiscordSocketClient c) => c.ShardId.ToString());
+ }
+
+ private void WithServer()
+ {
+ Register("%server%", static (IGuild g) => g.Name);
+ Register("%server.id%", static (IGuild g) => g.Id.ToString());
+ Register("%server.name%", static (IGuild g) => g.Name);
+ Register("%server.icon%", static (IGuild g) => g.IconUrl);
+ Register("%server.members%", static (IGuild g) => g.ApproximateMemberCount?.ToString() ?? "?");
+ Register("%server.boosters%", static (IGuild g) => g.PremiumSubscriptionCount.ToString());
+ Register("%server.boost_level%", static (IGuild g) => ((int)g.PremiumTier).ToString());
+ }
+
+ private void WithChannel()
+ {
+ Register("%channel%", static (IMessageChannel ch) => ch.Name);
+ Register("%channel.mention%",
+ static (IMessageChannel ch) => (ch as ITextChannel)?.Mention ?? "#" + ch.Name);
+ Register("%channel.name%", static (IMessageChannel ch) => ch.Name);
+ Register("%channel.id%", static (IMessageChannel ch) => ch.Id.ToString());
+ Register("%channel.created%",
+ static (IMessageChannel ch) => ch.CreatedAt.ToString("HH:mm dd.MM.yyyy"));
+ Register("%channel.nsfw%",
+ static (IMessageChannel ch) => (ch as ITextChannel)?.IsNsfw.ToString() ?? "-");
+ Register("%channel.topic%", static (IMessageChannel ch) => (ch as ITextChannel)?.Topic ?? "-");
+ }
+
+ private void WithUsers()
+ {
+ Register("%user%",
+ static (IUser[] users) => string.Join(" ", users.Select(user => user.Mention)));
+ Register("%user.mention%",
+ static (IUser[] users) => string.Join(" ", users.Select(user => user.Mention)));
+ Register("%user.fullname%",
+ static (IUser[] users) => string.Join(" ", users.Select(user => user.ToString())));
+ Register("%user.name%",
+ static (IUser[] users) => string.Join(" ", users.Select(user => user.Username)));
+ Register("%user.discrim%",
+ static (IUser[] users) => string.Join(" ", users.Select(user => user.Discriminator)));
+ Register("%user.avatar%",
+ static (IUser[] users)
+ => string.Join(" ", users.Select(user => user.RealAvatarUrl().ToString())));
+ Register("%user.id%",
+ static (IUser[] users) => string.Join(" ", users.Select(user => user.Id.ToString())));
+ Register("%user.created_time%",
+ static (IUser[] users)
+ => string.Join(" ", users.Select(user => user.CreatedAt.ToString("HH:mm"))));
+ Register("%user.created_date%",
+ static (IUser[] users)
+ => string.Join(" ", users.Select(user => user.CreatedAt.ToString("dd.MM.yyyy"))));
+ Register("%user.joined_time%",
+ static (IUser[] users) => string.Join(" ",
+ users.Select(user => (user as IGuildUser)?.JoinedAt?.ToString("HH:mm") ?? "-")));
+ Register("%user.joined_date%",
+ static (IUser[] users) => string.Join(" ",
+ users.Select(user => (user as IGuildUser)?.JoinedAt?.ToString("dd.MM.yyyy") ?? "-")));
+ }
+
+ private void WithRegex()
+ {
+ Register(_rngRegex,
+ match =>
+ {
+ var rng = new NadekoRandom();
+ 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();
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementService.cs b/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementService.cs
new file mode 100644
index 000000000..65f3bdeea
--- /dev/null
+++ b/src/Nadeko.Bot.Common/Replacements/Impl/ReplacementService.cs
@@ -0,0 +1,137 @@
+namespace NadekoBot.Common;
+
+public sealed class ReplacementService : IReplacementService, INService
+{
+ private readonly IReplacementPatternStore _repReg;
+
+ public ReplacementService(IReplacementPatternStore repReg)
+ {
+ _repReg = repReg;
+ }
+
+ public async ValueTask ReplaceAsync(SmartText input, ReplacementContext repCtx)
+ {
+ var reps = GetReplacementsForContext(repCtx);
+ var regReps = GetRegexReplacementsForContext(repCtx);
+
+ var inputData = GetInputData(repCtx);
+ var rep = new Replacer(reps.Values, regReps.Values, inputData);
+
+ return await rep.ReplaceAsync(input);
+ }
+
+ public async ValueTask ReplaceAsync(string input, ReplacementContext repCtx)
+ {
+ var reps = GetReplacementsForContext(repCtx);
+ var regReps = GetRegexReplacementsForContext(repCtx);
+
+ var inputData = GetInputData(repCtx);
+ var rep = new Replacer(reps.Values, regReps.Values, inputData);
+
+ return await rep.ReplaceAsync(input);
+ }
+
+ private object[] GetInputData(ReplacementContext repCtx)
+ {
+ var obj = new List