From f18808fb1c38af1f5f5dbb3bb09f7dafcea9bb70 Mon Sep 17 00:00:00 2001
From: Toastie <toastie@toastiet0ast.com>
Date: Fri, 20 Sep 2024 23:23:21 +1200
Subject: [PATCH] Added the removed common files

---
 .../_common/Abstractions/AsyncLazy.cs         |  19 +
 .../Abstractions/Cache/BotCacheExtensions.cs  |  46 ++
 .../_common/Abstractions/Cache/IBotCache.cs   |  47 ++
 .../Abstractions/Cache/MemoryBotCache.cs      |  71 +++
 .../Collections/ConcurrentHashSet.cs          |  84 +++
 .../Collections/IndexedCollection.cs          | 148 ++++++
 .../_common/Abstractions/EllieRandom.cs       |  66 +++
 .../Extensions/ArrayExtensions.cs             |  62 +++
 .../Extensions/EnumerableExtensions.cs        | 113 ++++
 .../Abstractions/Extensions/Extensions.cs     |   7 +
 .../Extensions/HttpClientExtensions.cs        |  35 ++
 .../Extensions/OneOfExtensions.cs             |  10 +
 .../Abstractions/Extensions/PipeExtensions.cs |  22 +
 .../Extensions/StringExtensions.cs            | 151 ++++++
 .../_common/Abstractions/Helpers/LogSetup.cs  |  35 ++
 .../Helpers/StandardConversions.cs            |   7 +
 src/EllieBot/_common/Abstractions/Kwum.cs     | 100 ++++
 .../Abstractions/PubSub/EventPubSub.cs        |  80 +++
 .../_common/Abstractions/PubSub/IPubSub.cs    |  10 +
 .../_common/Abstractions/PubSub/ISeria.cs     |   7 +
 .../_common/Abstractions/QueueRunner.cs       |  61 +++
 src/EllieBot/_common/Abstractions/TypedKey.cs |  30 ++
 .../_common/Abstractions/YamlHelper.cs        |  48 ++
 .../Abstractions/creds/IBotCredentials.cs     |  79 +++
 .../Abstractions/creds/IBotCredsProvider.cs   |   8 +
 .../Abstractions/strings/CommandStrings.cs    |  35 ++
 .../Abstractions/strings/IBotStrings.cs       |  16 +
 .../strings/IBotStringsExtensions.cs          |  17 +
 .../strings/IBotStringsProvider.cs            |  28 +
 .../Abstractions/strings/IStringsSource.cs    |  16 +
 .../_common/Abstractions/strings/LocStr.cs    |  13 +
 src/EllieBot/_common/AddRemove.cs             |  10 +
 .../_common/Attributes/AliasesAttribute.cs    |  12 +
 .../_common/Attributes/CmdAttribute.cs        |  18 +
 .../_common/Attributes/DIIgnoreAttribute.cs   |  11 +
 .../Attributes/EllieOptionsAttribute.cs       |   7 +
 .../Attributes/NoPublicBotAttribute.cs        |  18 +
 .../Attributes/OnlyPublicBotAttribute.cs      |  21 +
 .../_common/Attributes/OwnerOnlyAttribute.cs  |  19 +
 .../_common/Attributes/RatelimitAttribute.cs  |  37 ++
 .../_common/Attributes/UserPermAttribute.cs   |  29 ++
 src/EllieBot/_common/BotCommandTypeReader.cs  |  30 ++
 src/EllieBot/_common/CleanupModuleBase.cs     |  25 +
 src/EllieBot/_common/CleverBotResponseStr.cs  |  10 +
 src/EllieBot/_common/CmdStrings.cs            |  17 +
 src/EllieBot/_common/CommandData.cs           |   9 +
 src/EllieBot/_common/CommandNameLoadHelper.cs |  40 ++
 src/EllieBot/_common/Configs/BotConfig.cs     | 196 +++++++
 src/EllieBot/_common/Configs/IConfigSeria.cs  |  18 +
 src/EllieBot/_common/Creds.cs                 | 285 ++++++++++
 src/EllieBot/_common/DbService.cs             |  16 +
 src/EllieBot/_common/DoAsUserMessage.cs       | 215 ++++++++
 src/EllieBot/_common/DownloadTracker.cs       |  38 ++
 src/EllieBot/_common/DryIocExtensions.cs      |  43 ++
 src/EllieBot/_common/EllieModule.cs           | 108 ++++
 src/EllieBot/_common/EllieTypeReader.cs       |  15 +
 src/EllieBot/_common/Helpers.cs               |  13 +
 src/EllieBot/_common/IBot.cs                  |  12 +
 src/EllieBot/_common/ICloneable.cs            |   8 +
 src/EllieBot/_common/ICurrencyProvider.cs     |  29 ++
 .../_common/IDiscordPermOverrideService.cs    |   7 +
 src/EllieBot/_common/IEllieCommandOptions.cs  |   7 +
 src/EllieBot/_common/ILogCommandService.cs    |  34 ++
 src/EllieBot/_common/IPermissionChecker.cs    |  37 ++
 src/EllieBot/_common/IPlaceholderProvider.cs  |   7 +
 src/EllieBot/_common/ImageUrls.cs             |  36 ++
 .../_common/Interaction/EllieInteraction.cs   | 174 +++++++
 .../Interaction/EllieInteractionService.cs    |  82 +++
 .../Interaction/IEllieInteractionService.cs   |  33 ++
 .../_common/Interaction/InteractionHelpers.cs |   7 +
 .../Models/EllieButtonInteraction.cs          |  23 +
 .../Models/EllieInteractionExtensions.cs      |  15 +
 .../Models/EllieSelectInteraction.cs          |  21 +
 .../JsonConverters/CultureInfoConverter.cs    |  14 +
 .../JsonConverters/NumberToStringConverter.cs |  30 ++
 .../_common/JsonConverters/Rgba32Converter.cs |  14 +
 src/EllieBot/_common/LbOpts.cs                |  14 +
 src/EllieBot/_common/Linq2DbExpressions.cs    |  17 +
 src/EllieBot/_common/LoginErrorHandler.cs     |  52 ++
 src/EllieBot/_common/MessageType.cs           |   8 +
 .../_common/ModuleBehaviors/IBehavior.cs      |   6 +
 .../_common/ModuleBehaviors/IExecNoCommand.cs |  19 +
 .../_common/ModuleBehaviors/IExecOnMessage.cs |  21 +
 .../ModuleBehaviors/IExecPostCommand.cs       |  22 +
 .../ModuleBehaviors/IExecPreCommand.cs        |  25 +
 .../ModuleBehaviors/IInputTransformer.cs      |  25 +
 .../_common/ModuleBehaviors/IReadyExecutor.cs |  13 +
 src/EllieBot/_common/OptionsParser.cs         |  23 +
 .../_common/Patronage/FeatureLimitKey.cs      |  14 +
 src/EllieBot/_common/Patronage/IPatronData.cs |  11 +
 .../_common/Patronage/IPatronageService.cs    |  42 ++
 .../_common/Patronage/ISubscriptionHandler.cs |  16 +
 src/EllieBot/_common/Patronage/Patron.cs      |  38 ++
 .../_common/Patronage/PatronConfigData.cs     |  17 +
 .../_common/Patronage/PatronExtensions.cs     |  30 ++
 src/EllieBot/_common/Patronage/PatronTier.cs  |  14 +
 src/EllieBot/_common/Patronage/QuotaLimit.cs  |  23 +
 src/EllieBot/_common/Patronage/QuotaPer.cs    |   9 +
 .../Patronage/SubscriptionChargeStatus.cs     |  10 +
 src/EllieBot/_common/Pokemon/PokemonNameId.cs |   8 +
 src/EllieBot/_common/Pokemon/SearchPokemon.cs |  41 ++
 .../_common/Pokemon/SearchPokemonAbility.cs   |  10 +
 .../Replacements/IReplacementPatternStore.cs  |  20 +
 .../Replacements/IReplacementService.cs       |   7 +
 .../Replacements/Impl/ReplacementContext.cs   |  69 +++
 .../Replacements/Impl/ReplacementInfo.cs      |  57 ++
 .../Impl/ReplacementPatternStore.cs           | 130 +++++
 .../Impl/ReplacementRegistrator.default.cs    | 126 +++++
 .../Replacements/Impl/ReplacementService.cs   | 137 +++++
 .../_common/Replacements/Impl/Replacer.cs     | 141 +++++
 ...RequireObjectPropertiesContractResolver.cs |  15 +
 .../_common/Sender/IMessageSenderService.cs   |  12 +
 .../_common/Sender/MessageSenderService.cs    |  56 ++
 .../ResponseBuilder.PaginationSender.cs       | 167 ++++++
 .../_common/Sender/ResponseBuilder.cs         | 490 ++++++++++++++++++
 .../Sender/ResponseBuilderExtensions.cs       |  28 +
 .../_common/Sender/ResponseMessageModel.cs    |  12 +
 .../_common/ServiceCollectionExtensions.cs    | 133 +++++
 .../_common/Services/CommandHandler.cs        | 432 +++++++++++++++
 .../Services/Currency/CurrencyService.cs      | 115 ++++
 .../Currency/CurrencyServiceExtensions.cs     |  48 ++
 .../Services/Currency/DefaultWallet.cs        | 108 ++++
 .../Services/Currency/GamblingTxTracker.cs    | 110 ++++
 .../_common/Services/IBehaviourHandler.cs     |  17 +
 .../_common/Services/ICommandHandler.cs       |  12 +
 src/EllieBot/_common/Services/ICoordinator.cs |  20 +
 .../_common/Services/ICustomBehavior.cs       |  13 +
 src/EllieBot/_common/Services/IEService.cs    |   9 +
 .../_common/Services/IGoogleApiService.cs     |  18 +
 .../_common/Services/ILocalDataCache.cs       |  13 +
 .../_common/Services/ILocalization.cs         |  19 +
 .../_common/Services/IRemindService.cs        |  15 +
 .../_common/Services/IStatsService.cs         |  70 +++
 .../_common/Services/ITimezoneService.cs      |   6 +
 .../_common/Services/Impl/BehaviorExecutor.cs | 302 +++++++++++
 .../_common/Services/Impl/BlacklistService.cs | 141 +++++
 .../Services/Impl/CommandsUtilityService.cs   | 184 +++++++
 .../Impl/DiscordPermOverrideService.cs        | 136 +++++
 .../_common/Services/Impl/FontProvider.cs     |  54 ++
 .../_common/Services/Impl/IImageCache.cs      |  13 +
 .../_common/Services/Impl/ImagesConfig.cs     |  31 ++
 .../Services/Impl/RedisImageExtensions.cs     |  11 +
 .../Services/Impl/SingleProcessCoordinator.cs |  58 +++
 .../Impl/StartingGuildsListService.cs         |  18 +
 .../_common/Services/Impl/StatsService.cs     | 206 ++++++++
 .../_common/Services/Impl/YtdlOperation.cs    |  77 +++
 .../Services/strings/impl/BotStrings.cs       | 102 ++++
 .../strings/impl/LocalFileStringsSource.cs    |  73 +++
 .../strings/impl/MemoryBotStringsProvider.cs  |  38 ++
 .../_common/Settings/BotConfigService.cs      |  79 +++
 .../_common/Settings/ConfigParsers.cs         |  50 ++
 .../_common/Settings/ConfigServiceBase.cs     | 201 +++++++
 .../_common/Settings/IConfigService.cs        |  46 ++
 .../_common/Settings/SettingParser.cs         |   8 +
 .../_common/SmartText/SmartEmbedText.cs       | 184 +++++++
 .../_common/SmartText/SmartEmbedTextArray.cs  |  34 ++
 .../_common/SmartText/SmartPlainText.cs       |  19 +
 src/EllieBot/_common/SmartText/SmartText.cs   |  92 ++++
 .../_common/SmartText/SmartTextEmbedAuthor.cs |  16 +
 .../_common/SmartText/SmartTextEmbedField.cs  |   9 +
 .../_common/SmartText/SmartTextEmbedFooter.cs |  14 +
 src/EllieBot/_common/TriviaQuestionModel.cs   |  11 +
 src/EllieBot/_common/TypeReaderResult.cs      |  30 ++
 .../_common/TypeReaders/CommandOrExprInfo.cs  |  23 +
 .../_common/TypeReaders/EmoteTypeReader.cs    |  13 +
 .../TypeReaders/GuildDateTimeTypeReader.cs    |  49 ++
 .../_common/TypeReaders/GuildTypeReader.cs    |  24 +
 .../TypeReaders/GuildUserTypeReader.cs        |  33 ++
 .../_common/TypeReaders/KwumTypeReader.cs     |  19 +
 .../TypeReaders/Models/PermissionAction.cs    |  27 +
 .../_common/TypeReaders/Models/StoopidTime.cs |  55 ++
 .../_common/TypeReaders/ModuleTypeReader.cs   |  52 ++
 .../TypeReaders/PermissionActionTypeReader.cs |  39 ++
 .../_common/TypeReaders/Rgba32TypeReader.cs   |  20 +
 .../TypeReaders/StoopidTimeTypeReader.cs      |  22 +
 src/EllieBot/_common/Yml/CommentAttribute.cs  |  11 +
 .../Yml/CommentGatheringTypeInspector.cs      |  65 +++
 .../_common/Yml/CommentsObjectDescriptor.cs   |  30 ++
 .../_common/Yml/CommentsObjectGraphVisitor.cs |  29 ++
 .../Yml/MultilineScalarFlowStyleEmitter.cs    |  35 ++
 src/EllieBot/_common/Yml/Rgba32Converter.cs   |  47 ++
 src/EllieBot/_common/Yml/UriConverter.cs      |  25 +
 src/EllieBot/_common/Yml/Yaml.cs              |  30 ++
 .../_Extensions/BotCredentialsExtensions.cs   |  10 +
 .../_Extensions/CommandContextExtensions.cs   |  40 ++
 .../_common/_Extensions/DbExtensions.cs       |  10 +
 .../_common/_Extensions/Extensions.cs         | 231 +++++++++
 .../_Extensions/ImagesharpExtensions.cs       |  97 ++++
 .../_Extensions/LinkedListExtensions.cs       |  18 +
 .../_common/_Extensions/NumberExtensions.cs   |  30 ++
 .../_Extensions/ReflectionExtensions.cs       |  23 +
 .../_common/_Extensions/Rgba32Extensions.cs   |  57 ++
 .../SocketMessageComponentExtensions.cs       |  33 ++
 .../_common/_Extensions/UserExtensions.cs     |  21 +
 194 files changed, 9929 insertions(+)
 create mode 100644 src/EllieBot/_common/Abstractions/AsyncLazy.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Cache/BotCacheExtensions.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Cache/IBotCache.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Collections/ConcurrentHashSet.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Collections/IndexedCollection.cs
 create mode 100644 src/EllieBot/_common/Abstractions/EllieRandom.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Extensions/ArrayExtensions.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Extensions/EnumerableExtensions.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Extensions/Extensions.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Extensions/HttpClientExtensions.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Extensions/OneOfExtensions.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Extensions/PipeExtensions.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Extensions/StringExtensions.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Helpers/LogSetup.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Helpers/StandardConversions.cs
 create mode 100644 src/EllieBot/_common/Abstractions/Kwum.cs
 create mode 100644 src/EllieBot/_common/Abstractions/PubSub/EventPubSub.cs
 create mode 100644 src/EllieBot/_common/Abstractions/PubSub/IPubSub.cs
 create mode 100644 src/EllieBot/_common/Abstractions/PubSub/ISeria.cs
 create mode 100644 src/EllieBot/_common/Abstractions/QueueRunner.cs
 create mode 100644 src/EllieBot/_common/Abstractions/TypedKey.cs
 create mode 100644 src/EllieBot/_common/Abstractions/YamlHelper.cs
 create mode 100644 src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs
 create mode 100644 src/EllieBot/_common/Abstractions/creds/IBotCredsProvider.cs
 create mode 100644 src/EllieBot/_common/Abstractions/strings/CommandStrings.cs
 create mode 100644 src/EllieBot/_common/Abstractions/strings/IBotStrings.cs
 create mode 100644 src/EllieBot/_common/Abstractions/strings/IBotStringsExtensions.cs
 create mode 100644 src/EllieBot/_common/Abstractions/strings/IBotStringsProvider.cs
 create mode 100644 src/EllieBot/_common/Abstractions/strings/IStringsSource.cs
 create mode 100644 src/EllieBot/_common/Abstractions/strings/LocStr.cs
 create mode 100644 src/EllieBot/_common/AddRemove.cs
 create mode 100644 src/EllieBot/_common/Attributes/AliasesAttribute.cs
 create mode 100644 src/EllieBot/_common/Attributes/CmdAttribute.cs
 create mode 100644 src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs
 create mode 100644 src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs
 create mode 100644 src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs
 create mode 100644 src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs
 create mode 100644 src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs
 create mode 100644 src/EllieBot/_common/Attributes/RatelimitAttribute.cs
 create mode 100644 src/EllieBot/_common/Attributes/UserPermAttribute.cs
 create mode 100644 src/EllieBot/_common/BotCommandTypeReader.cs
 create mode 100644 src/EllieBot/_common/CleanupModuleBase.cs
 create mode 100644 src/EllieBot/_common/CleverBotResponseStr.cs
 create mode 100644 src/EllieBot/_common/CmdStrings.cs
 create mode 100644 src/EllieBot/_common/CommandData.cs
 create mode 100644 src/EllieBot/_common/CommandNameLoadHelper.cs
 create mode 100644 src/EllieBot/_common/Configs/BotConfig.cs
 create mode 100644 src/EllieBot/_common/Configs/IConfigSeria.cs
 create mode 100644 src/EllieBot/_common/Creds.cs
 create mode 100644 src/EllieBot/_common/DbService.cs
 create mode 100644 src/EllieBot/_common/DoAsUserMessage.cs
 create mode 100644 src/EllieBot/_common/DownloadTracker.cs
 create mode 100644 src/EllieBot/_common/DryIocExtensions.cs
 create mode 100644 src/EllieBot/_common/EllieModule.cs
 create mode 100644 src/EllieBot/_common/EllieTypeReader.cs
 create mode 100644 src/EllieBot/_common/Helpers.cs
 create mode 100644 src/EllieBot/_common/IBot.cs
 create mode 100644 src/EllieBot/_common/ICloneable.cs
 create mode 100644 src/EllieBot/_common/ICurrencyProvider.cs
 create mode 100644 src/EllieBot/_common/IDiscordPermOverrideService.cs
 create mode 100644 src/EllieBot/_common/IEllieCommandOptions.cs
 create mode 100644 src/EllieBot/_common/ILogCommandService.cs
 create mode 100644 src/EllieBot/_common/IPermissionChecker.cs
 create mode 100644 src/EllieBot/_common/IPlaceholderProvider.cs
 create mode 100644 src/EllieBot/_common/ImageUrls.cs
 create mode 100644 src/EllieBot/_common/Interaction/EllieInteraction.cs
 create mode 100644 src/EllieBot/_common/Interaction/EllieInteractionService.cs
 create mode 100644 src/EllieBot/_common/Interaction/IEllieInteractionService.cs
 create mode 100644 src/EllieBot/_common/Interaction/InteractionHelpers.cs
 create mode 100644 src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs
 create mode 100644 src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs
 create mode 100644 src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs
 create mode 100644 src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs
 create mode 100644 src/EllieBot/_common/JsonConverters/NumberToStringConverter.cs
 create mode 100644 src/EllieBot/_common/JsonConverters/Rgba32Converter.cs
 create mode 100644 src/EllieBot/_common/LbOpts.cs
 create mode 100644 src/EllieBot/_common/Linq2DbExpressions.cs
 create mode 100644 src/EllieBot/_common/LoginErrorHandler.cs
 create mode 100644 src/EllieBot/_common/MessageType.cs
 create mode 100644 src/EllieBot/_common/ModuleBehaviors/IBehavior.cs
 create mode 100644 src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs
 create mode 100644 src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs
 create mode 100644 src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs
 create mode 100644 src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs
 create mode 100644 src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs
 create mode 100644 src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs
 create mode 100644 src/EllieBot/_common/OptionsParser.cs
 create mode 100644 src/EllieBot/_common/Patronage/FeatureLimitKey.cs
 create mode 100644 src/EllieBot/_common/Patronage/IPatronData.cs
 create mode 100644 src/EllieBot/_common/Patronage/IPatronageService.cs
 create mode 100644 src/EllieBot/_common/Patronage/ISubscriptionHandler.cs
 create mode 100644 src/EllieBot/_common/Patronage/Patron.cs
 create mode 100644 src/EllieBot/_common/Patronage/PatronConfigData.cs
 create mode 100644 src/EllieBot/_common/Patronage/PatronExtensions.cs
 create mode 100644 src/EllieBot/_common/Patronage/PatronTier.cs
 create mode 100644 src/EllieBot/_common/Patronage/QuotaLimit.cs
 create mode 100644 src/EllieBot/_common/Patronage/QuotaPer.cs
 create mode 100644 src/EllieBot/_common/Patronage/SubscriptionChargeStatus.cs
 create mode 100644 src/EllieBot/_common/Pokemon/PokemonNameId.cs
 create mode 100644 src/EllieBot/_common/Pokemon/SearchPokemon.cs
 create mode 100644 src/EllieBot/_common/Pokemon/SearchPokemonAbility.cs
 create mode 100644 src/EllieBot/_common/Replacements/IReplacementPatternStore.cs
 create mode 100644 src/EllieBot/_common/Replacements/IReplacementService.cs
 create mode 100644 src/EllieBot/_common/Replacements/Impl/ReplacementContext.cs
 create mode 100644 src/EllieBot/_common/Replacements/Impl/ReplacementInfo.cs
 create mode 100644 src/EllieBot/_common/Replacements/Impl/ReplacementPatternStore.cs
 create mode 100644 src/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs
 create mode 100644 src/EllieBot/_common/Replacements/Impl/ReplacementService.cs
 create mode 100644 src/EllieBot/_common/Replacements/Impl/Replacer.cs
 create mode 100644 src/EllieBot/_common/RequireObjectPropertiesContractResolver.cs
 create mode 100644 src/EllieBot/_common/Sender/IMessageSenderService.cs
 create mode 100644 src/EllieBot/_common/Sender/MessageSenderService.cs
 create mode 100644 src/EllieBot/_common/Sender/ResponseBuilder.PaginationSender.cs
 create mode 100644 src/EllieBot/_common/Sender/ResponseBuilder.cs
 create mode 100644 src/EllieBot/_common/Sender/ResponseBuilderExtensions.cs
 create mode 100644 src/EllieBot/_common/Sender/ResponseMessageModel.cs
 create mode 100644 src/EllieBot/_common/ServiceCollectionExtensions.cs
 create mode 100644 src/EllieBot/_common/Services/CommandHandler.cs
 create mode 100644 src/EllieBot/_common/Services/Currency/CurrencyService.cs
 create mode 100644 src/EllieBot/_common/Services/Currency/CurrencyServiceExtensions.cs
 create mode 100644 src/EllieBot/_common/Services/Currency/DefaultWallet.cs
 create mode 100644 src/EllieBot/_common/Services/Currency/GamblingTxTracker.cs
 create mode 100644 src/EllieBot/_common/Services/IBehaviourHandler.cs
 create mode 100644 src/EllieBot/_common/Services/ICommandHandler.cs
 create mode 100644 src/EllieBot/_common/Services/ICoordinator.cs
 create mode 100644 src/EllieBot/_common/Services/ICustomBehavior.cs
 create mode 100644 src/EllieBot/_common/Services/IEService.cs
 create mode 100644 src/EllieBot/_common/Services/IGoogleApiService.cs
 create mode 100644 src/EllieBot/_common/Services/ILocalDataCache.cs
 create mode 100644 src/EllieBot/_common/Services/ILocalization.cs
 create mode 100644 src/EllieBot/_common/Services/IRemindService.cs
 create mode 100644 src/EllieBot/_common/Services/IStatsService.cs
 create mode 100644 src/EllieBot/_common/Services/ITimezoneService.cs
 create mode 100644 src/EllieBot/_common/Services/Impl/BehaviorExecutor.cs
 create mode 100644 src/EllieBot/_common/Services/Impl/BlacklistService.cs
 create mode 100644 src/EllieBot/_common/Services/Impl/CommandsUtilityService.cs
 create mode 100644 src/EllieBot/_common/Services/Impl/DiscordPermOverrideService.cs
 create mode 100644 src/EllieBot/_common/Services/Impl/FontProvider.cs
 create mode 100644 src/EllieBot/_common/Services/Impl/IImageCache.cs
 create mode 100644 src/EllieBot/_common/Services/Impl/ImagesConfig.cs
 create mode 100644 src/EllieBot/_common/Services/Impl/RedisImageExtensions.cs
 create mode 100644 src/EllieBot/_common/Services/Impl/SingleProcessCoordinator.cs
 create mode 100644 src/EllieBot/_common/Services/Impl/StartingGuildsListService.cs
 create mode 100644 src/EllieBot/_common/Services/Impl/StatsService.cs
 create mode 100644 src/EllieBot/_common/Services/Impl/YtdlOperation.cs
 create mode 100644 src/EllieBot/_common/Services/strings/impl/BotStrings.cs
 create mode 100644 src/EllieBot/_common/Services/strings/impl/LocalFileStringsSource.cs
 create mode 100644 src/EllieBot/_common/Services/strings/impl/MemoryBotStringsProvider.cs
 create mode 100644 src/EllieBot/_common/Settings/BotConfigService.cs
 create mode 100644 src/EllieBot/_common/Settings/ConfigParsers.cs
 create mode 100644 src/EllieBot/_common/Settings/ConfigServiceBase.cs
 create mode 100644 src/EllieBot/_common/Settings/IConfigService.cs
 create mode 100644 src/EllieBot/_common/Settings/SettingParser.cs
 create mode 100644 src/EllieBot/_common/SmartText/SmartEmbedText.cs
 create mode 100644 src/EllieBot/_common/SmartText/SmartEmbedTextArray.cs
 create mode 100644 src/EllieBot/_common/SmartText/SmartPlainText.cs
 create mode 100644 src/EllieBot/_common/SmartText/SmartText.cs
 create mode 100644 src/EllieBot/_common/SmartText/SmartTextEmbedAuthor.cs
 create mode 100644 src/EllieBot/_common/SmartText/SmartTextEmbedField.cs
 create mode 100644 src/EllieBot/_common/SmartText/SmartTextEmbedFooter.cs
 create mode 100644 src/EllieBot/_common/TriviaQuestionModel.cs
 create mode 100644 src/EllieBot/_common/TypeReaderResult.cs
 create mode 100644 src/EllieBot/_common/TypeReaders/CommandOrExprInfo.cs
 create mode 100644 src/EllieBot/_common/TypeReaders/EmoteTypeReader.cs
 create mode 100644 src/EllieBot/_common/TypeReaders/GuildDateTimeTypeReader.cs
 create mode 100644 src/EllieBot/_common/TypeReaders/GuildTypeReader.cs
 create mode 100644 src/EllieBot/_common/TypeReaders/GuildUserTypeReader.cs
 create mode 100644 src/EllieBot/_common/TypeReaders/KwumTypeReader.cs
 create mode 100644 src/EllieBot/_common/TypeReaders/Models/PermissionAction.cs
 create mode 100644 src/EllieBot/_common/TypeReaders/Models/StoopidTime.cs
 create mode 100644 src/EllieBot/_common/TypeReaders/ModuleTypeReader.cs
 create mode 100644 src/EllieBot/_common/TypeReaders/PermissionActionTypeReader.cs
 create mode 100644 src/EllieBot/_common/TypeReaders/Rgba32TypeReader.cs
 create mode 100644 src/EllieBot/_common/TypeReaders/StoopidTimeTypeReader.cs
 create mode 100644 src/EllieBot/_common/Yml/CommentAttribute.cs
 create mode 100644 src/EllieBot/_common/Yml/CommentGatheringTypeInspector.cs
 create mode 100644 src/EllieBot/_common/Yml/CommentsObjectDescriptor.cs
 create mode 100644 src/EllieBot/_common/Yml/CommentsObjectGraphVisitor.cs
 create mode 100644 src/EllieBot/_common/Yml/MultilineScalarFlowStyleEmitter.cs
 create mode 100644 src/EllieBot/_common/Yml/Rgba32Converter.cs
 create mode 100644 src/EllieBot/_common/Yml/UriConverter.cs
 create mode 100644 src/EllieBot/_common/Yml/Yaml.cs
 create mode 100644 src/EllieBot/_common/_Extensions/BotCredentialsExtensions.cs
 create mode 100644 src/EllieBot/_common/_Extensions/CommandContextExtensions.cs
 create mode 100644 src/EllieBot/_common/_Extensions/DbExtensions.cs
 create mode 100644 src/EllieBot/_common/_Extensions/Extensions.cs
 create mode 100644 src/EllieBot/_common/_Extensions/ImagesharpExtensions.cs
 create mode 100644 src/EllieBot/_common/_Extensions/LinkedListExtensions.cs
 create mode 100644 src/EllieBot/_common/_Extensions/NumberExtensions.cs
 create mode 100644 src/EllieBot/_common/_Extensions/ReflectionExtensions.cs
 create mode 100644 src/EllieBot/_common/_Extensions/Rgba32Extensions.cs
 create mode 100644 src/EllieBot/_common/_Extensions/SocketMessageComponentExtensions.cs
 create mode 100644 src/EllieBot/_common/_Extensions/UserExtensions.cs

diff --git a/src/EllieBot/_common/Abstractions/AsyncLazy.cs b/src/EllieBot/_common/Abstractions/AsyncLazy.cs
new file mode 100644
index 0000000..6c86693
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/AsyncLazy.cs
@@ -0,0 +1,19 @@
+using System.Runtime.CompilerServices;
+
+namespace Ellie.Common;
+
+public class AsyncLazy<T> : Lazy<Task<T>>
+{
+    public AsyncLazy(Func<T> valueFactory)
+        : base(() => Task.Run(valueFactory))
+    {
+    }
+
+    public AsyncLazy(Func<Task<T>> taskFactory)
+        : base(() => Task.Run(taskFactory))
+    {
+    }
+
+    public TaskAwaiter<T> GetAwaiter()
+        => Value.GetAwaiter();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Cache/BotCacheExtensions.cs b/src/EllieBot/_common/Abstractions/Cache/BotCacheExtensions.cs
new file mode 100644
index 0000000..2c6ea67
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Cache/BotCacheExtensions.cs
@@ -0,0 +1,46 @@
+using OneOf;
+using OneOf.Types;
+
+namespace Ellie.Common;
+
+public static class BotCacheExtensions
+{
+    public static async ValueTask<T?> GetOrDefaultAsync<T>(this IBotCache cache, TypedKey<T> key)
+    {
+        var result = await cache.GetAsync(key);
+        if (result.TryGetValue(out var val))
+            return val;
+
+        return default;
+    }
+    
+    private static TypedKey<byte[]> GetImgKey(Uri uri)
+        => new($"image:{uri}");
+
+    public static ValueTask SetImageDataAsync(this IBotCache c, string key, byte[] data)
+        => c.SetImageDataAsync(new Uri(key), data);
+    public static async ValueTask SetImageDataAsync(this IBotCache c, Uri key, byte[] data)
+        => await c.AddAsync(GetImgKey(key), data, expiry: TimeSpan.FromHours(48));
+
+    public static async ValueTask<OneOf<byte[], None>> GetImageDataAsync(this IBotCache c, Uri key)
+        => await c.GetAsync(GetImgKey(key));
+
+    public static async Task<TimeSpan?> GetRatelimitAsync(
+        this IBotCache c,
+        TypedKey<long> key,
+        TimeSpan length)
+    {
+        var now = DateTime.UtcNow;
+        var nowB = now.ToBinary();
+
+        var cachedValue = await c.GetOrAddAsync(key,
+            () => Task.FromResult(now.ToBinary()),
+            expiry: length);
+
+        if (cachedValue == nowB)
+            return null;
+
+        var diff = now - DateTime.FromBinary(cachedValue);
+        return length - diff;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Cache/IBotCache.cs b/src/EllieBot/_common/Abstractions/Cache/IBotCache.cs
new file mode 100644
index 0000000..15f9e3f
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Cache/IBotCache.cs
@@ -0,0 +1,47 @@
+using OneOf;
+using OneOf.Types;
+
+namespace Ellie.Common;
+
+public interface IBotCache
+{
+    /// <summary>
+    /// Adds an item to the cache
+    /// </summary>
+    /// <param name="key">Key to add</param>
+    /// <param name="value">Value to add to the cache</param>
+    /// <param name="expiry">Optional expiry</param>
+    /// <param name="overwrite">Whether old value should be overwritten</param>
+    /// <typeparam name="T">Type of the value</typeparam>
+    /// <returns>Returns whether add was sucessful. Always true unless ovewrite = false</returns>
+    ValueTask<bool> AddAsync<T>(TypedKey<T> key, T value, TimeSpan? expiry = null, bool overwrite = true);
+    
+    /// <summary>
+    /// Get an element from the cache
+    /// </summary>
+    /// <param name="key">Key</param>
+    /// <typeparam name="T">Type of the value</typeparam>
+    /// <returns>Either a value or <see cref="None"/></returns>
+    ValueTask<OneOf<T, None>> GetAsync<T>(TypedKey<T> key);
+    
+    /// <summary>
+    /// Remove a key from the cache
+    /// </summary>
+    /// <param name="key">Key to remove</param>
+    /// <typeparam name="T">Type of the value</typeparam>
+    /// <returns>Whether there was item</returns>
+    ValueTask<bool> RemoveAsync<T>(TypedKey<T> key);
+
+    /// <summary>
+    /// Get the key if it exists or add a new one
+    /// </summary>
+    /// <param name="key">Key to get and potentially add</param>
+    /// <param name="createFactory">Value creation factory</param>
+    /// <param name="expiry">Optional expiry</param>
+    /// <typeparam name="T">Type of the value</typeparam>
+    /// <returns>The retrieved or newly added value</returns>
+    ValueTask<T?> GetOrAddAsync<T>(
+        TypedKey<T> key,
+        Func<Task<T?>> createFactory,
+        TimeSpan? expiry = null);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs b/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs
new file mode 100644
index 0000000..2368ac2
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Cache/MemoryBotCache.cs
@@ -0,0 +1,71 @@
+using Microsoft.Extensions.Caching.Memory;
+using OneOf;
+using OneOf.Types;
+
+// ReSharper disable InconsistentlySynchronizedField
+
+namespace Ellie.Common;
+
+public sealed class MemoryBotCache : IBotCache
+{
+    // needed for overwrites and Delete return value
+    private readonly object _cacheLock = new object();
+    private readonly MemoryCache _cache;
+
+    public MemoryBotCache()
+    {
+        _cache = new MemoryCache(new MemoryCacheOptions());
+    }
+
+    public ValueTask<bool> AddAsync<T>(TypedKey<T> key, T value, TimeSpan? expiry = null, bool overwrite = true)
+    {
+        if (overwrite)
+        {
+            using var item = _cache.CreateEntry(key.Key);
+            item.Value = value;
+            item.AbsoluteExpirationRelativeToNow = expiry;
+            return new(true);
+        }
+        
+        lock (_cacheLock)
+        {
+            if (_cache.TryGetValue(key.Key, out var old) && old is not null)
+                return new(false);
+            
+            using var item = _cache.CreateEntry(key.Key);
+            item.Value = value;
+            item.AbsoluteExpirationRelativeToNow = expiry;
+            return new(true);
+        }
+    }
+
+    public async ValueTask<T?> GetOrAddAsync<T>(
+        TypedKey<T> key,
+        Func<Task<T?>> createFactory,
+        TimeSpan? expiry = null)
+        => await _cache.GetOrCreateAsync(key.Key,
+            async ce =>
+            {
+                ce.AbsoluteExpirationRelativeToNow = expiry;
+                var val = await createFactory();
+                return val;
+            });
+
+    public ValueTask<OneOf<T, None>> GetAsync<T>(TypedKey<T> key)
+    {
+        if (!_cache.TryGetValue(key.Key, out var val) || val is null)
+            return new(new None());
+
+        return new((T)val);
+    }
+
+    public ValueTask<bool> RemoveAsync<T>(TypedKey<T> key)
+    {
+        lock (_cacheLock)
+        {
+            var toReturn = _cache.TryGetValue(key.Key, out var old ) && old is not null;
+            _cache.Remove(key.Key);
+            return new(toReturn);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Collections/ConcurrentHashSet.cs b/src/EllieBot/_common/Abstractions/Collections/ConcurrentHashSet.cs
new file mode 100644
index 0000000..7d5bbc1
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Collections/ConcurrentHashSet.cs
@@ -0,0 +1,84 @@
+using System.Diagnostics;
+
+namespace System.Collections.Generic;
+
+[DebuggerDisplay("{_backingStore.Count}")]
+public sealed class ConcurrentHashSet<T> : IReadOnlyCollection<T>, ICollection<T> where T : notnull
+{
+    private readonly ConcurrentDictionary<T, bool> _backingStore;
+    
+    public ConcurrentHashSet()
+        => _backingStore = new();
+
+    public ConcurrentHashSet(IEnumerable<T> values, IEqualityComparer<T>? comparer = null)
+        => _backingStore = new(values.Select(x => new KeyValuePair<T, bool>(x, true)), comparer);
+
+    public IEnumerator<T> GetEnumerator()
+        => _backingStore.Keys.GetEnumerator();
+
+    IEnumerator IEnumerable.GetEnumerator()
+        => GetEnumerator();
+
+    /// <summary>
+    ///     Adds the specified item to the <see cref="ConcurrentHashSet{T}" />.
+    /// </summary>
+    /// <param name="item">The item to add.</param>
+    /// <returns>
+    ///     true if the items was added to the <see cref="ConcurrentHashSet{T}" />
+    ///     successfully; false if it already exists.
+    /// </returns>
+    /// <exception cref="T:System.OverflowException">
+    ///     The <see cref="ConcurrentHashSet{T}" />
+    ///     contains too many items.
+    /// </exception>
+    public bool Add(T item)
+        => _backingStore.TryAdd(item, true);
+
+    void ICollection<T>.Add(T item)
+        => Add(item);
+
+    public void Clear()
+        => _backingStore.Clear();
+
+    public bool Contains(T item)
+        => _backingStore.ContainsKey(item);
+
+    public void CopyTo(T[] array, int arrayIndex)
+    {
+        ArgumentNullException.ThrowIfNull(array);
+        ArgumentOutOfRangeException.ThrowIfNegative(arrayIndex);
+        ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(arrayIndex, array.Length);
+
+        CopyToInternal(array, arrayIndex);
+    }
+
+    private void CopyToInternal(T[] array, int arrayIndex)
+    {
+        var len = array.Length;
+        foreach (var (k, _) in _backingStore)
+        {
+            if (arrayIndex >= len)
+                throw new IndexOutOfRangeException(nameof(arrayIndex));
+            
+            array[arrayIndex++] = k;
+        }
+    }
+
+    bool ICollection<T>.Remove(T item)
+        => TryRemove(item);
+
+    public bool TryRemove(T item)
+        => _backingStore.TryRemove(item, out _);
+
+    public void RemoveWhere(Func<T, bool> predicate)
+    {
+        foreach (var elem in this.Where(predicate))
+            TryRemove(elem);
+    }
+
+    public int Count
+        => _backingStore.Count;
+
+    public bool IsReadOnly
+        => false;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Collections/IndexedCollection.cs b/src/EllieBot/_common/Abstractions/Collections/IndexedCollection.cs
new file mode 100644
index 0000000..dce86a0
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Collections/IndexedCollection.cs
@@ -0,0 +1,148 @@
+using System.Collections;
+
+namespace Ellie.Common;
+
+public interface IIndexed
+{
+    int Index { get; set; }
+}
+
+public class IndexedCollection<T> : IList<T>
+    where T : class, IIndexed
+{
+    public List<T> Source { get; }
+
+    public int Count
+        => Source.Count;
+
+    public bool IsReadOnly
+        => false;
+
+    public virtual T this[int index]
+    {
+        get => Source[index];
+        set
+        {
+            lock (_locker)
+            {
+                value.Index = index;
+                Source[index] = value;
+            }
+        }
+    }
+
+    private readonly object _locker = new();
+
+    public IndexedCollection()
+        => Source = new();
+
+    public IndexedCollection(IEnumerable<T> source)
+    {
+        lock (_locker)
+        {
+            Source = source.OrderBy(x => x.Index).ToList();
+            UpdateIndexes();
+        }
+    }
+
+    public int IndexOf(T item)
+        => item?.Index ?? -1;
+
+    public IEnumerator<T> GetEnumerator()
+        => Source.GetEnumerator();
+
+    IEnumerator IEnumerable.GetEnumerator()
+        => Source.GetEnumerator();
+
+    public void Add(T item)
+    {
+        ArgumentNullException.ThrowIfNull(item);
+
+        lock (_locker)
+        {
+            item.Index = Source.Count;
+            Source.Add(item);
+        }
+    }
+
+    public virtual void Clear()
+    {
+        lock (_locker)
+        {
+            Source.Clear();
+        }
+    }
+
+    public bool Contains(T item)
+    {
+        lock (_locker)
+        {
+            return Source.Contains(item);
+        }
+    }
+
+    public void CopyTo(T[] array, int arrayIndex)
+    {
+        lock (_locker)
+        {
+            Source.CopyTo(array, arrayIndex);
+        }
+    }
+
+    public virtual bool Remove(T item)
+    {
+        lock (_locker)
+        {
+            if (Source.Remove(item))
+            {
+                for (var i = 0; i < Source.Count; i++)
+                {
+                    if (Source[i].Index != i)
+                        Source[i].Index = i;
+                }
+
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    public virtual void Insert(int index, T item)
+    {
+        lock (_locker)
+        {
+            Source.Insert(index, item);
+            for (var i = index; i < Source.Count; i++)
+                Source[i].Index = i;
+        }
+    }
+
+    public virtual void RemoveAt(int index)
+    {
+        lock (_locker)
+        {
+            Source.RemoveAt(index);
+            for (var i = index; i < Source.Count; i++)
+                Source[i].Index = i;
+        }
+    }
+
+    public void UpdateIndexes()
+    {
+        lock (_locker)
+        {
+            for (var i = 0; i < Source.Count; i++)
+            {
+                if (Source[i].Index != i)
+                    Source[i].Index = i;
+            }
+        }
+    }
+
+    public static implicit operator List<T>(IndexedCollection<T> x)
+        => x.Source;
+
+    public List<T> ToList()
+        => Source.ToList();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/EllieRandom.cs b/src/EllieBot/_common/Abstractions/EllieRandom.cs
new file mode 100644
index 0000000..df2d073
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/EllieRandom.cs
@@ -0,0 +1,66 @@
+#nullable disable
+using System.Security.Cryptography;
+
+namespace Ellie.Common;
+
+public sealed class EllieRandom : Random
+{
+    private readonly RandomNumberGenerator _rng;
+
+    public EllieRandom()
+        => _rng = RandomNumberGenerator.Create();
+
+    public override int Next()
+    {
+        var bytes = new byte[sizeof(int)];
+        _rng.GetBytes(bytes);
+        return Math.Abs(BitConverter.ToInt32(bytes, 0));
+    }
+    
+    /// <summary>
+    /// Generates a random integer between 0 (inclusive) and
+    /// a specified exclusive upper bound using a cryptographically strong random number generator.
+    /// </summary>
+    /// <param name="maxValue">Exclusive max value</param>
+    /// <returns>A random number</returns>
+    public override int Next(int maxValue)
+        => RandomNumberGenerator.GetInt32(maxValue);
+
+    /// <summary>
+    /// Generates a random integer between a specified inclusive lower bound and a
+    /// specified exclusive upper bound using a cryptographically strong random number generator.
+    /// </summary>
+    /// <param name="minValue">Inclusive min value</param>
+    /// <param name="maxValue">Exclusive max value</param>
+    /// <returns>A random number</returns>
+    public override int Next(int minValue, int maxValue)
+        => RandomNumberGenerator.GetInt32(minValue, maxValue);
+
+    public long NextLong(long minValue, long maxValue)
+    {
+        ArgumentOutOfRangeException.ThrowIfGreaterThan(minValue, maxValue);
+        if (minValue == maxValue)
+            return minValue;
+        var bytes = new byte[sizeof(long)];
+        _rng.GetBytes(bytes);
+        var sign = Math.Sign(BitConverter.ToInt64(bytes, 0));
+        return (sign * BitConverter.ToInt64(bytes, 0) % (maxValue - minValue)) + minValue;
+    }
+
+    public override void NextBytes(byte[] buffer)
+        => _rng.GetBytes(buffer);
+
+    protected override double Sample()
+    {
+        var bytes = new byte[sizeof(double)];
+        _rng.GetBytes(bytes);
+        return Math.Abs((BitConverter.ToDouble(bytes, 0) / double.MaxValue) + 1);
+    }
+
+    public override double NextDouble()
+    {
+        var bytes = new byte[sizeof(double)];
+        _rng.GetBytes(bytes);
+        return BitConverter.ToDouble(bytes, 0);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Extensions/ArrayExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/ArrayExtensions.cs
new file mode 100644
index 0000000..f3e8ae3
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Extensions/ArrayExtensions.cs
@@ -0,0 +1,62 @@
+using System.Security.Cryptography;
+
+namespace Ellie.Common;
+
+// made for expressions because they almost never get added
+// and they get looped through constantly
+public static class ArrayExtensions
+{
+    /// <summary>
+    ///     Create a new array from the old array + new element at the end
+    /// </summary>
+    /// <param name="input">Input array</param>
+    /// <param name="added">Item to add to the end of the output array</param>
+    /// <typeparam name="T">Type of the array</typeparam>
+    /// <returns>A new array with the new element at the end</returns>
+    public static T[] With<T>(this T[] input, T added)
+    {
+        var newExprs = new T[input.Length + 1];
+        Array.Copy(input, 0, newExprs, 0, input.Length);
+        newExprs[input.Length] = added;
+        return newExprs;
+    }
+
+    /// <summary>
+    ///     Creates a new array by applying the specified function to every element in the input array
+    /// </summary>
+    /// <param name="arr">Array to modify</param>
+    /// <param name="f">Function to apply</param>
+    /// <typeparam name="TIn">Orignal type of the elements in the array</typeparam>
+    /// <typeparam name="TOut">Output type of the elements of the array</typeparam>
+    /// <returns>New array with updated elements</returns>
+    public static TOut[] Map<TIn, TOut>(this TIn[] arr, Func<TIn, TOut> f)
+        => Array.ConvertAll(arr, x => f(x));
+
+    /// <summary>
+    ///     Creates a new array by applying the specified function to every element in the input array
+    /// </summary>
+    /// <param name="col">Array to modify</param>
+    /// <param name="f">Function to apply</param>
+    /// <typeparam name="TIn">Orignal type of the elements in the array</typeparam>
+    /// <typeparam name="TOut">Output type of the elements of the array</typeparam>
+    /// <returns>New array with updated elements</returns>
+    public static TOut[] Map<TIn, TOut>(this IReadOnlyCollection<TIn> col, Func<TIn, TOut> f)
+    {
+        var toReturn = new TOut[col.Count];
+        
+        var i = 0;
+        foreach (var item in col)
+            toReturn[i++] = f(item);
+
+        return toReturn;
+    }
+
+    public static T? RandomOrDefault<T>(this T[] data)
+    {
+        if (data.Length == 0)
+            return default;
+
+        var index = RandomNumberGenerator.GetInt32(0, data.Length);
+        return data[index];
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Extensions/EnumerableExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/EnumerableExtensions.cs
new file mode 100644
index 0000000..1ebec24
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Extensions/EnumerableExtensions.cs
@@ -0,0 +1,113 @@
+using System.Security.Cryptography;
+
+namespace Ellie.Common;
+
+public static class EnumerableExtensions
+{
+    /// <summary>
+    ///     Concatenates the members of a collection, using the specified separator between each member.
+    /// </summary>
+    /// <param name="data">Collection to join</param>
+    /// <param name="separator">
+    ///     The character to use as a separator. separator is included in the returned string only if
+    ///     values has more than one element.
+    /// </param>
+    /// <param name="func">Optional transformation to apply to each element before concatenation.</param>
+    /// <typeparam name="T">The type of the members of values.</typeparam>
+    /// <returns>
+    ///     A string that consists of the members of values delimited by the separator character. -or- Empty if values has
+    ///     no elements.
+    /// </returns>
+    public static string Join<T>(this IEnumerable<T> data, char separator, Func<T, string>? func = null)
+        => string.Join(separator, data.Select(func ?? (x => x?.ToString() ?? string.Empty)));
+
+    /// <summary>
+    ///     Concatenates the members of a collection, using the specified separator between each member.
+    /// </summary>
+    /// <param name="data">Collection to join</param>
+    /// <param name="separator">
+    ///     The string to use as a separator.separator is included in the returned string only if values
+    ///     has more than one element.
+    /// </param>
+    /// <param name="func">Optional transformation to apply to each element before concatenation.</param>
+    /// <typeparam name="T">The type of the members of values.</typeparam>
+    /// <returns>
+    ///     A string that consists of the members of values delimited by the separator character. -or- Empty if values has
+    ///     no elements.
+    /// </returns>
+    public static string Join<T>(this IEnumerable<T> data, string separator, Func<T, string>? func = null)
+        => string.Join(separator, data.Select(func ?? (x => x?.ToString() ?? string.Empty)));
+    
+    /// <summary>
+    ///     Randomize element order by performing the Fisher-Yates shuffle
+    /// </summary>
+    /// <typeparam name="T">Item type</typeparam>
+    /// <param name="items">Items to shuffle</param>
+    public static IReadOnlyList<T> Shuffle<T>(this IEnumerable<T> items)
+    {
+        var list = items.ToArray();
+        var n = list.Length;
+        while (n-- > 1)
+        {
+            var k = RandomNumberGenerator.GetInt32(n);
+            (list[k], list[n]) = (list[n], list[k]);
+        }
+
+        return list;
+    }
+
+    /// <summary>
+    ///     Initializes a new instance of the <see cref="ConcurrentDictionary{TKey,TValue}" /> class
+    ///     that contains elements copied from the specified <see cref="IEnumerable{T}" />
+    ///     has the default concurrency level, has the default initial capacity,
+    ///     and uses the default comparer for the key type.
+    /// </summary>
+    /// <param name="dict">
+    ///     The <see cref="IEnumerable{T}" /> whose elements are copied to the new
+    ///     <see cref="ConcurrentDictionary{TKey,TValue}" />.
+    /// </param>
+    /// <returns>A new instance of the <see cref="ConcurrentDictionary{TKey,TValue}" /> class</returns>
+    public static ConcurrentDictionary<TKey, TValue> ToConcurrent<TKey, TValue>(
+        this IEnumerable<KeyValuePair<TKey, TValue>> dict)
+        where TKey : notnull
+        => new(dict);
+    
+    /// <summary>
+    ///     Initializes a new instance of the <see cref="ConcurrentDictionary{TKey,TValue}" /> class
+    ///     that contains elements copied from the specified <see cref="IEnumerable{T}" />
+    ///     has the default concurrency level, has the default initial capacity,
+    ///     and uses the default comparer for the key type.
+    /// </summary>
+    /// <param name="dict">
+    ///     The <see cref="IEnumerable{T}" /> whose elements are copied to the new
+    ///     <see cref="ConcurrentDictionary{TKey,TValue}" />.
+    /// </param>
+    /// <returns>A new instance of the <see cref="ConcurrentDictionary{TKey,TValue}" /> class</returns>
+    public static ConcurrentHashSet<TValue> ToConcurrentSet<TValue>(
+        this IReadOnlyCollection<TValue> dict)
+        where TValue : notnull
+        => new(dict);
+
+    public static IndexedCollection<T> ToIndexed<T>(this IEnumerable<T> enumerable)
+        where T : class, IIndexed
+        => new(enumerable);
+
+    /// <summary>
+    ///     Creates a task that will complete when all of the <see cref="Task{TResult}" /> objects in an enumerable
+    ///     collection have completed
+    /// </summary>
+    /// <param name="tasks">The tasks to wait on for completion.</param>
+    /// <typeparam name="TResult">The type of the completed task.</typeparam>
+    /// <returns>A task that represents the completion of all of the supplied tasks.</returns>
+    public static Task<TResult[]> WhenAll<TResult>(this IEnumerable<Task<TResult>> tasks)
+        => Task.WhenAll(tasks);
+
+    /// <summary>
+    ///     Creates a task that will complete when all of the <see cref="Task" /> objects in an enumerable
+    ///     collection have completed
+    /// </summary>
+    /// <param name="tasks">The tasks to wait on for completion.</param>
+    /// <returns>A task that represents the completion of all of the supplied tasks.</returns>
+    public static Task WhenAll(this IEnumerable<Task> tasks)
+        => Task.WhenAll(tasks);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Extensions/Extensions.cs b/src/EllieBot/_common/Abstractions/Extensions/Extensions.cs
new file mode 100644
index 0000000..8abf5d6
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Extensions/Extensions.cs
@@ -0,0 +1,7 @@
+namespace Ellie.Common;
+
+public static class Extensions
+{
+    public static long ToTimestamp(this in DateTime value)
+        => (value.Ticks - 621355968000000000) / 10000000;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Extensions/HttpClientExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/HttpClientExtensions.cs
new file mode 100644
index 0000000..38e6396
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Extensions/HttpClientExtensions.cs
@@ -0,0 +1,35 @@
+using System.Net.Http.Headers;
+
+namespace Ellie.Common;
+
+public static class HttpClientExtensions
+{
+    public static HttpClient AddFakeHeaders(this HttpClient http)
+    {
+        AddFakeHeaders(http.DefaultRequestHeaders);
+        return http;
+    }
+
+    public static void AddFakeHeaders(this HttpHeaders dict)
+    {
+        dict.Clear();
+        dict.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
+        dict.Add("User-Agent",
+            "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/535.1 (KHTML, like Gecko) Chrome/14.0.835.202 Safari/535.1");
+    }
+
+    public static bool IsImage(this HttpResponseMessage msg)
+        => IsImage(msg, out _);
+
+    public static bool IsImage(this HttpResponseMessage msg, out string? mimeType)
+    {
+        mimeType = msg.Content.Headers.ContentType?.MediaType;
+        if (mimeType is "image/png" or "image/jpeg" or "image/gif")
+            return true;
+
+        return false;
+    }
+
+    public static long GetContentLength(this HttpResponseMessage msg)
+        => msg.Content.Headers.ContentLength ?? long.MaxValue;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Extensions/OneOfExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/OneOfExtensions.cs
new file mode 100644
index 0000000..f9c4cde
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Extensions/OneOfExtensions.cs
@@ -0,0 +1,10 @@
+using OneOf.Types;
+using OneOf;
+
+namespace Ellie.Common;
+
+public static class OneOfExtensions
+{
+    public static bool TryGetValue<T>(this OneOf<T, None> oneOf, out T value)
+        => oneOf.TryPickT0(out value, out _);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Extensions/PipeExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/PipeExtensions.cs
new file mode 100644
index 0000000..65b5bb2
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Extensions/PipeExtensions.cs
@@ -0,0 +1,22 @@
+namespace Ellie.Common;
+
+public delegate TOut PipeFunc<TIn, out TOut>(in TIn a);
+public delegate TOut PipeFunc<TIn1, TIn2, out TOut>(in TIn1 a, in TIn2 b);
+
+public static class PipeExtensions
+{
+    public static TOut Pipe<TIn, TOut>(this TIn a, Func<TIn, TOut> fn)
+        => fn(a);
+    
+    public static TOut Pipe<TIn, TOut>(this TIn a, PipeFunc<TIn, TOut> fn)
+        => fn(a);
+    
+    public static TOut Pipe<TIn1, TIn2, TOut>(this (TIn1, TIn2) a, PipeFunc<TIn1, TIn2, TOut> fn)
+        => fn(a.Item1, a.Item2);
+
+    public static (TIn, TExtra) With<TIn, TExtra>(this TIn a, TExtra b)
+        => (a, b);
+    
+    public static async Task<TOut> Pipe<TIn, TOut>(this Task<TIn> a, Func<TIn, TOut> fn)
+        => fn(await a);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Extensions/StringExtensions.cs b/src/EllieBot/_common/Abstractions/Extensions/StringExtensions.cs
new file mode 100644
index 0000000..2515e45
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Extensions/StringExtensions.cs
@@ -0,0 +1,151 @@
+using EllieBot.Common.Yml;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Extensions;
+
+public static class StringExtensions
+{
+    private static readonly HashSet<char> _lettersAndDigits =
+    [
+        ..Enumerable.Range(48, 10)
+                    .Concat(Enumerable.Range(65, 26))
+                    .Concat(Enumerable.Range(97, 26))
+                    .Select(x => (char)x)
+    ];
+
+    private static readonly Regex _filterRegex = new(@"discord(?:\.gg|\.io|\.me|\.li|(?:app)?\.com\/invite)\/(\w+)",
+        RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+    private static readonly Regex _codePointRegex =
+        new(@"(\\U(?<code>[a-zA-Z0-9]{8})|\\u(?<code>[a-zA-Z0-9]{4})|\\x(?<code>[a-zA-Z0-9]{2}))",
+            RegexOptions.Compiled);
+
+    public static string PadBoth(this string str, int length)
+    {
+        var spaces = length - str.Length;
+        var padLeft = (spaces / 2) + str.Length;
+        return str.PadLeft(padLeft, ' ').PadRight(length, ' ');
+    }
+
+    public static string StripHtml(this string input)
+        => Regex.Replace(input, "<.*?>", string.Empty);
+
+    public static string? TrimTo(this string? str, int maxLength, bool hideDots = false)
+    {
+        if (hideDots)
+        {
+            return str?.Substring(0, Math.Min(str?.Length ?? 0, maxLength));
+        }
+
+        if (str is null || str.Length <= maxLength)
+            return str;
+
+        return string.Concat(str.AsSpan(0, maxLength - 1), "…");
+    }
+
+    public static string ToTitleCase(this string str)
+    {
+        var tokens = str.Split([" "], StringSplitOptions.RemoveEmptyEntries);
+        for (var i = 0; i < tokens.Length; i++)
+        {
+            var token = tokens[i];
+            tokens[i] = token[..1].ToUpperInvariant() + token[1..];
+        }
+
+        return tokens.Join(" ").Replace(" Of ", " of ").Replace(" The ", " the ");
+    }
+
+    //http://www.dotnetperls.com/levenshtein
+    public static int LevenshteinDistance(this string s, string t)
+    {
+        var n = s.Length;
+        var m = t.Length;
+        var d = new int[n + 1, m + 1];
+
+        // Step 1
+        if (n == 0)
+            return m;
+
+        if (m == 0)
+            return n;
+
+        // Step 2
+        for (var i = 0; i <= n; d[i, 0] = i++)
+        {
+        }
+
+        for (var j = 0; j <= m; d[0, j] = j++)
+        {
+        }
+
+        // Step 3
+        for (var i = 1; i <= n; i++)
+            //Step 4
+        for (var j = 1; j <= m; j++)
+        {
+            // Step 5
+            var cost = t[j - 1] == s[i - 1] ? 0 : 1;
+
+            // Step 6
+            d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost);
+        }
+
+        // Step 7
+        return d[n, m];
+    }
+
+    public static async Task<Stream> ToStream(this string str)
+    {
+        var ms = new MemoryStream();
+        var sw = new StreamWriter(ms);
+        await sw.WriteAsync(str);
+        await sw.FlushAsync();
+        ms.Position = 0;
+        return ms;
+    }
+
+    public static bool IsDiscordInvite(this string str)
+        => _filterRegex.IsMatch(str);
+
+    public static string Unmention(this string str)
+        => str.Replace("@", "ම", StringComparison.InvariantCulture);
+
+    public static string SanitizeMentions(this string str, bool sanitizeRoleMentions = false)
+    {
+        str = str.Replace("@everyone", "@everyοne", StringComparison.InvariantCultureIgnoreCase)
+                 .Replace("@here", "@һere", StringComparison.InvariantCultureIgnoreCase);
+        if (sanitizeRoleMentions)
+            str = str.SanitizeRoleMentions();
+
+        return str;
+    }
+
+    public static string SanitizeRoleMentions(this string str)
+        => str.Replace("<@&", "<ම&", StringComparison.InvariantCultureIgnoreCase);
+
+    public static string SanitizeAllMentions(this string str)
+        => str.SanitizeMentions().SanitizeRoleMentions();
+
+    public static string ToBase64(this string plainText)
+    {
+        var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
+        return Convert.ToBase64String(plainTextBytes);
+    }
+
+    public static string GetInitials(this string txt, string glue = "")
+        => txt.Split(' ').Select(x => x.FirstOrDefault()).Join(glue);
+
+    public static bool IsAlphaNumeric(this string txt)
+        => txt.All(c => _lettersAndDigits.Contains(c));
+
+    public static string UnescapeUnicodeCodePoints(this string input)
+        => _codePointRegex.Replace(input,
+            me =>
+            {
+                var str = me.Groups["code"].Value;
+                var newString = str.UnescapeUnicodeCodePoint();
+                return newString;
+            });
+    
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Helpers/LogSetup.cs b/src/EllieBot/_common/Abstractions/Helpers/LogSetup.cs
new file mode 100644
index 0000000..8983740
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Helpers/LogSetup.cs
@@ -0,0 +1,35 @@
+using Serilog.Events;
+using Serilog.Sinks.SystemConsole.Themes;
+using System.Text;
+
+namespace Ellie.Common;
+
+public static class LogSetup
+{
+    public static void SetupLogger(object source)
+    {
+        Log.Logger = new LoggerConfiguration().MinimumLevel.Override("Microsoft", LogEventLevel.Information)
+                                              .MinimumLevel.Override("System", LogEventLevel.Information)
+                                              .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
+                                              .Enrich.FromLogContext()
+                                              .WriteTo.Console(LogEventLevel.Information,
+                                                  theme: GetTheme(),
+                                                  outputTemplate:
+                                                  "[{Timestamp:HH:mm:ss} {Level:u3}] | #{LogSource} | {Message:lj}{NewLine}{Exception}")
+                                              .Enrich.WithProperty("LogSource", source)
+                                              .CreateLogger();
+
+        Console.OutputEncoding = Encoding.UTF8;
+    }
+
+    private static ConsoleTheme GetTheme()
+    {
+        if (Environment.OSVersion.Platform == PlatformID.Unix)
+            return AnsiConsoleTheme.Code;
+#if DEBUG
+        return AnsiConsoleTheme.Code;
+#else
+            return ConsoleTheme.None;
+#endif
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Helpers/StandardConversions.cs b/src/EllieBot/_common/Abstractions/Helpers/StandardConversions.cs
new file mode 100644
index 0000000..d277b7e
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Helpers/StandardConversions.cs
@@ -0,0 +1,7 @@
+namespace Ellie.Common;
+
+public static class StandardConversions
+{
+    public static double CelsiusToFahrenheit(double cel)
+        => (cel * 1.8f) + 32;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/Kwum.cs b/src/EllieBot/_common/Abstractions/Kwum.cs
new file mode 100644
index 0000000..267d38b
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/Kwum.cs
@@ -0,0 +1,100 @@
+using System.Runtime.CompilerServices;
+
+namespace Ellie.Common;
+
+// needs proper invalid input check (character array input out of range)
+// needs negative number support
+// ReSharper disable once InconsistentNaming
+#pragma warning disable CS8981
+public readonly struct kwum : IEquatable<kwum>
+#pragma warning restore CS8981
+{
+    private const string VALID_CHARACTERS = "23456789abcdefghijkmnpqrstuvwxyz";
+    private readonly int _value;
+
+    public kwum(int num)
+        => _value = num;
+
+    public kwum(in char c)
+    {
+        if (!IsValidChar(c))
+            throw new ArgumentException("Character needs to be a valid kwum character.", nameof(c));
+
+        _value = InternalCharToValue(c);
+    }
+
+    public kwum(in ReadOnlySpan<char> input)
+    {
+        _value = 0;
+        for (var index = 0; index < input.Length; index++)
+        {
+            var c = input[index];
+            if (!IsValidChar(c))
+                throw new ArgumentException("All characters need to be a valid kwum characters.", nameof(input));
+
+            _value += VALID_CHARACTERS.IndexOf(c) * (int)Math.Pow(VALID_CHARACTERS.Length, input.Length - index - 1);
+        }
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private static int InternalCharToValue(in char c)
+        => VALID_CHARACTERS.IndexOf(c);
+
+    public static bool TryParse(in ReadOnlySpan<char> input, out kwum value)
+    {
+        value = default;
+        foreach (var c in input)
+        {
+            if (!IsValidChar(c))
+                return false;
+        }
+
+        value = new(input);
+        return true;
+    }
+
+    public static kwum operator +(kwum left, kwum right)
+        => new(left._value + right._value);
+
+    public static bool operator ==(kwum left, kwum right)
+        => left._value == right._value;
+
+    public static bool operator !=(kwum left, kwum right)
+        => !(left == right);
+
+    public static implicit operator long(kwum kwum)
+        => kwum._value;
+
+    public static implicit operator int(kwum kwum)
+        => kwum._value;
+
+    public static implicit operator kwum(int num)
+        => new(num);
+
+    public static bool IsValidChar(char c)
+        => VALID_CHARACTERS.Contains(c);
+
+    public override string ToString()
+    {
+        var count = VALID_CHARACTERS.Length;
+        var localValue = _value;
+        var arrSize = (int)Math.Log(localValue, count) + 1;
+        Span<char> chars = new char[arrSize];
+        while (localValue > 0)
+        {
+            localValue = Math.DivRem(localValue, count, out var rem);
+            chars[--arrSize] = VALID_CHARACTERS[rem];
+        }
+
+        return new(chars);
+    }
+
+    public override bool Equals(object? obj)
+        => obj is kwum kw && kw == this;
+
+    public bool Equals(kwum other)
+        => other == this;
+
+    public override int GetHashCode()
+        => _value.GetHashCode();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/PubSub/EventPubSub.cs b/src/EllieBot/_common/Abstractions/PubSub/EventPubSub.cs
new file mode 100644
index 0000000..87ce07f
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/PubSub/EventPubSub.cs
@@ -0,0 +1,80 @@
+namespace Ellie.Common;
+
+public class EventPubSub : IPubSub
+{
+    private readonly Dictionary<string, Dictionary<Delegate, List<Func<object, ValueTask>>>> _actions = new();
+    private readonly object _locker = new();
+
+    public Task Sub<TData>(in TypedKey<TData> key, Func<TData, ValueTask> action)
+        where TData : notnull
+    {
+        Func<object, ValueTask> localAction = obj => action((TData)obj);
+        lock (_locker)
+        {
+            if (!_actions.TryGetValue(key.Key, out var keyActions))
+            {
+                keyActions = new();
+                _actions[key.Key] = keyActions;
+            }
+
+            if (!keyActions.TryGetValue(action, out var sameActions))
+            {
+                sameActions = new();
+                keyActions[action] = sameActions;
+            }
+
+            sameActions.Add(localAction);
+
+            return Task.CompletedTask;
+        }
+    }
+
+    public Task Pub<TData>(in TypedKey<TData> key, TData data)
+        where TData : notnull
+    {
+        lock (_locker)
+        {
+            if (_actions.TryGetValue(key.Key, out var actions))
+                // if this class ever gets used, this needs to be properly implemented
+                // 1. ignore all valuetasks which are completed
+                // 2. run all other tasks in parallel
+                return actions.SelectMany(kvp => kvp.Value).Select(action => action(data).AsTask()).WhenAll();
+
+            return Task.CompletedTask;
+        }
+    }
+
+    public Task Unsub<TData>(in TypedKey<TData> key, Func<TData, ValueTask> action)
+    {
+        lock (_locker)
+        {
+            // get subscriptions for this action
+            if (_actions.TryGetValue(key.Key, out var actions))
+                // get subscriptions which have the same action hash code
+                // note: having this as a list allows for multiple subscriptions of
+                //       the same insance's/static method
+            {
+                if (actions.TryGetValue(action, out var sameActions))
+                {
+                    // remove last subscription
+                    sameActions.RemoveAt(sameActions.Count - 1);
+
+                    // if the last subscription was the only subscription
+                    // we can safely remove this action's dictionary entry
+                    if (sameActions.Count == 0)
+                    {
+                        actions.Remove(action);
+
+                        // if our dictionary has no more elements after 
+                        // removing the entry
+                        // it's safe to remove it from the key's subscriptions
+                        if (actions.Count == 0)
+                            _actions.Remove(key.Key);
+                    }
+                }
+            }
+
+            return Task.CompletedTask;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/PubSub/IPubSub.cs b/src/EllieBot/_common/Abstractions/PubSub/IPubSub.cs
new file mode 100644
index 0000000..7c092cb
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/PubSub/IPubSub.cs
@@ -0,0 +1,10 @@
+namespace Ellie.Common;
+
+public interface IPubSub
+{
+    public Task Pub<TData>(in TypedKey<TData> key, TData data)
+        where TData : notnull;
+
+    public Task Sub<TData>(in TypedKey<TData> key, Func<TData, ValueTask> action)
+        where TData : notnull;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/PubSub/ISeria.cs b/src/EllieBot/_common/Abstractions/PubSub/ISeria.cs
new file mode 100644
index 0000000..57217c2
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/PubSub/ISeria.cs
@@ -0,0 +1,7 @@
+namespace Ellie.Common;
+
+public interface ISeria
+{
+    byte[] Serialize<T>(T data);
+    T? Deserialize<T>(byte[]? data);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/QueueRunner.cs b/src/EllieBot/_common/Abstractions/QueueRunner.cs
new file mode 100644
index 0000000..7bba829
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/QueueRunner.cs
@@ -0,0 +1,61 @@
+using System.Threading.Channels;
+
+namespace Ellie.Common;
+
+public sealed class QueueRunner
+{
+    private readonly Channel<Func<Task>> _channel;
+    private readonly int _delayMs;
+
+    public QueueRunner(int delayMs = 0, int maxCapacity = -1)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegative(delayMs);
+
+        _delayMs = delayMs;
+        _channel = maxCapacity switch
+        {
+            0 or < -1 => throw new ArgumentOutOfRangeException(nameof(maxCapacity)),
+            -1 => Channel.CreateUnbounded<Func<Task>>(new UnboundedChannelOptions()
+            {
+                SingleReader = true,
+                SingleWriter = false,
+                AllowSynchronousContinuations = true,
+            }),
+            _ => Channel.CreateBounded<Func<Task>>(new BoundedChannelOptions(maxCapacity)
+            {
+                Capacity = maxCapacity,
+                FullMode = BoundedChannelFullMode.DropOldest,
+                SingleReader = true,
+                SingleWriter = false,
+                AllowSynchronousContinuations = true
+            })
+        };
+    }
+
+    public async Task RunAsync(CancellationToken cancel = default)
+    {
+        while (true)
+        {
+            var func = await _channel.Reader.ReadAsync(cancel);
+            
+            try
+            {
+                await func();
+            }
+            catch (Exception ex)
+            {
+                Log.Warning(ex, "Exception executing a staggered func: {ErrorMessage}", ex.Message);
+            }
+            finally
+            {
+                if (_delayMs != 0)
+                {
+                    await Task.Delay(_delayMs, cancel);
+                }
+            }
+        }
+    }
+    
+    public ValueTask EnqueueAsync(Func<Task> action)
+        => _channel.Writer.WriteAsync(action);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/TypedKey.cs b/src/EllieBot/_common/Abstractions/TypedKey.cs
new file mode 100644
index 0000000..0ca2554
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/TypedKey.cs
@@ -0,0 +1,30 @@
+namespace Ellie.Common;
+
+public readonly struct TypedKey<TData>
+{
+    public string Key { get; }
+
+    public TypedKey(in string key)
+        => Key = key;
+
+    public static implicit operator TypedKey<TData>(in string input)
+        => new(input);
+
+    public static implicit operator string(in TypedKey<TData> input)
+        => input.Key;
+
+    public static bool operator ==(in TypedKey<TData> left, in TypedKey<TData> right)
+        => left.Key == right.Key;
+
+    public static bool operator !=(in TypedKey<TData> left, in TypedKey<TData> right)
+        => !(left == right);
+
+    public override bool Equals(object? obj)
+        => obj is TypedKey<TData> o && o == this;
+
+    public override int GetHashCode()
+        => Key?.GetHashCode() ?? 0;
+
+    public override string ToString()
+        => Key;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/YamlHelper.cs b/src/EllieBot/_common/Abstractions/YamlHelper.cs
new file mode 100644
index 0000000..e3c39f0
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/YamlHelper.cs
@@ -0,0 +1,48 @@
+#nullable disable
+namespace EllieBot.Common.Yml;
+
+public static class YamlHelper
+{
+    // https://github.com/aaubry/YamlDotNet/blob/0f4cc205e8b2dd8ef6589d96de32bf608a687c6f/YamlDotNet/Core/Scanner.cs#L1687
+    /// <summary>
+    ///     This is modified code from yamldotnet's repo which handles parsing unicode code points
+    ///     it is needed as yamldotnet doesn't support unescaped unicode characters
+    /// </summary>
+    /// <param name="point">Unicode code point</param>
+    /// <returns>Actual character</returns>
+    public static string UnescapeUnicodeCodePoint(this string point)
+    {
+        var character = 0;
+
+        // Scan the character value.
+
+        foreach (var c in point)
+        {
+            if (!IsHex(c))
+                return point;
+
+            character = (character << 4) + AsHex(c);
+        }
+
+        // Check the value and write the character.
+
+        if (character is (>= 0xD800 and <= 0xDFFF) or > 0x10FFFF)
+            return point;
+
+        return char.ConvertFromUtf32(character);
+    }
+
+    public static bool IsHex(char c)
+        => c is (>= '0' and <= '9') or (>= 'A' and <= 'F') or (>= 'a' and <= 'f');
+
+    public static int AsHex(char c)
+    {
+        if (c <= '9')
+            return c - '0';
+
+        if (c <= 'F')
+            return c - 'A' + 10;
+
+        return c - 'a' + 10;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs b/src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs
new file mode 100644
index 0000000..faeae4f
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/creds/IBotCredentials.cs
@@ -0,0 +1,79 @@
+#nullable disable
+namespace EllieBot;
+
+public interface IBotCredentials
+{
+    string Token { get; }
+    string EllieAiToken { get; }
+    ICollection<ulong> OwnerIds { get; set; }
+    string GoogleApiKey { get; }
+    bool UsePrivilegedIntents { get; }
+    string RapidApiKey { get; }
+
+    Creds.DbOptions Db { get; }
+    string OsuApiKey { get; }
+    int TotalShards { get; }
+    Creds.PatreonSettings Patreon { get; }
+    string CleverbotApiKey { get; }
+    string Gpt3ApiKey { get; }
+    RestartConfig RestartCommand { get; }
+    Creds.VotesSettings 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; }
+    GoogleApiConfig 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; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/creds/IBotCredsProvider.cs b/src/EllieBot/_common/Abstractions/creds/IBotCredsProvider.cs
new file mode 100644
index 0000000..ecc90f0
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/creds/IBotCredsProvider.cs
@@ -0,0 +1,8 @@
+namespace EllieBot;
+
+public interface IBotCredsProvider
+{
+    public void Reload();
+    public IBotCredentials GetCreds();
+    public void ModifyCredsFile(Action<IBotCredentials> func);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/strings/CommandStrings.cs b/src/EllieBot/_common/Abstractions/strings/CommandStrings.cs
new file mode 100644
index 0000000..64efd85
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/strings/CommandStrings.cs
@@ -0,0 +1,35 @@
+#nullable disable
+using YamlDotNet.Core;
+using YamlDotNet.Serialization;
+
+namespace EllieBot.Services;
+
+// public sealed record class CommandStrings
+// {
+//     [YamlMember(Alias = "desc")]
+//     public string Desc { get; set; }
+//
+//     [YamlMember(Alias = "args")]
+//     public string[] Args { get; set; }
+// }
+
+public sealed record class CommandStrings
+{
+    [YamlMember(Alias = "desc")]
+    public string Desc { get; set; }
+
+    [YamlMember(Alias = "ex")]
+    public string[] Examples { get; set; }
+    
+    [YamlMember(Alias = "params")]
+    public Dictionary<string, CommandStringParam>[] Params { get; set; }
+}
+
+public sealed record class CommandStringParam
+{
+    // [YamlMember(Alias = "type", ScalarStyle = ScalarStyle.DoubleQuoted)]
+    // public string Type { get; set; }
+    
+    [YamlMember(Alias = "desc", ScalarStyle = ScalarStyle.DoubleQuoted)]
+    public string Desc{ get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/strings/IBotStrings.cs b/src/EllieBot/_common/Abstractions/strings/IBotStrings.cs
new file mode 100644
index 0000000..5c43755
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/strings/IBotStrings.cs
@@ -0,0 +1,16 @@
+#nullable disable
+using System.Globalization;
+
+namespace EllieBot.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();
+    CommandStrings GetCommandStrings(string commandName, ulong? guildId = null);
+    CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/strings/IBotStringsExtensions.cs b/src/EllieBot/_common/Abstractions/strings/IBotStringsExtensions.cs
new file mode 100644
index 0000000..a635b4b
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/strings/IBotStringsExtensions.cs
@@ -0,0 +1,17 @@
+#nullable disable
+using System.Globalization;
+
+namespace EllieBot.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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/strings/IBotStringsProvider.cs b/src/EllieBot/_common/Abstractions/strings/IBotStringsProvider.cs
new file mode 100644
index 0000000..ef98051
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/strings/IBotStringsProvider.cs
@@ -0,0 +1,28 @@
+#nullable disable
+namespace EllieBot.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>
+    CommandStrings GetCommandStrings(string localeName, string commandName);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/strings/IStringsSource.cs b/src/EllieBot/_common/Abstractions/strings/IStringsSource.cs
new file mode 100644
index 0000000..08ad986
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/strings/IStringsSource.cs
@@ -0,0 +1,16 @@
+#nullable disable
+namespace EllieBot.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, CommandStrings>> GetCommandStrings();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Abstractions/strings/LocStr.cs b/src/EllieBot/_common/Abstractions/strings/LocStr.cs
new file mode 100644
index 0000000..78a8bb7
--- /dev/null
+++ b/src/EllieBot/_common/Abstractions/strings/LocStr.cs
@@ -0,0 +1,13 @@
+namespace EllieBot;
+
+public readonly struct LocStr
+{
+    public readonly string Key;
+    public readonly object[] Params;
+
+    public LocStr(string key, params object[] data)
+    {
+        Key = key;
+        Params = data;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/AddRemove.cs b/src/EllieBot/_common/AddRemove.cs
new file mode 100644
index 0000000..cccd892
--- /dev/null
+++ b/src/EllieBot/_common/AddRemove.cs
@@ -0,0 +1,10 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public enum AddRemove
+{
+    Add = int.MinValue,
+    Remove = int.MinValue + 1,
+    Rem = int.MinValue + 1,
+    Rm = int.MinValue + 1
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/AliasesAttribute.cs b/src/EllieBot/_common/Attributes/AliasesAttribute.cs
new file mode 100644
index 0000000..bef833e
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/AliasesAttribute.cs
@@ -0,0 +1,12 @@
+using System.Runtime.CompilerServices;
+
+namespace EllieBot.Common.Attributes;
+
+[AttributeUsage(AttributeTargets.Method)]
+public sealed class AliasesAttribute : AliasAttribute
+{
+    public AliasesAttribute([CallerMemberName] string memberName = "")
+        : base(CommandNameLoadHelper.GetAliasesFor(memberName))
+    {
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/CmdAttribute.cs b/src/EllieBot/_common/Attributes/CmdAttribute.cs
new file mode 100644
index 0000000..b778c7d
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/CmdAttribute.cs
@@ -0,0 +1,18 @@
+using System.Runtime.CompilerServices;
+
+namespace EllieBot.Common.Attributes;
+
+[AttributeUsage(AttributeTargets.Method)]
+public sealed class CmdAttribute : CommandAttribute
+{
+    public string MethodName { get; }
+
+    public CmdAttribute([CallerMemberName] string memberName = "")
+        : base(CommandNameLoadHelper.GetCommandNameFor(memberName))
+    {
+        MethodName = memberName.ToLowerInvariant();
+        Aliases = CommandNameLoadHelper.GetAliasesFor(memberName);
+        Remarks = memberName.ToLowerInvariant();
+        Summary = memberName.ToLowerInvariant();
+    }
+}
diff --git a/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs b/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs
new file mode 100644
index 0000000..608e079
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs
@@ -0,0 +1,11 @@
+#nullable disable
+namespace EllieBot.Common;
+
+/// <summary>
+/// Classed marked with this attribute will not be added to the service provider 
+/// </summary>
+[AttributeUsage(AttributeTargets.Class)]
+public class DIIgnoreAttribute : Attribute
+{
+    
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs b/src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs
new file mode 100644
index 0000000..7db4315
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Common.Attributes;
+
+[AttributeUsage(AttributeTargets.Method)]
+public sealed class EllieOptionsAttribute<TOption> : Attribute
+    where TOption: IEllieCommandOptions
+{
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs b/src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs
new file mode 100644
index 0000000..183fee5
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs
@@ -0,0 +1,18 @@
+#nullable disable
+namespace EllieBot.Common;
+
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
+public sealed class NoPublicBotAttribute : PreconditionAttribute
+{
+    public override Task<PreconditionResult> CheckPermissionsAsync(
+        ICommandContext context,
+        CommandInfo command,
+        IServiceProvider services)
+    {
+#if GLOBAL_ELLIE
+        return Task.FromResult(PreconditionResult.FromError("Not available on the public bot. To learn how to selfhost a private bot, click [here](https://docs.elliebot.net)."));
+#else
+        return Task.FromResult(PreconditionResult.FromSuccess());
+#endif
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs b/src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs
new file mode 100644
index 0000000..2ee7958
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/OnlyPublicBotAttribute.cs
@@ -0,0 +1,21 @@
+#nullable disable
+using System.Diagnostics.CodeAnalysis;
+
+namespace EllieBot.Common;
+
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
+[SuppressMessage("Style", "IDE0022:Use expression body for methods")]
+public sealed class OnlyPublicBotAttribute : PreconditionAttribute
+{
+    public override Task<PreconditionResult> CheckPermissionsAsync(
+        ICommandContext context,
+        CommandInfo command,
+        IServiceProvider services)
+    {
+#if GLOBAL_ELLIE || DEBUG
+        return Task.FromResult(PreconditionResult.FromSuccess());
+#else
+        return Task.FromResult(PreconditionResult.FromError("Only available on the public bot."));
+#endif
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs b/src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs
new file mode 100644
index 0000000..c1baa53
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/OwnerOnlyAttribute.cs
@@ -0,0 +1,19 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace EllieBot.Common.Attributes;
+
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
+public sealed class OwnerOnlyAttribute : PreconditionAttribute
+{
+    public override Task<PreconditionResult> CheckPermissionsAsync(
+        ICommandContext context,
+        CommandInfo command,
+        IServiceProvider services)
+    {
+        var creds = services.GetRequiredService<IBotCredsProvider>().GetCreds();
+
+        return Task.FromResult(creds.IsOwner(context.User) || context.Client.CurrentUser.Id == context.User.Id
+            ? PreconditionResult.FromSuccess()
+            : PreconditionResult.FromError("Not owner"));
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/RatelimitAttribute.cs b/src/EllieBot/_common/Attributes/RatelimitAttribute.cs
new file mode 100644
index 0000000..54402d9
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/RatelimitAttribute.cs
@@ -0,0 +1,37 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace EllieBot.Common.Attributes;
+
+[AttributeUsage(AttributeTargets.Method)]
+public sealed class RatelimitAttribute : PreconditionAttribute
+{
+    public int Seconds { get; }
+
+    public RatelimitAttribute(int seconds)
+    {
+        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(seconds);
+
+        Seconds = seconds;
+    }
+
+    public override async Task<PreconditionResult> CheckPermissionsAsync(
+        ICommandContext context,
+        CommandInfo command,
+        IServiceProvider services)
+    {
+        if (Seconds == 0)
+            return PreconditionResult.FromSuccess();
+
+        var cache = services.GetRequiredService<IBotCache>();
+        var rem = await cache.GetRatelimitAsync(
+            new($"precondition:{context.User.Id}:{command.Name}"),
+            Seconds.Seconds());
+
+        if (rem is null)
+            return PreconditionResult.FromSuccess();
+
+        var msgContent = $"You can use this command again in {rem.Value.TotalSeconds:F1}s.";
+
+        return PreconditionResult.FromError(msgContent);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Attributes/UserPermAttribute.cs b/src/EllieBot/_common/Attributes/UserPermAttribute.cs
new file mode 100644
index 0000000..2e0af03
--- /dev/null
+++ b/src/EllieBot/_common/Attributes/UserPermAttribute.cs
@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Discord;
+
+[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+public class UserPermAttribute : RequireUserPermissionAttribute
+{
+    public UserPermAttribute(GuildPerm permission)
+        : base(permission)
+    {
+    }
+
+    public UserPermAttribute(ChannelPerm permission)
+        : base(permission)
+    {
+    }
+
+    public override Task<PreconditionResult> CheckPermissionsAsync(
+        ICommandContext context,
+        CommandInfo command,
+        IServiceProvider services)
+    {
+        var permService = services.GetRequiredService<IDiscordPermOverrideService>();
+        if (permService.TryGetOverrides(context.Guild?.Id ?? 0, command.Name.ToUpperInvariant(), out _))
+            return Task.FromResult(PreconditionResult.FromSuccess());
+
+        return base.CheckPermissionsAsync(context, command, services);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/BotCommandTypeReader.cs b/src/EllieBot/_common/BotCommandTypeReader.cs
new file mode 100644
index 0000000..fc839bd
--- /dev/null
+++ b/src/EllieBot/_common/BotCommandTypeReader.cs
@@ -0,0 +1,30 @@
+#nullable disable
+namespace EllieBot.Common.TypeReaders;
+
+public sealed class CommandTypeReader : EllieTypeReader<CommandInfo>
+{
+    private readonly CommandService _cmds;
+    private readonly ICommandHandler _handler;
+
+    public CommandTypeReader(ICommandHandler handler, CommandService cmds)
+    {
+        _handler = handler;
+        _cmds = cmds;
+    }
+
+    public override ValueTask<TypeReaderResult<CommandInfo>> ReadAsync(ICommandContext ctx, string input)
+    {
+        input = input.ToUpperInvariant();
+        var prefix = _handler.GetPrefix(ctx.Guild);
+        if (!input.StartsWith(prefix.ToUpperInvariant(), StringComparison.InvariantCulture))
+            return new(TypeReaderResult.FromError<CommandInfo>(CommandError.ParseFailed, "No such command found."));
+
+        input = input[prefix.Length..];
+
+        var cmd = _cmds.Commands.FirstOrDefault(c => c.Aliases.Select(a => a.ToUpperInvariant()).Contains(input));
+        if (cmd is null)
+            return new(TypeReaderResult.FromError<CommandInfo>(CommandError.ParseFailed, "No such command found."));
+
+        return new(TypeReaderResult.FromSuccess(cmd));
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/CleanupModuleBase.cs b/src/EllieBot/_common/CleanupModuleBase.cs
new file mode 100644
index 0000000..1e97a66
--- /dev/null
+++ b/src/EllieBot/_common/CleanupModuleBase.cs
@@ -0,0 +1,25 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public abstract class CleanupModuleBase : EllieModule
+{
+    protected async Task ConfirmActionInternalAsync(string name, Func<Task> action)
+    {
+        try
+        {
+            var embed = _sender.CreateEmbed()
+                .WithTitle(GetText(strs.sql_confirm_exec))
+                .WithDescription(name);
+
+            if (!await PromptUserConfirmAsync(embed))
+                return;
+
+            await action();
+            await ctx.OkAsync();
+        }
+        catch (Exception ex)
+        {
+            await Response().Error(ex.ToString()).SendAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/CleverBotResponseStr.cs b/src/EllieBot/_common/CleverBotResponseStr.cs
new file mode 100644
index 0000000..2f06a67
--- /dev/null
+++ b/src/EllieBot/_common/CleverBotResponseStr.cs
@@ -0,0 +1,10 @@
+#nullable disable
+using System.Runtime.InteropServices;
+
+namespace EllieBot.Modules.Permissions;
+
+[StructLayout(LayoutKind.Sequential, Size = 1)]
+public readonly struct CleverBotResponseStr
+{
+    public const string CLEVERBOT_RESPONSE = "CLEVERBOT:RESPONSE";
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/CmdStrings.cs b/src/EllieBot/_common/CmdStrings.cs
new file mode 100644
index 0000000..c28ed1a
--- /dev/null
+++ b/src/EllieBot/_common/CmdStrings.cs
@@ -0,0 +1,17 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.Common;
+
+public class CmdStrings
+{
+    public string[] Usages { get; }
+    public string Description { get; }
+
+    [JsonConstructor]
+    public CmdStrings([JsonProperty("args")] string[] usages, [JsonProperty("desc")] string description)
+    {
+        Usages = usages;
+        Description = description;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/CommandData.cs b/src/EllieBot/_common/CommandData.cs
new file mode 100644
index 0000000..f0514da
--- /dev/null
+++ b/src/EllieBot/_common/CommandData.cs
@@ -0,0 +1,9 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public class CommandData
+{
+    public string Cmd { get; set; }
+    public string Desc { get; set; }
+    public string[] Usage { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/CommandNameLoadHelper.cs b/src/EllieBot/_common/CommandNameLoadHelper.cs
new file mode 100644
index 0000000..0a811e2
--- /dev/null
+++ b/src/EllieBot/_common/CommandNameLoadHelper.cs
@@ -0,0 +1,40 @@
+using EllieBot.Common.Yml;
+using YamlDotNet.Serialization;
+
+namespace EllieBot.Common.Attributes;
+
+public static class CommandNameLoadHelper
+{
+    private static readonly IDeserializer _deserializer = new Deserializer();
+
+    private static readonly Lazy<Dictionary<string, string[]>> _lazyCommandAliases
+        = new(() => LoadAliases());
+
+    public static Dictionary<string, string[]> LoadAliases(string aliasesFilePath = "data/aliases.yml")
+    {
+        var text = File.ReadAllText(aliasesFilePath);
+        return _deserializer.Deserialize<Dictionary<string, string[]>>(text);
+    }
+
+    public static Dictionary<string, CommandStrings> LoadCommandStrings(
+        string commandsFilePath = "data/strings/commands.yml")
+    {
+        var text = File.ReadAllText(commandsFilePath);
+
+        return Yaml.Deserializer.Deserialize<Dictionary<string, CommandStrings>>(text);
+    }
+
+    public static string[] GetAliasesFor(string methodName)
+        => _lazyCommandAliases.Value.TryGetValue(methodName.ToLowerInvariant(), out var aliases) && aliases.Length > 1
+            ? aliases.ToArray()
+            : Array.Empty<string>();
+
+    public static string GetCommandNameFor(string methodName)
+    {
+        methodName = methodName.ToLowerInvariant();
+        var toReturn = _lazyCommandAliases.Value.TryGetValue(methodName, out var aliases) && aliases.Length > 0
+            ? aliases[0]
+            : methodName;
+        return toReturn;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Configs/BotConfig.cs b/src/EllieBot/_common/Configs/BotConfig.cs
new file mode 100644
index 0000000..df6bc1a
--- /dev/null
+++ b/src/EllieBot/_common/Configs/BotConfig.cs
@@ -0,0 +1,196 @@
+#nullable disable
+using Cloneable;
+using EllieBot.Common.Yml;
+using SixLabors.ImageSharp.PixelFormats;
+using System.Globalization;
+using YamlDotNet.Core;
+using YamlDotNet.Serialization;
+
+namespace EllieBot.Common.Configs;
+
+[Cloneable]
+public sealed partial class BotConfig : ICloneable<BotConfig>
+{
+    [Comment("""DO NOT CHANGE""")]
+    public int Version { get; set; } = 8;
+
+    [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("""
+             Should the bot ignore messages from other bots?
+             Settings this to false might get your bot banned if it gets into a spam loop with another bot.
+             This will only affect command executions, other features will still block bots from access.
+             Default true
+             """)]
+    public bool IgnoreOtherBots { 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("""
+        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://cdn.elliebot.net/Ellie.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://commands.elliebot.net",
+                  "inline": false
+                },
+                {
+                  "name": "Ellie Support Server",
+                  "value": "https://discord.gg/etQdZxSyEH ",
+                  "inline": true
+                }
+              ]
+            }
+            """;
+        var blocked = new BlockedConfig();
+        Blocked = blocked;
+        Prefix = ".";
+        RotateStatuses = false;
+        DmHelpTextKeywords =
+        [
+            "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 = [];
+        Commands = [];
+    }
+}
+
+[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
+}
diff --git a/src/EllieBot/_common/Configs/IConfigSeria.cs b/src/EllieBot/_common/Configs/IConfigSeria.cs
new file mode 100644
index 0000000..a5d3a10
--- /dev/null
+++ b/src/EllieBot/_common/Configs/IConfigSeria.cs
@@ -0,0 +1,18 @@
+namespace EllieBot.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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Creds.cs b/src/EllieBot/_common/Creds.cs
new file mode 100644
index 0000000..65de7e6
--- /dev/null
+++ b/src/EllieBot/_common/Creds.cs
@@ -0,0 +1,285 @@
+#nullable disable
+using EllieBot.Common.Yml;
+
+namespace EllieBot.Common;
+
+public sealed class Creds : IBotCredentials
+{
+    [Comment("""DO NOT CHANGE""")]
+    public int Version { get; set; }
+
+    [Comment("""Bot token. Do not share with anyone ever -> https://discordapp.com/developers/applications/""")]
+    public string Token { get; set; }
+
+    [Comment("""
+        List of Ids of the users who have bot owner permissions
+        **DO NOT ADD PEOPLE YOU DON'T TRUST**
+        """)]
+    public ICollection<ulong> OwnerIds { get; set; }
+    
+    [Comment("Keep this on 'true' unless you're sure your bot shouldn't use privileged intents or you're waiting to be accepted")]
+    public bool UsePrivilegedIntents { get; set; }
+
+    [Comment("""
+        The number of shards that the bot will be running on.
+        Leave at 1 if you don't know what you're doing.
+        
+        note: If you are planning to have more than one shard, then you must change botCache to 'redis'.
+              Also, in that case you should be using EllieBot.Coordinator to start the bot, and it will correctly override this value.
+        """)]
+    public int TotalShards { get; set; }
+
+    [Comment("""
+             Pledge 5$ or more on https://patreon.com/elliebot and connect your discord account to Patreon.
+             Go to https://dashy.elliebot.net/me and login with your discord account
+             Go to the Keys page and click "Generate New Key" and copy it here
+             You and anyone else with the permission to run `.prompt` command will be able to use natural language to run bot's commands.
+             For example '@Bot how's the weather in Paris' will return the current weather in Paris as if you were to run `.weather Paris` command.
+             ⚠ This does not currently work and is a work in progress.
+             """)]
+    public string EllieAiToken { get; set; }
+    
+    [Comment(   
+        """
+            Login to https://console.cloud.google.com, create a new project, go to APIs & Services -> Library -> YouTube Data API and enable it.
+            Then, go to APIs and Services -> Credentials and click Create credentials -> API key.
+            Used only for Youtube Data Api (at the moment).
+            """)]
+    public string GoogleApiKey { get; set; }
+    
+    [Comment(   
+        """
+            Create a new custom search here https://programmablesearchengine.google.com/cse/create/new
+            Enable SafeSearch
+            Remove all Sites to Search
+            Enable Search the entire web
+            Copy the 'Search Engine ID' to the SearchId field
+            
+            Do all steps again but enable image search for the ImageSearchId
+            """)]
+    public GoogleApiConfig Google { get; set; }
+
+    [Comment("""Settings for voting system for discordbots. Meant for use on global Ellie.""")]
+    public VotesSettings Votes { get; set; }
+
+    [Comment("""
+        Patreon auto reward system settings.
+        go to https://www.patreon.com/portal -> my clients -> create client
+        """)]
+    public PatreonSettings Patreon { get; set; }
+
+    [Comment("""Api key for sending stats to DiscordBotList.""")]
+    public string BotListToken { get; set; }
+
+    [Comment("""Official cleverbot api key.""")]
+    public string CleverbotApiKey { get; set; }
+
+    [Comment(@"OpenAi api key.")]
+    public string Gpt3ApiKey { get; set; }
+    
+    [Comment("""
+        Which cache implementation should bot use.
+        'memory' - Cache will be in memory of the bot's process itself. Only use this on bots with a single shard. When the bot is restarted the cache is reset. 
+        'redis' - Uses redis (which needs to be separately downloaded and installed). The cache will persist through bot restarts. You can configure connection string in creds.yml
+        """)]
+    public BotCacheImplemenation BotCache { get; set; }
+    
+    [Comment("""
+        Redis connection string. Don't change if you don't know what you're doing.
+        Only used if botCache is set to 'redis'
+        """)]
+    public string RedisOptions { get; set; }
+
+    [Comment("""Database options. Don't change if you don't know what you're doing. Leave null for default values""")]
+    public DbOptions Db { get; set; }
+
+    [Comment("""
+        Address and port of the coordinator endpoint. Leave empty for default.
+        Change only if you've changed the coordinator address or port.
+        """)]
+    public string CoordinatorUrl { get; set; }
+
+    [Comment(
+        """Api key obtained on https://rapidapi.com (go to MyApps -> Add New App -> Enter Name -> Application key)""")]
+    public string RapidApiKey { get; set; }
+
+    [Comment("""
+        https://locationiq.com api key (register and you will receive the token in the email).
+        Used only for .time command.
+        """)]
+    public string LocationIqApiKey { get; set; }
+
+    [Comment("""
+        https://timezonedb.com api key (register and you will receive the token in the email).
+        Used only for .time command
+        """)]
+    public string TimezoneDbApiKey { get; set; }
+
+    [Comment("""
+        https://pro.coinmarketcap.com/account/ api key. There is a free plan for personal use.
+        Used for cryptocurrency related commands.
+        """)]
+    public string CoinmarketcapApiKey { get; set; }
+    
+//     [Comment(@"https://polygon.io/dashboard/api-keys api key. Free plan allows for 5 queries per minute.
+// Used for stocks related commands.")]
+//     public string PolygonIoApiKey { get; set; }
+
+    [Comment("""Api key used for Osu related commands. Obtain this key at https://osu.ppy.sh/p/api""")]
+    public string OsuApiKey { get; set; }
+
+    [Comment("""
+        Optional Trovo client id.
+        You should use this if Trovo stream notifications stopped working or you're getting ratelimit errors.
+        """)]
+    public string TrovoClientId { get; set; }
+
+    [Comment("""Obtain by creating an application at https://dev.twitch.tv/console/apps""")]
+    public string TwitchClientId { get; set; }
+
+    [Comment("""Obtain by creating an application at https://dev.twitch.tv/console/apps""")]
+    public string TwitchClientSecret { get; set; }
+
+    [Comment("""
+        Command and args which will be used to restart the bot.
+        Only used if bot is executed directly (NOT through the coordinator)
+        placeholders: 
+            {0} -> shard id 
+            {1} -> total shards
+        Linux default
+            cmd: dotnet
+            args: "EllieBot.dll -- {0}"
+        Windows default
+            cmd: EllieBot.exe
+            args: "{0}"
+        """)]
+    public RestartConfig RestartCommand { get; set; }
+
+    public Creds()
+    {
+        Version = 9;
+        Token = string.Empty;
+        UsePrivilegedIntents = true;
+        OwnerIds = new List<ulong>();
+        TotalShards = 1;
+        GoogleApiKey = string.Empty;
+        Votes = new VotesSettings(string.Empty, string.Empty, string.Empty, string.Empty);
+        Patreon = new PatreonSettings(string.Empty, string.Empty, string.Empty, string.Empty);
+        BotListToken = string.Empty;
+        CleverbotApiKey = string.Empty;
+        Gpt3ApiKey = string.Empty;
+        BotCache = BotCacheImplemenation.Memory;
+        RedisOptions = "localhost:6379,syncTimeout=30000,responseTimeout=30000,allowAdmin=true,password=";
+        Db = new DbOptions()
+        {
+            Type = "sqlite",
+            ConnectionString = "Data Source=data/EllieBot.db"
+        };
+
+        CoordinatorUrl = "http://localhost:3442";
+
+        RestartCommand = new RestartConfig();
+        Google = new GoogleApiConfig();
+    }
+    
+    public class DbOptions
+        : IDbOptions
+    {
+        [Comment("""
+            Database type. "sqlite", "mysql" and "postgresql" are supported.
+            Default is "sqlite"
+            """)]
+        public string Type { get; set; }
+
+        [Comment("""
+            Database connection string.
+            You MUST change this if you're not using "sqlite" type.
+            Default is "Data Source=data/EllieBot.db"
+            Example for mysql: "Server=localhost;Port=3306;Uid=root;Pwd=my_super_secret_mysql_password;Database=ellie"
+            Example for postgresql: "Server=localhost;Port=5432;User Id=postgres;Password=my_super_secret_postgres_password;Database=ellie;"
+            """)]
+        public string ConnectionString { get; set; }
+    }
+
+    public sealed record PatreonSettings : IPatreonSettings
+    {
+        public string ClientId { get; set; }
+        public string AccessToken { get; set; }
+        public string RefreshToken { get; set; }
+        public string ClientSecret { get; set; }
+
+        [Comment(
+            """Campaign ID of your patreon page. Go to your patreon page (make sure you're logged in) and type "prompt('Campaign ID', window.patreon.bootstrap.creator.data.id);" in the console. (ctrl + shift + i)""")]
+        public string CampaignId { get; set; }
+
+        public PatreonSettings(
+            string accessToken,
+            string refreshToken,
+            string clientSecret,
+            string campaignId)
+        {
+            AccessToken = accessToken;
+            RefreshToken = refreshToken;
+            ClientSecret = clientSecret;
+            CampaignId = campaignId;
+        }
+
+        public PatreonSettings()
+        {
+        }
+    }
+
+    public sealed record VotesSettings : IVotesSettings
+    {
+        [Comment("""
+            top.gg votes service url
+            This is the url of your instance of the EllieBot.Votes api
+            Example: https://votes.my.cool.bot.com
+            """)]
+        public string TopggServiceUrl { get; set; }
+
+        [Comment("""
+            Authorization header value sent to the TopGG service url with each request
+            This should be equivalent to the TopggKey in your EllieBot.Votes api appsettings.json file
+            """)]
+        public string TopggKey { get; set; }
+
+        [Comment("""
+            discords.com votes service url
+            This is the url of your instance of the EllieBot.Votes api
+            Example: https://votes.my.cool.bot.com
+            """)]
+        public string DiscordsServiceUrl { get; set; }
+
+        [Comment("""
+            Authorization header value sent to the Discords service url with each request
+            This should be equivalent to the DiscordsKey in your EllieBot.Votes api appsettings.json file
+            """)]
+        public string DiscordsKey { get; set; }
+
+        public VotesSettings()
+        {
+        }
+
+        public VotesSettings(
+            string topggServiceUrl,
+            string topggKey,
+            string discordsServiceUrl,
+            string discordsKey)
+        {
+            TopggServiceUrl = topggServiceUrl;
+            TopggKey = topggKey;
+            DiscordsServiceUrl = discordsServiceUrl;
+            DiscordsKey = discordsKey;
+        }
+    }
+}
+
+public class GoogleApiConfig : IGoogleApiConfig
+{
+    public string SearchId { get; init; }
+    public string ImageSearchId { get; init; }
+}
+
+
+
diff --git a/src/EllieBot/_common/DbService.cs b/src/EllieBot/_common/DbService.cs
new file mode 100644
index 0000000..089c1c2
--- /dev/null
+++ b/src/EllieBot/_common/DbService.cs
@@ -0,0 +1,16 @@
+#nullable disable
+
+using Microsoft.EntityFrameworkCore;
+
+namespace EllieBot.Services;
+
+public abstract class DbService
+{
+    /// <summary>
+    /// Call this to apply all migrations
+    /// </summary>
+    public abstract Task SetupAsync();
+
+    public abstract DbContext CreateRawDbContext(string dbType, string connString);
+    public abstract EllieContext GetDbContext();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/DoAsUserMessage.cs b/src/EllieBot/_common/DoAsUserMessage.cs
new file mode 100644
index 0000000..68a9188
--- /dev/null
+++ b/src/EllieBot/_common/DoAsUserMessage.cs
@@ -0,0 +1,215 @@
+using MessageType = Discord.MessageType;
+
+namespace EllieBot.Modules.Administration;
+
+public sealed class DoAsUserMessage : IUserMessage
+{
+    private readonly string _message;
+    private IUserMessage _msg;
+    private readonly IUser _user;
+
+    public DoAsUserMessage(SocketUserMessage msg, IUser user, string message)
+    {
+        _msg = msg;
+        _user = user;
+        _message = message;
+    }
+
+    public ulong Id
+        => _msg.Id;
+
+    public DateTimeOffset CreatedAt
+        => _msg.CreatedAt;
+
+    public Task DeleteAsync(RequestOptions? options = null)
+    {
+        return _msg.DeleteAsync(options);
+    }
+
+    public Task AddReactionAsync(IEmote emote, RequestOptions? options = null)
+    {
+        return _msg.AddReactionAsync(emote, options);
+    }
+
+    public Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions? options = null)
+    {
+        return _msg.RemoveReactionAsync(emote, user, options);
+    }
+
+    public Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions? options = null)
+    {
+        return _msg.RemoveReactionAsync(emote, userId, options);
+    }
+
+    public Task RemoveAllReactionsAsync(RequestOptions? options = null)
+    {
+        return _msg.RemoveAllReactionsAsync(options);
+    }
+
+    public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions? options = null)
+    {
+        return _msg.RemoveAllReactionsForEmoteAsync(emote, options);
+    }
+
+    public IAsyncEnumerable<IReadOnlyCollection<IUser>> GetReactionUsersAsync(
+        IEmote emoji,
+        int limit,
+        RequestOptions? options = null,
+        ReactionType type = ReactionType.Normal)
+        => _msg.GetReactionUsersAsync(emoji, limit, options, type);
+
+    public IAsyncEnumerable<IReadOnlyCollection<IUser>> GetReactionUsersAsync(
+        IEmote emoji,
+        int limit,
+        RequestOptions? options = null)
+    {
+        return _msg.GetReactionUsersAsync(emoji, limit, options);
+    }
+
+    public MessageType Type
+        => _msg.Type;
+
+    public MessageSource Source
+        => _msg.Source;
+
+    public bool IsTTS
+        => _msg.IsTTS;
+
+    public bool IsPinned
+        => _msg.IsPinned;
+
+    public bool IsSuppressed
+        => _msg.IsSuppressed;
+
+    public bool MentionedEveryone
+        => _msg.MentionedEveryone;
+
+    public string Content
+        => _message;
+
+    public string CleanContent
+        => _msg.CleanContent;
+
+    public DateTimeOffset Timestamp
+        => _msg.Timestamp;
+
+    public DateTimeOffset? EditedTimestamp
+        => _msg.EditedTimestamp;
+
+    public IMessageChannel Channel
+        => _msg.Channel;
+
+    public IUser Author
+        => _user;
+
+    public IThreadChannel Thread
+        => _msg.Thread;
+
+    public IReadOnlyCollection<IAttachment> Attachments
+        => _msg.Attachments;
+
+    public IReadOnlyCollection<IEmbed> Embeds
+        => _msg.Embeds;
+
+    public IReadOnlyCollection<ITag> Tags
+        => _msg.Tags;
+
+    public IReadOnlyCollection<ulong> MentionedChannelIds
+        => _msg.MentionedChannelIds;
+
+    public IReadOnlyCollection<ulong> MentionedRoleIds
+        => _msg.MentionedRoleIds;
+
+    public IReadOnlyCollection<ulong> MentionedUserIds
+        => _msg.MentionedUserIds;
+
+    public MessageActivity Activity
+        => _msg.Activity;
+
+    public MessageApplication Application
+        => _msg.Application;
+
+    public MessageReference Reference
+        => _msg.Reference;
+
+    public IReadOnlyDictionary<IEmote, ReactionMetadata> Reactions
+        => _msg.Reactions;
+
+    public IReadOnlyCollection<IMessageComponent> Components
+        => _msg.Components;
+
+    public IReadOnlyCollection<IStickerItem> Stickers
+        => _msg.Stickers;
+
+    public MessageFlags? Flags
+        => _msg.Flags;
+
+    [Obsolete("Obsolete in favor of InteractionMetadata")]
+    public IMessageInteraction Interaction
+        => _msg.Interaction;
+
+    public MessageRoleSubscriptionData RoleSubscriptionData
+        => _msg.RoleSubscriptionData;
+
+    public PurchaseNotification PurchaseNotification
+        => _msg.PurchaseNotification;
+
+    public MessageCallData? CallData
+        => _msg.CallData;
+
+    public Task ModifyAsync(Action<MessageProperties> func, RequestOptions? options = null)
+    {
+        return _msg.ModifyAsync(func, options);
+    }
+
+    public Task PinAsync(RequestOptions? options = null)
+    {
+        return _msg.PinAsync(options);
+    }
+
+    public Task UnpinAsync(RequestOptions? options = null)
+    {
+        return _msg.UnpinAsync(options);
+    }
+
+    public Task CrosspostAsync(RequestOptions? options = null)
+    {
+        return _msg.CrosspostAsync(options);
+    }
+
+    public string Resolve(
+        TagHandling userHandling = TagHandling.Name,
+        TagHandling channelHandling = TagHandling.Name,
+        TagHandling roleHandling = TagHandling.Name,
+        TagHandling everyoneHandling = TagHandling.Ignore,
+        TagHandling emojiHandling = TagHandling.Name)
+    {
+        return _msg.Resolve(userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling);
+    }
+
+    public Task EndPollAsync(RequestOptions options)
+        => _msg.EndPollAsync(options);
+
+    public IAsyncEnumerable<IReadOnlyCollection<IUser>> GetPollAnswerVotersAsync(
+        uint answerId,
+        int? limit = null,
+        ulong? afterId = null,
+        RequestOptions? options = null)
+        => _msg.GetPollAnswerVotersAsync(
+            answerId,
+            limit,
+            afterId,
+            options);
+
+    public MessageResolvedData ResolvedData
+        => _msg.ResolvedData;
+
+    public IUserMessage ReferencedMessage
+        => _msg.ReferencedMessage;
+
+    public IMessageInteractionMetadata InteractionMetadata
+        => _msg.InteractionMetadata;
+
+    public Poll? Poll
+        => _msg.Poll;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/DownloadTracker.cs b/src/EllieBot/_common/DownloadTracker.cs
new file mode 100644
index 0000000..07a5670
--- /dev/null
+++ b/src/EllieBot/_common/DownloadTracker.cs
@@ -0,0 +1,38 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public class DownloadTracker : IEService
+{
+    private ConcurrentDictionary<ulong, DateTime> LastDownloads { get; } = new();
+    private readonly SemaphoreSlim _downloadUsersSemaphore = new(1, 1);
+
+    /// <summary>
+    ///     Ensures all users on the specified guild were downloaded within the last hour.
+    /// </summary>
+    /// <param name="guild">Guild to check and potentially download users from</param>
+    /// <returns>Task representing download state</returns>
+    public async Task EnsureUsersDownloadedAsync(IGuild guild)
+    {
+#if GLOBAL_NADEKO
+        return;
+#endif
+        await _downloadUsersSemaphore.WaitAsync();
+        try
+        {
+            var now = DateTime.UtcNow;
+
+            // download once per hour at most
+            var added = LastDownloads.AddOrUpdate(guild.Id,
+                now,
+                (_, old) => now - old > TimeSpan.FromHours(1) ? now : old);
+
+            // means that this entry was just added - download the users
+            if (added == now)
+                await guild.DownloadUsersAsync();
+        }
+        finally
+        {
+            _downloadUsersSemaphore.Release();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/DryIocExtensions.cs b/src/EllieBot/_common/DryIocExtensions.cs
new file mode 100644
index 0000000..c7d8c2a
--- /dev/null
+++ b/src/EllieBot/_common/DryIocExtensions.cs
@@ -0,0 +1,43 @@
+using DryIoc;
+
+namespace EllieBot.Extensions;
+
+public static class DryIocExtensions
+{
+    public static IContainer AddSingleton<TSvc, TImpl>(this IContainer container)
+        where TImpl : TSvc
+    {
+        container.Register<TSvc, TImpl>(Reuse.Singleton);
+
+        return container;
+    }
+    
+    public static IContainer AddSingleton<TSvc, TImpl>(this IContainer container, TImpl obj)
+        where TImpl : TSvc
+    {
+        container.RegisterInstance<TSvc>(obj);
+
+        return container;
+    }
+
+    public static IContainer AddSingleton<TImpl>(this IContainer container)
+    {
+        container.Register<TImpl>(Reuse.Singleton);
+
+        return container;
+    }
+
+    public static IContainer AddSingleton<TImpl>(this IContainer container, TImpl obj)
+    {
+        container.RegisterInstance<TImpl>(obj);
+
+        return container;
+    }
+    
+    public static IContainer AddSingleton<TImpl>(this IContainer container, Func<IResolverContext, TImpl> factory)
+    {
+        container.RegisterDelegate(factory);
+
+        return container;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/EllieModule.cs b/src/EllieBot/_common/EllieModule.cs
new file mode 100644
index 0000000..d8867c1
--- /dev/null
+++ b/src/EllieBot/_common/EllieModule.cs
@@ -0,0 +1,108 @@
+#nullable disable
+using System.Globalization;
+
+// ReSharper disable InconsistentNaming
+
+namespace EllieBot.Common;
+
+[UsedImplicitly(ImplicitUseTargetFlags.Default
+                | ImplicitUseTargetFlags.WithInheritors
+                | ImplicitUseTargetFlags.WithMembers)]
+public abstract class EllieModule : 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 IEllieInteractionService _inter { get; set; }
+    public IReplacementService repSvc { get; set; }
+    public IMessageSenderService _sender { get; set; }
+    public BotConfigService _bcs { get; set; }
+
+    protected string prefix
+        => _cmdHandler.GetPrefix(ctx.Guild);
+
+    protected ICommandContext ctx
+        => Context;
+
+    public ResponseBuilder Response()
+        => new ResponseBuilder(Strings, _bcs, (DiscordSocketClient)ctx.Client)
+            .Context(ctx);
+
+    protected override void BeforeExecute(CommandInfo command)
+        => Culture = _localization.GetCultureInfo(ctx.Guild?.Id);
+
+    protected string GetText(in LocStr data)
+        => Strings.GetText(data, Culture);
+
+    // localized normal
+    public async Task<bool> PromptUserConfirmAsync(EmbedBuilder embed)
+    {
+        embed.WithPendingColor()
+             .WithFooter("yes/no");
+
+        var msg = await Response().Embed(embed).SendAsync();
+        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, Func<string, bool> validate = null)
+    {
+        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 (validate is not null && !validate(arg.Content))
+                    return Task.CompletedTask;
+                
+                if (userInputTask.TrySetResult(arg.Content))
+                    userMsg.DeleteAfter(1);
+
+                return Task.CompletedTask;
+            });
+            return Task.CompletedTask;
+        }
+    }
+}
+
+public abstract class EllieModule<TService> : EllieModule
+{
+    public TService _service { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/EllieTypeReader.cs b/src/EllieBot/_common/EllieTypeReader.cs
new file mode 100644
index 0000000..f14aa1e
--- /dev/null
+++ b/src/EllieBot/_common/EllieTypeReader.cs
@@ -0,0 +1,15 @@
+#nullable disable
+
+namespace EllieBot.Common.TypeReaders;
+
+[MeansImplicitUse(ImplicitUseTargetFlags.Default | ImplicitUseTargetFlags.WithInheritors)]
+public abstract class EllieTypeReader<T> : TypeReader
+{
+    public abstract ValueTask<TypeReaderResult<T>> ReadAsync(ICommandContext ctx, string input);
+
+    public override async Task<Discord.Commands.TypeReaderResult> ReadAsync(
+        ICommandContext ctx,
+        string input,
+        IServiceProvider services)
+        => await ReadAsync(ctx, input);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Helpers.cs b/src/EllieBot/_common/Helpers.cs
new file mode 100644
index 0000000..a7d458f
--- /dev/null
+++ b/src/EllieBot/_common/Helpers.cs
@@ -0,0 +1,13 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public static class Helpers
+{
+    public static void ReadErrorAndExit(int exitCode)
+    {
+        if (!Console.IsInputRedirected)
+            Console.ReadKey();
+
+        Environment.Exit(exitCode);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/IBot.cs b/src/EllieBot/_common/IBot.cs
new file mode 100644
index 0000000..c6c5a06
--- /dev/null
+++ b/src/EllieBot/_common/IBot.cs
@@ -0,0 +1,12 @@
+#nullable disable
+using EllieBot.Db.Models;
+
+namespace EllieBot;
+
+public interface IBot
+{
+    IReadOnlyList<ulong> GetCurrentGuildIds();
+    event Func<GuildConfig, Task> JoinedGuild;
+    IReadOnlyCollection<GuildConfig> AllGuildConfigs { get; }
+    bool IsReady { get; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ICloneable.cs b/src/EllieBot/_common/ICloneable.cs
new file mode 100644
index 0000000..c8d3fa8
--- /dev/null
+++ b/src/EllieBot/_common/ICloneable.cs
@@ -0,0 +1,8 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public interface ICloneable<T>
+    where T : new()
+{
+    public T Clone();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ICurrencyProvider.cs b/src/EllieBot/_common/ICurrencyProvider.cs
new file mode 100644
index 0000000..0cca0ae
--- /dev/null
+++ b/src/EllieBot/_common/ICurrencyProvider.cs
@@ -0,0 +1,29 @@
+using System.Globalization;
+using System.Numerics;
+
+namespace EllieBot.Common;
+
+public interface ICurrencyProvider
+{
+    string GetCurrencySign();
+}
+
+public static class CurrencyHelper
+{
+    public static string N<T>(T cur, IFormatProvider format)
+        where T : INumber<T>
+        => cur.ToString("C0", format);
+
+    public static string N<T>(T cur, CultureInfo culture, string currencySign)
+        where T : INumber<T>
+        => N(cur, GetCurrencyFormat(culture, currencySign));
+
+    private static IFormatProvider GetCurrencyFormat(CultureInfo culture, string currencySign)
+    {
+        var flowersCurrencyCulture = (CultureInfo)culture.Clone();
+        flowersCurrencyCulture.NumberFormat.CurrencySymbol = currencySign;
+        flowersCurrencyCulture.NumberFormat.CurrencyNegativePattern = 5;
+
+        return flowersCurrencyCulture;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/IDiscordPermOverrideService.cs b/src/EllieBot/_common/IDiscordPermOverrideService.cs
new file mode 100644
index 0000000..b8471c3
--- /dev/null
+++ b/src/EllieBot/_common/IDiscordPermOverrideService.cs
@@ -0,0 +1,7 @@
+#nullable disable
+namespace Ellie.Common;
+
+public interface IDiscordPermOverrideService
+{
+    bool TryGetOverrides(ulong guildId, string commandName, out EllieBot.Db.GuildPerm? perm);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/IEllieCommandOptions.cs b/src/EllieBot/_common/IEllieCommandOptions.cs
new file mode 100644
index 0000000..bb758b5
--- /dev/null
+++ b/src/EllieBot/_common/IEllieCommandOptions.cs
@@ -0,0 +1,7 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public interface IEllieCommandOptions
+{
+    void NormalizeOptions();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ILogCommandService.cs b/src/EllieBot/_common/ILogCommandService.cs
new file mode 100644
index 0000000..7e5bcd5
--- /dev/null
+++ b/src/EllieBot/_common/ILogCommandService.cs
@@ -0,0 +1,34 @@
+using EllieBot.Db.Models;
+
+namespace EllieBot.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,
+    UserMuted,
+    UserWarned,
+    
+    ThreadDeleted,
+    ThreadCreated
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/IPermissionChecker.cs b/src/EllieBot/_common/IPermissionChecker.cs
new file mode 100644
index 0000000..5b4bc48
--- /dev/null
+++ b/src/EllieBot/_common/IPermissionChecker.cs
@@ -0,0 +1,37 @@
+using OneOf;
+
+namespace EllieBot.Common;
+
+public interface IPermissionChecker
+{
+    Task<PermCheckResult> CheckPermsAsync(IGuild guild,
+        IMessageChannel channel,
+        IUser author,
+        string module,
+        string? cmd);
+}
+
+[GenerateOneOf]
+public partial class PermCheckResult
+    : OneOfBase<PermAllowed, PermCooldown, PermGlobalBlock, PermDisallowed>
+{
+    public bool IsAllowed
+        => IsT0;
+    
+    public bool IsCooldown 
+        => IsT1;
+    
+    public bool IsGlobalBlock 
+        => IsT2;
+    
+    public bool IsDisallowed 
+        => IsT3;
+}
+
+public readonly record struct PermAllowed;
+
+public readonly record struct PermCooldown;
+
+public readonly record struct PermGlobalBlock;
+
+public readonly record struct PermDisallowed(int PermIndex, string PermText, bool IsVerbose);
\ No newline at end of file
diff --git a/src/EllieBot/_common/IPlaceholderProvider.cs b/src/EllieBot/_common/IPlaceholderProvider.cs
new file mode 100644
index 0000000..20fcc12
--- /dev/null
+++ b/src/EllieBot/_common/IPlaceholderProvider.cs
@@ -0,0 +1,7 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public interface IPlaceholderProvider
+{
+    public IEnumerable<(string Name, Func<string> Func)> GetPlaceholders();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ImageUrls.cs b/src/EllieBot/_common/ImageUrls.cs
new file mode 100644
index 0000000..fa253a7
--- /dev/null
+++ b/src/EllieBot/_common/ImageUrls.cs
@@ -0,0 +1,36 @@
+#nullable disable
+using EllieBot.Common.Yml;
+using Cloneable;
+
+namespace EllieBot.Common;
+
+[Cloneable]
+public partial class ImageUrls : ICloneable<ImageUrls> 
+{
+    [Comment("DO NOT CHANGE")]
+    public int Version { get; set; } = 5;
+
+    public CoinData Coins { get; set; }
+    public Uri[] Currency { get; set; }
+    public Uri[] Dice { get; set; }
+    public XpData Xp { get; set; }
+
+    public SlotData Slots { get; set; }
+
+    public class SlotData
+    {
+        public Uri[] Emojis { get; set; }
+        public Uri Bg { get; set; }
+    }
+
+    public class CoinData
+    {
+        public Uri[] Heads { get; set; }
+        public Uri[] Tails { get; set; }
+    }
+
+    public class XpData
+    {
+        public Uri Bg { get; set; }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/EllieInteraction.cs b/src/EllieBot/_common/Interaction/EllieInteraction.cs
new file mode 100644
index 0000000..0206599
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/EllieInteraction.cs
@@ -0,0 +1,174 @@
+namespace EllieBot;
+
+public abstract class EllieInteractionBase
+{
+    private readonly ulong _authorId;
+    private readonly Func<SocketMessageComponent, Task> _onAction;
+    private readonly bool _onlyAuthor;
+    public DiscordSocketClient Client { get; }
+
+    private readonly TaskCompletionSource<bool> _interactionCompletedSource;
+
+    private IUserMessage message = null!;
+    private readonly string _customId;
+    private readonly bool _singleUse;
+    private readonly bool _clearAfter;
+
+    public EllieInteractionBase(
+        DiscordSocketClient client,
+        ulong authorId,
+        string customId,
+        Func<SocketMessageComponent, Task> onAction,
+        bool onlyAuthor,
+        bool singleUse = true,
+        bool clearAfter = true)
+    {
+        _authorId = authorId;
+        _customId = customId;
+        _onAction = onAction;
+        _onlyAuthor = onlyAuthor;
+        _singleUse = singleUse;
+        _clearAfter = clearAfter;
+        
+        _interactionCompletedSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+        Client = client;
+    }
+
+    public async Task RunAsync(IUserMessage msg)
+    {
+        message = msg;
+
+        Client.InteractionCreated += OnInteraction;
+        await Task.WhenAny(Task.Delay(30_000), _interactionCompletedSource.Task);
+        Client.InteractionCreated -= OnInteraction;
+
+        if (_clearAfter)
+            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 != _customId)
+            return Task.CompletedTask;
+
+        if (_interactionCompletedSource.Task.IsCompleted)
+            return Task.CompletedTask;
+
+        _ = Task.Run(async () =>
+        {
+            try
+            {
+                if (_singleUse)
+                    _interactionCompletedSource.TrySetResult(true);
+                
+                await ExecuteOnActionAsync(smc);
+
+                if (!smc.HasResponded)
+                {
+                    await smc.DeferAsync();
+                }
+            }
+            catch (Exception ex)
+            {
+                Log.Warning(ex, "An exception occured while handling an interaction: {Message}", ex.Message);
+            }
+        });
+
+        return Task.CompletedTask;
+    }
+
+
+    public abstract void AddTo(ComponentBuilder cb);
+
+    public Task ExecuteOnActionAsync(SocketMessageComponent smc)
+        => _onAction(smc);
+
+    public void SetCompleted()
+        => _interactionCompletedSource.TrySetResult(true);
+}
+
+public sealed class EllieModalSubmitHandler
+{
+    private readonly ulong _authorId;
+    private readonly Func<SocketModal, Task> _onAction;
+    private readonly bool _onlyAuthor;
+    public DiscordSocketClient Client { get; }
+
+    private readonly TaskCompletionSource<bool> _interactionCompletedSource;
+
+    private IUserMessage message = null!;
+    private readonly string _customId;
+
+    public EllieModalSubmitHandler(
+        DiscordSocketClient client,
+        ulong authorId,
+        string customId,
+        Func<SocketModal, Task> onAction,
+        bool onlyAuthor)
+    {
+        _authorId = authorId;
+        _customId = customId;
+        _onAction = onAction;
+        _onlyAuthor = onlyAuthor;
+        _interactionCompletedSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+        Client = client;
+    }
+
+    public async Task RunAsync(IUserMessage msg)
+    {
+        message = msg;
+
+        Client.ModalSubmitted += OnInteraction;
+        await Task.WhenAny(Task.Delay(300_000), _interactionCompletedSource.Task);
+        Client.ModalSubmitted -= OnInteraction;
+
+        await msg.ModifyAsync(m => m.Components = new ComponentBuilder().Build());
+    }
+
+    private Task OnInteraction(SocketModal sm)
+    {
+        if (sm.Message.Id != message.Id)
+            return Task.CompletedTask;
+
+        if (_onlyAuthor && sm.User.Id != _authorId)
+            return Task.CompletedTask;
+
+        if (sm.Data.CustomId != _customId)
+            return Task.CompletedTask;
+
+        _ = Task.Run(async () =>
+        {
+            try
+            {
+                _interactionCompletedSource.TrySetResult(true);
+                await ExecuteOnActionAsync(sm);
+
+                if (!sm.HasResponded)
+                {
+                    await sm.DeferAsync();
+                }
+            }
+            catch (Exception ex)
+            {
+                Log.Warning(ex, "An exception occured while handling a: {Message}", ex.Message);
+            }
+        });
+
+        return Task.CompletedTask;
+    }
+
+
+    public Task ExecuteOnActionAsync(SocketModal smd)
+        => _onAction(smd);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/EllieInteractionService.cs b/src/EllieBot/_common/Interaction/EllieInteractionService.cs
new file mode 100644
index 0000000..813ba1f
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/EllieInteractionService.cs
@@ -0,0 +1,82 @@
+namespace EllieBot;
+
+public class EllieInteractionService : IEllieInteractionService, IEService
+{
+    private readonly DiscordSocketClient _client;
+
+    public EllieInteractionService(DiscordSocketClient client)
+    {
+        _client = client;
+    }
+
+    public EllieInteractionBase Create(
+        ulong userId,
+        ButtonBuilder button,
+        Func<SocketMessageComponent, Task> onTrigger,
+        bool singleUse = true,
+        bool clearAfter = true)
+        => new EllieButtonInteractionHandler(_client,
+            userId,
+            button,
+            onTrigger,
+            onlyAuthor: true,
+            singleUse: singleUse,
+            clearAfter: clearAfter);
+
+    public EllieInteractionBase Create<T>(
+        ulong userId,
+        ButtonBuilder button,
+        Func<SocketMessageComponent, T, Task> onTrigger,
+        in T state,
+        bool singleUse = true,
+        bool clearAfter = true
+    )
+        => Create(userId,
+            button,
+            ((Func<T, Func<SocketMessageComponent, Task>>)((data)
+                => smc => onTrigger(smc, data)))(state),
+            singleUse,
+            clearAfter);
+
+    public EllieInteractionBase Create(
+        ulong userId,
+        SelectMenuBuilder menu,
+        Func<SocketMessageComponent, Task> onTrigger,
+        bool singleUse = true)
+        => new EllieButtonSelectInteractionHandler(_client,
+            userId,
+            menu,
+            onTrigger,
+            onlyAuthor: true,
+            singleUse: singleUse);
+
+
+    /// <summary>
+    /// Create an interaction which opens a modal
+    /// </summary>
+    /// <param name="userId">Id of the author</param>
+    /// <param name="button">Button builder for the button that will open the modal</param>
+    /// <param name="modal">Modal</param>
+    /// <param name="onTrigger">The function that will be called when the modal is submitted</param>
+    /// <param name="singleUse">Whether the button is single use</param>
+    /// <returns></returns>
+    public EllieInteractionBase Create(
+        ulong userId,
+        ButtonBuilder button,
+        ModalBuilder modal,
+        Func<SocketModal, Task> onTrigger,
+        bool singleUse = true)
+        => Create(userId,
+            button,
+            async (smc) =>
+            {
+                await smc.RespondWithModalAsync(modal.Build());
+                var modalHandler = new EllieModalSubmitHandler(_client,
+                    userId,
+                    modal.CustomId,
+                    onTrigger,
+                    true);
+                await modalHandler.RunAsync(smc.Message);
+            },
+            singleUse: singleUse);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/IEllieInteractionService.cs b/src/EllieBot/_common/Interaction/IEllieInteractionService.cs
new file mode 100644
index 0000000..26adc74
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/IEllieInteractionService.cs
@@ -0,0 +1,33 @@
+namespace EllieBot;
+
+public interface IEllieInteractionService
+{
+    public EllieInteractionBase Create(
+        ulong userId,
+        ButtonBuilder button,
+        Func<SocketMessageComponent, Task> onTrigger,
+        bool singleUse = true,
+        bool clearAfter = true);
+
+    public EllieInteractionBase Create<T>(
+        ulong userId,
+        ButtonBuilder button,
+        Func<SocketMessageComponent, T, Task> onTrigger,
+        in T state,
+        bool singleUse = true,
+        bool clearAfter = true);
+
+    EllieInteractionBase Create(
+        ulong userId,
+        SelectMenuBuilder menu,
+        Func<SocketMessageComponent, Task> onTrigger,
+        bool singleUse = true);
+    
+    EllieInteractionBase Create(
+        ulong userId, 
+        ButtonBuilder button,
+        ModalBuilder modal,
+        Func<SocketModal, Task> onTrigger,
+        bool singleUse = true);
+    
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/InteractionHelpers.cs b/src/EllieBot/_common/Interaction/InteractionHelpers.cs
new file mode 100644
index 0000000..0bac67f
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/InteractionHelpers.cs
@@ -0,0 +1,7 @@
+namespace EllieBot;
+
+public static class InteractionHelpers
+{
+    public static readonly IEmote ArrowLeft = Emote.Parse("<:x:1232256519844790302>");
+    public static readonly IEmote ArrowRight = Emote.Parse("<:x:1232256515298295838>");
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs b/src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs
new file mode 100644
index 0000000..676ab5d
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs
@@ -0,0 +1,23 @@
+namespace EllieBot;
+
+public sealed class EllieButtonInteractionHandler : EllieInteractionBase
+{
+    public EllieButtonInteractionHandler(
+        DiscordSocketClient client,
+        ulong authorId,
+        ButtonBuilder button,
+        Func<SocketMessageComponent, Task> onAction,
+        bool onlyAuthor,
+        bool singleUse = true,
+        bool clearAfter = true)
+        : base(client, authorId, button.CustomId, onAction, onlyAuthor, singleUse, clearAfter)
+    {
+        Button = button;
+    }
+
+    public ButtonBuilder Button { get; }
+
+    public override void AddTo(ComponentBuilder cb)
+        => cb.WithButton(Button);
+
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs b/src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs
new file mode 100644
index 0000000..fa3b0e0
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/Models/EllieInteractionExtensions.cs
@@ -0,0 +1,15 @@
+namespace EllieBot;
+
+public static class EllieInteractionExtensions
+{
+    public static MessageComponent CreateComponent(
+        this EllieInteractionBase nadekoInteractionBase
+    )
+    {
+        var cb = new ComponentBuilder();
+
+        nadekoInteractionBase.AddTo(cb);
+
+        return cb.Build();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs b/src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs
new file mode 100644
index 0000000..7100f01
--- /dev/null
+++ b/src/EllieBot/_common/Interaction/Models/EllieSelectInteraction.cs
@@ -0,0 +1,21 @@
+namespace EllieBot;
+
+public sealed class EllieButtonSelectInteractionHandler : EllieInteractionBase
+{
+    public EllieButtonSelectInteractionHandler(
+        DiscordSocketClient client,
+        ulong authorId,
+        SelectMenuBuilder menu,
+        Func<SocketMessageComponent, Task> onAction,
+        bool onlyAuthor,
+        bool singleUse = true)
+        : base(client, authorId, menu.CustomId, onAction, onlyAuthor, singleUse)
+    {
+        Menu = menu;
+    }
+
+    public SelectMenuBuilder Menu { get; }
+
+    public override void AddTo(ComponentBuilder cb)
+        => cb.WithSelectMenu(Menu);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs b/src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs
new file mode 100644
index 0000000..969a416
--- /dev/null
+++ b/src/EllieBot/_common/JsonConverters/CultureInfoConverter.cs
@@ -0,0 +1,14 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Common.JsonConverters;
+
+public class CultureInfoConverter : JsonConverter<CultureInfo>
+{
+    public override CultureInfo Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+        => new(reader.GetString() ?? "en-US");
+
+    public override void Write(Utf8JsonWriter writer, CultureInfo value, JsonSerializerOptions options)
+        => writer.WriteStringValue(value.Name);
+}
diff --git a/src/EllieBot/_common/JsonConverters/NumberToStringConverter.cs b/src/EllieBot/_common/JsonConverters/NumberToStringConverter.cs
new file mode 100644
index 0000000..bc20b10
--- /dev/null
+++ b/src/EllieBot/_common/JsonConverters/NumberToStringConverter.cs
@@ -0,0 +1,30 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+public class NumberToStringConverter : JsonConverter<object>
+{
+    public override bool CanConvert(Type typeToConvert)
+        => typeof(string) == typeToConvert;
+
+    public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+    {
+        switch (reader.TokenType)
+        {
+            case JsonTokenType.Number:
+                return reader.TryGetInt64(out var l)
+                    ? l.ToString()
+                    : reader.GetDouble().ToString(CultureInfo.InvariantCulture);
+            case JsonTokenType.String:
+                return reader.GetString() ?? string.Empty;
+            default:
+                {
+                    using var document = JsonDocument.ParseValue(ref reader);
+                    return document.RootElement.Clone().ToString();
+                }
+        }
+    }
+    
+    public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
+        => writer.WriteStringValue(value.ToString());
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/JsonConverters/Rgba32Converter.cs b/src/EllieBot/_common/JsonConverters/Rgba32Converter.cs
new file mode 100644
index 0000000..4b2a313
--- /dev/null
+++ b/src/EllieBot/_common/JsonConverters/Rgba32Converter.cs
@@ -0,0 +1,14 @@
+using SixLabors.ImageSharp.PixelFormats;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Common.JsonConverters;
+
+public class Rgba32Converter : JsonConverter<Rgba32>
+{
+    public override Rgba32 Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+        => Rgba32.ParseHex(reader.GetString()!);
+
+    public override void Write(Utf8JsonWriter writer, Rgba32 value, JsonSerializerOptions options)
+        => writer.WriteStringValue(value.ToHex());
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/LbOpts.cs b/src/EllieBot/_common/LbOpts.cs
new file mode 100644
index 0000000..5df4986
--- /dev/null
+++ b/src/EllieBot/_common/LbOpts.cs
@@ -0,0 +1,14 @@
+#nullable disable
+using CommandLine;
+
+namespace EllieBot.Common;
+
+public class LbOpts : IEllieCommandOptions
+{
+    [Option('c', "clean", Default = false, HelpText = "Only show users who are on the server.")]
+    public bool Clean { get; set; }
+
+    public void NormalizeOptions()
+    {
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Linq2DbExpressions.cs b/src/EllieBot/_common/Linq2DbExpressions.cs
new file mode 100644
index 0000000..baa1f74
--- /dev/null
+++ b/src/EllieBot/_common/Linq2DbExpressions.cs
@@ -0,0 +1,17 @@
+#nullable disable
+using LinqToDB;
+using Microsoft.EntityFrameworkCore;
+using System.Linq.Expressions;
+
+namespace EllieBot.Common;
+
+public static class Linq2DbExpressions
+{
+    [ExpressionMethod(nameof(GuildOnShardExpression))]
+    public static bool GuildOnShard(ulong guildId, int totalShards, int shardId)
+        => throw new NotSupportedException();
+    
+    private static Expression<Func<ulong, int, int, bool>> GuildOnShardExpression()
+        => (guildId, totalShards, shardId)
+            => guildId / 4194304 % (ulong)totalShards == (ulong)shardId;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/LoginErrorHandler.cs b/src/EllieBot/_common/LoginErrorHandler.cs
new file mode 100644
index 0000000..bbdc9ce
--- /dev/null
+++ b/src/EllieBot/_common/LoginErrorHandler.cs
@@ -0,0 +1,52 @@
+#nullable disable
+using System.Net;
+using System.Runtime.CompilerServices;
+
+namespace EllieBot.Common;
+
+public class LoginErrorHandler
+{
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static void Handle(Exception ex)
+        => Log.Fatal(ex, "A fatal error has occurred while attempting to connect to Discord");
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    public static void Handle(HttpException ex)
+    {
+        switch (ex.HttpCode)
+        {
+            case HttpStatusCode.Unauthorized:
+                Log.Error("Your bot token is wrong.\n"
+                          + "You can find the bot token under the Bot tab in the developer page.\n"
+                          + "Fix your token in the credentials file and restart the bot");
+                break;
+
+            case HttpStatusCode.BadRequest:
+                Log.Error("Something has been incorrectly formatted in your credentials file.\n"
+                          + "Use the JSON Guide as reference to fix it and restart the bot");
+                Log.Error("If you are on Linux, make sure Redis is installed and running");
+                break;
+
+            case HttpStatusCode.RequestTimeout:
+                Log.Error("The request timed out. Make sure you have no external program blocking the bot "
+                          + "from connecting to the internet");
+                break;
+
+            case HttpStatusCode.ServiceUnavailable:
+            case HttpStatusCode.InternalServerError:
+                Log.Error("Discord is having internal issues. Please, try again later");
+                break;
+
+            case HttpStatusCode.TooManyRequests:
+                Log.Error("Your bot has been ratelimited by Discord. Please, try again later.\n"
+                          + "Global ratelimits usually last for an hour");
+                break;
+
+            default:
+                Log.Warning("An error occurred while attempting to connect to Discord");
+                break;
+        }
+
+        Log.Fatal(ex, "Fatal error occurred while loading credentials");
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/MessageType.cs b/src/EllieBot/_common/MessageType.cs
new file mode 100644
index 0000000..ada8d99
--- /dev/null
+++ b/src/EllieBot/_common/MessageType.cs
@@ -0,0 +1,8 @@
+namespace EllieBot.Common;
+
+public enum MsgType
+{
+    Ok,
+    Pending,
+    Error
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ModuleBehaviors/IBehavior.cs b/src/EllieBot/_common/ModuleBehaviors/IBehavior.cs
new file mode 100644
index 0000000..5fdcc5c
--- /dev/null
+++ b/src/EllieBot/_common/ModuleBehaviors/IBehavior.cs
@@ -0,0 +1,6 @@
+namespace EllieBot.Common.ModuleBehaviors;
+
+public interface IBehavior
+{
+    public virtual string Name => this.GetType().Name;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs b/src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs
new file mode 100644
index 0000000..f23d085
--- /dev/null
+++ b/src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs
@@ -0,0 +1,19 @@
+namespace EllieBot.Common.ModuleBehaviors;
+
+/// <summary>
+///     Executed if no command was found for this message
+/// </summary>
+public interface IExecNoCommand : IBehavior
+{
+    /// <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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs b/src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs
new file mode 100644
index 0000000..3e39152
--- /dev/null
+++ b/src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs
@@ -0,0 +1,21 @@
+namespace EllieBot.Common.ModuleBehaviors;
+
+/// <summary>
+///     Implemented by modules to handle non-bot messages received
+/// </summary>
+public interface IExecOnMessage : IBehavior
+{
+    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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs b/src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs
new file mode 100644
index 0000000..bad88f8
--- /dev/null
+++ b/src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs
@@ -0,0 +1,22 @@
+namespace EllieBot.Common.ModuleBehaviors;
+
+/// <summary>
+/// This interface's method is executed after the command successfully finished execution.
+/// ***There is no support for this method in EllieBot services.***
+/// It is only meant to be used in medusa system
+/// </summary>
+public interface IExecPostCommand : IBehavior
+{
+    /// <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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs b/src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs
new file mode 100644
index 0000000..4320d8f
--- /dev/null
+++ b/src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs
@@ -0,0 +1,25 @@
+namespace EllieBot.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 : IBehavior
+{
+    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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs b/src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs
new file mode 100644
index 0000000..7039989
--- /dev/null
+++ b/src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs
@@ -0,0 +1,25 @@
+namespace EllieBot.Common.ModuleBehaviors;
+
+/// <summary>
+/// Implemented by services which may transform input before a command is searched for
+/// </summary>
+public interface IInputTransformer : IBehavior
+{
+    /// <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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs b/src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs
new file mode 100644
index 0000000..9364286
--- /dev/null
+++ b/src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs
@@ -0,0 +1,13 @@
+namespace EllieBot.Common.ModuleBehaviors;
+
+/// <summary>
+///     All services which need to execute something after
+///     the bot is ready should implement this interface
+/// </summary>
+public interface IReadyExecutor : IBehavior
+{
+    /// <summary>
+    ///     Executed when bot is ready
+    /// </summary>
+    public Task OnReadyAsync();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/OptionsParser.cs b/src/EllieBot/_common/OptionsParser.cs
new file mode 100644
index 0000000..6908c8f
--- /dev/null
+++ b/src/EllieBot/_common/OptionsParser.cs
@@ -0,0 +1,23 @@
+using CommandLine;
+
+namespace EllieBot.Common;
+
+public static class OptionsParser
+{
+    public static T ParseFrom<T>(string[]? args)
+        where T : IEllieCommandOptions, new()
+        => ParseFrom(new T(), args).Item1;
+
+    public static (T, bool) ParseFrom<T>(T options, string[]? args)
+        where T : IEllieCommandOptions
+    {
+        using var p = new Parser(x =>
+        {
+            x.HelpWriter = null;
+        });
+        var res = p.ParseArguments<T>(args);
+        var output = res.MapResult(x => x, _ => options);
+        output.NormalizeOptions();
+        return (output, res.Tag == ParserResultType.Parsed);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Patronage/FeatureLimitKey.cs b/src/EllieBot/_common/Patronage/FeatureLimitKey.cs
new file mode 100644
index 0000000..10278e1
--- /dev/null
+++ b/src/EllieBot/_common/Patronage/FeatureLimitKey.cs
@@ -0,0 +1,14 @@
+namespace EllieBot.Modules.Patronage;
+
+public enum LimitedFeatureName
+{
+    ChatBot,
+    ReactionRole,
+    Prune,
+    
+}
+public readonly struct FeatureLimitKey
+{
+    public string PrettyName { get; init; }
+    public string Key { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Patronage/IPatronData.cs b/src/EllieBot/_common/Patronage/IPatronData.cs
new file mode 100644
index 0000000..8cd99e0
--- /dev/null
+++ b/src/EllieBot/_common/Patronage/IPatronData.cs
@@ -0,0 +1,11 @@
+namespace EllieBot.Modules.Patronage;
+
+public interface ISubscriberData
+{
+    public string UniquePlatformUserId { get; }
+    public ulong UserId { get; }
+    public int Cents { get; }
+    
+    public DateTime? LastCharge { get; }
+    public SubscriptionChargeStatus ChargeStatus { get; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Patronage/IPatronageService.cs b/src/EllieBot/_common/Patronage/IPatronageService.cs
new file mode 100644
index 0000000..379de6b
--- /dev/null
+++ b/src/EllieBot/_common/Patronage/IPatronageService.cs
@@ -0,0 +1,42 @@
+namespace EllieBot.Modules.Patronage;
+
+/// <summary>
+/// Manages patrons and provides access to their data  
+/// </summary>
+public interface IPatronageService
+{
+    /// <summary>
+    /// Called when the payment is made.
+    /// Either as a single payment for that patron,
+    /// or as a recurring monthly donation.
+    /// </summary>
+    public event Func<Patron, Task> OnNewPatronPayment;
+    
+    /// <summary>
+    /// Called when the patron changes the pledge amount
+    /// (Patron old, Patron new) => Task
+    /// </summary>
+    public event Func<Patron, Patron, Task> OnPatronUpdated;
+    
+    /// <summary>
+    /// Called when the patron refunds the purchase or it's marked as fraud
+    /// </summary>
+    public event Func<Patron, Task> OnPatronRefunded;
+
+    /// <summary>
+    /// Gets a Patron with the specified userId
+    /// </summary>
+    /// <param name="userId">UserId for which to get the patron data for.</param>
+    /// <returns>A patron with the specifeid userId</returns>
+    public Task<Patron?> GetPatronAsync(ulong userId);
+    
+    Task<bool> LimitHitAsync(LimitedFeatureName key, ulong userId, int amount = 1);
+    Task<bool> LimitForceHit(LimitedFeatureName key, ulong userId, int amount);
+    Task<QuotaLimit> GetUserLimit(LimitedFeatureName name, ulong userId);
+    
+    Task<Dictionary<LimitedFeatureName, (int, QuotaLimit)>> LimitStats(ulong userId);
+
+    PatronConfigData GetConfig();
+    int PercentBonus(Patron? user);
+    int PercentBonus(long amount);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Patronage/ISubscriptionHandler.cs b/src/EllieBot/_common/Patronage/ISubscriptionHandler.cs
new file mode 100644
index 0000000..95160f7
--- /dev/null
+++ b/src/EllieBot/_common/Patronage/ISubscriptionHandler.cs
@@ -0,0 +1,16 @@
+#nullable disable
+namespace EllieBot.Modules.Patronage;
+
+/// <summary>
+/// Services implementing this interface are handling pledges/subscriptions/payments coming
+/// from a payment platform.
+/// </summary>
+public interface ISubscriptionHandler
+{
+    /// <summary>
+    /// Get Current patrons in batches.
+    /// This will only return patrons who have their discord account connected
+    /// </summary>
+    /// <returns>Batched patrons</returns>
+    public IAsyncEnumerable<IReadOnlyCollection<ISubscriberData>> GetPatronsAsync();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Patronage/Patron.cs b/src/EllieBot/_common/Patronage/Patron.cs
new file mode 100644
index 0000000..93a7575
--- /dev/null
+++ b/src/EllieBot/_common/Patronage/Patron.cs
@@ -0,0 +1,38 @@
+namespace EllieBot.Modules.Patronage;
+
+public readonly struct Patron
+{
+    /// <summary>
+    /// Unique id assigned to this patron by the payment platform
+    /// </summary>
+    public string UniquePlatformUserId { get; init; }
+
+    /// <summary>
+    /// Discord UserId to which this <see cref="UniquePlatformUserId"/> is connected to
+    /// </summary>
+    public ulong UserId { get; init; }
+
+    /// <summary>
+    /// Amount the Patron is currently pledging or paid in cents
+    /// </summary>
+    public int Amount { get; init; }
+
+    /// <summary>
+    /// Current Tier of the patron
+    /// (do not question it in consumer classes, as the calculation should be always internal and may change)
+    /// </summary>
+    public PatronTier Tier { get; init; }
+
+    /// <summary>
+    /// When was the last time this <see cref="Amount"/> was paid
+    /// </summary>
+    public DateTime PaidAt { get; init; }
+
+    /// <summary>
+    /// After which date does the user's Patronage benefit end
+    /// </summary>
+    public DateTime ValidThru { get; init; }
+
+    public bool IsActive
+        => !ValidThru.IsBeforeToday();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Patronage/PatronConfigData.cs b/src/EllieBot/_common/Patronage/PatronConfigData.cs
new file mode 100644
index 0000000..9becae0
--- /dev/null
+++ b/src/EllieBot/_common/Patronage/PatronConfigData.cs
@@ -0,0 +1,17 @@
+using EllieBot.Common.Yml;
+using Cloneable;
+
+namespace EllieBot.Modules.Patronage;
+
+[Cloneable]
+public partial class PatronConfigData : ICloneable<PatronConfigData>
+{
+    [Comment("DO NOT CHANGE")]
+    public int Version { get; set; } = 3;
+
+    [Comment("Whether the patronage feature is enabled")]
+    public bool IsEnabled { get; set; }
+
+    [Comment("Who can do how much of what")]
+    public Dictionary<int, Dictionary<LimitedFeatureName, QuotaLimit>> Limits { get; set; } = new();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Patronage/PatronExtensions.cs b/src/EllieBot/_common/Patronage/PatronExtensions.cs
new file mode 100644
index 0000000..1686a4c
--- /dev/null
+++ b/src/EllieBot/_common/Patronage/PatronExtensions.cs
@@ -0,0 +1,30 @@
+namespace EllieBot.Modules.Patronage;
+
+public static class PatronExtensions
+{
+    public static string ToFullName(this PatronTier tier)
+        => tier switch
+        {
+            _ => $"Patron Tier {tier}",
+        };
+
+    public static DateTime DayOfNextMonth(this DateTime date, int day)
+    {
+        var nextMonth = date.AddMonths(1);
+        var dt = DateTime.SpecifyKind(new(nextMonth.Year, nextMonth.Month, day), DateTimeKind.Utc);
+        return dt;
+    }
+
+    public static DateTime FirstOfNextMonth(this DateTime date)
+        => date.DayOfNextMonth(1);
+
+    public static DateTime SecondOfNextMonth(this DateTime date)
+        => date.DayOfNextMonth(2);
+
+    public static string ToShortAndRelativeTimestampTag(this DateTime date)
+    {
+        var fullResetStr = TimestampTag.FromDateTime(date, TimestampTagStyles.ShortDateTime);
+        var relativeResetStr = TimestampTag.FromDateTime(date, TimestampTagStyles.Relative);
+        return $"{fullResetStr}\n{relativeResetStr}";
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Patronage/PatronTier.cs b/src/EllieBot/_common/Patronage/PatronTier.cs
new file mode 100644
index 0000000..0bbe804
--- /dev/null
+++ b/src/EllieBot/_common/Patronage/PatronTier.cs
@@ -0,0 +1,14 @@
+// ReSharper disable InconsistentNaming
+namespace EllieBot.Modules.Patronage;
+
+public enum PatronTier
+{
+    None,
+    I,
+    V,
+    X,
+    XX,
+    L,
+    C,
+    ComingSoon
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Patronage/QuotaLimit.cs b/src/EllieBot/_common/Patronage/QuotaLimit.cs
new file mode 100644
index 0000000..5669c0c
--- /dev/null
+++ b/src/EllieBot/_common/Patronage/QuotaLimit.cs
@@ -0,0 +1,23 @@
+namespace EllieBot.Modules.Patronage;
+
+/// <summary>
+/// Represents information about why the user has triggered a quota limit
+/// </summary>
+public readonly struct QuotaLimit
+{
+    /// <summary>
+    /// Amount of usages reached, which is the limit
+    /// </summary>
+    public int Quota { get; init; }
+    
+    /// <summary>
+    /// Which period is this quota limit for (hourly, daily, monthly, etc...)
+    /// </summary>
+    public QuotaPer QuotaPeriod { get; init; }
+    
+    public QuotaLimit(int quota, QuotaPer quotaPeriod)
+    {
+        Quota = quota;
+        QuotaPeriod = quotaPeriod;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Patronage/QuotaPer.cs b/src/EllieBot/_common/Patronage/QuotaPer.cs
new file mode 100644
index 0000000..9f67a40
--- /dev/null
+++ b/src/EllieBot/_common/Patronage/QuotaPer.cs
@@ -0,0 +1,9 @@
+namespace EllieBot.Modules.Patronage;
+
+public enum QuotaPer
+{
+    PerHour,
+    PerDay,
+    PerMonth,
+    Total,
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Patronage/SubscriptionChargeStatus.cs b/src/EllieBot/_common/Patronage/SubscriptionChargeStatus.cs
new file mode 100644
index 0000000..7ef541f
--- /dev/null
+++ b/src/EllieBot/_common/Patronage/SubscriptionChargeStatus.cs
@@ -0,0 +1,10 @@
+#nullable disable
+namespace EllieBot.Modules.Patronage;
+
+public enum SubscriptionChargeStatus
+{
+    Paid,
+    Refunded,
+    Unpaid,
+    Other,
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Pokemon/PokemonNameId.cs b/src/EllieBot/_common/Pokemon/PokemonNameId.cs
new file mode 100644
index 0000000..341b5c2
--- /dev/null
+++ b/src/EllieBot/_common/Pokemon/PokemonNameId.cs
@@ -0,0 +1,8 @@
+#nullable disable
+namespace EllieBot.Common.Pokemon;
+
+public class PokemonNameId
+{
+    public int Id { get; set; }
+    public string Name { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Pokemon/SearchPokemon.cs b/src/EllieBot/_common/Pokemon/SearchPokemon.cs
new file mode 100644
index 0000000..b4ecae1
--- /dev/null
+++ b/src/EllieBot/_common/Pokemon/SearchPokemon.cs
@@ -0,0 +1,41 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Common.Pokemon;
+
+public class SearchPokemon
+{
+    [JsonPropertyName("num")]
+    public int Id { get; set; }
+
+    public string Species { get; set; }
+    public string[] Types { get; set; }
+    public GenderRatioClass GenderRatio { get; set; }
+    public BaseStatsClass BaseStats { get; set; }
+    public Dictionary<string, string> Abilities { get; set; }
+    public float HeightM { get; set; }
+    public float WeightKg { get; set; }
+    public string Color { get; set; }
+    public string[] Evos { get; set; }
+    public string[] EggGroups { get; set; }
+
+    public class GenderRatioClass
+    {
+        public float M { get; set; }
+        public float F { get; set; }
+    }
+
+    public class BaseStatsClass
+    {
+        public int Hp { get; set; }
+        public int Atk { get; set; }
+        public int Def { get; set; }
+        public int Spa { get; set; }
+        public int Spd { get; set; }
+        public int Spe { get; set; }
+
+        public override string ToString()
+            => $@"💚**HP:**  {Hp,-4} ⚔**ATK:** {Atk,-4} 🛡**DEF:** {Def,-4}
+✨**SPA:** {Spa,-4} 🎇**SPD:** {Spd,-4} 💨**SPE:** {Spe,-4}";
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Pokemon/SearchPokemonAbility.cs b/src/EllieBot/_common/Pokemon/SearchPokemonAbility.cs
new file mode 100644
index 0000000..f401284
--- /dev/null
+++ b/src/EllieBot/_common/Pokemon/SearchPokemonAbility.cs
@@ -0,0 +1,10 @@
+#nullable disable
+namespace EllieBot.Common.Pokemon;
+
+public class SearchPokemonAbility
+{
+    public string Desc { get; set; }
+    public string ShortDesc { get; set; }
+    public string Name { get; set; }
+    public float Rating { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Replacements/IReplacementPatternStore.cs b/src/EllieBot/_common/Replacements/IReplacementPatternStore.cs
new file mode 100644
index 0000000..0a6483a
--- /dev/null
+++ b/src/EllieBot/_common/Replacements/IReplacementPatternStore.cs
@@ -0,0 +1,20 @@
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Common;
+
+public interface IReplacementPatternStore : IEService
+{
+    IReadOnlyDictionary<string, ReplacementInfo> Replacements { get; }
+    IReadOnlyDictionary<string, RegexReplacementInfo> RegexReplacements { get; }
+
+    ValueTask<Guid?> Register(string token, Func<ValueTask<string>> repFactory);
+    ValueTask<Guid?> Register<T1>(string token, Func<T1, ValueTask<string>> repFactory);
+    ValueTask<Guid?> Register<T1, T2>(string token, Func<T1, T2, ValueTask<string>> repFactory);
+    
+    ValueTask<Guid?> Register(string token, Func<string> repFactory);
+    ValueTask<Guid?> Register<T1>(string token, Func<T1, string> repFactory);
+    ValueTask<Guid?> Register<T1, T2>(string token, Func<T1, T2, string> repFactory);
+    
+    ValueTask<Guid?> Register(Regex regex, Func<Match, string> repFactory);
+    ValueTask<Guid?> Register<T1>(Regex regex, Func<Match, T1, string> repFactory);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Replacements/IReplacementService.cs b/src/EllieBot/_common/Replacements/IReplacementService.cs
new file mode 100644
index 0000000..66a0896
--- /dev/null
+++ b/src/EllieBot/_common/Replacements/IReplacementService.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Common;
+
+public interface IReplacementService
+{
+    ValueTask<string?> ReplaceAsync(string input, ReplacementContext repCtx);
+    ValueTask<SmartText> ReplaceAsync(SmartText input, ReplacementContext repCtx);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Replacements/Impl/ReplacementContext.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementContext.cs
new file mode 100644
index 0000000..4e87ce9
--- /dev/null
+++ b/src/EllieBot/_common/Replacements/Impl/ReplacementContext.cs
@@ -0,0 +1,69 @@
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Common;
+
+public sealed class ReplacementContext
+{
+    public DiscordSocketClient? Client { get; }
+    public IGuild? Guild { get; }
+    public IMessageChannel? Channel { get; }
+    public IUser? User { get; }
+
+    private readonly List<ReplacementInfo> _overrides = new();
+    private readonly HashSet<string> _tokens = new();
+
+    public IReadOnlyList<ReplacementInfo> Overrides
+        => _overrides.AsReadOnly();
+
+    private readonly List<RegexReplacementInfo> _regexOverrides = new();
+    private readonly HashSet<string> _regexPatterns = new();
+
+    public IReadOnlyList<RegexReplacementInfo> 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,
+        IUser? user = null)
+    {
+        Client = client;
+        Guild = guild;
+        Channel = channel;
+        User = user;
+    }
+
+    public ReplacementContext WithOverride(string key, Func<ValueTask<string>> repFactory)
+    {
+        if (_tokens.Add(key))
+        {
+            _overrides.Add(new(key, repFactory));
+        }
+
+        return this;
+    }
+
+    public ReplacementContext WithOverride(string key, Func<string> repFactory)
+        => WithOverride(key, () => new ValueTask<string>(repFactory()));
+
+    public ReplacementContext WithOverride(Regex regex, Func<Match, ValueTask<string>> repFactory)
+    {
+        if (_regexPatterns.Add(regex.ToString()))
+        {
+            _regexOverrides.Add(new(regex, repFactory));
+        }
+
+        return this;
+    }
+
+    public ReplacementContext WithOverride(Regex regex, Func<Match, string> repFactory)
+        => WithOverride(regex, (Match m) => new ValueTask<string>(repFactory(m)));
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Replacements/Impl/ReplacementInfo.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementInfo.cs
new file mode 100644
index 0000000..254de96
--- /dev/null
+++ b/src/EllieBot/_common/Replacements/Impl/ReplacementInfo.cs
@@ -0,0 +1,57 @@
+using System.Reflection;
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Common;
+
+public sealed class ReplacementInfo
+{
+    private readonly Delegate _del;
+    public IReadOnlyCollection<Type> InputTypes { get; }
+    public string Token { get; }
+
+    private static readonly Func<ValueTask<string?>> _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<string?> GetValueAsync(params object?[]? objs)
+        => await (ValueTask<string?>)(_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<Type> InputTypes { get; }
+
+    public Regex Regex { get; }
+    public string Pattern { get; }
+
+    private static readonly Func<Match, ValueTask<string?>> _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<string?> GetValueAsync(Match m, params object?[]? objs)
+        => await ((Func<Match, ValueTask<string?>>)(_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/EllieBot/_common/Replacements/Impl/ReplacementPatternStore.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementPatternStore.cs
new file mode 100644
index 0000000..3692184
--- /dev/null
+++ b/src/EllieBot/_common/Replacements/Impl/ReplacementPatternStore.cs
@@ -0,0 +1,130 @@
+using System.Text.RegularExpressions;
+using OneOf;
+
+namespace EllieBot.Common;
+
+public sealed partial class ReplacementPatternStore : IReplacementPatternStore, IEService
+{
+    private readonly ConcurrentDictionary<Guid, OneOf<string, Regex>> _guids = new();
+
+    private readonly ConcurrentDictionary<string, ReplacementInfo> _defaultReplacements = new();
+    private readonly ConcurrentDictionary<string, RegexReplacementInfo> _regexReplacements = new();
+
+    public IReadOnlyDictionary<string, ReplacementInfo> Replacements
+        => _defaultReplacements.AsReadOnly();
+
+    public IReadOnlyDictionary<string, RegexReplacementInfo> RegexReplacements
+        => _regexReplacements.AsReadOnly();
+
+    public ReplacementPatternStore()
+    {
+        WithClient();
+        WithChannel();
+        WithServer();
+        WithUsers();
+        WithDefault();
+        WithRegex();
+    }
+
+    // private async ValueTask<string> 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<Guid?> 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<Guid?> Register(string token, Func<ValueTask<string>> repFactory)
+        => InternalRegister(token, repFactory);
+
+    public ValueTask<Guid?> Register<T1>(string token, Func<T1, ValueTask<string>> repFactory)
+        => InternalRegister(token, repFactory);
+
+    public ValueTask<Guid?> Register<T1, T2>(string token, Func<T1, T2, ValueTask<string>> repFactory)
+        => InternalRegister(token, repFactory);
+
+    public ValueTask<Guid?> Register(string token, Func<string> repFactory)
+        => InternalRegister(token, () => new ValueTask<string>(repFactory()));
+
+    public ValueTask<Guid?> Register<T1>(string token, Func<T1, string> repFactory)
+        => InternalRegister(token, (T1 a) => new ValueTask<string>(repFactory(a)));
+
+    public ValueTask<Guid?> Register<T1, T2>(string token, Func<T1, T2, string> repFactory)
+        => InternalRegister(token, (T1 a, T2 b) => new ValueTask<string>(repFactory(a, b)));
+
+
+    private ValueTask<Guid?> 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<Guid?> Register(Regex regex, Func<Match, string> repFactory)
+        => InternalRegexRegister(regex, () => (Match m) => new ValueTask<string>(repFactory(m)));
+
+    public ValueTask<Guid?> Register<T1>(Regex regex, Func<Match, T1, string> repFactory)
+        => InternalRegexRegister(regex, (T1 a) => (Match m) => new ValueTask<string>(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/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs
new file mode 100644
index 0000000..82d1d14
--- /dev/null
+++ b/src/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs
@@ -0,0 +1,126 @@
+#nullable disable
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Common;
+
+public sealed partial class ReplacementPatternStore
+{
+    private static readonly Regex _rngRegex = new(@"%rng(?:(?<from>(?:-)?\d+)-(?<to>(?:-)?\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 as SocketGuild)?.MemberCount.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 user) => user.Mention);
+        Register("%user.mention%", static (IUser user) => user.Mention);
+        Register("%user.fullname%", static (IUser user) => user.ToString()!);
+        Register("%user.name%", static (IUser user) => user.Username);
+        Register("%user.discrim%", static (IUser user) => user.Discriminator);
+        Register("%user.avatar%", static (IUser user) => user.RealAvatarUrl().ToString());
+        Register("%user.id%", static (IUser user) => user.Id.ToString());
+        Register("%user.created_time%", static (IUser user) => user.CreatedAt.ToString("HH:mm"));
+        Register("%user.created_date%", static (IUser user) => user.CreatedAt.ToString("dd.MM.yyyy"));
+        Register("%user.joined_time%", static (IGuildUser user) => user.JoinedAt?.ToString("HH:mm"));
+        Register("%user.joined_date%", static (IGuildUser user) => user.JoinedAt?.ToString("dd.MM.yyyy"));
+        
+        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 EllieRandom();
+                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/EllieBot/_common/Replacements/Impl/ReplacementService.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementService.cs
new file mode 100644
index 0000000..18712d8
--- /dev/null
+++ b/src/EllieBot/_common/Replacements/Impl/ReplacementService.cs
@@ -0,0 +1,137 @@
+namespace EllieBot.Common;
+
+public sealed class ReplacementService : IReplacementService, IEService
+{
+    private readonly IReplacementPatternStore _repReg;
+
+    public ReplacementService(IReplacementPatternStore repReg)
+    {
+        _repReg = repReg;
+    }
+
+    public async ValueTask<SmartText> 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<string?> 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<object>();
+        if (repCtx.Client is not null)
+            obj.Add(repCtx.Client);
+
+        if (repCtx.Guild is not null)
+            obj.Add(repCtx.Guild);
+
+        if (repCtx.User is not null)
+            obj.Add(repCtx.User);
+
+        if (repCtx.Channel is not null)
+            obj.Add(repCtx.Channel);
+
+        return obj.ToArray();
+    }
+
+    private IDictionary<string, ReplacementInfo> GetReplacementsForContext(ReplacementContext repCtx)
+    {
+        var reps = GetOriginalReplacementsForContext(repCtx);
+        foreach (var ovrd in repCtx.Overrides)
+        {
+            reps.Remove(ovrd.Token);
+            reps.TryAdd(ovrd.Token, ovrd);
+        }
+
+        return reps;
+    }
+
+    private IDictionary<string, RegexReplacementInfo> GetRegexReplacementsForContext(ReplacementContext repCtx)
+    {
+        var reps = GetOriginalRegexReplacementsForContext(repCtx);
+        foreach (var ovrd in repCtx.RegexOverrides)
+        {
+            reps.Remove(ovrd.Pattern);
+            reps.TryAdd(ovrd.Pattern, ovrd);
+        }
+
+        return reps;
+    }
+
+    private IDictionary<string, ReplacementInfo> GetOriginalReplacementsForContext(ReplacementContext repCtx)
+    {
+        var objs = new List<object>();
+        if (repCtx.Client is not null)
+        {
+            objs.Add(repCtx.Client);
+        }
+
+        if (repCtx.Channel is not null)
+        {
+            objs.Add(repCtx.Channel);
+        }
+
+        if (repCtx.User is not null)
+        {
+            objs.Add(repCtx.User);
+        }
+
+        if (repCtx.Guild is not null)
+        {
+            objs.Add(repCtx.Guild);
+        }
+
+        var types = objs.Map(x => x.GetType()).OrderBy(x => x.Name).ToHashSet();
+
+        return _repReg.Replacements
+            .Values
+            .Where(rep => rep.InputTypes.All(t => types.Any(x => x.IsAssignableTo((t)))))
+            .ToDictionary(rep => rep.Token, rep => rep);
+    }
+
+    private IDictionary<string, RegexReplacementInfo> GetOriginalRegexReplacementsForContext(ReplacementContext repCtx)
+    {
+        var objs = new List<object>();
+        if (repCtx.Client is not null)
+        {
+            objs.Add(repCtx.Client);
+        }
+
+        if (repCtx.Channel is not null)
+        {
+            objs.Add(repCtx.Channel);
+        }
+
+        if (repCtx.User is not null)
+        {
+            objs.Add(repCtx.User);
+        }
+
+        if (repCtx.Guild is not null)
+        {
+            objs.Add(repCtx.Guild);
+        }
+
+        var types = objs.Map(x => x.GetType()).OrderBy(x => x.Name).ToHashSet();
+
+        return _repReg.RegexReplacements
+            .Values
+            .Where(rep => rep.InputTypes.All(t => types.Any(x => x.IsAssignableTo((t)))))
+            .ToDictionary(rep => rep.Pattern, rep => rep);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Replacements/Impl/Replacer.cs b/src/EllieBot/_common/Replacements/Impl/Replacer.cs
new file mode 100644
index 0000000..adc5e4d
--- /dev/null
+++ b/src/EllieBot/_common/Replacements/Impl/Replacer.cs
@@ -0,0 +1,141 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Common;
+
+public sealed partial class Replacer
+{
+    private readonly IEnumerable<ReplacementInfo> _reps;
+    private readonly IEnumerable<RegexReplacementInfo> _regexReps;
+    private readonly object[] _inputData;
+
+    // [GeneratedRegex(@"\%[\p{L}\p{N}\._]*[\p{L}\p{N}]+[\p{L}\p{N}\._]*\%")]
+    // private static partial Regex TokenExtractionRegex();
+
+    public Replacer(IEnumerable<ReplacementInfo> reps, IEnumerable<RegexReplacementInfo> regexReps, object[] inputData)
+    {
+        _reps = reps;
+        _inputData = inputData;
+        _regexReps = regexReps;
+    }
+
+    public async ValueTask<string?> ReplaceAsync(string? input)
+    {
+        if (string.IsNullOrWhiteSpace(input))
+            return input;
+
+        // var matches = TokenExtractionRegex().IsMatch(input);
+
+        // if (matches)
+        // {
+        foreach (var rep in _reps)
+        {
+            if (input.Contains(rep.Token, StringComparison.InvariantCulture))
+            {
+                var objs = GetParams(rep.InputTypes);
+                input = input.Replace(rep.Token, await rep.GetValueAsync(objs), StringComparison.InvariantCulture);
+            }
+        }
+        // }
+
+        foreach (var rep in _regexReps)
+        {
+            var sb = new StringBuilder();
+
+            var objs = GetParams(rep.InputTypes);
+            var match = rep.Regex.Match(input);
+            if (match.Success)
+            {
+                sb.Append(input, 0, match.Index)
+                  .Append(await rep.GetValueAsync(match, objs));
+
+                var lastIndex = match.Index + match.Length;
+                sb.Append(input, lastIndex, input.Length - lastIndex);
+                input = sb.ToString();
+            }
+        }
+
+        return input;
+    }
+
+    private object?[]? GetParams(IReadOnlyCollection<Type> inputTypes)
+    {
+        if (inputTypes.Count == 0)
+            return null;
+
+        var objs = new List<object>();
+        foreach (var t in inputTypes)
+        {
+            var datum = _inputData.FirstOrDefault(x => x.GetType().IsAssignableTo(t));
+            if (datum is not null)
+                objs.Add(datum);
+        }
+
+        return objs.ToArray();
+    }
+
+    public async ValueTask<SmartText> ReplaceAsync(SmartText data)
+        => data switch
+        {
+            SmartEmbedText embedData => await ReplaceAsync(embedData) with
+            {
+                PlainText = await ReplaceAsync(embedData.PlainText),
+                Color = embedData.Color
+            },
+            SmartPlainText plain => await ReplaceAsync(plain),
+            SmartEmbedTextArray arr => await ReplaceAsync(arr),
+            _ => throw new ArgumentOutOfRangeException(nameof(data), "Unsupported argument type")
+        };
+
+    private async Task<SmartEmbedTextArray> ReplaceAsync(SmartEmbedTextArray embedArr)
+        => new()
+        {
+            Embeds = await embedArr.Embeds.Map(async e => await ReplaceAsync(e) with
+                                   {
+                                       Color = e.Color
+                                   })
+                                   .WhenAll(),
+            Content = await ReplaceAsync(embedArr.Content)
+        };
+
+    private async ValueTask<SmartPlainText> ReplaceAsync(SmartPlainText plain)
+        => await ReplaceAsync(plain.Text);
+
+    private async Task<T> ReplaceAsync<T>(T embedData)
+        where T : SmartEmbedTextBase, new()
+    {
+        var newEmbedData = new T
+        {
+            Description = await ReplaceAsync(embedData.Description),
+            Title = await ReplaceAsync(embedData.Title),
+            Thumbnail = await ReplaceAsync(embedData.Thumbnail),
+            Image = await ReplaceAsync(embedData.Image),
+            Url = await ReplaceAsync(embedData.Url),
+            Author = embedData.Author is null
+                ? null
+                : new()
+                {
+                    Name = await ReplaceAsync(embedData.Author.Name),
+                    IconUrl = await ReplaceAsync(embedData.Author.IconUrl)
+                },
+            Fields = await Task.WhenAll(embedData
+                                        .Fields?
+                                        .Map(async f => new SmartTextEmbedField
+                                        {
+                                            Name = await ReplaceAsync(f.Name),
+                                            Value = await ReplaceAsync(f.Value),
+                                            Inline = f.Inline
+                                        })
+                                        ?? []),
+            Footer = embedData.Footer is null
+                ? null
+                : new()
+                {
+                    Text = await ReplaceAsync(embedData.Footer.Text),
+                    IconUrl = await ReplaceAsync(embedData.Footer.IconUrl)
+                }
+        };
+
+        return newEmbedData;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/RequireObjectPropertiesContractResolver.cs b/src/EllieBot/_common/RequireObjectPropertiesContractResolver.cs
new file mode 100644
index 0000000..74a0367
--- /dev/null
+++ b/src/EllieBot/_common/RequireObjectPropertiesContractResolver.cs
@@ -0,0 +1,15 @@
+#nullable disable
+using Newtonsoft.Json;
+using Newtonsoft.Json.Serialization;
+
+namespace EllieBot.Common;
+
+public class RequireObjectPropertiesContractResolver : DefaultContractResolver
+{
+    protected override JsonObjectContract CreateObjectContract(Type objectType)
+    {
+        var contract = base.CreateObjectContract(objectType);
+        contract.ItemRequired = Required.DisallowNull;
+        return contract;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Sender/IMessageSenderService.cs b/src/EllieBot/_common/Sender/IMessageSenderService.cs
new file mode 100644
index 0000000..ccc8c0f
--- /dev/null
+++ b/src/EllieBot/_common/Sender/IMessageSenderService.cs
@@ -0,0 +1,12 @@
+namespace EllieBot.Extensions;
+
+public interface IMessageSenderService
+{
+    ResponseBuilder Response(IMessageChannel channel);
+    ResponseBuilder Response(ICommandContext ctx);
+    ResponseBuilder Response(IUser user);
+
+    ResponseBuilder Response(SocketMessageComponent smc);
+
+    EllieEmbedBuilder CreateEmbed();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Sender/MessageSenderService.cs b/src/EllieBot/_common/Sender/MessageSenderService.cs
new file mode 100644
index 0000000..88f1c40
--- /dev/null
+++ b/src/EllieBot/_common/Sender/MessageSenderService.cs
@@ -0,0 +1,56 @@
+using EllieBot.Common.Configs;
+
+namespace EllieBot.Extensions;
+
+public sealed class MessageSenderService : IMessageSenderService, IEService
+{
+    private readonly IBotStrings _bs;
+    private readonly BotConfigService _bcs;
+    private readonly DiscordSocketClient _client;
+
+    public MessageSenderService(IBotStrings bs, BotConfigService bcs, DiscordSocketClient client)
+    {
+        _bs = bs;
+        _bcs = bcs;
+        _client = client;
+    }
+
+
+    public ResponseBuilder Response(IMessageChannel channel)
+        => new ResponseBuilder(_bs, _bcs, _client)
+            .Channel(channel);
+
+    public ResponseBuilder Response(ICommandContext ctx)
+        => new ResponseBuilder(_bs, _bcs, _client)
+            .Context(ctx);
+
+    public ResponseBuilder Response(IUser user)
+        => new ResponseBuilder(_bs, _bcs, _client)
+            .User(user);
+
+    public ResponseBuilder Response(SocketMessageComponent smc)
+        => new ResponseBuilder(_bs, _bcs, _client)
+            .Channel(smc.Channel);
+
+    public EllieEmbedBuilder CreateEmbed()
+        => new EllieEmbedBuilder(_bcs);
+}
+
+public class EllieEmbedBuilder : EmbedBuilder
+{
+    private readonly BotConfig _bc;
+
+    public EllieEmbedBuilder(BotConfigService bcs)
+    {
+        _bc = bcs.Data;
+    }
+
+    public EmbedBuilder WithOkColor()
+        => WithColor(_bc.Color.Ok.ToDiscordColor());
+
+    public EmbedBuilder WithErrorColor()
+        => WithColor(_bc.Color.Error.ToDiscordColor());
+
+    public EmbedBuilder WithPendingColor()
+        => WithColor(_bc.Color.Pending.ToDiscordColor());
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Sender/ResponseBuilder.PaginationSender.cs b/src/EllieBot/_common/Sender/ResponseBuilder.PaginationSender.cs
new file mode 100644
index 0000000..92d871a
--- /dev/null
+++ b/src/EllieBot/_common/Sender/ResponseBuilder.PaginationSender.cs
@@ -0,0 +1,167 @@
+namespace EllieBot.Extensions;
+
+public partial class ResponseBuilder
+{
+    public class PaginationSender<T>
+    {
+        private const string BUTTON_LEFT = "BUTTON_LEFT";
+        private const string BUTTON_RIGHT = "BUTTON_RIGHT";
+
+        private readonly SourcedPaginatedResponseBuilder<T> _paginationBuilder;
+        private readonly ResponseBuilder _builder;
+        private readonly DiscordSocketClient _client;
+        private int currentPage;
+        
+        private EllieButtonInteractionHandler? left;
+        private EllieButtonInteractionHandler? right;
+        private EllieInteractionBase? extra;
+
+        public PaginationSender(
+            SourcedPaginatedResponseBuilder<T> paginationBuilder,
+            ResponseBuilder builder)
+        {
+            _paginationBuilder = paginationBuilder;
+            _builder = builder;
+
+            _client = builder.Client;
+            currentPage = paginationBuilder.InitialPage;
+        }
+
+        public async Task SendAsync(bool ephemeral = false)
+        {
+            var lastPage = (_paginationBuilder.Elems - 1)
+                           / _paginationBuilder.ItemsPerPage;
+
+            var items = (await _paginationBuilder.ItemsFunc(currentPage)).ToArray();
+            var embed = await _paginationBuilder.PageFunc(items, currentPage);
+
+            if (_paginationBuilder.AddPaginatedFooter)
+                embed.AddPaginatedFooter(currentPage, lastPage);
+
+            EllieInteractionBase? maybeInter = null;
+
+            var model = await _builder.BuildAsync(ephemeral);
+
+            async Task<(EllieButtonInteractionHandler left, EllieInteractionBase? extra, EllieButtonInteractionHandler right)>
+                GetInteractions()
+            {
+                var leftButton = new ButtonBuilder()
+                                 .WithStyle(ButtonStyle.Primary)
+                                 .WithCustomId(BUTTON_LEFT)
+                                 .WithEmote(InteractionHelpers.ArrowLeft)
+                                 .WithDisabled(lastPage == 0 || currentPage <= 0);
+
+                var leftBtnInter = new EllieButtonInteractionHandler(_client,
+                    model.User?.Id ?? 0,
+                    leftButton,
+                    (smc) =>
+                    {
+                        try
+                        {
+                            if (currentPage > 0)
+                                currentPage--;
+
+                            _ = UpdatePageAsync(smc);
+                        }
+                        catch (Exception ex)
+                        {
+                            Log.Error(ex, "Error in pagination: {ErrorMessage}", ex.Message);
+                        }
+
+                        return Task.CompletedTask;
+                    },
+                    true,
+                    singleUse: false,
+                    clearAfter: false);
+
+                if (_paginationBuilder.InteractionFunc is not null)
+                {
+                    maybeInter = await _paginationBuilder.InteractionFunc(currentPage);
+                }
+
+                var rightButton = new ButtonBuilder()
+                                  .WithStyle(ButtonStyle.Primary)
+                                  .WithCustomId(BUTTON_RIGHT)
+                                  .WithEmote(InteractionHelpers.ArrowRight)
+                                  .WithDisabled(lastPage == 0 || currentPage >= lastPage);
+
+                var rightBtnInter = new EllieButtonInteractionHandler(_client,
+                    model.User?.Id ?? 0,
+                    rightButton,
+                    (smc) =>
+                    {
+                        try
+                        {
+                            if (currentPage >= lastPage)
+                                return Task.CompletedTask;
+
+                            currentPage++;
+
+                            _ = UpdatePageAsync(smc);
+                        }
+                        catch (Exception ex)
+                        {
+                            Log.Error(ex, "Error in pagination: {ErrorMessage}", ex.Message);
+                        }
+
+                        return Task.CompletedTask;
+                    },
+                    true,
+                    singleUse: false,
+                    clearAfter: false);
+
+                return (leftBtnInter, maybeInter, rightBtnInter);
+            }
+            
+            (left, extra, right) = await GetInteractions();
+
+            async Task UpdatePageAsync(SocketMessageComponent smc)
+            {
+                var pageItems = (await _paginationBuilder.ItemsFunc(currentPage)).ToArray();
+                var toSend = await _paginationBuilder.PageFunc(pageItems, currentPage);
+                if (_paginationBuilder.AddPaginatedFooter)
+                    toSend.AddPaginatedFooter(currentPage, lastPage);
+
+                left?.SetCompleted();
+                right?.SetCompleted();
+                extra?.SetCompleted();
+                (left, extra, right) = (await GetInteractions());
+
+                var cb = new ComponentBuilder();
+                left.AddTo(cb);
+                right.AddTo(cb);
+                extra?.AddTo(cb);
+                
+                await smc.ModifyOriginalResponseAsync(x =>
+                {
+                    x.Embed = toSend.Build();
+                    x.Components = cb.Build();
+                });
+                
+                await Task.WhenAll(left.RunAsync(smc.Message), extra?.RunAsync(smc.Message) ?? Task.CompletedTask, right.RunAsync(smc.Message));
+            }
+
+
+            var cb = new ComponentBuilder();
+            left.AddTo(cb);
+            right.AddTo(cb);
+            extra?.AddTo(cb);
+
+            var msg = await model.TargetChannel
+                                 .SendMessageAsync(model.Text,
+                                     embed: embed.Build(),
+                                     components: cb.Build(),
+                                     allowedMentions: model.SanitizeMentions,
+                                     messageReference: model.MessageReference);
+
+            if (lastPage == 0 && _paginationBuilder.InteractionFunc is null)
+                return;
+            
+            await Task.WhenAll(left.RunAsync(msg), extra?.RunAsync(msg) ?? Task.CompletedTask, right.RunAsync(msg));
+
+            await Task.Delay(30_000);
+            
+            await msg.ModifyAsync(mp => mp.Components = new ComponentBuilder().Build());
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Sender/ResponseBuilder.cs b/src/EllieBot/_common/Sender/ResponseBuilder.cs
new file mode 100644
index 0000000..08d1ad3
--- /dev/null
+++ b/src/EllieBot/_common/Sender/ResponseBuilder.cs
@@ -0,0 +1,490 @@
+using System.Collections.ObjectModel;
+
+namespace EllieBot.Extensions;
+
+public sealed partial class ResponseBuilder
+{
+    private ICommandContext? ctx;
+    private IMessageChannel? channel;
+    private string? plainText;
+    private IReadOnlyCollection<EmbedBuilder>? embeds;
+    private IUserMessage? msg;
+    private IUser? user;
+    private bool sanitizeMentions = true;
+    private LocStr? locTxt;
+    private object[] locParams = [];
+    private bool shouldReply = true;
+    private readonly IBotStrings _bs;
+    private readonly BotConfigService _bcs;
+    private EmbedBuilder? embedBuilder;
+    private EllieInteractionBase? inter;
+    private Stream? fileStream;
+    private string? fileName;
+    private EmbedColor color = EmbedColor.Ok;
+    private LocStr? embedLocDesc;
+
+    public DiscordSocketClient Client { get; set; }
+
+    public ResponseBuilder(IBotStrings bs, BotConfigService bcs, DiscordSocketClient client)
+    {
+        _bs = bs;
+        _bcs = bcs;
+        Client = client;
+    }
+
+
+    private MessageReference? CreateMessageReference(IMessageChannel targetChannel)
+    {
+        if (!shouldReply)
+            return null;
+
+        var replyTo = msg ?? ctx?.Message;
+        // what message are we replying to
+        if (replyTo is null)
+            return null;
+
+        // we have to have a channel where we are sending the message in order to know whether we can reply to it
+        if (targetChannel.Id != replyTo.Channel.Id)
+            return null;
+
+        return new(replyTo.Id,
+            replyTo.Channel.Id,
+            (replyTo.Channel as ITextChannel)?.GuildId,
+            failIfNotExists: false);
+    }
+
+    public async Task<ResponseMessageModel> BuildAsync(bool ephemeral)
+    {
+        var targetChannel = await InternalResolveChannel() ?? throw new ArgumentNullException(nameof(channel));
+        var msgReference = CreateMessageReference(targetChannel);
+
+        var txt = GetText(locTxt, targetChannel);
+
+        if (embedLocDesc is LocStr ls)
+        {
+            InternalCreateEmbed(null, GetText(ls, targetChannel));
+        }
+
+        if (embedBuilder is not null)
+            PaintEmbedInternal(embedBuilder);
+
+        var finalEmbed = embedBuilder?.Build();
+
+
+        var buildModel = new ResponseMessageModel()
+        {
+            TargetChannel = targetChannel,
+            MessageReference = msgReference,
+            Text = txt,
+            User = user ?? ctx?.User,
+            Embed = finalEmbed,
+            Embeds = embeds?.Map(x => x.Build()),
+            SanitizeMentions = sanitizeMentions ? new(AllowedMentionTypes.Users) : AllowedMentions.All,
+            Ephemeral = ephemeral,
+            Interaction = inter
+        };
+
+        return buildModel;
+    }
+
+    public async Task<IUserMessage> SendAsync(bool ephemeral = false)
+    {
+        var model = await BuildAsync(ephemeral);
+        var sentMsg = await SendAsync(model);
+
+
+        return sentMsg;
+    }
+
+    public async Task<IUserMessage> SendAsync(ResponseMessageModel model)
+    {
+        IUserMessage sentMsg;
+        if (fileStream is Stream stream)
+        {
+            sentMsg = await model.TargetChannel.SendFileAsync(stream,
+                filename: fileName,
+                model.Text,
+                embed: model.Embed,
+                components: inter?.CreateComponent(),
+                allowedMentions: model.SanitizeMentions,
+                messageReference: model.MessageReference);
+        }
+        else
+        {
+            sentMsg = await model.TargetChannel.SendMessageAsync(
+                model.Text,
+                embed: model.Embed,
+                embeds: model.Embeds,
+                components: inter?.CreateComponent(),
+                allowedMentions: model.SanitizeMentions,
+                messageReference: model.MessageReference);
+        }
+
+        if (model.Interaction is not null)
+        {
+            await model.Interaction.RunAsync(sentMsg);
+        }
+
+        return sentMsg;
+    }
+
+    private EmbedBuilder PaintEmbedInternal(EmbedBuilder eb)
+        => color switch
+        {
+            EmbedColor.Ok => eb.WithOkColor(),
+            EmbedColor.Pending => eb.WithPendingColor(),
+            EmbedColor.Error => eb.WithErrorColor(),
+            _ => throw new NotSupportedException()
+        };
+
+    private ulong? InternalResolveGuildId(IMessageChannel? targetChannel)
+        => ctx?.Guild?.Id ?? (targetChannel as ITextChannel)?.GuildId;
+
+    private async Task<IMessageChannel?> InternalResolveChannel()
+    {
+        if (user is not null)
+        {
+            var ch = await user.CreateDMChannelAsync();
+
+            if (ch is not null)
+            {
+                return ch;
+            }
+        }
+
+        return channel ?? ctx?.Channel ?? msg?.Channel;
+    }
+
+    private string? GetText(LocStr? locStr, IMessageChannel targetChannel)
+    {
+        var guildId = InternalResolveGuildId(targetChannel);
+        return locStr is LocStr ls ? _bs.GetText(ls.Key, guildId, locParams) : plainText;
+    }
+
+    private string GetText(LocStr locStr, IMessageChannel targetChannel)
+    {
+        var guildId = InternalResolveGuildId(targetChannel);
+        return _bs.GetText(locStr.Key, guildId, locStr.Params);
+    }
+
+    public ResponseBuilder Text(LocStr str)
+    {
+        locTxt = str;
+        return this;
+    }
+
+    public ResponseBuilder Text(SmartText text)
+    {
+        if (text is SmartPlainText spt)
+            plainText = spt.Text;
+        else if (text is SmartEmbedText set)
+        {
+            plainText = set.PlainText ?? plainText;
+            embedBuilder = set.GetEmbed();
+        }
+        else if (text is SmartEmbedTextArray ser)
+        {
+            plainText = ser.Content ?? plainText;
+            embeds = ser.GetEmbedBuilders();
+        }
+
+        return this;
+    }
+
+    private void InternalCreateEmbed(
+        string? title,
+        string text,
+        string? url = null,
+        string? footer = null)
+    {
+        var eb = new EllieEmbedBuilder(_bcs)
+            .WithDescription(text);
+
+        if (!string.IsNullOrWhiteSpace(title))
+            eb.WithTitle(title);
+
+        if (!string.IsNullOrWhiteSpace(url))
+            eb = eb.WithUrl(url);
+
+        if (!string.IsNullOrWhiteSpace(footer))
+            eb = eb.WithFooter(footer);
+
+        embedBuilder = eb;
+    }
+
+    public ResponseBuilder Confirm(
+        string? title,
+        string text,
+        string? url = null,
+        string? footer = null)
+    {
+        InternalCreateEmbed(title, text, url, footer);
+        color = EmbedColor.Ok;
+        return this;
+    }
+
+    public ResponseBuilder Error(
+        string? title,
+        string text,
+        string? url = null,
+        string? footer = null)
+    {
+        InternalCreateEmbed(title, text, url, footer);
+        color = EmbedColor.Error;
+        return this;
+    }
+
+    public ResponseBuilder Pending(
+        string? title,
+        string text,
+        string? url = null,
+        string? footer = null)
+    {
+        InternalCreateEmbed(title, text, url, footer);
+        color = EmbedColor.Pending;
+        return this;
+    }
+
+    public ResponseBuilder Confirm(string text)
+    {
+        InternalCreateEmbed(null, text);
+        color = EmbedColor.Ok;
+        return this;
+    }
+
+    public ResponseBuilder Confirm(LocStr str)
+    {
+        embedLocDesc = str;
+        color = EmbedColor.Ok;
+        return this;
+    }
+
+    public ResponseBuilder Pending(string text)
+    {
+        InternalCreateEmbed(null, text);
+        color = EmbedColor.Pending;
+        return this;
+    }
+
+    public ResponseBuilder Pending(LocStr str)
+    {
+        embedLocDesc = str;
+        color = EmbedColor.Pending;
+        return this;
+    }
+
+    public ResponseBuilder Error(string text)
+    {
+        InternalCreateEmbed(null, text);
+        color = EmbedColor.Error;
+        return this;
+    }
+
+    public ResponseBuilder Error(LocStr str)
+    {
+        embedLocDesc = str;
+        color = EmbedColor.Error;
+        return this;
+    }
+
+    public ResponseBuilder UserBasedMentions()
+    {
+        sanitizeMentions = !((InternalResolveUser() as IGuildUser)?.GuildPermissions.MentionEveryone ?? false);
+        return this;
+    }
+
+    private IUser? InternalResolveUser()
+        => ctx?.User ?? user ?? msg?.Author;
+
+    public ResponseBuilder Embed(EmbedBuilder eb)
+    {
+        embedBuilder = eb;
+        return this;
+    }
+
+    public ResponseBuilder Channel(IMessageChannel ch)
+    {
+        channel = ch;
+        return this;
+    }
+
+    public ResponseBuilder Sanitize(bool shouldSantize = true)
+    {
+        sanitizeMentions = shouldSantize;
+        return this;
+    }
+
+    public ResponseBuilder Context(ICommandContext context)
+    {
+        ctx = context;
+        return this;
+    }
+
+    public ResponseBuilder Message(IUserMessage message)
+    {
+        msg = message;
+        return this;
+    }
+
+    public ResponseBuilder User(IUser usr)
+    {
+        user = usr;
+        return this;
+    }
+
+    public ResponseBuilder NoReply()
+    {
+        shouldReply = false;
+        return this;
+    }
+
+    public ResponseBuilder Interaction(EllieInteractionBase? interaction)
+    {
+        inter = interaction;
+        return this;
+    }
+
+    public ResponseBuilder Embeds(IReadOnlyCollection<EmbedBuilder> inputEmbeds)
+    {
+        embeds = inputEmbeds;
+        return this;
+    }
+
+    public ResponseBuilder File(Stream stream, string name)
+    {
+        fileStream = stream;
+        fileName = name;
+        return this;
+    }
+
+    public PaginatedResponseBuilder Paginated()
+        => new(this);
+}
+
+public class PaginatedResponseBuilder
+{
+    protected readonly ResponseBuilder _builder;
+
+    public PaginatedResponseBuilder(ResponseBuilder builder)
+    {
+        _builder = builder;
+    }
+
+    public SourcedPaginatedResponseBuilder<T> Items<T>(IReadOnlyCollection<T> items)
+        => new SourcedPaginatedResponseBuilder<T>(_builder)
+            .Items(items);
+
+    public SourcedPaginatedResponseBuilder<T> PageItems<T>(Func<int, Task<IReadOnlyCollection<T>>> items)
+        => new SourcedPaginatedResponseBuilder<T>(_builder)
+            .PageItems(items);
+}
+
+public sealed class SourcedPaginatedResponseBuilder<T> : PaginatedResponseBuilder
+{
+    private IReadOnlyCollection<T>? items;
+
+    public Func<IReadOnlyList<T>, int, Task<EmbedBuilder>> PageFunc { get; private set; } = static delegate
+    {
+        return Task.FromResult<EmbedBuilder>(new());
+    };
+
+    public Func<int, Task<IReadOnlyCollection<T>>> ItemsFunc { get; set; } = static delegate
+    {
+        return Task.FromResult<IReadOnlyCollection<T>>(ReadOnlyCollection<T>.Empty);
+    };
+
+    public Func<int, Task<EllieInteractionBase>>? InteractionFunc { get; private set; }
+
+    public int? Elems { get; private set; } = 1;
+    public int ItemsPerPage { get; private set; } = 9;
+    public bool AddPaginatedFooter { get; private set; } = true;
+    public bool IsEphemeral { get; private set; }
+
+    public int InitialPage { get; set; }
+
+    public SourcedPaginatedResponseBuilder(ResponseBuilder builder)
+        : base(builder)
+    {
+    }
+
+    public SourcedPaginatedResponseBuilder<T> Items(IReadOnlyCollection<T> col)
+    {
+        items = col;
+        Elems = col.Count;
+        ItemsFunc = (i) => Task.FromResult(items.Skip(i * ItemsPerPage).Take(ItemsPerPage).ToArray() as IReadOnlyCollection<T>);
+        return this;
+    }
+
+    public SourcedPaginatedResponseBuilder<T> TotalElements(int i)
+    {
+        Elems = i;
+        return this;
+    }
+
+    public SourcedPaginatedResponseBuilder<T> PageItems(Func<int, Task<IReadOnlyCollection<T>>> func)
+    {
+        Elems = null;
+        ItemsFunc = func;
+        return this;
+    }
+
+
+    public SourcedPaginatedResponseBuilder<T> PageSize(int i)
+    {
+        ItemsPerPage = i;
+        return this;
+    }
+
+    public SourcedPaginatedResponseBuilder<T> CurrentPage(int i)
+    {
+        InitialPage = i;
+        return this;
+    }
+
+
+    public SourcedPaginatedResponseBuilder<T> Page(Func<IReadOnlyList<T>, int, EmbedBuilder> pageFunc)
+    {
+        PageFunc = (xs, x) => Task.FromResult(pageFunc(xs, x));
+        return this;
+    }
+
+    public SourcedPaginatedResponseBuilder<T> Page(Func<IReadOnlyList<T>, int, Task<EmbedBuilder>> pageFunc)
+    {
+        PageFunc = pageFunc;
+        return this;
+    }
+
+    public SourcedPaginatedResponseBuilder<T> AddFooter(bool addFooter = true)
+    {
+        AddPaginatedFooter = addFooter;
+        return this;
+    }
+
+    public SourcedPaginatedResponseBuilder<T> Ephemeral()
+    {
+        IsEphemeral = true;
+        return this;
+    }
+
+
+    public Task SendAsync()
+    {
+        var paginationSender = new ResponseBuilder.PaginationSender<T>(
+            this,
+            _builder);
+
+        return paginationSender.SendAsync(IsEphemeral);
+    }
+
+    public SourcedPaginatedResponseBuilder<T> Interaction(Func<int, Task<EllieInteractionBase>> func)
+    {
+        InteractionFunc = func; //async (i) => await func(i);
+        return this;
+    }
+
+    public SourcedPaginatedResponseBuilder<T> Interaction(EllieInteractionBase inter)
+    {
+        InteractionFunc = _ => Task.FromResult(inter);
+        return this;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Sender/ResponseBuilderExtensions.cs b/src/EllieBot/_common/Sender/ResponseBuilderExtensions.cs
new file mode 100644
index 0000000..40123a6
--- /dev/null
+++ b/src/EllieBot/_common/Sender/ResponseBuilderExtensions.cs
@@ -0,0 +1,28 @@
+namespace EllieBot.Extensions;
+
+public static class ResponseBuilderExtensions
+{
+    public static EmbedBuilder WithPendingColor(this EmbedBuilder eb)
+    {
+        if (eb is EllieEmbedBuilder neb)
+            return neb.WithPendingColor();
+
+        return eb;
+    }
+
+    public static EmbedBuilder WithOkColor(this EmbedBuilder eb)
+    {
+        if (eb is EllieEmbedBuilder neb)
+            return neb.WithOkColor();
+
+        return eb;
+    }
+
+    public static EmbedBuilder WithErrorColor(this EmbedBuilder eb)
+    {
+        if (eb is EllieEmbedBuilder neb)
+            return neb.WithErrorColor();
+
+        return eb;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Sender/ResponseMessageModel.cs b/src/EllieBot/_common/Sender/ResponseMessageModel.cs
new file mode 100644
index 0000000..675cd09
--- /dev/null
+++ b/src/EllieBot/_common/Sender/ResponseMessageModel.cs
@@ -0,0 +1,12 @@
+public class ResponseMessageModel
+{
+    public required IMessageChannel TargetChannel { get; set; }
+    public MessageReference? MessageReference { get; set; }
+    public string? Text { get; set; }
+    public Embed? Embed { get; set; }
+    public Embed[]? Embeds { get; set; }
+    public required AllowedMentions SanitizeMentions { get; set; }
+    public IUser? User { get; set; }
+    public bool Ephemeral { get; set; }
+    public EllieInteractionBase? Interaction { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/ServiceCollectionExtensions.cs b/src/EllieBot/_common/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..92a204d
--- /dev/null
+++ b/src/EllieBot/_common/ServiceCollectionExtensions.cs
@@ -0,0 +1,133 @@
+using DryIoc;
+using LinqToDB.Extensions;
+using Microsoft.Extensions.DependencyInjection;
+using EllieBot.Modules.Music;
+using EllieBot.Modules.Music.Resolvers;
+using EllieBot.Modules.Music.Services;
+using StackExchange.Redis;
+using System.Net;
+using System.Reflection;
+using EllieBot.Common.ModuleBehaviors;
+
+namespace EllieBot.Extensions;
+
+public static class ServiceCollectionExtensions
+{
+    public static IContainer AddBotStringsServices(this IContainer svcs, BotCacheImplemenation botCache)
+    {
+        if (botCache == BotCacheImplemenation.Memory)
+        {
+            svcs.AddSingleton<IStringsSource, LocalFileStringsSource>();
+            svcs.AddSingleton<IBotStringsProvider, MemoryBotStringsProvider>();
+        }
+        else
+        {
+            svcs.AddSingleton<IStringsSource, LocalFileStringsSource>();
+            svcs.AddSingleton<IBotStringsProvider, RedisBotStringsProvider>();
+        }
+
+        svcs.AddSingleton<IBotStrings, BotStrings>();
+
+        return svcs;
+    }
+
+    public static IContainer AddConfigServices(this IContainer svcs, Assembly a)
+    {
+        
+        foreach (var type in a.GetTypes()
+                           .Where(x => !x.IsAbstract && x.IsAssignableToGenericType(typeof(ConfigServiceBase<>))))
+        {
+            svcs.RegisterMany([type],
+                getServiceTypes: type => type.GetImplementedTypes(ReflectionTools.AsImplementedType.SourceType),
+                getImplFactory: type => ReflectionFactory.Of(type, Reuse.Singleton));
+        }
+        
+        return svcs;
+    }
+
+
+    public static IContainer AddMusic(this IContainer svcs)
+    {
+        svcs.RegisterMany<MusicService>(Reuse.Singleton);
+
+        svcs.AddSingleton<ITrackResolveProvider, TrackResolveProvider>();
+        svcs.AddSingleton<YtdlYoutubeResolver>();
+        svcs.AddSingleton<InvidiousYoutubeResolver>();
+        svcs.AddSingleton<IYoutubeResolverFactory, YoutubeResolverFactory>();
+        svcs.AddSingleton<ILocalTrackResolver, LocalTrackResolver>();
+        svcs.AddSingleton<IRadioResolver, RadioResolver>();
+        svcs.AddSingleton<ITrackCacher, TrackCacher>();
+
+        return svcs;
+    }
+
+    public static IContainer AddCache(this IContainer cont, IBotCredentials creds)
+    {
+        if (creds.BotCache == BotCacheImplemenation.Redis)
+        {
+            var conf = ConfigurationOptions.Parse(creds.RedisOptions);
+            cont.AddSingleton<ConnectionMultiplexer>(ConnectionMultiplexer.Connect(conf));
+            cont.AddSingleton<IBotCache, RedisBotCache>();
+            cont.AddSingleton<IPubSub, RedisPubSub>();
+        }
+        else
+        {
+            cont.AddSingleton<IBotCache, MemoryBotCache>();
+            cont.AddSingleton<IPubSub, EventPubSub>();
+        }
+
+        return cont
+            .AddBotStringsServices(creds.BotCache);
+    }
+
+    public static IContainer AddHttpClients(this IContainer svcs)
+    {
+        IServiceCollection proxySvcs = new ServiceCollection();
+        proxySvcs.AddHttpClient();
+        proxySvcs.AddHttpClient("memelist")
+                 .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
+                 {
+                     AllowAutoRedirect = false
+                 });
+
+        proxySvcs.AddHttpClient("google:search")
+                 .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler()
+                 {
+                     AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
+                 });
+
+        var prov = proxySvcs.BuildServiceProvider();
+        
+        svcs.RegisterDelegate<IHttpClientFactory>(_ => prov.GetRequiredService<IHttpClientFactory>());
+        svcs.RegisterDelegate<HttpClient>(_ => prov.GetRequiredService<HttpClient>());
+
+        return svcs;
+    }
+
+    public static IContainer AddLifetimeServices(this IContainer svcs, Assembly a)
+    {
+        Type[] types =
+        [
+            typeof(IExecOnMessage),
+            typeof(IExecPreCommand),
+            typeof(IExecPostCommand),
+            typeof(IExecNoCommand),
+            typeof(IInputTransformer),
+            typeof(IEService)
+        ];
+        
+        foreach (var svc in a.GetTypes()
+                           .Where(type => type.IsClass && types.Any(t => type.IsAssignableTo(t)) && !type.HasAttribute<DIIgnoreAttribute>()
+#if GLOBAL_NADEKO
+                            && !type.HasAttribute<NoPublicBotAttribute>()
+#endif
+                           ))
+        {
+            svcs.RegisterMany([svc],
+                getServiceTypes: type => type.GetImplementedTypes(ReflectionTools.AsImplementedType.SourceType),
+                getImplFactory: type => ReflectionFactory.Of(type, Reuse.Singleton));
+        }
+
+        return svcs;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/CommandHandler.cs b/src/EllieBot/_common/Services/CommandHandler.cs
new file mode 100644
index 0000000..4a4e3cd
--- /dev/null
+++ b/src/EllieBot/_common/Services/CommandHandler.cs
@@ -0,0 +1,432 @@
+#nullable disable
+using EllieBot.Common.Configs;
+using EllieBot.Common.ModuleBehaviors;
+using ExecuteResult = Discord.Commands.ExecuteResult;
+using PreconditionResult = Discord.Commands.PreconditionResult;
+
+namespace EllieBot.Services;
+
+public class CommandHandler : IEService, IReadyExecutor, ICommandHandler
+{
+    private const int GLOBAL_COMMANDS_COOLDOWN = 200;
+
+    private const float ONE_THOUSANDTH = 1.0f / 1000;
+
+    public event Func<IUserMessage, CommandInfo, Task> CommandExecuted = delegate { return Task.CompletedTask; };
+    public event Func<CommandInfo, ITextChannel, string, Task> CommandErrored = delegate { return Task.CompletedTask; };
+
+    //userid/msg count
+    public ConcurrentDictionary<ulong, uint> UserMessagesSent { get; } = new();
+
+    public ConcurrentHashSet<ulong> UsersOnShortCooldown { get; } = new();
+
+    private readonly DiscordSocketClient _client;
+    private readonly CommandService _commandService;
+    private readonly BotConfigService _bcs;
+    private readonly IBot _bot;
+    private readonly IBehaviorHandler _behaviorHandler;
+    private readonly IServiceProvider _services;
+
+    private readonly ConcurrentDictionary<ulong, string> _prefixes;
+
+    private readonly DbService _db;
+
+    private readonly BotConfig _bc;
+    // private readonly InteractionService _interactions;
+
+    public CommandHandler(
+        DiscordSocketClient client,
+        DbService db,
+        CommandService commandService,
+        BotConfigService bcs,
+        IBot bot,
+        IBehaviorHandler behaviorHandler,
+        // InteractionService interactions,
+        IServiceProvider services)
+    {
+        _client = client;
+        _commandService = commandService;
+        _bc = bcs.Data;
+        _bcs = bcs;
+        _bot = bot;
+        _behaviorHandler = behaviorHandler;
+        _db = db;
+        _services = services;
+        // _interactions = interactions;
+
+        _prefixes = bot.AllGuildConfigs.Where(x => x.Prefix is not null)
+                       .ToDictionary(x => x.GuildId, x => x.Prefix)
+                       .ToConcurrent();
+    }
+
+    public async Task OnReadyAsync()
+    {
+        // clear users on short cooldown every GLOBAL_COMMANDS_COOLDOWN miliseconds
+        using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(GLOBAL_COMMANDS_COOLDOWN));
+        while (await timer.WaitForNextTickAsync())
+            UsersOnShortCooldown.Clear();
+    }
+
+    public string GetPrefix(IGuild guild)
+        => GetPrefix(guild?.Id);
+
+    public string GetPrefix(ulong? id = null)
+    {
+        if (id is null || !_prefixes.TryGetValue(id.Value, out var prefix))
+            return _bcs.Data.Prefix;
+
+        return prefix;
+    }
+
+    public string SetDefaultPrefix(string prefix)
+    {
+        if (string.IsNullOrWhiteSpace(prefix))
+            throw new ArgumentNullException(nameof(prefix));
+
+        _bcs.ModifyConfig(bs =>
+        {
+            bs.Prefix = prefix;
+        });
+
+        return prefix;
+    }
+
+    public string SetPrefix(IGuild guild, string prefix)
+    {
+        ArgumentNullException.ThrowIfNullOrWhiteSpace(prefix);
+        ArgumentNullException.ThrowIfNull(guild);
+
+        using (var uow = _db.GetDbContext())
+        {
+            var gc = uow.GuildConfigsForId(guild.Id, set => set);
+            gc.Prefix = prefix;
+            uow.SaveChanges();
+        }
+
+        _prefixes[guild.Id] = prefix;
+
+        return prefix;
+    }
+
+    public async Task ExecuteExternal(ulong? guildId, ulong channelId, string commandText)
+    {
+        if (guildId is not null)
+        {
+            var guild = _client.GetGuild(guildId.Value);
+            if (guild?.GetChannel(channelId) is not SocketTextChannel channel)
+            {
+                Log.Warning("Channel for external execution not found");
+                return;
+            }
+
+            try
+            {
+                IUserMessage msg = await channel.SendMessageAsync(commandText);
+                msg = (IUserMessage)await channel.GetMessageAsync(msg.Id);
+                await TryRunCommand(guild, channel, msg);
+                //msg.DeleteAfter(5);
+            }
+            catch { }
+        }
+    }
+
+    public Task StartHandling()
+    {
+        _client.MessageReceived += MessageReceivedHandler;
+        // _client.SlashCommandExecuted += SlashCommandExecuted;
+        return Task.CompletedTask;
+    }
+
+    // private async Task SlashCommandExecuted(SocketSlashCommand arg)
+    // {
+    //     var ctx = new SocketInteractionContext<SocketSlashCommand>(_client, arg);
+    //     await _interactions.ExecuteCommandAsync(ctx, _services);
+    // }
+
+    private Task LogSuccessfulExecution(IUserMessage usrMsg, ITextChannel channel, params int[] execPoints)
+    {
+        if (_bcs.Data.ConsoleOutputType == ConsoleOutputType.Normal)
+        {
+            Log.Information("""
+                            Command Executed after {ExecTime}s
+                            	User: {User}
+                            	Server: {Server}
+                            	Channel: {Channel}
+                            	Message: {Message}
+                            """,
+                string.Join("/", execPoints.Select(x => (x * ONE_THOUSANDTH).ToString("F3"))),
+                usrMsg.Author + " [" + usrMsg.Author.Id + "]",
+                channel is null ? "PRIVATE" : channel.Guild.Name + " [" + channel.Guild.Id + "]",
+                channel is null ? "PRIVATE" : channel.Name + " [" + channel.Id + "]",
+                usrMsg.Content);
+        }
+        else
+        {
+            Log.Information("Succ | g:{GuildId} | c: {ChannelId} | u: {UserId} | msg: {Message}",
+                channel?.Guild.Id.ToString() ?? "-",
+                channel?.Id.ToString() ?? "-",
+                usrMsg.Author.Id,
+                usrMsg.Content.TrimTo(10));
+        }
+
+        return Task.CompletedTask;
+    }
+
+    private void LogErroredExecution(
+        string errorMessage,
+        IUserMessage usrMsg,
+        ITextChannel channel,
+        params int[] execPoints)
+    {
+        if (_bcs.Data.ConsoleOutputType == ConsoleOutputType.Normal)
+        {
+            Log.Warning("""
+                        Command Errored after {ExecTime}s
+                        	User: {User}
+                        	Server: {Guild}
+                        	Channel: {Channel}
+                        	Message: {Message}
+                        	Error: {ErrorMessage}
+                        """,
+                string.Join("/", execPoints.Select(x => (x * ONE_THOUSANDTH).ToString("F3"))),
+                usrMsg.Author + " [" + usrMsg.Author.Id + "]",
+                channel is null ? "DM" : channel.Guild.Name + " [" + channel.Guild.Id + "]",
+                channel is null ? "DM" : channel.Name + " [" + channel.Id + "]",
+                usrMsg.Content,
+                errorMessage);
+        }
+        else
+        {
+            Log.Warning("""
+                        Err | g:{GuildId} | c: {ChannelId} | u: {UserId} | msg: {Message}
+                        	Err: {ErrorMessage}
+                        """,
+                channel?.Guild.Id.ToString() ?? "-",
+                channel?.Id.ToString() ?? "-",
+                usrMsg.Author.Id,
+                usrMsg.Content.TrimTo(10),
+                errorMessage);
+        }
+    }
+
+    private Task MessageReceivedHandler(SocketMessage msg)
+    {
+        if (!_bot.IsReady)
+            return Task.CompletedTask;
+
+        if (_bc.IgnoreOtherBots)
+        {
+            if (msg.Author.IsBot)
+                return Task.CompletedTask;
+        }
+        else if (msg.Author.Id == _client.CurrentUser.Id)
+            return Task.CompletedTask;
+
+        if (msg is not SocketUserMessage usrMsg)
+            return Task.CompletedTask;
+
+        Task.Run(async () =>
+        {
+            try
+            {
+#if !GLOBAL_NADEKO
+                // track how many messages each user is sending
+                UserMessagesSent.AddOrUpdate(usrMsg.Author.Id, 1, (_, old) => ++old);
+#endif
+
+                var channel = msg.Channel;
+                var guild = (msg.Channel as SocketTextChannel)?.Guild;
+
+                await TryRunCommand(guild, channel, usrMsg);
+            }
+            catch (Exception ex)
+            {
+                Log.Warning(ex, "Error in CommandHandler");
+                if (ex.InnerException is not null)
+                    Log.Warning(ex.InnerException, "Inner Exception of the error in CommandHandler");
+            }
+        });
+
+        return Task.CompletedTask;
+    }
+
+    public async Task TryRunCommand(SocketGuild guild, ISocketMessageChannel channel, IUserMessage usrMsg)
+    {
+        var startTime = Environment.TickCount;
+
+        var blocked = await _behaviorHandler.RunExecOnMessageAsync(guild, usrMsg);
+        if (blocked)
+            return;
+
+        var blockTime = Environment.TickCount - startTime;
+
+        var messageContent = await _behaviorHandler.RunInputTransformersAsync(guild, usrMsg);
+        
+        var prefix = GetPrefix(guild?.Id);
+        var isPrefixCommand = messageContent.StartsWith(".prefix", StringComparison.InvariantCultureIgnoreCase);
+        // execute the command and measure the time it took
+        if (isPrefixCommand || messageContent.StartsWith(prefix, StringComparison.InvariantCulture))
+        {
+            var context = new CommandContext(_client, usrMsg);
+            var (success, error, info) = await ExecuteCommandAsync(context,
+                messageContent,
+                isPrefixCommand ? 1 : prefix.Length,
+                _services,
+                MultiMatchHandling.Best);
+
+            startTime = Environment.TickCount - startTime;
+
+            // if a command is found
+            if (info is not null)
+            {
+                // if it successfully executed
+                if (success)
+                {
+                    await LogSuccessfulExecution(usrMsg, channel as ITextChannel, blockTime, startTime);
+                    await CommandExecuted(usrMsg, info);
+                    await _behaviorHandler.RunPostCommandAsync(context, info.Module.GetTopLevelModule().Name, info);
+                    return;
+                }
+
+                // if it errored
+                if (error is not null)
+                {
+                    error = HumanizeError(error);
+                    LogErroredExecution(error, usrMsg, channel as ITextChannel, blockTime, startTime);
+
+                    if (guild is not null)
+                        await CommandErrored(info, channel as ITextChannel, error);
+
+                    return;
+                }
+            }
+        }
+
+        await _behaviorHandler.RunOnNoCommandAsync(guild, usrMsg);
+    }
+
+    private string HumanizeError(string error)
+    {
+        if (error.Contains("parse int", StringComparison.OrdinalIgnoreCase)
+            || error.Contains("parse float"))
+            return "Invalid number specified. Make sure you're specifying parameters in the correct order.";
+
+        return error;
+    }
+
+    public Task<(bool Success, string Error, CommandInfo Info)> ExecuteCommandAsync(
+        ICommandContext context,
+        string input,
+        int argPos,
+        IServiceProvider serviceProvider,
+        MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception)
+        => ExecuteCommand(context, input[argPos..], serviceProvider, multiMatchHandling);
+
+
+    public async Task<(bool Success, string Error, CommandInfo Info)> ExecuteCommand(
+        ICommandContext context,
+        string input,
+        IServiceProvider services,
+        MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception)
+    {
+        var searchResult = _commandService.Search(context, input);
+        if (!searchResult.IsSuccess)
+            return (false, null, null);
+
+        var commands = searchResult.Commands;
+        var preconditionResults = new Dictionary<CommandMatch, PreconditionResult>();
+
+        foreach (var match in commands)
+            preconditionResults[match] = await match.Command.CheckPreconditionsAsync(context, services);
+
+        var successfulPreconditions = preconditionResults.Where(x => x.Value.IsSuccess).ToArray();
+
+        if (successfulPreconditions.Length == 0)
+        {
+            //All preconditions failed, return the one from the highest priority command
+            var bestCandidate = preconditionResults.OrderByDescending(x => x.Key.Command.Priority)
+                                                   .FirstOrDefault(x => !x.Value.IsSuccess);
+            return (false, bestCandidate.Value.ErrorReason, commands[0].Command);
+        }
+
+        var parseResultsDict = new Dictionary<CommandMatch, ParseResult>();
+        foreach (var pair in successfulPreconditions)
+        {
+            var parseResult = await pair.Key.ParseAsync(context, searchResult, pair.Value, services);
+
+            if (parseResult.Error == CommandError.MultipleMatches)
+            {
+                IReadOnlyList<TypeReaderValue> argList, paramList;
+                switch (multiMatchHandling)
+                {
+                    case MultiMatchHandling.Best:
+                        argList = parseResult.ArgValues
+                                             .Map(x => x.Values.MaxBy(y => y.Score));
+                        paramList = parseResult.ParamValues
+                                               .Map(x => x.Values.MaxBy(y => y.Score));
+                        parseResult = ParseResult.FromSuccess(argList, paramList);
+                        break;
+                }
+            }
+
+            parseResultsDict[pair.Key] = parseResult;
+        }
+
+        // Calculates the 'score' of a command given a parse result
+        float CalculateScore(CommandMatch match, ParseResult parseResult)
+        {
+            float argValuesScore = 0, paramValuesScore = 0;
+
+            if (match.Command.Parameters.Count > 0)
+            {
+                var argValuesSum =
+                    parseResult.ArgValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score)
+                    ?? 0;
+                var paramValuesSum =
+                    parseResult.ParamValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score)
+                    ?? 0;
+
+                argValuesScore = argValuesSum / match.Command.Parameters.Count;
+                paramValuesScore = paramValuesSum / match.Command.Parameters.Count;
+            }
+
+            var totalArgsScore = (argValuesScore + paramValuesScore) / 2;
+            return match.Command.Priority + (totalArgsScore * 0.99f);
+        }
+
+        //Order the parse results by their score so that we choose the most likely result to execute
+        var parseResults = parseResultsDict.OrderByDescending(x => CalculateScore(x.Key, x.Value)).ToList();
+
+        var successfulParses = parseResults.Where(x => x.Value.IsSuccess).ToArray();
+
+        if (successfulParses.Length == 0)
+        {
+            //All parses failed, return the one from the highest priority command, using score as a tie breaker
+            var bestMatch = parseResults.FirstOrDefault(x => !x.Value.IsSuccess);
+            return (false, bestMatch.Value.ErrorReason, commands[0].Command);
+        }
+
+        var cmd = successfulParses[0].Key.Command;
+
+        // Bot will ignore commands which are ran more often than what specified by
+        // GlobalCommandsCooldown constant (miliseconds)
+        if (!UsersOnShortCooldown.Add(context.Message.Author.Id))
+            return (false, null, cmd);
+        //return SearchResult.FromError(CommandError.Exception, "You are on a global cooldown.");
+
+        var blocked = await _behaviorHandler.RunPreCommandAsync(context, cmd);
+        if (blocked)
+            return (false, null, cmd);
+
+        //If we get this far, at least one parse was successful. Execute the most likely overload.
+        var chosenOverload = successfulParses[0];
+        var execResult = (ExecuteResult)await chosenOverload.Key.ExecuteAsync(context, chosenOverload.Value, services);
+
+        if (execResult.Exception is not null
+            && (execResult.Exception is not HttpException he
+                || he.DiscordCode != DiscordErrorCode.InsufficientPermissions))
+            Log.Warning(execResult.Exception, "Command Error");
+
+        return (true, null, cmd);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Currency/CurrencyService.cs b/src/EllieBot/_common/Services/Currency/CurrencyService.cs
new file mode 100644
index 0000000..66029ff
--- /dev/null
+++ b/src/EllieBot/_common/Services/Currency/CurrencyService.cs
@@ -0,0 +1,115 @@
+#nullable disable
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using EllieBot.Db.Models;
+using EllieBot.Services.Currency;
+
+namespace EllieBot.Services;
+
+public sealed class CurrencyService : ICurrencyService, IEService
+{
+    private readonly DbService _db;
+    private readonly ITxTracker _txTracker;
+
+    public CurrencyService(DbService db, ITxTracker txTracker)
+    {
+        _db = db;
+        _txTracker = txTracker;
+    }
+
+    public Task<IWallet> GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default)
+    {
+        if (type == CurrencyType.Default)
+            return Task.FromResult<IWallet>(new DefaultWallet(userId, _db));
+
+        throw new ArgumentOutOfRangeException(nameof(type));
+    }
+
+    public async Task AddBulkAsync(
+        IReadOnlyCollection<ulong> userIds,
+        long amount,
+        TxData txData,
+        CurrencyType type = CurrencyType.Default)
+    {
+        if (type == CurrencyType.Default)
+        {
+            foreach (var userId in userIds)
+            {
+                var wallet = await GetWalletAsync(userId);
+                await wallet.Add(amount, txData);
+            }
+
+            return;
+        }
+
+        throw new ArgumentOutOfRangeException(nameof(type));
+    }
+
+    public async Task RemoveBulkAsync(
+        IReadOnlyCollection<ulong> userIds,
+        long amount,
+        TxData txData,
+        CurrencyType type = CurrencyType.Default)
+    {
+        if (type == CurrencyType.Default)
+        {
+            await using var ctx = _db.GetDbContext();
+            await ctx
+                .GetTable<DiscordUser>()
+                .Where(x => userIds.Contains(x.UserId))
+                .UpdateAsync(du => new()
+                {
+                    CurrencyAmount = du.CurrencyAmount >= amount
+                        ? du.CurrencyAmount - amount
+                        : 0
+                });
+            await ctx.SaveChangesAsync();
+            return;
+        }
+
+        throw new ArgumentOutOfRangeException(nameof(type));
+    }
+
+    public async Task AddAsync(
+        ulong userId,
+        long amount,
+        TxData txData)
+    {
+        var wallet = await GetWalletAsync(userId);
+        await wallet.Add(amount, txData);
+        await _txTracker.TrackAdd(amount, txData);
+    }
+
+    public async Task AddAsync(
+        IUser user,
+        long amount,
+        TxData txData)
+        => await AddAsync(user.Id, amount, txData);
+
+    public async Task<bool> RemoveAsync(
+        ulong userId,
+        long amount,
+        TxData txData)
+    {
+        if (amount == 0)
+            return true;
+
+        var wallet = await GetWalletAsync(userId);
+        var result = await wallet.Take(amount, txData);
+        if (result)
+            await _txTracker.TrackRemove(amount, txData);
+        return result;
+    }
+
+    public async Task<bool> RemoveAsync(
+        IUser user,
+        long amount,
+        TxData txData)
+        => await RemoveAsync(user.Id, amount, txData);
+
+    public async Task<IReadOnlyList<DiscordUser>> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9)
+    {
+        await using var uow = _db.GetDbContext();
+        return await uow.Set<DiscordUser>().GetTopRichest(ignoreId, page, perPage);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Currency/CurrencyServiceExtensions.cs b/src/EllieBot/_common/Services/Currency/CurrencyServiceExtensions.cs
new file mode 100644
index 0000000..7007ee4
--- /dev/null
+++ b/src/EllieBot/_common/Services/Currency/CurrencyServiceExtensions.cs
@@ -0,0 +1,48 @@
+using EllieBot.Services.Currency;
+
+namespace EllieBot.Services;
+
+public static class CurrencyServiceExtensions
+{
+    public static async Task<long> GetBalanceAsync(this ICurrencyService cs, ulong userId)
+    {
+        var wallet = await cs.GetWalletAsync(userId);
+        return await wallet.GetBalance();
+    }
+
+    // FUTURE should be a transaction
+    public static async Task<bool> TransferAsync(
+        this ICurrencyService cs,
+        IMessageSenderService sender,
+        IUser from,
+        IUser to,
+        long amount,
+        string? note,
+        string formattedAmount)
+    {
+        var fromWallet = await cs.GetWalletAsync(from.Id);
+        var toWallet = await cs.GetWalletAsync(to.Id);
+
+        var extra = new TxData("gift", from.ToString()!, note, from.Id);
+
+        if (await fromWallet.Transfer(amount, toWallet, extra))
+        {
+            try
+            {
+                await sender.Response(to)
+                            .Confirm(string.IsNullOrWhiteSpace(note)
+                                ? $"Received {formattedAmount} from {from} "
+                                : $"Received {formattedAmount} from {from}: {note}")
+                            .SendAsync();
+            }
+            catch
+            {
+                //ignored
+            }
+
+            return true;
+        }
+
+        return false;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Currency/DefaultWallet.cs b/src/EllieBot/_common/Services/Currency/DefaultWallet.cs
new file mode 100644
index 0000000..a556985
--- /dev/null
+++ b/src/EllieBot/_common/Services/Currency/DefaultWallet.cs
@@ -0,0 +1,108 @@
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Services.Currency;
+
+public class DefaultWallet : IWallet
+{
+    private readonly DbService _db;
+    public ulong UserId { get; }
+
+    public DefaultWallet(ulong userId, DbService db)
+    {
+        UserId = userId;
+        _db = db;
+    }
+
+    public async Task<long> GetBalance()
+    {
+        await using var ctx = _db.GetDbContext();
+        var userId = UserId;
+        return await ctx
+                     .GetTable<DiscordUser>()
+                     .Where(x => x.UserId == userId)
+                     .Select(x => x.CurrencyAmount)
+                     .FirstOrDefaultAsync();
+    }
+
+    public async Task<bool> Take(long amount, TxData? txData)
+    {
+        if (amount < 0)
+            throw new ArgumentOutOfRangeException(nameof(amount), "Amount to take must be non negative.");
+
+        await using var ctx = _db.GetDbContext();
+
+        var userId = UserId;
+        var changed = await ctx
+                            .GetTable<DiscordUser>()
+                            .Where(x => x.UserId == userId && x.CurrencyAmount >= amount)
+                            .UpdateAsync(x => new()
+                            {
+                                CurrencyAmount = x.CurrencyAmount - amount
+                            });
+
+        if (changed == 0)
+            return false;
+
+        if (txData is not null)
+        {
+            await ctx
+                  .GetTable<CurrencyTransaction>()
+                  .InsertAsync(() => new()
+                  {
+                      Amount = -amount,
+                      Note = txData.Note,
+                      UserId = userId,
+                      Type = txData.Type,
+                      Extra = txData.Extra,
+                      OtherId = txData.OtherId,
+                      DateAdded = DateTime.UtcNow
+                  });
+        }
+
+        return true;
+    }
+
+    public async Task Add(long amount, TxData? txData)
+    {
+        if (amount <= 0)
+            throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be greater than 0.");
+
+        await using var ctx = _db.GetDbContext();
+        var userId = UserId;
+
+
+        await ctx.GetTable<DiscordUser>()
+                 .InsertOrUpdateAsync(() => new()
+                     {
+                         UserId = userId,
+                         Username = "Unknown",
+                         Discriminator = "????",
+                         CurrencyAmount = amount,
+                     },
+                     (old) => new()
+                     {
+                         CurrencyAmount = old.CurrencyAmount + amount
+                     },
+                     () => new()
+                     {
+                         UserId = userId
+                     });
+
+        if (txData is not null)
+        {
+            await ctx.GetTable<CurrencyTransaction>()
+                     .InsertAsync(() => new()
+                     {
+                         Amount = amount,
+                         UserId = userId,
+                         Note = txData.Note,
+                         Type = txData.Type,
+                         Extra = txData.Extra,
+                         OtherId = txData.OtherId,
+                         DateAdded = DateTime.UtcNow
+                     });
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Currency/GamblingTxTracker.cs b/src/EllieBot/_common/Services/Currency/GamblingTxTracker.cs
new file mode 100644
index 0000000..5751281
--- /dev/null
+++ b/src/EllieBot/_common/Services/Currency/GamblingTxTracker.cs
@@ -0,0 +1,110 @@
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using EllieBot.Common.ModuleBehaviors;
+using EllieBot.Services.Currency;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Services;
+
+public sealed class GamblingTxTracker : ITxTracker, IEService, IReadyExecutor
+{
+    private static readonly IReadOnlySet<string> _gamblingTypes = new HashSet<string>(new[]
+    {
+        "lula",
+        "betroll",
+        "betflip",
+        "blackjack",
+        "betdraw",
+        "slot",
+    });
+
+    private ConcurrentDictionary<string, (decimal Bet, decimal PaidOut)> _stats = new();
+
+    private readonly DbService _db;
+
+    public GamblingTxTracker(DbService db)
+    {
+        _db = db;
+    }
+
+    public async Task OnReadyAsync()
+    {
+        using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
+        while (await timer.WaitForNextTickAsync())
+        {
+            await using var ctx = _db.GetDbContext();
+            await using var trans = await ctx.Database.BeginTransactionAsync();
+
+            try
+            {
+                var keys = _stats.Keys;
+                foreach (var key in keys)
+                {
+                    if (_stats.TryRemove(key, out var stat))
+                    {
+                        await ctx.GetTable<GamblingStats>()
+                            .InsertOrUpdateAsync(() => new()
+                            {
+                                Feature = key,
+                                Bet = stat.Bet,
+                                PaidOut = stat.PaidOut,
+                                DateAdded = DateTime.UtcNow
+                            }, old => new()
+                            {
+                                Bet = old.Bet + stat.Bet,
+                                PaidOut = old.PaidOut + stat.PaidOut,
+                            }, () => new()
+                            {
+                                Feature = key
+                            });
+                    }
+                }
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "An error occurred in gambling tx tracker");
+            }
+            finally
+            {
+                await trans.CommitAsync();
+            }
+        }
+    }
+
+    public Task TrackAdd(long amount, TxData? txData)
+    {
+        if (txData is null)
+            return Task.CompletedTask;
+        
+        if (_gamblingTypes.Contains(txData.Type))
+        {
+            _stats.AddOrUpdate(txData.Type,
+                _ => (0, amount),
+                (_, old) => (old.Bet, old.PaidOut + amount));
+        }
+
+        return Task.CompletedTask;
+    }
+
+    public Task TrackRemove(long amount, TxData? txData)
+    {
+        if (txData is null)
+            return Task.CompletedTask;
+        
+        if (_gamblingTypes.Contains(txData.Type))
+        {
+            _stats.AddOrUpdate(txData.Type,
+                _ => (amount, 0),
+                (_, old) => (old.Bet + amount, old.PaidOut));
+        }
+
+        return Task.CompletedTask;
+    }
+
+    public async Task<IReadOnlyCollection<GamblingStats>> GetAllAsync()
+    {
+        await using var ctx = _db.GetDbContext();
+        return await ctx.Set<GamblingStats>()
+            .ToListAsyncEF();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/IBehaviourHandler.cs b/src/EllieBot/_common/Services/IBehaviourHandler.cs
new file mode 100644
index 0000000..2f75074
--- /dev/null
+++ b/src/EllieBot/_common/Services/IBehaviourHandler.cs
@@ -0,0 +1,17 @@
+#nullable disable
+namespace EllieBot.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();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/ICommandHandler.cs b/src/EllieBot/_common/Services/ICommandHandler.cs
new file mode 100644
index 0000000..f838743
--- /dev/null
+++ b/src/EllieBot/_common/Services/ICommandHandler.cs
@@ -0,0 +1,12 @@
+namespace EllieBot.Services;
+
+public interface ICommandHandler
+{
+    string GetPrefix(IGuild ctxGuild);
+    string GetPrefix(ulong? id = null);
+    string SetDefaultPrefix(string toSet);
+    string SetPrefix(IGuild ctxGuild, string toSet);
+    ConcurrentDictionary<ulong, uint> UserMessagesSent { get; }
+
+    Task TryRunCommand(SocketGuild guild, ISocketMessageChannel channel, IUserMessage usrMsg);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/ICoordinator.cs b/src/EllieBot/_common/Services/ICoordinator.cs
new file mode 100644
index 0000000..10ec4ae
--- /dev/null
+++ b/src/EllieBot/_common/Services/ICoordinator.cs
@@ -0,0 +1,20 @@
+#nullable disable
+namespace EllieBot.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; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/ICustomBehavior.cs b/src/EllieBot/_common/Services/ICustomBehavior.cs
new file mode 100644
index 0000000..2e4bedb
--- /dev/null
+++ b/src/EllieBot/_common/Services/ICustomBehavior.cs
@@ -0,0 +1,13 @@
+using EllieBot.Common.ModuleBehaviors;
+
+namespace EllieBot.Services;
+
+public interface ICustomBehavior
+    : IExecOnMessage,
+        IInputTransformer,
+        IExecPreCommand,
+        IExecNoCommand,
+        IExecPostCommand
+{
+
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/IEService.cs b/src/EllieBot/_common/Services/IEService.cs
new file mode 100644
index 0000000..944d8cc
--- /dev/null
+++ b/src/EllieBot/_common/Services/IEService.cs
@@ -0,0 +1,9 @@
+#nullable disable
+namespace EllieBot.Services;
+
+/// <summary>
+///     All services must implement this interface in order to be auto-discovered by the DI system
+/// </summary>
+public interface IEService
+{
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/IGoogleApiService.cs b/src/EllieBot/_common/Services/IGoogleApiService.cs
new file mode 100644
index 0000000..856bd51
--- /dev/null
+++ b/src/EllieBot/_common/Services/IGoogleApiService.cs
@@ -0,0 +1,18 @@
+#nullable disable
+namespace EllieBot.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, string Thumbnail)>> 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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/ILocalDataCache.cs b/src/EllieBot/_common/Services/ILocalDataCache.cs
new file mode 100644
index 0000000..e6977e3
--- /dev/null
+++ b/src/EllieBot/_common/Services/ILocalDataCache.cs
@@ -0,0 +1,13 @@
+#nullable disable
+using EllieBot.Common.Pokemon;
+using EllieBot.Modules.Games.Common.Trivia;
+
+namespace EllieBot.Services;
+
+public interface ILocalDataCache
+{
+    Task<IReadOnlyDictionary<string, SearchPokemon>> GetPokemonsAsync();
+    Task<IReadOnlyDictionary<string, SearchPokemonAbility>> GetPokemonAbilitiesAsync();
+    Task<TriviaQuestionModel[]> GetTriviaQuestionsAsync();
+    Task<IReadOnlyDictionary<int, string>> GetPokemonMapAsync();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/ILocalization.cs b/src/EllieBot/_common/Services/ILocalization.cs
new file mode 100644
index 0000000..ab37be5
--- /dev/null
+++ b/src/EllieBot/_common/Services/ILocalization.cs
@@ -0,0 +1,19 @@
+#nullable disable
+using System.Globalization;
+
+namespace EllieBot.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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/IRemindService.cs b/src/EllieBot/_common/Services/IRemindService.cs
new file mode 100644
index 0000000..5a057c8
--- /dev/null
+++ b/src/EllieBot/_common/Services/IRemindService.cs
@@ -0,0 +1,15 @@
+#nullable disable
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Utility.Services;
+
+public interface IRemindService
+{
+    Task AddReminderAsync(ulong userId,
+        ulong targetId,
+        ulong? guildId,
+        bool isPrivate,
+        DateTime time,
+        string message,
+        ReminderType reminderType);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/IStatsService.cs b/src/EllieBot/_common/Services/IStatsService.cs
new file mode 100644
index 0000000..3dee0a6
--- /dev/null
+++ b/src/EllieBot/_common/Services/IStatsService.cs
@@ -0,0 +1,70 @@
+#nullable disable
+namespace EllieBot.Services;
+
+public interface IStatsService
+{
+    /// <summary>
+    ///     The author of the bot.
+    /// </summary>
+    string Author { get; }
+
+    /// <summary>
+    ///     The total amount of commands ran since startup.
+    /// </summary>
+    long CommandsRan { get; }
+
+    /// <summary>
+    ///     The amount of messages seen by the bot since startup.
+    /// </summary>
+    long MessageCounter { get; }
+
+    /// <summary>
+    ///     The rate of messages the bot sees every second.
+    /// </summary>
+    double MessagesPerSecond { get; }
+
+    /// <summary>
+    ///     The total amount of text channels the bot can see.
+    /// </summary>
+    long TextChannels { get; }
+
+    /// <summary>
+    ///     The total amount of voice channels the bot can see.
+    /// </summary>
+    long VoiceChannels { get; }
+
+    /// <summary>
+    ///     Gets for how long the bot has been up since startup.
+    /// </summary>
+    TimeSpan GetUptime();
+
+    /// <summary>
+    ///     Gets a formatted string of how long the bot has been up since startup.
+    /// </summary>
+    /// <param name="separator">The formatting separator.</param>
+    string GetUptimeString(string separator = ", ");
+
+    /// <summary>
+    ///     Gets total amount of private memory currently in use by the bot, in Megabytes.
+    /// </summary>
+    double GetPrivateMemoryMegabytes();
+
+    GuildInfo GetGuildInfo(string name);
+    GuildInfo GetGuildInfo(ulong id);
+}
+
+public record struct GuildInfo
+{
+    public required string Name { get; init; }
+    public required string IconUrl { get; init; }
+    public required string Owner { get; init; }
+    public required ulong OwnerId { get; init; }
+    public required ulong Id { get; init; }
+    public required int TextChannels { get; init; }
+    public required int VoiceChannels { get; init; }
+    public required DateTime CreatedAt { get; init; }
+    public required IReadOnlyList<string> Features { get; init; }
+    public required IReadOnlyList<Emote> Emojis { get; init; }
+    public required IReadOnlyList<IRole> Roles { get; init; }
+    public int MemberCount { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/ITimezoneService.cs b/src/EllieBot/_common/Services/ITimezoneService.cs
new file mode 100644
index 0000000..e70b985
--- /dev/null
+++ b/src/EllieBot/_common/Services/ITimezoneService.cs
@@ -0,0 +1,6 @@
+namespace EllieBot.Common;
+
+public interface ITimezoneService
+{
+    TimeZoneInfo GetTimeZoneOrUtc(ulong? guildId);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Impl/BehaviorExecutor.cs b/src/EllieBot/_common/Services/Impl/BehaviorExecutor.cs
new file mode 100644
index 0000000..7e471a0
--- /dev/null
+++ b/src/EllieBot/_common/Services/Impl/BehaviorExecutor.cs
@@ -0,0 +1,302 @@
+#nullable disable
+using Microsoft.Extensions.DependencyInjection;
+using EllieBot.Common.ModuleBehaviors;
+
+namespace EllieBot.Services;
+
+// should be renamed to handler as it's not only executing
+public sealed class BehaviorHandler : IBehaviorHandler 
+{
+    private readonly IServiceProvider _services;
+    
+    private IReadOnlyCollection<IExecNoCommand> noCommandExecs;
+    private IReadOnlyCollection<IExecPreCommand> preCommandExecs;
+    private IReadOnlyCollection<IExecOnMessage> onMessageExecs;
+    private IReadOnlyCollection<IInputTransformer> inputTransformers;
+
+    private readonly SemaphoreSlim _customLock = new(1, 1);
+    private readonly List<ICustomBehavior> _customExecs = new();
+
+    public BehaviorHandler(IServiceProvider services)
+    {
+        _services = services;
+    }
+
+    public void Initialize()
+    {
+        noCommandExecs = _services.GetServices<IExecNoCommand>().ToArray();
+        preCommandExecs = _services.GetServices<IExecPreCommand>().OrderByDescending(x => x.Priority).ToArray();
+        onMessageExecs = _services.GetServices<IExecOnMessage>().OrderByDescending(x => x.Priority).ToArray();
+        inputTransformers = _services.GetServices<IInputTransformer>().ToArray();
+    }
+
+    #region Add/Remove
+
+    public async Task AddRangeAsync(IEnumerable<ICustomBehavior> execs)
+    {
+        await _customLock.WaitAsync();
+        try
+        {
+            foreach (var exe in execs)
+            {
+                if (_customExecs.Contains(exe))
+                    continue;
+
+                _customExecs.Add(exe);
+            }
+        }
+        finally
+        {
+            _customLock.Release();
+        }
+    }
+    
+    public async Task<bool> AddAsync(ICustomBehavior behavior)
+    {
+        await _customLock.WaitAsync();
+        try
+        {
+            if (_customExecs.Contains(behavior))
+                return false;
+
+            _customExecs.Add(behavior);
+            return true;
+        }
+        finally
+        {
+            _customLock.Release();
+        }
+    }
+    
+    public async Task<bool> RemoveAsync(ICustomBehavior behavior)
+    {
+        await _customLock.WaitAsync();
+        try
+        {
+            return _customExecs.Remove(behavior);
+        }
+        finally
+        {
+            _customLock.Release();
+        }
+    }
+    
+    public async Task RemoveRangeAsync(IEnumerable<ICustomBehavior> behs)
+    {
+        await _customLock.WaitAsync();
+        try
+        {
+            foreach(var beh in behs)
+                _customExecs.Remove(beh);
+        }
+        finally
+        {
+            _customLock.Release();
+        }
+    }
+
+    #endregion
+    
+    #region Running
+
+    public async Task<bool> RunExecOnMessageAsync(SocketGuild guild, IUserMessage usrMsg)
+    {
+        async Task<bool> Exec<T>(IReadOnlyCollection<T> execs)
+            where T : IExecOnMessage
+        {
+            foreach (var exec in execs)
+            {
+                try
+                {
+                    if (await exec.ExecOnMessageAsync(guild, usrMsg))
+                    {
+                        Log.Information("{TypeName} blocked message g:{GuildId} u:{UserId} c:{ChannelId} msg:{Message}",
+                            GetExecName(exec),
+                            guild?.Id,
+                            usrMsg.Author.Id,
+                            usrMsg.Channel.Id,
+                            usrMsg.Content?.TrimTo(10));
+                        
+                        return true;
+                    }
+                }
+                catch (Exception ex)
+                {
+                    Log.Error(ex,
+                        "An error occurred in {TypeName} late blocker: {ErrorMessage}",
+                        GetExecName(exec),
+                        ex.Message);
+                }
+            }
+
+            return false;
+        }
+
+        if (await Exec(onMessageExecs))
+        {
+            return true;
+        }
+
+        await _customLock.WaitAsync();
+        try
+        {
+            if (await Exec(_customExecs))
+                return true;
+        }
+        finally
+        {
+            _customLock.Release();
+        }
+
+        return false;
+    }
+
+    private string GetExecName(IBehavior exec)
+        => exec.Name;
+
+    public async Task<bool> RunPreCommandAsync(ICommandContext ctx, CommandInfo cmd)
+    {
+        async Task<bool> Exec<T>(IReadOnlyCollection<T> execs) where T: IExecPreCommand
+        {
+            foreach (var exec in execs)
+            {
+                try
+                {
+                    if (await exec.ExecPreCommandAsync(ctx, cmd.Module.GetTopLevelModule().Name, cmd))
+                    {
+                        Log.Information("{TypeName} Pre-Command blocked [{User}] Command: [{Command}]",
+                            GetExecName(exec),
+                            ctx.User,
+                            cmd.Aliases[0]);
+                        return true;
+                    }
+                }
+                catch (Exception ex)
+                {
+                    Log.Error(ex,
+                        "An error occurred in {TypeName} PreCommand: {ErrorMessage}",
+                        GetExecName(exec),
+                        ex.Message);
+                }
+            }
+
+            return false;
+        }
+
+        if (await Exec(preCommandExecs))
+            return true;
+
+        await _customLock.WaitAsync();
+        try
+        {
+            if (await Exec(_customExecs))
+                return true;
+        }
+        finally
+        {
+            _customLock.Release();
+        }
+
+        return false;
+    }
+
+    public async Task RunOnNoCommandAsync(SocketGuild guild, IUserMessage usrMsg)
+    {
+        async Task Exec<T>(IReadOnlyCollection<T> execs) where T : IExecNoCommand
+        {
+            foreach (var exec in execs)
+            {
+                try
+                {
+                    await exec.ExecOnNoCommandAsync(guild, usrMsg);
+                }
+                catch (Exception ex)
+                {
+                    Log.Error(ex,
+                        "An error occurred in {TypeName} OnNoCommand: {ErrorMessage}",
+                        GetExecName(exec),
+                        ex.Message);
+                }
+            }
+        }
+
+        await Exec(noCommandExecs);
+        
+        await _customLock.WaitAsync();
+        try
+        {
+            await Exec(_customExecs);
+        }
+        finally
+        {
+            _customLock.Release();
+        }
+    }
+
+    public async Task<string> RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg)
+    {
+        async Task<string> Exec<T>(IReadOnlyCollection<T> execs, string content)
+            where T : IInputTransformer
+        {
+            foreach (var exec in execs)
+            {
+                try
+                {
+                    var newContent = await exec.TransformInput(guild, usrMsg.Channel, usrMsg.Author, content);
+                    if (newContent is not null)
+                    {
+                        Log.Information("{ExecName} transformed content {OldContent} -> {NewContent}",
+                            GetExecName(exec),
+                            content,
+                            newContent);
+                        return newContent;
+                    }
+                }
+                catch (Exception ex)
+                {
+                    Log.Warning(ex, "An error occured during InputTransform handling: {ErrorMessage}", ex.Message);
+                }
+            }
+
+            return null;
+        }
+
+        var newContent = await Exec(inputTransformers, usrMsg.Content);
+        if (newContent is not null)
+            return newContent;
+        
+        await _customLock.WaitAsync();
+        try
+        {
+            newContent = await Exec(_customExecs, usrMsg.Content);
+            if (newContent is not null)
+                return newContent;
+        }
+        finally
+        {
+            _customLock.Release();
+        }
+
+        return usrMsg.Content;
+    }
+
+    public async ValueTask RunPostCommandAsync(ICommandContext ctx, string moduleName, CommandInfo cmd)
+    {
+        foreach (var exec in _customExecs)
+        {
+            try
+            {
+                await exec.ExecPostCommandAsync(ctx, moduleName, cmd.Name);
+            }
+            catch (Exception ex)
+            {
+                Log.Warning(ex,
+                    "An error occured during PostCommand handling in {ExecName}: {ErrorMessage}",
+                    GetExecName(exec),
+                    ex.Message);
+            }
+        }
+    }
+    
+    #endregion
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Impl/BlacklistService.cs b/src/EllieBot/_common/Services/Impl/BlacklistService.cs
new file mode 100644
index 0000000..ac01491
--- /dev/null
+++ b/src/EllieBot/_common/Services/Impl/BlacklistService.cs
@@ -0,0 +1,141 @@
+#nullable disable
+using LinqToDB;
+using LinqToDB.Data;
+using LinqToDB.EntityFrameworkCore;
+using EllieBot.Common.ModuleBehaviors;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Permissions.Services;
+
+public sealed class BlacklistService : IExecOnMessage
+{
+    public int Priority
+        => int.MaxValue;
+
+    private readonly DbService _db;
+    private readonly IPubSub _pubSub;
+    private readonly IBotCredentials _creds;
+    private IReadOnlyList<BlacklistEntry> blacklist;
+
+    private readonly TypedKey<BlacklistEntry[]> _blPubKey = new("blacklist.reload");
+
+    public BlacklistService(DbService db, IPubSub pubSub, IBotCredentials creds)
+    {
+        _db = db;
+        _pubSub = pubSub;
+        _creds = creds;
+
+        Reload(false);
+        _pubSub.Sub(_blPubKey, OnReload);
+    }
+
+    private ValueTask OnReload(BlacklistEntry[] newBlacklist)
+    {
+        blacklist = newBlacklist;
+        return default;
+    }
+
+    public Task<bool> ExecOnMessageAsync(IGuild guild, IUserMessage usrMsg)
+    {
+        foreach (var bl in blacklist)
+        {
+            if (guild is not null && bl.Type == BlacklistType.Server && bl.ItemId == guild.Id)
+            {
+                Log.Information("Blocked input from blacklisted guild: {GuildName} [{GuildId}]", guild.Name, guild.Id);
+
+                return Task.FromResult(true);
+            }
+
+            if (bl.Type == BlacklistType.Channel && bl.ItemId == usrMsg.Channel.Id)
+            {
+                Log.Information("Blocked input from blacklisted channel: {ChannelName} [{ChannelId}]",
+                    usrMsg.Channel.Name,
+                    usrMsg.Channel.Id);
+
+                return Task.FromResult(true);
+            }
+
+            if (bl.Type == BlacklistType.User && bl.ItemId == usrMsg.Author.Id)
+            {
+                Log.Information("Blocked input from blacklisted user: {UserName} [{UserId}]",
+                    usrMsg.Author.ToString(),
+                    usrMsg.Author.Id);
+
+                return Task.FromResult(true);
+            }
+        }
+
+        return Task.FromResult(false);
+    }
+
+    public IReadOnlyList<BlacklistEntry> GetBlacklist()
+        => blacklist;
+
+    public void Reload(bool publish = true)
+    {
+        using var uow = _db.GetDbContext();
+        var toPublish = uow.GetTable<BlacklistEntry>().ToArray();
+        blacklist = toPublish;
+        if (publish)
+            _pubSub.Pub(_blPubKey, toPublish);
+    }
+
+    public async Task Blacklist(BlacklistType type, ulong id)
+    {
+        if (_creds.OwnerIds.Contains(id))
+            return;
+
+        await using var uow = _db.GetDbContext();
+
+        await uow
+            .GetTable<BlacklistEntry>()
+            .InsertAsync(() => new()
+            {
+                ItemId = id,
+                Type = type,
+            });
+
+        if (type == BlacklistType.User)
+        {
+            await uow.GetTable<DiscordUser>()
+                .Where(x => x.UserId == id)
+                .UpdateAsync(_ => new()
+                {
+                    CurrencyAmount = 0
+                });
+        }
+
+        Reload();
+    }
+
+    public async Task UnBlacklist(BlacklistType type, ulong id)
+    {
+        await using var uow = _db.GetDbContext();
+        await uow.GetTable<BlacklistEntry>()
+            .Where(bi => bi.ItemId == id && bi.Type == type)
+            .DeleteAsync();
+
+        Reload();
+    }
+
+    public async Task BlacklistUsers(IReadOnlyCollection<ulong> toBlacklist)
+    {
+        await using var uow = _db.GetDbContext();
+        var bc = uow.GetTable<BlacklistEntry>();
+        await bc.BulkCopyAsync(toBlacklist.Select(uid => new BlacklistEntry
+        {
+            ItemId = uid,
+            Type = BlacklistType.User
+        }));
+
+        var blList = toBlacklist.ToList();
+        await uow.GetTable<DiscordUser>()
+            .Where(x => blList.Contains(x.UserId))
+            .UpdateAsync(_ => new()
+            {
+                CurrencyAmount = 0
+            });
+
+        Reload();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Impl/CommandsUtilityService.cs b/src/EllieBot/_common/Services/Impl/CommandsUtilityService.cs
new file mode 100644
index 0000000..ed57096
--- /dev/null
+++ b/src/EllieBot/_common/Services/Impl/CommandsUtilityService.cs
@@ -0,0 +1,184 @@
+using CommandLine;
+using Ellie.Common.Marmalade;
+
+namespace EllieBot.Common;
+
+public sealed class CommandsUtilityService : ICommandsUtilityService, IEService
+{
+    private readonly CommandHandler _ch;
+    private readonly IBotStrings _strings;
+    private readonly DiscordPermOverrideService _dpos;
+    private readonly IMessageSenderService _sender;
+    private readonly ILocalization _loc;
+    private readonly IMarmaladeLoaderService _marmalades;
+
+    public CommandsUtilityService(
+        CommandHandler ch,
+        IBotStrings strings,
+        DiscordPermOverrideService dpos,
+        IMessageSenderService sender,
+        ILocalization loc,
+        IMarmaladeLoaderService marmalades)
+    {
+        _ch = ch;
+        _strings = strings;
+        _dpos = dpos;
+        _sender = sender;
+        _loc = loc;
+        _marmalades = marmalades;
+    }
+
+    public EmbedBuilder GetCommandHelp(CommandInfo com, IGuild guild)
+    {
+        var prefix = _ch.GetPrefix(guild);
+
+        var str = $"**`{prefix + com.Aliases.First()}`**";
+        var alias = com.Aliases.Skip(1).FirstOrDefault();
+        if (alias is not null)
+            str += $" **| `{prefix + alias}`**";
+
+        var culture = _loc.GetCultureInfo(guild);
+
+        var em = _sender.CreateEmbed()
+                        .AddField(str, $"{com.RealSummary(_strings, _marmalades, culture, prefix)}", true);
+
+        _dpos.TryGetOverrides(guild?.Id ?? 0, com.Name, out var overrides);
+        var reqs = GetCommandRequirements(com, (GuildPermission?)overrides);
+        if (reqs.Any())
+            em.AddField(GetText(strs.requires, guild), string.Join("\n", reqs));
+
+        var paramList = _strings.GetCommandStrings(com.Name, culture)?.Params;
+        em
+            .WithOkColor()
+            .AddField(_strings.GetText(strs.usage),
+                string.Join("\n", com.RealRemarksArr(_strings, _marmalades, culture, prefix).Map(arg => Format.Code(arg))))
+            .WithFooter(GetText(strs.module(com.Module.GetTopLevelModule().Name), guild));
+
+        if (paramList is not null and not [])
+        {
+            var pl = paramList
+                     .Select(x => Format.Code($"{prefix}{com.Name} {x.Keys.Select(y => $"<{y}>").Join(' ')}"))
+                     .Join('\n');
+
+            em.AddField(GetText(strs.overloads, guild), pl);
+        }
+
+        var opt = GetEllieOptionType(com.Attributes);
+        if (opt is not null)
+        {
+            var hs = GetCommandOptionHelp(opt);
+            if (!string.IsNullOrWhiteSpace(hs))
+                em.AddField(GetText(strs.options, guild), hs);
+        }
+
+        return em;
+    }
+
+    public static string GetCommandOptionHelp(Type opt)
+    {
+        var strs = GetCommandOptionHelpList(opt);
+
+        return string.Join("\n", strs);
+    }
+
+    public static List<string> GetCommandOptionHelpList(Type opt)
+    {
+        var strs = opt.GetProperties()
+                      .Select(x => x.GetCustomAttributes(true).FirstOrDefault(a => a is OptionAttribute))
+                      .Where(x => x is not null)
+                      .Cast<OptionAttribute>()
+                      .Select(x =>
+                      {
+                          var toReturn = $"`--{x.LongName}`";
+
+                          if (!string.IsNullOrWhiteSpace(x.ShortName))
+                              toReturn += $" (`-{x.ShortName}`)";
+
+                          toReturn += $"   {x.HelpText}  ";
+                          return toReturn;
+                      })
+                      .ToList();
+
+        return strs;
+    }
+
+    public static Type? GetEllieOptionType(IEnumerable<Attribute> attributes)
+        => attributes
+           .Select(a => a.GetType())
+           .Where(a => a.IsGenericType
+                       && a.GetGenericTypeDefinition() == typeof(EllieOptionsAttribute<>))
+           .Select(a => a.GenericTypeArguments[0])
+           .FirstOrDefault();
+
+    public static string[] GetCommandRequirements(CommandInfo cmd, GuildPerm? overrides = null)
+    {
+        var toReturn = new List<string>();
+
+        if (cmd.Preconditions.Any(x => x is OwnerOnlyAttribute))
+            toReturn.Add("Bot Owner Only");
+
+        if (cmd.Preconditions.Any(x => x is NoPublicBotAttribute)
+            || cmd.Module
+                  .Preconditions
+                  .Any(x => x is NoPublicBotAttribute)
+            || cmd.Module.GetTopLevelModule()
+                  .Preconditions
+                  .Any(x => x is NoPublicBotAttribute))
+            toReturn.Add("No Public Bot");
+
+        if (cmd.Preconditions
+               .Any(x => x is OnlyPublicBotAttribute)
+            || cmd.Module
+                  .Preconditions
+                  .Any(x => x is OnlyPublicBotAttribute)
+            || cmd.Module.GetTopLevelModule()
+                  .Preconditions
+                  .Any(x => x is OnlyPublicBotAttribute))
+            toReturn.Add("Only Public Bot");
+
+        var userPermString = cmd.Preconditions
+                                .Where(ca => ca is UserPermAttribute)
+                                .Cast<UserPermAttribute>()
+                                .Select(userPerm =>
+                                {
+                                    if (userPerm.ChannelPermission is { } cPerm)
+                                        return GetPreconditionString(cPerm);
+
+                                    if (userPerm.GuildPermission is { } gPerm)
+                                        return GetPreconditionString(gPerm);
+
+                                    return string.Empty;
+                                })
+                                .Where(x => !string.IsNullOrWhiteSpace(x))
+                                .Join('\n');
+
+        if (overrides is null)
+        {
+            if (!string.IsNullOrWhiteSpace(userPermString))
+                toReturn.Add(userPermString);
+        }
+        else
+        {
+            if (!string.IsNullOrWhiteSpace(userPermString))
+                toReturn.Add(Format.Strikethrough(userPermString));
+
+            toReturn.Add(GetPreconditionString(overrides.Value));
+        }
+
+        return toReturn.ToArray();
+    }
+
+    public static string GetPreconditionString(ChannelPerm perm)
+        => (perm + " Channel Permission").Replace("Guild", "Server");
+
+    public static string GetPreconditionString(GuildPerm perm)
+        => (perm + " Server Permission").Replace("Guild", "Server");
+
+    public string GetText(LocStr str, IGuild? guild)
+        => _strings.GetText(str, guild?.Id);
+}
+
+public interface ICommandsUtilityService
+{
+    EmbedBuilder GetCommandHelp(CommandInfo com, IGuild guild);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Impl/DiscordPermOverrideService.cs b/src/EllieBot/_common/Services/Impl/DiscordPermOverrideService.cs
new file mode 100644
index 0000000..2eb7093
--- /dev/null
+++ b/src/EllieBot/_common/Services/Impl/DiscordPermOverrideService.cs
@@ -0,0 +1,136 @@
+#nullable disable
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Common.ModuleBehaviors;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Services;
+
+public class DiscordPermOverrideService : IEService, IExecPreCommand, IDiscordPermOverrideService
+{
+    public int Priority { get; } = int.MaxValue;
+    private readonly DbService _db;
+    private readonly IServiceProvider _services;
+
+    private readonly ConcurrentDictionary<(ulong, string), DiscordPermOverride> _overrides;
+
+    public DiscordPermOverrideService(DbService db, IServiceProvider services)
+    {
+        _db = db;
+        _services = services;
+        using var uow = _db.GetDbContext();
+        _overrides = uow.Set<DiscordPermOverride>()
+            .AsNoTracking()
+            .AsEnumerable()
+            .ToDictionary(o => (o.GuildId ?? 0, o.Command), o => o)
+            .ToConcurrent();
+    }
+
+    public bool TryGetOverrides(ulong guildId, string commandName, out EllieBot.Db.GuildPerm? perm)
+    {
+        commandName = commandName.ToLowerInvariant();
+        if (_overrides.TryGetValue((guildId, commandName), out var dpo))
+        {
+            perm = dpo.Perm;
+            return true;
+        }
+
+        perm = null;
+        return false;
+    }
+
+    public Task<PreconditionResult> ExecuteOverrides(
+        ICommandContext ctx,
+        CommandInfo command,
+        GuildPerm perms,
+        IServiceProvider services)
+    {
+        var rupa = new RequireUserPermissionAttribute(perms);
+        return rupa.CheckPermissionsAsync(ctx, command, services);
+    }
+
+    public async Task AddOverride(ulong guildId, string commandName, GuildPerm perm)
+    {
+        commandName = commandName.ToLowerInvariant();
+        await using var uow = _db.GetDbContext();
+        var over = await uow.Set<DiscordPermOverride>()
+            .AsQueryable()
+            .FirstOrDefaultAsync(x => x.GuildId == guildId && commandName == x.Command);
+
+        if (over is null)
+        {
+            uow.Set<DiscordPermOverride>()
+                .Add(over = new()
+                {
+                    Command = commandName,
+                    Perm = (EllieBot.Db.GuildPerm)perm,
+                    GuildId = guildId
+                });
+        }
+        else
+            over.Perm = (EllieBot.Db.GuildPerm)perm;
+
+        _overrides[(guildId, commandName)] = over;
+
+        await uow.SaveChangesAsync();
+    }
+
+    public async Task ClearAllOverrides(ulong guildId)
+    {
+        await using var uow = _db.GetDbContext();
+        var overrides = await uow.Set<DiscordPermOverride>()
+            .AsQueryable()
+            .AsNoTracking()
+            .Where(x => x.GuildId == guildId)
+            .ToListAsync();
+
+        uow.RemoveRange(overrides);
+        await uow.SaveChangesAsync();
+
+        foreach (var over in overrides)
+            _overrides.TryRemove((guildId, over.Command), out _);
+    }
+
+    public async Task RemoveOverride(ulong guildId, string commandName)
+    {
+        commandName = commandName.ToLowerInvariant();
+
+        await using var uow = _db.GetDbContext();
+        var over = await uow.Set<DiscordPermOverride>()
+            .AsQueryable()
+            .AsNoTracking()
+            .FirstOrDefaultAsync(x => x.GuildId == guildId && x.Command == commandName);
+
+        if (over is null)
+            return;
+
+        uow.Remove(over);
+        await uow.SaveChangesAsync();
+
+        _overrides.TryRemove((guildId, commandName), out _);
+    }
+
+    public async Task<List<DiscordPermOverride>> GetAllOverrides(ulong guildId)
+    {
+        await using var uow = _db.GetDbContext();
+        return await uow.Set<DiscordPermOverride>()
+            .AsQueryable()
+            .AsNoTracking()
+            .Where(x => x.GuildId == guildId)
+            .ToListAsync();
+    }
+
+    public async Task<bool> ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command)
+    {
+        if (TryGetOverrides(context.Guild?.Id ?? 0, command.Name, out var perm) && perm is not null)
+        {
+            var result =
+                await new RequireUserPermissionAttribute((GuildPermission)perm).CheckPermissionsAsync(context,
+                    command,
+                    _services);
+
+            return !result.IsSuccess;
+        }
+
+        return false;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Impl/FontProvider.cs b/src/EllieBot/_common/Services/Impl/FontProvider.cs
new file mode 100644
index 0000000..2bead90
--- /dev/null
+++ b/src/EllieBot/_common/Services/Impl/FontProvider.cs
@@ -0,0 +1,54 @@
+#nullable disable
+using SixLabors.Fonts;
+
+namespace EllieBot.Services;
+
+public class FontProvider : IEService
+{
+    public FontFamily DottyFont { get; }
+
+    public FontFamily UniSans { get; }
+
+    public FontFamily NotoSans { get; }
+    //public FontFamily Emojis { get; }
+
+    public List<FontFamily> FallBackFonts { get; }
+    private readonly FontCollection _fonts;
+
+    public FontProvider()
+    {
+        _fonts = new();
+
+        NotoSans = _fonts.Add("data/fonts/NotoSans-Bold.ttf");
+        UniSans = _fonts.Add("data/fonts/Uni Sans.ttf");
+
+        FallBackFonts = new();
+
+        //FallBackFonts.Add(_fonts.Install("data/fonts/OpenSansEmoji.ttf"));
+
+        // try loading some emoji and jap fonts on windows as fallback fonts
+        if (Environment.OSVersion.Platform == PlatformID.Win32NT)
+        {
+            try
+            {
+                var fontsfolder = Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
+                FallBackFonts.Add(_fonts.Add(Path.Combine(fontsfolder, "seguiemj.ttf")));
+                FallBackFonts.AddRange(_fonts.AddCollection(Path.Combine(fontsfolder, "msgothic.ttc")));
+                FallBackFonts.AddRange(_fonts.AddCollection(Path.Combine(fontsfolder, "segoe.ttc")));
+            }
+            catch { }
+        }
+
+        // any fonts present in data/fonts should be added as fallback fonts
+        // this will allow support for special characters when drawing text
+        foreach (var font in Directory.GetFiles(@"data/fonts"))
+        {
+            if (font.EndsWith(".ttf"))
+                FallBackFonts.Add(_fonts.Add(font));
+            else if (font.EndsWith(".ttc"))
+                FallBackFonts.AddRange(_fonts.AddCollection(font));
+        }
+
+        DottyFont = FallBackFonts.First(x => x.Name == "dotty");
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Impl/IImageCache.cs b/src/EllieBot/_common/Services/Impl/IImageCache.cs
new file mode 100644
index 0000000..5a7e01c
--- /dev/null
+++ b/src/EllieBot/_common/Services/Impl/IImageCache.cs
@@ -0,0 +1,13 @@
+namespace EllieBot.Services;
+
+public interface IImageCache
+{
+    Task<byte[]?> GetHeadsImageAsync();
+    Task<byte[]?> GetTailsImageAsync();
+    Task<byte[]?> GetCurrencyImageAsync();
+    Task<byte[]?> GetXpBackgroundImageAsync();
+    Task<byte[]?> GetDiceAsync(int num);
+    Task<byte[]?> GetSlotEmojiAsync(int number);
+    Task<byte[]?> GetSlotBgAsync();
+    Task<byte[]?> GetImageDataAsync(Uri url);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Impl/ImagesConfig.cs b/src/EllieBot/_common/Services/Impl/ImagesConfig.cs
new file mode 100644
index 0000000..a730a94
--- /dev/null
+++ b/src/EllieBot/_common/Services/Impl/ImagesConfig.cs
@@ -0,0 +1,31 @@
+using EllieBot.Common.Configs;
+
+namespace EllieBot.Services;
+
+public sealed class ImagesConfig : ConfigServiceBase<ImageUrls>
+{
+    private const string PATH = "data/images.yml";
+
+    private static readonly TypedKey<ImageUrls> _changeKey =
+        new("config.images.updated");
+    
+    public override string Name
+        => "images";
+
+    public ImagesConfig(IConfigSeria serializer, IPubSub pubSub)
+        : base(PATH, serializer, pubSub, _changeKey)
+    {
+        Migrate();
+    }
+
+    private void Migrate()
+    {
+        if (data.Version < 5)
+        {
+            ModifyConfig(c =>
+            {
+                c.Version = 5;
+            });
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Impl/RedisImageExtensions.cs b/src/EllieBot/_common/Services/Impl/RedisImageExtensions.cs
new file mode 100644
index 0000000..79d501b
--- /dev/null
+++ b/src/EllieBot/_common/Services/Impl/RedisImageExtensions.cs
@@ -0,0 +1,11 @@
+#nullable disable
+namespace EllieBot.Services;
+
+public static class RedisImageExtensions
+{
+    private const string OLD_CDN_URL = "nadeko-pictures.nyc3.digitaloceanspaces.com";
+    private const string NEW_CDN_URL = "cdn.nadeko.bot";
+
+    public static Uri ToNewCdn(this Uri uri)
+        => new(uri.ToString().Replace(OLD_CDN_URL, NEW_CDN_URL));
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Impl/SingleProcessCoordinator.cs b/src/EllieBot/_common/Services/Impl/SingleProcessCoordinator.cs
new file mode 100644
index 0000000..0784fc7
--- /dev/null
+++ b/src/EllieBot/_common/Services/Impl/SingleProcessCoordinator.cs
@@ -0,0 +1,58 @@
+#nullable disable
+using System.Diagnostics;
+
+namespace EllieBot.Services;
+
+public class SingleProcessCoordinator : ICoordinator
+{
+    private readonly IBotCredentials _creds;
+    private readonly DiscordSocketClient _client;
+
+    public SingleProcessCoordinator(IBotCredentials creds, DiscordSocketClient client)
+    {
+        _creds = creds;
+        _client = client;
+    }
+
+    public bool RestartBot()
+    {
+        if (string.IsNullOrWhiteSpace(_creds.RestartCommand?.Cmd)
+            || string.IsNullOrWhiteSpace(_creds.RestartCommand?.Args))
+        {
+            Log.Error("You must set RestartCommand.Cmd and RestartCommand.Args in creds.yml");
+            return false;
+        }
+
+        Process.Start(_creds.RestartCommand.Cmd, _creds.RestartCommand.Args);
+        _ = Task.Run(async () =>
+        {
+            await Task.Delay(2000);
+            Die();
+        });
+        return true;
+    }
+
+    public void Die(bool graceful = false)
+        => Environment.Exit(5);
+
+    public bool RestartShard(int shardId)
+        => RestartBot();
+
+    public IList<ShardStatus> GetAllShardStatuses()
+        => new[]
+        {
+            new ShardStatus
+            {
+                ConnectionState = _client.ConnectionState,
+                GuildCount = _client.Guilds.Count,
+                LastUpdate = DateTime.UtcNow,
+                ShardId = _client.ShardId
+            }
+        };
+
+    public int GetGuildCount()
+        => _client.Guilds.Count;
+
+    public Task Reload()
+        => Task.CompletedTask;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Impl/StartingGuildsListService.cs b/src/EllieBot/_common/Services/Impl/StartingGuildsListService.cs
new file mode 100644
index 0000000..3d102cc
--- /dev/null
+++ b/src/EllieBot/_common/Services/Impl/StartingGuildsListService.cs
@@ -0,0 +1,18 @@
+#nullable disable
+using System.Collections;
+
+namespace EllieBot.Services;
+
+public class StartingGuildsService : IEnumerable<ulong>, IEService
+{
+    private readonly IReadOnlyList<ulong> _guilds;
+
+    public StartingGuildsService(DiscordSocketClient client)
+        => _guilds = client.Guilds.Select(x => x.Id).ToList();
+
+    public IEnumerator<ulong> GetEnumerator()
+        => _guilds.GetEnumerator();
+
+    IEnumerator IEnumerable.GetEnumerator()
+        => _guilds.GetEnumerator();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Impl/StatsService.cs b/src/EllieBot/_common/Services/Impl/StatsService.cs
new file mode 100644
index 0000000..9387e51
--- /dev/null
+++ b/src/EllieBot/_common/Services/Impl/StatsService.cs
@@ -0,0 +1,206 @@
+#nullable disable
+using EllieBot.Common.ModuleBehaviors;
+using System.Diagnostics;
+
+namespace EllieBot.Services;
+
+public sealed class StatsService : IStatsService, IReadyExecutor, IEService
+{
+    public static string BotVersion
+        => typeof(Bot).Assembly.GetName().Version?.ToString(3) ?? "Custom";
+
+    public string Author
+        => "toastie_t0ast";
+
+    public double MessagesPerSecond
+        => MessageCounter / GetUptime().TotalSeconds;
+
+    public long TextChannels
+        => Interlocked.Read(ref textChannels);
+
+    public long VoiceChannels
+        => Interlocked.Read(ref voiceChannels);
+
+    public long MessageCounter
+        => Interlocked.Read(ref messageCounter);
+
+    public long CommandsRan
+        => Interlocked.Read(ref commandsRan);
+
+    private readonly Process _currentProcess = Process.GetCurrentProcess();
+    private readonly DiscordSocketClient _client;
+    private readonly IBotCredentials _creds;
+    private readonly DateTime _started;
+
+    private long textChannels;
+    private long voiceChannels;
+    private long messageCounter;
+    private long commandsRan;
+
+    private readonly IHttpClientFactory _httpFactory;
+
+    public StatsService(
+        DiscordSocketClient client,
+        CommandHandler cmdHandler,
+        IBotCredentials creds,
+        IHttpClientFactory factory)
+    {
+        _client = client;
+        _creds = creds;
+        _httpFactory = factory;
+
+        _started = DateTime.UtcNow;
+        _client.MessageReceived += _ => Task.FromResult(Interlocked.Increment(ref messageCounter));
+        cmdHandler.CommandExecuted += (_, _) => Task.FromResult(Interlocked.Increment(ref commandsRan));
+
+        _client.ChannelCreated += c =>
+        {
+            if (c is IVoiceChannel)
+                Interlocked.Increment(ref voiceChannels);
+            else if (c is ITextChannel)
+                Interlocked.Increment(ref textChannels);
+
+            return Task.CompletedTask;
+        };
+
+        _client.ChannelDestroyed += c =>
+        {
+            if (c is IVoiceChannel)
+                Interlocked.Decrement(ref voiceChannels);
+            else if (c is ITextChannel)
+                Interlocked.Decrement(ref textChannels);
+
+            return Task.CompletedTask;
+        };
+
+        _client.GuildAvailable += g =>
+        {
+            var tc = g.Channels.Count(cx => cx is ITextChannel and not IVoiceChannel);
+            var vc = g.Channels.Count(cx => cx is IVoiceChannel);
+            Interlocked.Add(ref textChannels, tc);
+            Interlocked.Add(ref voiceChannels, vc);
+
+            return Task.CompletedTask;
+        };
+
+        _client.JoinedGuild += g =>
+        {
+            var tc = g.Channels.Count(cx => cx is ITextChannel and not IVoiceChannel);
+            var vc = g.Channels.Count(cx => cx is IVoiceChannel);
+            Interlocked.Add(ref textChannels, tc);
+            Interlocked.Add(ref voiceChannels, vc);
+
+            return Task.CompletedTask;
+        };
+
+        _client.GuildUnavailable += g =>
+        {
+            var tc = g.Channels.Count(cx => cx is ITextChannel and not IVoiceChannel);
+            var vc = g.Channels.Count(cx => cx is IVoiceChannel);
+            Interlocked.Add(ref textChannels, -tc);
+            Interlocked.Add(ref voiceChannels, -vc);
+
+            return Task.CompletedTask;
+        };
+
+        _client.LeftGuild += g =>
+        {
+            var tc = g.Channels.Count(cx => cx is ITextChannel and not IVoiceChannel);
+            var vc = g.Channels.Count(cx => cx is IVoiceChannel);
+            Interlocked.Add(ref textChannels, -tc);
+            Interlocked.Add(ref voiceChannels, -vc);
+
+            return Task.CompletedTask;
+        };
+    }
+
+    private void InitializeChannelCount()
+    {
+        var guilds = _client.Guilds;
+        textChannels = guilds.Sum(static g => g.Channels.Count(static cx => cx is ITextChannel and not IVoiceChannel));
+        voiceChannels = guilds.Sum(static g => g.Channels.Count(static cx => cx is IVoiceChannel));
+    }
+
+    public async Task OnReadyAsync()
+    {
+        InitializeChannelCount();
+
+        using var timer = new PeriodicTimer(TimeSpan.FromHours(1));
+        do
+        {
+            if (string.IsNullOrWhiteSpace(_creds.BotListToken))
+                continue;
+
+            try
+            {
+                using var http = _httpFactory.CreateClient();
+                using var content = new FormUrlEncodedContent(new Dictionary<string, string>
+                {
+                    { "shard_count", _creds.TotalShards.ToString() },
+                    { "shard_id", _client.ShardId.ToString() },
+                    { "server_count", _client.Guilds.Count().ToString() }
+                });
+                content.Headers.Clear();
+                content.Headers.Add("Content-Type", "application/x-www-form-urlencoded");
+                http.DefaultRequestHeaders.Add("Authorization", _creds.BotListToken);
+
+                using var res = await http.PostAsync(
+                    new Uri($"https://discordbots.org/api/bots/{_client.CurrentUser.Id}/stats"),
+                    content);
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "Error in botlist post");
+            }
+        } while (await timer.WaitForNextTickAsync());
+    }
+
+    public TimeSpan GetUptime()
+        => DateTime.UtcNow - _started;
+
+    public string GetUptimeString(string separator = ", ")
+    {
+        var time = GetUptime();
+
+        if (time.Days > 0)
+            return $"{time.Days}d {time.Hours}h {time.Minutes}m";
+
+        if (time.Hours > 0)
+            return $"{time.Hours}h {time.Minutes}m";
+
+        if (time.Minutes > 0)
+            return $"{time.Minutes}m {time.Seconds}s";
+
+        return $"{time.Seconds}s";
+    }
+
+    public double GetPrivateMemoryMegabytes()
+    {
+        _currentProcess.Refresh();
+        return _currentProcess.PrivateMemorySize64 / 1.Megabytes();
+    }
+
+    public GuildInfo GetGuildInfo(string name)
+        => throw new NotImplementedException();
+
+    public GuildInfo GetGuildInfo(ulong id)
+    {
+        var g = _client.GetGuild(id);
+
+        return new GuildInfo()
+        {
+            Id = g.Id,
+            IconUrl = g.IconUrl,
+            Name = g.Name,
+            Owner = g.Owner.Username,
+            OwnerId = g.OwnerId,
+            CreatedAt = g.CreatedAt.UtcDateTime,
+            VoiceChannels = g.VoiceChannels.Count,
+            TextChannels = g.TextChannels.Count,
+            Features = g.Features.Value.ToString().Split(","),
+            Emojis = g.Emotes.ToArray(),
+            Roles = g.Roles.OrderByDescending(x => x.Position).ToArray(),
+            MemberCount = g.MemberCount,
+        };
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/Impl/YtdlOperation.cs b/src/EllieBot/_common/Services/Impl/YtdlOperation.cs
new file mode 100644
index 0000000..3813b80
--- /dev/null
+++ b/src/EllieBot/_common/Services/Impl/YtdlOperation.cs
@@ -0,0 +1,77 @@
+#nullable disable
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Text;
+
+namespace EllieBot.Services;
+
+public class YtdlOperation
+{
+    private readonly string _baseArgString;
+    private readonly bool _isYtDlp;
+
+    public YtdlOperation(string baseArgString, bool isYtDlp = false)
+    {
+        _baseArgString = baseArgString;
+        _isYtDlp = isYtDlp;
+    }
+
+    private Process CreateProcess(string[] args)
+    {
+        var newArgs = args.Map(arg => (object)arg.Replace("\"", ""));
+        return new()
+        {
+            StartInfo = new()
+            {
+                FileName = _isYtDlp ? "yt-dlp" : "youtube-dl",
+                Arguments = string.Format(_baseArgString, newArgs),
+                UseShellExecute = false,
+                RedirectStandardError = true,
+                RedirectStandardOutput = true,
+                StandardOutputEncoding = Encoding.UTF8,
+                StandardErrorEncoding = Encoding.UTF8,
+                CreateNoWindow = true
+            }
+        };
+    }
+
+    public async Task<string> GetDataAsync(params string[] args)
+    {
+        try
+        {
+            using var process = CreateProcess(args);
+
+            Log.Debug("Executing {FileName} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
+            process.Start();
+
+            var str = await process.StandardOutput.ReadToEndAsync();
+            var err = await process.StandardError.ReadToEndAsync();
+            if (!string.IsNullOrEmpty(err))
+                Log.Warning("YTDL warning: {YtdlWarning}", err);
+
+            return str;
+        }
+        catch (Win32Exception)
+        {
+            Log.Error("youtube-dl is likely not installed. Please install it before running the command again");
+            return default;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Exception running youtube-dl: {ErrorMessage}", ex.Message);
+            return default;
+        }
+    }
+
+    public async IAsyncEnumerable<string> EnumerateDataAsync(params string[] args)
+    {
+        using var process = CreateProcess(args);
+
+        Log.Debug("Executing {FileName} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
+        process.Start();
+
+        string line;
+        while ((line = await process.StandardOutput.ReadLineAsync()) is not null)
+            yield return line;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/strings/impl/BotStrings.cs b/src/EllieBot/_common/Services/strings/impl/BotStrings.cs
new file mode 100644
index 0000000..45bbd6e
--- /dev/null
+++ b/src/EllieBot/_common/Services/strings/impl/BotStrings.cs
@@ -0,0 +1,102 @@
+#nullable disable
+using System.Globalization;
+
+namespace EllieBot.Services;
+
+public class BotStrings : IBotStrings
+{
+    /// <summary>
+    ///     Used as failsafe in case response key doesn't exist in the selected or default language.
+    /// </summary>
+    private readonly CultureInfo _usCultureInfo = new("en-US");
+
+    private readonly ILocalization _localization;
+    private readonly IBotStringsProvider _stringsProvider;
+
+    public BotStrings(ILocalization loc, IBotStringsProvider stringsProvider)
+    {
+        _localization = loc;
+        _stringsProvider = stringsProvider;
+    }
+
+    private string GetString(string key, CultureInfo cultureInfo)
+        => _stringsProvider.GetText(cultureInfo.Name, key);
+
+    public string GetText(string key, ulong? guildId = null, params object[] data)
+        => GetText(key, _localization.GetCultureInfo(guildId), data);
+
+    public string GetText(string key, CultureInfo cultureInfo)
+    {
+        var text = GetString(key, cultureInfo);
+
+        if (string.IsNullOrWhiteSpace(text))
+        {
+            Log.Warning("'{Key}' key is missing from '{LanguageName}' response strings. You may ignore this message",
+                key,
+                cultureInfo.Name);
+            text = GetString(key, _usCultureInfo) ?? $"Error: dkey {key} not found!";
+            if (string.IsNullOrWhiteSpace(text))
+            {
+                return
+                    "I can't tell you if the command is executed, because there was an error printing out the response."
+                    + $" Key '{key}' is missing from resources. You may ignore this message.";
+            }
+        }
+
+        return text;
+    }
+
+    public string GetText(string key, CultureInfo cultureInfo, params object[] data)
+    {
+        try
+        {
+            return string.Format(GetText(key, cultureInfo), data);
+        }
+        catch (FormatException)
+        {
+            Log.Warning(
+                " Key '{Key}' is not properly formatted in '{LanguageName}' response strings. Please report this",
+                key,
+                cultureInfo.Name);
+            if (cultureInfo.Name != _usCultureInfo.Name)
+                return GetText(key, _usCultureInfo, data);
+            return
+                "I can't tell you if the command is executed, because there was an error printing out the response.\n"
+                + $"Key '{key}' is not properly formatted. Please report this.";
+        }
+    }
+
+    public CommandStrings GetCommandStrings(string commandName, ulong? guildId = null)
+        => GetCommandStrings(commandName, _localization.GetCultureInfo(guildId));
+
+    public CommandStrings GetCommandStrings(string commandName, CultureInfo cultureInfo)
+    {
+        var cmdStrings = _stringsProvider.GetCommandStrings(cultureInfo.Name, commandName);
+        if (cmdStrings is null)
+        {
+            if (cultureInfo.Name == _usCultureInfo.Name)
+            {
+                Log.Warning("'{CommandName}' doesn't exist in 'en-US' command strings. Please report this",
+                    commandName);
+
+                return new CommandStrings()
+                {
+                    Examples = [""],
+                    Desc = "?",
+                    Params = []
+                };
+            }
+
+//                 Log.Warning(@"'{CommandName}' command strings don't exist in '{LanguageName}' culture.
+// This message is safe to ignore, however you can ask in Ellie support server how you can contribute command translations",
+//                     commandName, cultureInfo.Name);
+
+            return GetCommandStrings(commandName, _usCultureInfo);
+        }
+
+        return cmdStrings;
+    }
+
+    public void Reload()
+        => _stringsProvider.Reload();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/strings/impl/LocalFileStringsSource.cs b/src/EllieBot/_common/Services/strings/impl/LocalFileStringsSource.cs
new file mode 100644
index 0000000..12b6ba9
--- /dev/null
+++ b/src/EllieBot/_common/Services/strings/impl/LocalFileStringsSource.cs
@@ -0,0 +1,73 @@
+#nullable disable
+using Newtonsoft.Json;
+using YamlDotNet.Serialization;
+
+namespace EllieBot.Services;
+
+/// <summary>
+///     Loads strings from the local default filepath <see cref="_responsesPath" />
+/// </summary>
+public class LocalFileStringsSource : IStringsSource
+{
+    private readonly string _responsesPath = "data/strings/responses";
+    private readonly string _commandsPath = "data/strings/commands";
+
+    public LocalFileStringsSource(
+        string responsesPath = "data/strings/responses",
+        string commandsPath = "data/strings/commands")
+    {
+        _responsesPath = responsesPath;
+        _commandsPath = commandsPath;
+    }
+
+    public Dictionary<string, Dictionary<string, string>> GetResponseStrings()
+    {
+        var outputDict = new Dictionary<string, Dictionary<string, string>>();
+        foreach (var file in Directory.GetFiles(_responsesPath))
+        {
+            try
+            {
+                var langDict = JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(file));
+                var localeName = GetLocaleName(file);
+                outputDict[localeName] = langDict;
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "Error loading {FileName} response strings: {ErrorMessage}", file, ex.Message);
+            }
+        }
+
+        return outputDict;
+    }
+
+    public Dictionary<string, Dictionary<string, CommandStrings>> GetCommandStrings()
+    {
+        var deserializer = new DeserializerBuilder().Build();
+
+        var outputDict = new Dictionary<string, Dictionary<string, CommandStrings>>();
+        foreach (var file in Directory.GetFiles(_commandsPath))
+        {
+            try
+            {
+                var text = File.ReadAllText(file);
+                var langDict = deserializer.Deserialize<Dictionary<string, CommandStrings>>(text);
+                var localeName = GetLocaleName(file);
+                outputDict[localeName] = langDict;
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "Error loading {FileName} command strings: {ErrorMessage}", file, ex.Message);
+            }
+        }
+
+        return outputDict;
+    }
+
+    private static string GetLocaleName(string fileName)
+    {
+        fileName = Path.GetFileName(fileName);
+        var dotIndex = fileName.IndexOf('.') + 1;
+        var secondDotIndex = fileName.LastIndexOf('.');
+        return fileName.Substring(dotIndex, secondDotIndex - dotIndex);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Services/strings/impl/MemoryBotStringsProvider.cs b/src/EllieBot/_common/Services/strings/impl/MemoryBotStringsProvider.cs
new file mode 100644
index 0000000..6676dd8
--- /dev/null
+++ b/src/EllieBot/_common/Services/strings/impl/MemoryBotStringsProvider.cs
@@ -0,0 +1,38 @@
+#nullable disable
+namespace EllieBot.Services;
+
+public class MemoryBotStringsProvider : IBotStringsProvider
+{
+    private readonly IStringsSource _source;
+    private IReadOnlyDictionary<string, Dictionary<string, string>> responseStrings;
+    private IReadOnlyDictionary<string, Dictionary<string, CommandStrings>> commandStrings;
+
+    public MemoryBotStringsProvider(IStringsSource source)
+    {
+        _source = source;
+        Reload();
+    }
+
+    public string GetText(string localeName, string key)
+    {
+        if (responseStrings.TryGetValue(localeName, out var langStrings) && langStrings.TryGetValue(key, out var text))
+            return text;
+
+        return null;
+    }
+
+    public void Reload()
+    {
+        responseStrings = _source.GetResponseStrings();
+        commandStrings = _source.GetCommandStrings();
+    }
+
+    public CommandStrings GetCommandStrings(string localeName, string commandName)
+    {
+        if (commandStrings.TryGetValue(localeName, out var langStrings)
+            && langStrings.TryGetValue(commandName, out var strings))
+            return strings;
+
+        return null;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Settings/BotConfigService.cs b/src/EllieBot/_common/Settings/BotConfigService.cs
new file mode 100644
index 0000000..5e98049
--- /dev/null
+++ b/src/EllieBot/_common/Settings/BotConfigService.cs
@@ -0,0 +1,79 @@
+#nullable disable
+using EllieBot.Common.Configs;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace EllieBot.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;
+            });
+        
+        if(data.Version < 7)
+            ModifyConfig(c =>
+            {
+                c.Version = 7;
+                c.IgnoreOtherBots = true;
+            });
+        
+        if(data.Version < 8)
+            ModifyConfig(c =>
+            {
+                c.Version = 8;
+            });
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Settings/ConfigParsers.cs b/src/EllieBot/_common/Settings/ConfigParsers.cs
new file mode 100644
index 0000000..8eda144
--- /dev/null
+++ b/src/EllieBot/_common/Settings/ConfigParsers.cs
@@ -0,0 +1,50 @@
+#nullable disable
+using SixLabors.ImageSharp.PixelFormats;
+using System.Globalization;
+
+namespace EllieBot.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");
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Settings/ConfigServiceBase.cs b/src/EllieBot/_common/Settings/ConfigServiceBase.cs
new file mode 100644
index 0000000..df6a0b9
--- /dev/null
+++ b/src/EllieBot/_common/Settings/ConfigServiceBase.cs
@@ -0,0 +1,201 @@
+using EllieBot.Common.Configs;
+using EllieBot.Common.Yml;
+using System.Linq.Expressions;
+using System.Reflection;
+
+namespace EllieBot.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();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Settings/IConfigService.cs b/src/EllieBot/_common/Settings/IConfigService.cs
new file mode 100644
index 0000000..ae97198
--- /dev/null
+++ b/src/EllieBot/_common/Settings/IConfigService.cs
@@ -0,0 +1,46 @@
+#nullable disable
+namespace EllieBot.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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Settings/SettingParser.cs b/src/EllieBot/_common/Settings/SettingParser.cs
new file mode 100644
index 0000000..1437591
--- /dev/null
+++ b/src/EllieBot/_common/Settings/SettingParser.cs
@@ -0,0 +1,8 @@
+#nullable disable
+namespace EllieBot.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);
\ No newline at end of file
diff --git a/src/EllieBot/_common/SmartText/SmartEmbedText.cs b/src/EllieBot/_common/SmartText/SmartEmbedText.cs
new file mode 100644
index 0000000..b52c0cb
--- /dev/null
+++ b/src/EllieBot/_common/SmartText/SmartEmbedText.cs
@@ -0,0 +1,184 @@
+#nullable disable warnings
+using SixLabors.ImageSharp.PixelFormats;
+using System.Text.Json.Serialization;
+
+namespace EllieBot;
+
+public sealed record SmartEmbedArrayElementText : SmartEmbedTextBase
+{
+    public string Color { get; init; } = string.Empty;
+
+    public SmartEmbedArrayElementText()
+    {
+        
+    }
+    
+    public SmartEmbedArrayElementText(IEmbed eb) : base(eb)
+    {
+        Color = eb.Color is { } c ? new Rgba32(c.R, c.G, c.B).ToHex() : string.Empty;
+    }
+
+    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; }
+
+    [JsonIgnore]
+    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);
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/SmartText/SmartEmbedTextArray.cs b/src/EllieBot/_common/SmartText/SmartEmbedTextArray.cs
new file mode 100644
index 0000000..3147132
--- /dev/null
+++ b/src/EllieBot/_common/SmartText/SmartEmbedTextArray.cs
@@ -0,0 +1,34 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot;
+
+public sealed record SmartEmbedTextArray : SmartText
+{
+    public string Content { get; set; }
+    public SmartEmbedArrayElementText[] Embeds { get; set; }
+
+    [JsonIgnore]
+    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();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/SmartText/SmartPlainText.cs b/src/EllieBot/_common/SmartText/SmartPlainText.cs
new file mode 100644
index 0000000..0da4a35
--- /dev/null
+++ b/src/EllieBot/_common/SmartText/SmartPlainText.cs
@@ -0,0 +1,19 @@
+#nullable disable
+namespace EllieBot;
+
+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;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/SmartText/SmartText.cs b/src/EllieBot/_common/SmartText/SmartText.cs
new file mode 100644
index 0000000..74aee76
--- /dev/null
+++ b/src/EllieBot/_common/SmartText/SmartText.cs
@@ -0,0 +1,92 @@
+#nullable disable
+using Newtonsoft.Json.Linq;
+using System.Text.Json.Serialization;
+
+namespace EllieBot;
+
+public abstract record SmartText
+{
+    [JsonIgnore]
+    public bool IsEmbed
+        => this is SmartEmbedText;
+
+    [JsonIgnore]
+    public bool IsPlainText
+        => this is SmartPlainText;
+
+    [JsonIgnore]
+    public bool IsEmbedArray
+        => this is SmartEmbedTextArray;
+
+    public static implicit operator SmartText(string input)
+        => new SmartPlainText(input);
+    
+    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);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/SmartText/SmartTextEmbedAuthor.cs b/src/EllieBot/_common/SmartText/SmartTextEmbedAuthor.cs
new file mode 100644
index 0000000..9d3cc1c
--- /dev/null
+++ b/src/EllieBot/_common/SmartText/SmartTextEmbedAuthor.cs
@@ -0,0 +1,16 @@
+#nullable disable
+using Newtonsoft.Json;
+using System.Text.Json.Serialization;
+
+namespace EllieBot;
+
+public class SmartTextEmbedAuthor
+{
+    public string Name { get; set; }
+
+    [JsonProperty("icon_url")]
+    [JsonPropertyName("icon_url")]
+    public string IconUrl { get; set; }
+
+    public string Url { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/SmartText/SmartTextEmbedField.cs b/src/EllieBot/_common/SmartText/SmartTextEmbedField.cs
new file mode 100644
index 0000000..7989b32
--- /dev/null
+++ b/src/EllieBot/_common/SmartText/SmartTextEmbedField.cs
@@ -0,0 +1,9 @@
+#nullable disable
+namespace EllieBot;
+
+public class SmartTextEmbedField
+{
+    public string Name { get; set; }
+    public string Value { get; set; }
+    public bool Inline { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/SmartText/SmartTextEmbedFooter.cs b/src/EllieBot/_common/SmartText/SmartTextEmbedFooter.cs
new file mode 100644
index 0000000..6b06223
--- /dev/null
+++ b/src/EllieBot/_common/SmartText/SmartTextEmbedFooter.cs
@@ -0,0 +1,14 @@
+#nullable disable
+using Newtonsoft.Json;
+using System.Text.Json.Serialization;
+
+namespace EllieBot;
+
+public class SmartTextEmbedFooter
+{
+    public string Text { get; set; }
+
+    [JsonProperty("icon_url")]
+    [JsonPropertyName("icon_url")]
+    public string IconUrl { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TriviaQuestionModel.cs b/src/EllieBot/_common/TriviaQuestionModel.cs
new file mode 100644
index 0000000..ddd92d3
--- /dev/null
+++ b/src/EllieBot/_common/TriviaQuestionModel.cs
@@ -0,0 +1,11 @@
+#nullable disable
+namespace EllieBot.Modules.Games.Common.Trivia;
+
+public sealed class TriviaQuestionModel
+{
+    public string Category { get; init; }
+    public string Question { get; init; }
+    public string ImageUrl { get; init; }
+    public string AnswerImageUrl { get; init; }
+    public string Answer { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TypeReaderResult.cs b/src/EllieBot/_common/TypeReaderResult.cs
new file mode 100644
index 0000000..309ed7b
--- /dev/null
+++ b/src/EllieBot/_common/TypeReaderResult.cs
@@ -0,0 +1,30 @@
+namespace EllieBot.Common.TypeReaders;
+
+public readonly struct TypeReaderResult<T>
+{
+    public bool IsSuccess
+        => _result.IsSuccess;
+
+    public IReadOnlyCollection<TypeReaderValue> Values
+        => _result.Values;
+
+    private readonly Discord.Commands.TypeReaderResult _result;
+
+    private TypeReaderResult(in Discord.Commands.TypeReaderResult result)
+        => _result = result;
+
+    public static implicit operator TypeReaderResult<T>(in Discord.Commands.TypeReaderResult result)
+        => new(result);
+
+    public static implicit operator Discord.Commands.TypeReaderResult(in TypeReaderResult<T> wrapper)
+        => wrapper._result;
+}
+
+public static class TypeReaderResult
+{
+    public static TypeReaderResult<T> FromError<T>(CommandError error, string reason)
+        => Discord.Commands.TypeReaderResult.FromError(error, reason);
+
+    public static TypeReaderResult<T> FromSuccess<T>(in T value)
+        => Discord.Commands.TypeReaderResult.FromSuccess(value);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TypeReaders/CommandOrExprInfo.cs b/src/EllieBot/_common/TypeReaders/CommandOrExprInfo.cs
new file mode 100644
index 0000000..18d9184
--- /dev/null
+++ b/src/EllieBot/_common/TypeReaders/CommandOrExprInfo.cs
@@ -0,0 +1,23 @@
+#nullable disable
+namespace EllieBot.Common.TypeReaders;
+
+public class CommandOrExprInfo
+{
+    public enum Type
+    {
+        Normal,
+        Custom
+    }
+
+    public string Name { get; set; }
+    public Type CmdType { get; set; }
+
+    public bool IsCustom
+        => CmdType == Type.Custom;
+
+    public CommandOrExprInfo(string input, Type type)
+    {
+        Name = input;
+        CmdType = type;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TypeReaders/EmoteTypeReader.cs b/src/EllieBot/_common/TypeReaders/EmoteTypeReader.cs
new file mode 100644
index 0000000..e473e49
--- /dev/null
+++ b/src/EllieBot/_common/TypeReaders/EmoteTypeReader.cs
@@ -0,0 +1,13 @@
+#nullable disable
+namespace EllieBot.Common.TypeReaders;
+
+public sealed class EmoteTypeReader : EllieTypeReader<Emote>
+{
+    public override ValueTask<TypeReaderResult<Emote>> ReadAsync(ICommandContext ctx, string input)
+    {
+        if (!Emote.TryParse(input, out var emote))
+            return new(TypeReaderResult.FromError<Emote>(CommandError.ParseFailed, "Input is not a valid emote"));
+
+        return new(TypeReaderResult.FromSuccess(emote));
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TypeReaders/GuildDateTimeTypeReader.cs b/src/EllieBot/_common/TypeReaders/GuildDateTimeTypeReader.cs
new file mode 100644
index 0000000..97f9eb4
--- /dev/null
+++ b/src/EllieBot/_common/TypeReaders/GuildDateTimeTypeReader.cs
@@ -0,0 +1,49 @@
+#nullable disable
+namespace EllieBot.Common.TypeReaders;
+
+public sealed class GuildDateTimeTypeReader : EllieTypeReader<GuildDateTime>
+{
+    private readonly ITimezoneService _gts;
+
+    public GuildDateTimeTypeReader(ITimezoneService gts)
+        => _gts = gts;
+
+    public override ValueTask<TypeReaderResult<GuildDateTime>> ReadAsync(ICommandContext context, string input)
+    {
+        var gdt = Parse(context.Guild.Id, input);
+        if (gdt is null)
+        {
+            return new(TypeReaderResult.FromError<GuildDateTime>(CommandError.ParseFailed,
+                "Input string is in an incorrect format."));
+        }
+
+        return new(TypeReaderResult.FromSuccess(gdt));
+    }
+
+    private GuildDateTime Parse(ulong guildId, string input)
+    {
+        if (!DateTime.TryParse(input, out var dt))
+            return null;
+
+        var tz = _gts.GetTimeZoneOrUtc(guildId);
+
+        return new(tz, dt);
+    }
+}
+
+public class GuildDateTime
+{
+    public TimeZoneInfo Timezone { get; }
+    public DateTime CurrentGuildTime { get; }
+    public DateTime InputTime { get; }
+    public DateTime InputTimeUtc { get; }
+
+    public GuildDateTime(TimeZoneInfo guildTimezone, DateTime inputTime)
+    {
+        var now = DateTime.UtcNow;
+        Timezone = guildTimezone;
+        CurrentGuildTime = TimeZoneInfo.ConvertTime(now, TimeZoneInfo.Utc, Timezone);
+        InputTime = inputTime;
+        InputTimeUtc = TimeZoneInfo.ConvertTime(inputTime, Timezone, TimeZoneInfo.Utc);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TypeReaders/GuildTypeReader.cs b/src/EllieBot/_common/TypeReaders/GuildTypeReader.cs
new file mode 100644
index 0000000..9a29f95
--- /dev/null
+++ b/src/EllieBot/_common/TypeReaders/GuildTypeReader.cs
@@ -0,0 +1,24 @@
+#nullable disable
+namespace EllieBot.Common.TypeReaders;
+
+public sealed class GuildTypeReader : EllieTypeReader<IGuild>
+{
+    private readonly DiscordSocketClient _client;
+
+    public GuildTypeReader(DiscordSocketClient client)
+        => _client = client;
+
+    public override ValueTask<TypeReaderResult<IGuild>> ReadAsync(ICommandContext context, string input)
+    {
+        input = input.Trim().ToUpperInvariant();
+        var guilds = _client.Guilds;
+        IGuild guild = guilds.FirstOrDefault(g => g.Id.ToString().Trim().ToUpperInvariant() == input) //by id
+                       ?? guilds.FirstOrDefault(g => g.Name.Trim().ToUpperInvariant() == input); //by name
+
+        if (guild is not null)
+            return new(TypeReaderResult.FromSuccess(guild));
+
+        return new(
+            TypeReaderResult.FromError<IGuild>(CommandError.ParseFailed, "No guild by that name or Id found"));
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TypeReaders/GuildUserTypeReader.cs b/src/EllieBot/_common/TypeReaders/GuildUserTypeReader.cs
new file mode 100644
index 0000000..6c4b9d6
--- /dev/null
+++ b/src/EllieBot/_common/TypeReaders/GuildUserTypeReader.cs
@@ -0,0 +1,33 @@
+namespace EllieBot.Common.TypeReaders;
+
+public sealed class GuildUserTypeReader : EllieTypeReader<IGuildUser>
+{
+    public override async ValueTask<TypeReaderResult<IGuildUser>> ReadAsync(ICommandContext ctx, string input)
+    {
+        if (ctx.Guild is null)
+            return TypeReaderResult.FromError<IGuildUser>(CommandError.Unsuccessful, "Must be in a guild.");
+
+        input = input.Trim();
+        IGuildUser? user = null;
+        if (MentionUtils.TryParseUser(input, out var id))
+            user = await ctx.Guild.GetUserAsync(id, CacheMode.AllowDownload);
+        
+        if (ulong.TryParse(input, out id))
+            user = await ctx.Guild.GetUserAsync(id, CacheMode.AllowDownload);
+
+        if (user is null)
+        {
+            var users = await ctx.Guild.GetUsersAsync(CacheMode.CacheOnly);
+            user = users.FirstOrDefault(x => x.Username == input)
+                   ?? users.FirstOrDefault(x =>
+                       string.Equals(x.ToString(), input, StringComparison.InvariantCultureIgnoreCase))
+                   ?? users.FirstOrDefault(x =>
+                       string.Equals(x.Username, input, StringComparison.InvariantCultureIgnoreCase));
+        }
+
+        if (user is null)
+            return TypeReaderResult.FromError<IGuildUser>(CommandError.ObjectNotFound, "User not found.");
+        
+        return TypeReaderResult.FromSuccess(user);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TypeReaders/KwumTypeReader.cs b/src/EllieBot/_common/TypeReaders/KwumTypeReader.cs
new file mode 100644
index 0000000..608a852
--- /dev/null
+++ b/src/EllieBot/_common/TypeReaders/KwumTypeReader.cs
@@ -0,0 +1,19 @@
+#nullable disable
+namespace EllieBot.Common.TypeReaders;
+
+public sealed class KwumTypeReader : EllieTypeReader<kwum>
+{
+    public override ValueTask<TypeReaderResult<kwum>> ReadAsync(ICommandContext context, string input)
+    {
+        if (kwum.TryParse(input, out var val))
+            return new(TypeReaderResult.FromSuccess(val));
+
+        return new(TypeReaderResult.FromError<kwum>(CommandError.ParseFailed, "Input is not a valid kwum"));
+    }
+}
+
+public sealed class SmartTextTypeReader : EllieTypeReader<SmartText>
+{
+    public override ValueTask<TypeReaderResult<SmartText>> ReadAsync(ICommandContext ctx, string input)
+        => new(TypeReaderResult.FromSuccess(SmartText.CreateFrom(input)));
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TypeReaders/Models/PermissionAction.cs b/src/EllieBot/_common/TypeReaders/Models/PermissionAction.cs
new file mode 100644
index 0000000..1aec85b
--- /dev/null
+++ b/src/EllieBot/_common/TypeReaders/Models/PermissionAction.cs
@@ -0,0 +1,27 @@
+#nullable disable
+namespace EllieBot.Common.TypeReaders.Models;
+
+public class PermissionAction
+{
+    public static PermissionAction Enable
+        => new(true);
+
+    public static PermissionAction Disable
+        => new(false);
+
+    public bool Value { get; }
+
+    public PermissionAction(bool value)
+        => Value = value;
+
+    public override bool Equals(object obj)
+    {
+        if (obj is null || GetType() != obj.GetType())
+            return false;
+
+        return Value == ((PermissionAction)obj).Value;
+    }
+
+    public override int GetHashCode()
+        => Value.GetHashCode();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TypeReaders/Models/StoopidTime.cs b/src/EllieBot/_common/TypeReaders/Models/StoopidTime.cs
new file mode 100644
index 0000000..08c69b8
--- /dev/null
+++ b/src/EllieBot/_common/TypeReaders/Models/StoopidTime.cs
@@ -0,0 +1,55 @@
+#nullable disable
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Common.TypeReaders.Models;
+
+public class StoopidTime
+{
+    private static readonly Regex _regex = new(
+        @"^(?:(?<months>\d)mo)?(?:(?<weeks>\d{1,2})w)?(?:(?<days>\d{1,2})d)?(?:(?<hours>\d{1,4})h)?(?:(?<minutes>\d{1,5})m)?(?:(?<seconds>\d{1,6})s)?$",
+        RegexOptions.Compiled | RegexOptions.Multiline);
+
+    public string Input { get; set; }
+    public TimeSpan Time { get; set; }
+
+    private StoopidTime() { }
+
+    public static StoopidTime FromInput(string input)
+    {
+        var m = _regex.Match(input);
+
+        if (m.Length == 0)
+            throw new ArgumentException("Invalid string input format.");
+
+        var namesAndValues = new Dictionary<string, int>();
+
+        foreach (var groupName in _regex.GetGroupNames())
+        {
+            if (groupName == "0")
+                continue;
+            if (!int.TryParse(m.Groups[groupName].Value, out var value))
+            {
+                namesAndValues[groupName] = 0;
+                continue;
+            }
+
+            if (value < 1)
+                throw new ArgumentException($"Invalid {groupName} value.");
+
+            namesAndValues[groupName] = value;
+        }
+
+        var ts = new TimeSpan((30 * namesAndValues["months"]) + (7 * namesAndValues["weeks"]) + namesAndValues["days"],
+            namesAndValues["hours"],
+            namesAndValues["minutes"],
+            namesAndValues["seconds"]);
+        if (ts > TimeSpan.FromDays(90))
+            throw new ArgumentException("Time is too long.");
+
+        return new()
+        {
+            Input = input,
+            Time = ts
+        };
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TypeReaders/ModuleTypeReader.cs b/src/EllieBot/_common/TypeReaders/ModuleTypeReader.cs
new file mode 100644
index 0000000..fbbaff0
--- /dev/null
+++ b/src/EllieBot/_common/TypeReaders/ModuleTypeReader.cs
@@ -0,0 +1,52 @@
+#nullable disable
+using EllieBot.Modules.Permissions;
+
+namespace EllieBot.Common.TypeReaders;
+
+public sealed class ModuleTypeReader : EllieTypeReader<ModuleInfo>
+{
+    private readonly CommandService _cmds;
+
+    public ModuleTypeReader(CommandService cmds)
+        => _cmds = cmds;
+
+    public override ValueTask<TypeReaderResult<ModuleInfo>> ReadAsync(ICommandContext context, string input)
+    {
+        input = input.ToUpperInvariant();
+        var module = _cmds.Modules.GroupBy(m => m.GetTopLevelModule())
+                          .FirstOrDefault(m => m.Key.Name.ToUpperInvariant() == input)
+                          ?.Key;
+        if (module is null)
+            return new(TypeReaderResult.FromError<ModuleInfo>(CommandError.ParseFailed, "No such module found."));
+
+        return new(TypeReaderResult.FromSuccess(module));
+    }
+}
+
+public sealed class ModuleOrExprTypeReader : EllieTypeReader<ModuleOrExpr>
+{
+    private readonly CommandService _cmds;
+
+    public ModuleOrExprTypeReader(CommandService cmds)
+        => _cmds = cmds;
+
+    public override ValueTask<TypeReaderResult<ModuleOrExpr>> ReadAsync(ICommandContext context, string input)
+    {
+        input = input.ToUpperInvariant();
+        var module = _cmds.Modules.GroupBy(m => m.GetTopLevelModule())
+                          .FirstOrDefault(m => m.Key.Name.ToUpperInvariant() == input)
+                          ?.Key;
+        if (module is null && input != "ACTUALEXPRESSIONS" && input != CleverBotResponseStr.CLEVERBOT_RESPONSE)
+            return new(TypeReaderResult.FromError<ModuleOrExpr>(CommandError.ParseFailed, "No such module found."));
+
+        return new(TypeReaderResult.FromSuccess(new ModuleOrExpr
+        {
+            Name = input
+        }));
+    }
+}
+
+public sealed class ModuleOrExpr
+{
+    public string Name { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TypeReaders/PermissionActionTypeReader.cs b/src/EllieBot/_common/TypeReaders/PermissionActionTypeReader.cs
new file mode 100644
index 0000000..168cceb
--- /dev/null
+++ b/src/EllieBot/_common/TypeReaders/PermissionActionTypeReader.cs
@@ -0,0 +1,39 @@
+#nullable disable
+using EllieBot.Common.TypeReaders.Models;
+
+namespace EllieBot.Common.TypeReaders;
+
+/// <summary>
+///     Used instead of bool for more flexible keywords for true/false only in the permission module
+/// </summary>
+public sealed class PermissionActionTypeReader : EllieTypeReader<PermissionAction>
+{
+    public override ValueTask<TypeReaderResult<PermissionAction>> ReadAsync(ICommandContext context, string input)
+    {
+        input = input.ToUpperInvariant();
+        switch (input)
+        {
+            case "1":
+            case "T":
+            case "TRUE":
+            case "ENABLE":
+            case "ENABLED":
+            case "ALLOW":
+            case "PERMIT":
+            case "UNBAN":
+                return new(TypeReaderResult.FromSuccess(PermissionAction.Enable));
+            case "0":
+            case "F":
+            case "FALSE":
+            case "DENY":
+            case "DISABLE":
+            case "DISABLED":
+            case "DISALLOW":
+            case "BAN":
+                return new(TypeReaderResult.FromSuccess(PermissionAction.Disable));
+            default:
+                return new(TypeReaderResult.FromError<PermissionAction>(CommandError.ParseFailed,
+                    "Did not receive a valid boolean value"));
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TypeReaders/Rgba32TypeReader.cs b/src/EllieBot/_common/TypeReaders/Rgba32TypeReader.cs
new file mode 100644
index 0000000..77e7c6a
--- /dev/null
+++ b/src/EllieBot/_common/TypeReaders/Rgba32TypeReader.cs
@@ -0,0 +1,20 @@
+using Color = SixLabors.ImageSharp.Color;
+
+#nullable disable
+namespace EllieBot.Common.TypeReaders;
+
+public sealed class Rgba32TypeReader : EllieTypeReader<Color>
+{
+    public override ValueTask<TypeReaderResult<Color>> ReadAsync(ICommandContext context, string input)
+    {
+        input = input.Replace("#", "", StringComparison.InvariantCulture);
+        try
+        {
+            return ValueTask.FromResult(TypeReaderResult.FromSuccess(Color.ParseHex(input)));
+        }
+        catch
+        {
+            return ValueTask.FromResult(TypeReaderResult.FromError<Color>(CommandError.ParseFailed, "Parameter is not a valid color hex."));
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/TypeReaders/StoopidTimeTypeReader.cs b/src/EllieBot/_common/TypeReaders/StoopidTimeTypeReader.cs
new file mode 100644
index 0000000..c5b9418
--- /dev/null
+++ b/src/EllieBot/_common/TypeReaders/StoopidTimeTypeReader.cs
@@ -0,0 +1,22 @@
+#nullable disable
+using EllieBot.Common.TypeReaders.Models;
+
+namespace EllieBot.Common.TypeReaders;
+
+public sealed class StoopidTimeTypeReader : EllieTypeReader<StoopidTime>
+{
+    public override ValueTask<TypeReaderResult<StoopidTime>> ReadAsync(ICommandContext context, string input)
+    {
+        if (string.IsNullOrWhiteSpace(input))
+            return new(TypeReaderResult.FromError<StoopidTime>(CommandError.Unsuccessful, "Input is empty."));
+        try
+        {
+            var time = StoopidTime.FromInput(input);
+            return new(TypeReaderResult.FromSuccess(time));
+        }
+        catch (Exception ex)
+        {
+            return new(TypeReaderResult.FromError<StoopidTime>(CommandError.Exception, ex.Message));
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Yml/CommentAttribute.cs b/src/EllieBot/_common/Yml/CommentAttribute.cs
new file mode 100644
index 0000000..1c1ad89
--- /dev/null
+++ b/src/EllieBot/_common/Yml/CommentAttribute.cs
@@ -0,0 +1,11 @@
+#nullable disable
+namespace EllieBot.Common.Yml;
+
+[AttributeUsage(AttributeTargets.Property)]
+public class CommentAttribute : Attribute
+{
+    public string Comment { get; }
+
+    public CommentAttribute(string comment)
+        => Comment = comment;
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Yml/CommentGatheringTypeInspector.cs b/src/EllieBot/_common/Yml/CommentGatheringTypeInspector.cs
new file mode 100644
index 0000000..f9f5739
--- /dev/null
+++ b/src/EllieBot/_common/Yml/CommentGatheringTypeInspector.cs
@@ -0,0 +1,65 @@
+#nullable disable
+using YamlDotNet.Core;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.TypeInspectors;
+
+namespace EllieBot.Common.Yml;
+
+public class CommentGatheringTypeInspector : TypeInspectorSkeleton
+{
+    private readonly ITypeInspector _innerTypeDescriptor;
+
+    public CommentGatheringTypeInspector(ITypeInspector innerTypeDescriptor)
+        => _innerTypeDescriptor = innerTypeDescriptor ?? throw new ArgumentNullException(nameof(innerTypeDescriptor));
+
+    public override IEnumerable<IPropertyDescriptor> GetProperties(Type type, object container)
+        => _innerTypeDescriptor.GetProperties(type, container).Select(d => new CommentsPropertyDescriptor(d));
+
+    private sealed class CommentsPropertyDescriptor : IPropertyDescriptor
+    {
+        public string Name { get; }
+
+        public Type Type
+            => _baseDescriptor.Type;
+
+        public Type TypeOverride
+        {
+            get => _baseDescriptor.TypeOverride;
+            set => _baseDescriptor.TypeOverride = value;
+        }
+
+        public int Order { get; set; }
+
+        public ScalarStyle ScalarStyle
+        {
+            get => _baseDescriptor.ScalarStyle;
+            set => _baseDescriptor.ScalarStyle = value;
+        }
+
+        public bool CanWrite
+            => _baseDescriptor.CanWrite;
+
+        private readonly IPropertyDescriptor _baseDescriptor;
+
+        public CommentsPropertyDescriptor(IPropertyDescriptor baseDescriptor)
+        {
+            _baseDescriptor = baseDescriptor;
+            Name = baseDescriptor.Name;
+        }
+
+        public void Write(object target, object value)
+            => _baseDescriptor.Write(target, value);
+
+        public T GetCustomAttribute<T>()
+            where T : Attribute
+            => _baseDescriptor.GetCustomAttribute<T>();
+
+        public IObjectDescriptor Read(object target)
+        {
+            var comment = _baseDescriptor.GetCustomAttribute<CommentAttribute>();
+            return comment is not null
+                ? new CommentsObjectDescriptor(_baseDescriptor.Read(target), comment.Comment)
+                : _baseDescriptor.Read(target);
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Yml/CommentsObjectDescriptor.cs b/src/EllieBot/_common/Yml/CommentsObjectDescriptor.cs
new file mode 100644
index 0000000..ce54758
--- /dev/null
+++ b/src/EllieBot/_common/Yml/CommentsObjectDescriptor.cs
@@ -0,0 +1,30 @@
+#nullable disable
+using YamlDotNet.Core;
+using YamlDotNet.Serialization;
+
+namespace EllieBot.Common.Yml;
+
+public sealed class CommentsObjectDescriptor : IObjectDescriptor
+{
+    public string Comment { get; }
+
+    public object Value
+        => _innerDescriptor.Value;
+
+    public Type Type
+        => _innerDescriptor.Type;
+
+    public Type StaticType
+        => _innerDescriptor.StaticType;
+
+    public ScalarStyle ScalarStyle
+        => _innerDescriptor.ScalarStyle;
+
+    private readonly IObjectDescriptor _innerDescriptor;
+
+    public CommentsObjectDescriptor(IObjectDescriptor innerDescriptor, string comment)
+    {
+        _innerDescriptor = innerDescriptor;
+        Comment = comment;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Yml/CommentsObjectGraphVisitor.cs b/src/EllieBot/_common/Yml/CommentsObjectGraphVisitor.cs
new file mode 100644
index 0000000..1c89a95
--- /dev/null
+++ b/src/EllieBot/_common/Yml/CommentsObjectGraphVisitor.cs
@@ -0,0 +1,29 @@
+#nullable disable
+using YamlDotNet.Core;
+using YamlDotNet.Core.Events;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.ObjectGraphVisitors;
+
+namespace EllieBot.Common.Yml;
+
+public class CommentsObjectGraphVisitor : ChainedObjectGraphVisitor
+{
+    public CommentsObjectGraphVisitor(IObjectGraphVisitor<IEmitter> nextVisitor)
+        : base(nextVisitor)
+    {
+    }
+
+    public override bool EnterMapping(IPropertyDescriptor key, IObjectDescriptor value, IEmitter context)
+    {
+        if (value is CommentsObjectDescriptor commentsDescriptor
+            && !string.IsNullOrWhiteSpace(commentsDescriptor.Comment))
+        {
+            var parts = commentsDescriptor.Comment.Split('\n');
+
+            foreach (var part in parts)
+                context.Emit(new Comment(part.Trim(), false));
+        }
+
+        return base.EnterMapping(key, value, context);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Yml/MultilineScalarFlowStyleEmitter.cs b/src/EllieBot/_common/Yml/MultilineScalarFlowStyleEmitter.cs
new file mode 100644
index 0000000..c63dcab
--- /dev/null
+++ b/src/EllieBot/_common/Yml/MultilineScalarFlowStyleEmitter.cs
@@ -0,0 +1,35 @@
+#nullable disable
+using YamlDotNet.Core;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.EventEmitters;
+
+namespace EllieBot.Common.Yml;
+
+public class MultilineScalarFlowStyleEmitter : ChainedEventEmitter
+{
+    public MultilineScalarFlowStyleEmitter(IEventEmitter nextEmitter)
+        : base(nextEmitter)
+    {
+    }
+
+    public override void Emit(ScalarEventInfo eventInfo, IEmitter emitter)
+    {
+        if (typeof(string).IsAssignableFrom(eventInfo.Source.Type))
+        {
+            var value = eventInfo.Source.Value as string;
+            if (!string.IsNullOrEmpty(value))
+            {
+                var isMultiLine = value.IndexOfAny(['\r', '\n', '\x85', '\x2028', '\x2029']) >= 0;
+                if (isMultiLine)
+                {
+                    eventInfo = new(eventInfo.Source)
+                    {
+                        Style = ScalarStyle.Literal
+                    };
+                }
+            }
+        }
+
+        nextEmitter.Emit(eventInfo, emitter);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Yml/Rgba32Converter.cs b/src/EllieBot/_common/Yml/Rgba32Converter.cs
new file mode 100644
index 0000000..12f6cf9
--- /dev/null
+++ b/src/EllieBot/_common/Yml/Rgba32Converter.cs
@@ -0,0 +1,47 @@
+#nullable disable
+using SixLabors.ImageSharp.PixelFormats;
+using System.Globalization;
+using YamlDotNet.Core;
+using YamlDotNet.Core.Events;
+using YamlDotNet.Serialization;
+
+namespace EllieBot.Common.Yml;
+
+public class Rgba32Converter : IYamlTypeConverter
+{
+    public bool Accepts(Type type)
+        => type == typeof(Rgba32);
+
+    public object ReadYaml(IParser parser, Type type)
+    {
+        var scalar = parser.Consume<Scalar>();
+        var result = Rgba32.ParseHex(scalar.Value);
+        return result;
+    }
+
+    public void WriteYaml(IEmitter emitter, object value, Type type)
+    {
+        var color = (Rgba32)value;
+        var val = (uint)((color.B << 0) | (color.G << 8) | (color.R << 16));
+        emitter.Emit(new Scalar(val.ToString("X6").ToLower()));
+    }
+}
+
+public class CultureInfoConverter : IYamlTypeConverter
+{
+    public bool Accepts(Type type)
+        => type == typeof(CultureInfo);
+
+    public object ReadYaml(IParser parser, Type type)
+    {
+        var scalar = parser.Consume<Scalar>();
+        var result = new CultureInfo(scalar.Value);
+        return result;
+    }
+
+    public void WriteYaml(IEmitter emitter, object value, Type type)
+    {
+        var ci = (CultureInfo)value;
+        emitter.Emit(new Scalar(ci.Name));
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Yml/UriConverter.cs b/src/EllieBot/_common/Yml/UriConverter.cs
new file mode 100644
index 0000000..66e2ca0
--- /dev/null
+++ b/src/EllieBot/_common/Yml/UriConverter.cs
@@ -0,0 +1,25 @@
+#nullable disable
+using YamlDotNet.Core;
+using YamlDotNet.Core.Events;
+using YamlDotNet.Serialization;
+
+namespace EllieBot.Common.Yml;
+
+public class UriConverter : IYamlTypeConverter
+{
+    public bool Accepts(Type type)
+        => type == typeof(Uri);
+
+    public object ReadYaml(IParser parser, Type type)
+    {
+        var scalar = parser.Consume<Scalar>();
+        var result = new Uri(scalar.Value);
+        return result;
+    }
+
+    public void WriteYaml(IEmitter emitter, object value, Type type)
+    {
+        var uri = (Uri)value;
+        emitter.Emit(new Scalar(uri.ToString()));
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/Yml/Yaml.cs b/src/EllieBot/_common/Yml/Yaml.cs
new file mode 100644
index 0000000..c4779cc
--- /dev/null
+++ b/src/EllieBot/_common/Yml/Yaml.cs
@@ -0,0 +1,30 @@
+#nullable disable
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
+namespace EllieBot.Common.Yml;
+
+public class Yaml
+{
+    public static ISerializer Serializer
+        => new SerializerBuilder()
+           .WithTypeInspector(inner => new CommentGatheringTypeInspector(inner))
+                                  .DisableAliases()
+                                  .WithEmissionPhaseObjectGraphVisitor(args
+                                      => new CommentsObjectGraphVisitor(args.InnerVisitor))
+                                  .WithEventEmitter(args => new MultilineScalarFlowStyleEmitter(args))
+                                  .WithNamingConvention(CamelCaseNamingConvention.Instance)
+                                  .WithIndentedSequences()
+                                  .WithTypeConverter(new Rgba32Converter())
+                                  .WithTypeConverter(new CultureInfoConverter())
+                                  .WithTypeConverter(new UriConverter())
+                                  .Build();
+
+    public static IDeserializer Deserializer
+        => new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance)
+                                    .WithTypeConverter(new Rgba32Converter())
+                                    .WithTypeConverter(new CultureInfoConverter())
+                                    .WithTypeConverter(new UriConverter())
+                                    .IgnoreUnmatchedProperties()
+                                    .Build();
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/_Extensions/BotCredentialsExtensions.cs b/src/EllieBot/_common/_Extensions/BotCredentialsExtensions.cs
new file mode 100644
index 0000000..890bab3
--- /dev/null
+++ b/src/EllieBot/_common/_Extensions/BotCredentialsExtensions.cs
@@ -0,0 +1,10 @@
+namespace EllieBot.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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/_Extensions/CommandContextExtensions.cs b/src/EllieBot/_common/_Extensions/CommandContextExtensions.cs
new file mode 100644
index 0000000..ebb019b
--- /dev/null
+++ b/src/EllieBot/_common/_Extensions/CommandContextExtensions.cs
@@ -0,0 +1,40 @@
+namespace EllieBot.Extensions;
+
+public static class CommandContextExtensions
+{
+    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);
+    
+    
+    public static Task OkAsync(this IUserMessage msg)
+        => msg.AddReactionAsync(_okEmoji);
+    
+    public static Task ErrorAsync(this IUserMessage msg)
+        => msg.AddReactionAsync(_errorEmoji);
+    
+    public static Task WarningAsync(this IUserMessage msg)
+        => msg.AddReactionAsync(_warnEmoji);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/_Extensions/DbExtensions.cs b/src/EllieBot/_common/_Extensions/DbExtensions.cs
new file mode 100644
index 0000000..0975a1f
--- /dev/null
+++ b/src/EllieBot/_common/_Extensions/DbExtensions.cs
@@ -0,0 +1,10 @@
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Extensions;
+
+public static class DbExtensions
+{
+    public static DiscordUser GetOrCreateUser(this DbContext ctx, IUser original, Func<IQueryable<DiscordUser>, IQueryable<DiscordUser>>? includes = null)
+        => ctx.GetOrCreateUser(original.Id, original.Username, original.Discriminator, original.AvatarId, includes);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/_Extensions/Extensions.cs b/src/EllieBot/_common/_Extensions/Extensions.cs
new file mode 100644
index 0000000..dca195d
--- /dev/null
+++ b/src/EllieBot/_common/_Extensions/Extensions.cs
@@ -0,0 +1,231 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Ellie.Common.Marmalade;
+
+namespace EllieBot.Extensions;
+
+public static class Extensions
+{
+    private static readonly Regex _urlRegex =
+        new(@"^(https?|ftp)://(?<path>[^\s/$.?#].[^\s]*)$", RegexOptions.Compiled);
+
+    /// <summary>
+    ///     Converts <see cref="DateTime"/> to <see cref="DateOnly"/>
+    /// </summary>
+    /// <param name="dateTime"> The <see cref="DateTime"/> to convert. </param>
+    /// <returns> The <see cref="DateOnly"/>. </returns>
+    public static DateOnly ToDateOnly(this DateTime dateTime)
+        => DateOnly.FromDateTime(dateTime);
+
+    /// <summary>
+    ///     Determines if <see cref="DateTime"/> is before today
+    /// </summary>
+    /// <param name="date"> The <see cref="DateTime"/> to check. </param>
+    /// <returns> True if <see cref="DateTime"/> is before today. </returns>
+    public static bool IsBeforeToday(this DateTime date)
+        => date < DateTime.UtcNow.Date;
+
+    public static Task EditAsync(this IUserMessage msg, SmartText text)
+        => text switch
+        {
+            SmartEmbedText set => msg.ModifyAsync(x =>
+            {
+                x.Embed = set.IsValid ? set.GetEmbed().Build() : null;
+                x.Content = set.PlainText?.SanitizeMentions() ?? "";
+            }),
+            SmartEmbedTextArray set => msg.ModifyAsync(x =>
+            {
+                x.Embeds = set.GetEmbedBuilders().Map(eb => eb.Build());
+                x.Content = set.Content?.SanitizeMentions() ?? "";
+            }),
+            SmartPlainText spt => msg.ModifyAsync(x =>
+            {
+                x.Content = spt.Text.SanitizeMentions();
+                x.Embed = null;
+            }),
+            _ => throw new ArgumentOutOfRangeException(nameof(text))
+        };
+
+    public static ulong[] GetGuildIds(this DiscordSocketClient client)
+        => client.Guilds
+                 .Map(x => x.Id);
+
+    /// <summary>
+    ///     Generates a string in the format HHH:mm if timespan is &gt;= 2m.
+    ///     Generates a string in the format 00:mm:ss if timespan is less than 2m.
+    /// </summary>
+    /// <param name="span">Timespan to convert to string</param>
+    /// <returns>Formatted duration string</returns>
+    public static string ToPrettyStringHm(this TimeSpan span)
+    {
+        if(span > TimeSpan.FromHours(24))
+            return $"{span.Days:00}d:{span.Hours:00}h";
+        
+        if (span > TimeSpan.FromMinutes(2))
+            return $"{span.Hours:00}h:{span.Minutes:00}m";
+
+        return $"{span.Minutes:00}m:{span.Seconds:00}s";
+    }
+
+    public static double Megabytes(this int mb)
+        => mb * 1024d * 1024;
+
+    public static TimeSpan Hours(this int hours)
+        => TimeSpan.FromHours(hours);
+
+    public static TimeSpan Minutes(this int minutes)
+        => TimeSpan.FromMinutes(minutes);
+    
+    public static TimeSpan Days(this int days)
+        => TimeSpan.FromDays(days);
+
+    public static TimeSpan Seconds(this int seconds)
+        => TimeSpan.FromSeconds(seconds);
+
+    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,
+        IMarmaladeLoaderService marmalades,
+        CultureInfo culture,
+        string prefix)
+    {
+        string description;
+        if (cmd.Remarks?.StartsWith("marmalade///") ?? false)
+        {
+            // command method name is kept in Summary
+            // marmalade///<marmalade-name-here> is kept in remarks
+            // this way I can find the name of the marmalade, and then name of the command for which
+            // the description should be loaded
+            var marmaladeName = cmd.Remarks.Split("///")[1];
+            description = marmalades.GetCommandDescription(marmaladeName, 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,
+        IMarmaladeLoaderService marmalades,
+        CultureInfo culture,
+        string prefix)
+    {
+        string[] args;
+        if (cmd.Remarks?.StartsWith("marmalade///") ?? false)
+        {
+            // command method name is kept in Summary
+            // marmalade///<marmalade-name-here> is kept in remarks
+            // this way I can find the name of the marmalade,
+            // and command for which data should be loaded
+            var marmaladeName = cmd.Remarks.Split("///")[1];
+            args = marmalades.GetCommandExampleArgs(marmaladeName, cmd.Summary, culture);
+        }
+        else
+        {
+            args = strings.GetCommandStrings(cmd.Summary, culture).Examples;
+        }
+
+        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 EmbedBuilder AddPaginatedFooter(this EmbedBuilder embed, int curPage, int? lastPage)
+    {
+        if (lastPage is not null)
+            return embed.WithFooter($"{curPage + 1} / {lastPage + 1}");
+        
+        return embed.WithFooter((curPage + 1).ToString());
+    }
+
+    // public static EmbedBuilder WithOkColor(this EmbedBuilder eb)
+    //     => eb.WithColor(EmbedColor.Ok);
+    //
+    // public static EmbedBuilder WithPendingColor(this EmbedBuilder eb)
+    //     => eb.WithColor(EmbedColor.Pending);
+    //
+    // public static EmbedBuilder WithErrorColor(this EmbedBuilder 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);
+
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/_Extensions/ImagesharpExtensions.cs b/src/EllieBot/_common/_Extensions/ImagesharpExtensions.cs
new file mode 100644
index 0000000..e8414dd
--- /dev/null
+++ b/src/EllieBot/_common/_Extensions/ImagesharpExtensions.cs
@@ -0,0 +1,97 @@
+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 EllieBot.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;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/_Extensions/LinkedListExtensions.cs b/src/EllieBot/_common/_Extensions/LinkedListExtensions.cs
new file mode 100644
index 0000000..018359e
--- /dev/null
+++ b/src/EllieBot/_common/_Extensions/LinkedListExtensions.cs
@@ -0,0 +1,18 @@
+namespace EllieBot.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;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/_Extensions/NumberExtensions.cs b/src/EllieBot/_common/_Extensions/NumberExtensions.cs
new file mode 100644
index 0000000..3e28588
--- /dev/null
+++ b/src/EllieBot/_common/_Extensions/NumberExtensions.cs
@@ -0,0 +1,30 @@
+using System.Globalization;
+
+namespace EllieBot.Extensions;
+
+public static class NumberExtensions
+{
+    public static DateTimeOffset ToUnixTimestamp(this double number)
+        => new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero).AddSeconds(number);
+
+    public static string ToShortString(this decimal value)
+    {
+        if (value <= 1_000)
+            return Math.Round(value, 2).ToString(CultureInfo.InvariantCulture);
+        if (value <= 1_000_000)
+            return Math.Round(value, 1).ToString(CultureInfo.InvariantCulture);
+        var tokens = "  MBtq";
+        var i = 2;
+        while (true)
+        {
+            var num = (decimal)Math.Pow(1000, i);
+            if (num > value)
+            {
+                var num2 = (decimal)Math.Pow(1000, i - 1);
+                return $"{Math.Round((value / num2), 1)}{tokens[i - 1]}".Trim();
+            }
+
+            i++;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/_Extensions/ReflectionExtensions.cs b/src/EllieBot/_common/_Extensions/ReflectionExtensions.cs
new file mode 100644
index 0000000..49be90e
--- /dev/null
+++ b/src/EllieBot/_common/_Extensions/ReflectionExtensions.cs
@@ -0,0 +1,23 @@
+namespace EllieBot.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;
+
+        var baseType = givenType.BaseType;
+        if (baseType == null) return false;
+
+        return IsAssignableToGenericType(baseType, genericType);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/_Extensions/Rgba32Extensions.cs b/src/EllieBot/_common/_Extensions/Rgba32Extensions.cs
new file mode 100644
index 0000000..6fb4c7c
--- /dev/null
+++ b/src/EllieBot/_common/_Extensions/Rgba32Extensions.cs
@@ -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 EllieBot.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 Point(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;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/_Extensions/SocketMessageComponentExtensions.cs b/src/EllieBot/_common/_Extensions/SocketMessageComponentExtensions.cs
new file mode 100644
index 0000000..1b99fa7
--- /dev/null
+++ b/src/EllieBot/_common/_Extensions/SocketMessageComponentExtensions.cs
@@ -0,0 +1,33 @@
+namespace EllieBot.Extensions;
+
+public static class SocketMessageComponentExtensions
+{
+    public static async Task RespondAsync(
+        this SocketMessageComponent ch,
+        IMessageSenderService sender,
+        string text,
+        MsgType type,
+        bool ephemeral = false)
+    {
+        var embed = sender.CreateEmbed().WithDescription(text);
+
+        embed = (type switch
+        {
+            MsgType.Error => embed.WithErrorColor(),
+            MsgType.Ok => embed.WithOkColor(),
+            MsgType.Pending => embed.WithPendingColor(),
+            _ => throw new ArgumentOutOfRangeException(nameof(type))
+        });
+
+        await ch.RespondAsync(embeds: [embed.Build()], ephemeral: ephemeral);
+    }
+
+    // embed title and optional footer overloads
+
+    public static Task RespondConfirmAsync(
+        this SocketMessageComponent smc,
+        IMessageSenderService sender,
+        string text,
+        bool ephemeral = false)
+        => smc.RespondAsync(sender, text, MsgType.Ok, ephemeral);
+}
\ No newline at end of file
diff --git a/src/EllieBot/_common/_Extensions/UserExtensions.cs b/src/EllieBot/_common/_Extensions/UserExtensions.cs
new file mode 100644
index 0000000..8f81f61
--- /dev/null
+++ b/src/EllieBot/_common/_Extensions/UserExtensions.cs
@@ -0,0 +1,21 @@
+using EllieBot.Db.Models;
+
+namespace EllieBot.Extensions;
+
+public static class UserExtensions
+{
+    // 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)
+    {
+        if (!string.IsNullOrWhiteSpace(usr.AvatarId))
+            return new Uri(CDN.GetUserAvatarUrl(usr.UserId, usr.AvatarId, 128, ImageFormat.Png));
+
+        return Uri.TryCreate(CDN.GetDefaultUserAvatarUrl(usr.UserId), UriKind.Absolute, out var uri)
+            ? uri
+            : null;
+    }
+}
\ No newline at end of file