From 547aa8b34deccb047ca651d258794bfc0fb22486 Mon Sep 17 00:00:00 2001 From: Toastie Date: Tue, 18 Jun 2024 23:44:07 +1200 Subject: [PATCH] Added common files This took way too long --- 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 | 20 + .../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 | 210 ++++ src/EllieBot/_common/Configs/IConfigSeria.cs | 18 + src/EllieBot/_common/Creds.cs | 272 ++++++ src/EllieBot/_common/Currency/CurrencyType.cs | 6 + src/EllieBot/_common/Currency/IBankService.cs | 10 + .../_common/Currency/ICurrencyService.cs | 43 + src/EllieBot/_common/Currency/ITxTracker.cs | 9 + src/EllieBot/_common/Currency/IWallet.cs | 40 + src/EllieBot/_common/Currency/TxData.cs | 7 + src/EllieBot/_common/DbService.cs | 15 + src/EllieBot/_common/Deck/Deck.cs | 309 ++++++ src/EllieBot/_common/Deck/NewCard.cs | 5 + src/EllieBot/_common/Deck/NewDeck.cs | 54 ++ .../MultipleRegularDeck.cs | 28 + .../_common/Deck/Regular/RegularCard.cs | 4 + .../_common/Deck/Regular/RegularDeck.cs | 15 + .../Deck/Regular/RegularDeckExtensions.cs | 56 ++ .../_common/Deck/Regular/RegularSuit.cs | 9 + .../_common/Deck/Regular/RegularValue.cs | 18 + src/EllieBot/_common/DoAsUserMessage.cs | 154 +++ src/EllieBot/_common/DownloadTracker.cs | 38 + src/EllieBot/_common/EllieModule.cs | 108 +++ src/EllieBot/_common/EllieTypeReader.cs | 14 + .../Gambling/Betdraw/BetdrawColorGuess.cs | 7 + .../_common/Gambling/Betdraw/BetdrawGame.cs | 86 ++ .../_common/Gambling/Betdraw/BetdrawResult.cs | 11 + .../Gambling/Betdraw/BetdrawResultType.cs | 7 + .../Gambling/Betdraw/BetdrawValueGuess.cs | 7 + .../_common/Gambling/Betflip/BetflipGame.cs | 33 + .../_common/Gambling/Betflip/BetflipResult.cs | 8 + .../_common/Gambling/Betroll/BetrollGame.cs | 42 + .../_common/Gambling/Betroll/BetrollResult.cs | 9 + src/EllieBot/_common/Gambling/Rps/RpsGame.cs | 75 ++ .../_common/Gambling/Slot/SlotGame.cs | 116 +++ .../_common/Gambling/Slot/SlotResult.cs | 9 + .../_common/Gambling/Wof/LuLaResult.cs | 9 + src/EllieBot/_common/Gambling/Wof/WofGame.cs | 34 + 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 | 51 + .../_common/Interaction/EllieInteraction.cs | 164 ++++ .../Interaction/EllieInteractionService.cs | 77 ++ .../Interaction/IEllieInteractionService.cs | 30 + .../_common/Interaction/InteractionHelpers.cs | 7 + .../Models/EllieButtonInteraction.cs | 21 + .../Models/EllieInteractionExtensions.cs | 15 + .../Models/EllieSelectInteraction.cs | 21 + .../JsonConverters/CultureInfoConverter.cs | 14 + .../_common/JsonConverters/Rgba32Converter.cs | 14 + src/EllieBot/_common/LbOpts.cs | 14 + src/EllieBot/_common/Linq2DbExpressions.cs | 16 + src/EllieBot/_common/LoginErrorHandler.cs | 52 + .../Common/Adapters/BehaviorAdapter.cs | 78 ++ .../Common/Adapters/ContextAdapterFactory.cs | 9 + .../Common/Adapters/DmContextAdapter.cs | 47 + .../Common/Adapters/FilterAdapter.cs | 33 + .../Common/Adapters/GuildContextAdapter.cs | 49 + .../Common/Adapters/ParamParserAdapter.cs | 34 + .../Marmalade/Common/CommandContextType.cs | 27 + .../Common/Config/IMarmaladeConfigService.cs | 8 + .../Common/Config/MarmaladeConfig.cs | 20 + .../Common/Config/MarmaladeConfigService.cs | 45 + .../Common/MarmaladeAssemblyLoadContext.cs | 35 + .../Common/MarmaladeIoCKernelModule.cs | 74 ++ .../Common/MarmaladeLoaderService.cs | 916 ++++++++++++++++++ .../Common/Models/CanaryCommandData.cs | 44 + .../Marmalade/Common/Models/CanaryData.cs | 11 + .../Marmalade/Common/Models/ParamData.cs | 10 + .../Common/Models/ResolvedMarmalade.cs | 15 + .../Marmalade/IMarmaladeLoaderService.cs | 24 + .../_common/Marmalade/MarmaladeLoadResult.cs | 10 + .../Marmalade/MarmaladeUnloadResult.cs | 9 + 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 + .../_common/NinjectKernelExtensions.cs | 51 + src/EllieBot/_common/OldCreds.cs | 45 + src/EllieBot/_common/OptionsParser.cs | 23 + .../_common/Patronage/FeatureLimitKey.cs | 7 + .../_common/Patronage/FeatureQuotaStats.cs | 8 + src/EllieBot/_common/Patronage/IPatronData.cs | 11 + .../_common/Patronage/IPatronageService.cs | 56 ++ .../_common/Patronage/ISubscriptionHandler.cs | 16 + src/EllieBot/_common/Patronage/Patron.cs | 38 + .../_common/Patronage/PatronConfigData.cs | 37 + .../_common/Patronage/PatronExtensions.cs | 39 + src/EllieBot/_common/Patronage/PatronTier.cs | 14 + src/EllieBot/_common/Patronage/QuotaLimit.cs | 66 ++ src/EllieBot/_common/Patronage/QuotaPer.cs | 8 + .../Patronage/SubscriptionChargeStatus.cs | 10 + .../_common/Patronage/UserQuotaStats.cs | 25 + 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 | 113 +++ .../Replacements/Impl/ReplacementService.cs | 137 +++ .../_common/Replacements/Impl/Replacer.cs | 138 +++ ...RequireObjectPropertiesContractResolver.cs | 15 + .../_common/Sender/IMessageSenderService.cs | 12 + .../_common/Sender/MessageSenderService.cs | 57 ++ .../ResponseBuilder.PaginationSender.cs | 153 +++ .../_common/Sender/ResponseBuilder.cs | 492 ++++++++++ .../Sender/ResponseBuilderExtensions.cs | 28 + .../_common/Sender/ResponseMessageModel.cs | 12 + .../_common/ServiceCollectionExtensions.cs | 131 +++ .../_common/Services/CommandHandler.cs | 433 +++++++++ .../Services/Currency/CurrencyService.cs | 116 +++ .../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 | 60 ++ .../_common/Services/Impl/IImageCache.cs | 17 + .../_common/Services/Impl/ImagesConfig.cs | 19 + .../Services/Impl/RedisImageExtensions.cs | 11 + .../Services/Impl/SingleProcessCoordinator.cs | 58 ++ .../Impl/StartingGuildsListService.cs | 18 + .../_common/Services/Impl/StatsService.cs | 222 +++++ .../_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 | 73 ++ .../_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 | 36 + 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 | 30 + .../_common/_Extensions/DbExtensions.cs | 11 + .../_common/_Extensions/Extensions.cs | 237 +++++ .../_Extensions/ImagesharpExtensions.cs | 97 ++ .../_Extensions/LinkedListExtensions.cs | 18 + .../_common/_Extensions/NumberExtensions.cs | 7 + .../_Extensions/ReflectionExtensions.cs | 23 + .../_common/_Extensions/Rgba32Extensions.cs | 57 ++ .../SocketMessageComponentExtensions.cs | 33 + .../_common/_Extensions/UserExtensions.cs | 21 + 214 files changed, 11046 insertions(+) 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/Currency/CurrencyType.cs create mode 100644 src/EllieBot/_common/Currency/IBankService.cs create mode 100644 src/EllieBot/_common/Currency/ICurrencyService.cs create mode 100644 src/EllieBot/_common/Currency/ITxTracker.cs create mode 100644 src/EllieBot/_common/Currency/IWallet.cs create mode 100644 src/EllieBot/_common/Currency/TxData.cs create mode 100644 src/EllieBot/_common/DbService.cs create mode 100644 src/EllieBot/_common/Deck/Deck.cs create mode 100644 src/EllieBot/_common/Deck/NewCard.cs create mode 100644 src/EllieBot/_common/Deck/NewDeck.cs create mode 100644 src/EllieBot/_common/Deck/Regular/MultipleRegularDeck/MultipleRegularDeck.cs create mode 100644 src/EllieBot/_common/Deck/Regular/RegularCard.cs create mode 100644 src/EllieBot/_common/Deck/Regular/RegularDeck.cs create mode 100644 src/EllieBot/_common/Deck/Regular/RegularDeckExtensions.cs create mode 100644 src/EllieBot/_common/Deck/Regular/RegularSuit.cs create mode 100644 src/EllieBot/_common/Deck/Regular/RegularValue.cs create mode 100644 src/EllieBot/_common/DoAsUserMessage.cs create mode 100644 src/EllieBot/_common/DownloadTracker.cs create mode 100644 src/EllieBot/_common/EllieModule.cs create mode 100644 src/EllieBot/_common/EllieTypeReader.cs create mode 100644 src/EllieBot/_common/Gambling/Betdraw/BetdrawColorGuess.cs create mode 100644 src/EllieBot/_common/Gambling/Betdraw/BetdrawGame.cs create mode 100644 src/EllieBot/_common/Gambling/Betdraw/BetdrawResult.cs create mode 100644 src/EllieBot/_common/Gambling/Betdraw/BetdrawResultType.cs create mode 100644 src/EllieBot/_common/Gambling/Betdraw/BetdrawValueGuess.cs create mode 100644 src/EllieBot/_common/Gambling/Betflip/BetflipGame.cs create mode 100644 src/EllieBot/_common/Gambling/Betflip/BetflipResult.cs create mode 100644 src/EllieBot/_common/Gambling/Betroll/BetrollGame.cs create mode 100644 src/EllieBot/_common/Gambling/Betroll/BetrollResult.cs create mode 100644 src/EllieBot/_common/Gambling/Rps/RpsGame.cs create mode 100644 src/EllieBot/_common/Gambling/Slot/SlotGame.cs create mode 100644 src/EllieBot/_common/Gambling/Slot/SlotResult.cs create mode 100644 src/EllieBot/_common/Gambling/Wof/LuLaResult.cs create mode 100644 src/EllieBot/_common/Gambling/Wof/WofGame.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/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/Marmalade/Common/Adapters/BehaviorAdapter.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Adapters/ContextAdapterFactory.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Adapters/DmContextAdapter.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Adapters/FilterAdapter.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Adapters/GuildContextAdapter.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Adapters/ParamParserAdapter.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/CommandContextType.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Config/IMarmaladeConfigService.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfig.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfigService.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/MarmaladeAssemblyLoadContext.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/MarmaladeIoCKernelModule.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/MarmaladeLoaderService.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Models/CanaryCommandData.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Models/CanaryData.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Models/ParamData.cs create mode 100644 src/EllieBot/_common/Marmalade/Common/Models/ResolvedMarmalade.cs create mode 100644 src/EllieBot/_common/Marmalade/IMarmaladeLoaderService.cs create mode 100644 src/EllieBot/_common/Marmalade/MarmaladeLoadResult.cs create mode 100644 src/EllieBot/_common/Marmalade/MarmaladeUnloadResult.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/NinjectKernelExtensions.cs create mode 100644 src/EllieBot/_common/OldCreds.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/FeatureQuotaStats.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/Patronage/UserQuotaStats.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/AddRemove.cs b/src/EllieBot/_common/AddRemove.cs new file mode 100644 index 0000000..bb3862e --- /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..a02fd1e --- /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(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs b/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs new file mode 100644 index 0000000..7be799a --- /dev/null +++ b/src/EllieBot/_common/Attributes/DIIgnoreAttribute.cs @@ -0,0 +1,11 @@ +#nullable disable +namespace EllieBot.Common; + +/// +/// Classed marked with this attribute will not be added to the service provider +/// +[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..c94b109 --- /dev/null +++ b/src/EllieBot/_common/Attributes/EllieOptionsAttribute.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Common.Attributes; + +[AttributeUsage(AttributeTargets.Method)] +public sealed class EllieOptionsAttribute : 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..2ce8ccc --- /dev/null +++ b/src/EllieBot/_common/Attributes/NoPublicBotAttribute.cs @@ -0,0 +1,20 @@ +#nullable disable +using System.Diagnostics.CodeAnalysis; + +namespace EllieBot.Common; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class NoPublicBotAttribute : PreconditionAttribute +{ + public override Task 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/ellie/).")); +#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..6ae9408 --- /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 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..7aa9317 --- /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 CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + var creds = services.GetRequiredService().GetCreds(); + + return Task.FromResult(creds.IsOwner(context.User) || context.Client.CurrentUser.Id == context.User.Id + ? PreconditionResult.FromSuccess() + : PreconditionResult.FromError("Not owner")); + } +} diff --git a/src/EllieBot/_common/Attributes/RatelimitAttribute.cs b/src/EllieBot/_common/Attributes/RatelimitAttribute.cs new file mode 100644 index 0000000..7fcf9c8 --- /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 CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + if (Seconds == 0) + return PreconditionResult.FromSuccess(); + + var cache = services.GetRequiredService(); + 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..1b4ee75 --- /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 CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + var permService = services.GetRequiredService(); + 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 +{ + private readonly CommandService _cmds; + private readonly ICommandHandler _handler; + + public CommandTypeReader(ICommandHandler handler, CommandService cmds) + { + _handler = handler; + _cmds = cmds; + } + + public override ValueTask> 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(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(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 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..6675a41 --- /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..3d69f2e --- /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> _lazyCommandAliases + = new(() => LoadAliases()); + + public static Dictionary LoadAliases(string aliasesFilePath = "data/aliases.yml") + { + var text = File.ReadAllText(aliasesFilePath); + return _deserializer.Deserialize>(text); + } + + public static Dictionary LoadCommandStrings( + string commandsFilePath = "data/strings/commands.yml") + { + var text = File.ReadAllText(commandsFilePath); + + return Yaml.Deserializer.Deserialize>(text); + } + + public static string[] GetAliasesFor(string methodName) + => _lazyCommandAliases.Value.TryGetValue(methodName.ToLowerInvariant(), out var aliases) && aliases.Length > 1 + ? aliases.ToArray() + : Array.Empty(); + + 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..0715701 --- /dev/null +++ b/src/EllieBot/_common/Configs/BotConfig.cs @@ -0,0 +1,210 @@ +#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 +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } = 7; + + [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 DmHelpTextKeywords { get; set; } + + [Comment("""This is the response for the .h command""")] + [YamlMember(ScalarStyle = ScalarStyle.Literal)] + public string HelpText { get; set; } + + [Comment("""List of modules and commands completely blocked on the bot""")] + public BlockedConfig Blocked { get; set; } + + [Comment("""Which string will be used to recognize the commands""")] + public string Prefix { get; set; } + + [Comment(""" + Toggles whether your bot will group greet/bye messages into a single message every 5 seconds. + 1st user who joins will get greeted immediately + If more users join within the next 5 seconds, they will be greeted in groups of 5. + This will cause %user.mention% and other placeholders to be replaced with multiple users. + Keep in mind this might break some of your embeds - for example if you have %user.avatar% in the thumbnail, + it will become invalid, as it will resolve to a list of avatars of grouped users. + note: This setting is primarily used if you're afraid of raids, or you're running medium/large bots where some + servers might get hundreds of people join at once. This is used to prevent the bot from getting ratelimited, + and (slightly) reduce the greet spam in those servers. + """)] + public bool GroupGreets { get; set; } + + [Comment(""" + Whether the bot will rotate through all specified statuses. + This setting can be changed via .ropl command. + See RotatingStatuses submodule in Administration. + """)] + public bool RotateStatuses { get; set; } + + public BotConfig() + { + var color = new ColorConfig(); + Color = color; + DefaultLocale = new("en-US"); + ConsoleOutputType = ConsoleOutputType.Normal; + ForwardMessages = false; + ForwardToAllOwners = false; + DmHelpText = """{"description": "Type `%prefix%h` for help."}"""; + HelpText = """ + { + "title": "To invite me to your server, use this link", + "description": "https://discordapp.com/oauth2/authorize?client_id={0}&scope=bot&permissions=66186303", + "color": 53380, + "thumbnail": "https://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; + GroupGreets = 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 Commands { get; set; } + public HashSet 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 +} \ No newline at end of file diff --git a/src/EllieBot/_common/Configs/IConfigSeria.cs b/src/EllieBot/_common/Configs/IConfigSeria.cs new file mode 100644 index 0000000..1f96850 --- /dev/null +++ b/src/EllieBot/_common/Configs/IConfigSeria.cs @@ -0,0 +1,18 @@ +namespace EllieBot.Common.Configs; + +/// +/// Base interface for available config serializers +/// +public interface IConfigSeria +{ + /// + /// Serialize the object to string + /// + public string Serialize(T obj) + where T : notnull; + + /// + /// Deserialize string data into an object of the specified type + /// + public T Deserialize(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..f6aef5d --- /dev/null +++ b/src/EllieBot/_common/Creds.cs @@ -0,0 +1,272 @@ +#nullable disable +using EllieBot.Common.Yml; + +namespace Ellie.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 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( + """ + 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(@"Official GPT-3 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 = 7; + Token = string.Empty; + UsePrivilegedIntents = true; + OwnerIds = new List(); + 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/Currency/CurrencyType.cs b/src/EllieBot/_common/Currency/CurrencyType.cs new file mode 100644 index 0000000..1037fa4 --- /dev/null +++ b/src/EllieBot/_common/Currency/CurrencyType.cs @@ -0,0 +1,6 @@ +namespace EllieBot.Services.Currency; + +public enum CurrencyType +{ + Default +} \ No newline at end of file diff --git a/src/EllieBot/_common/Currency/IBankService.cs b/src/EllieBot/_common/Currency/IBankService.cs new file mode 100644 index 0000000..f563fb9 --- /dev/null +++ b/src/EllieBot/_common/Currency/IBankService.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Modules.Gambling.Bank; + +public interface IBankService +{ + Task DepositAsync(ulong userId, long amount); + Task WithdrawAsync(ulong userId, long amount); + Task GetBalanceAsync(ulong userId); + Task AwardAsync(ulong userId, long amount); + Task TakeAsync(ulong userId, long amount); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Currency/ICurrencyService.cs b/src/EllieBot/_common/Currency/ICurrencyService.cs new file mode 100644 index 0000000..35e8273 --- /dev/null +++ b/src/EllieBot/_common/Currency/ICurrencyService.cs @@ -0,0 +1,43 @@ +using EllieBot.Db.Models; +using EllieBot.Services.Currency; + +namespace EllieBot.Services; + +public interface ICurrencyService +{ + Task GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default); + + Task AddBulkAsync( + IReadOnlyCollection userIds, + long amount, + TxData? txData, + CurrencyType type = CurrencyType.Default); + + Task RemoveBulkAsync( + IReadOnlyCollection userIds, + long amount, + TxData? txData, + CurrencyType type = CurrencyType.Default); + + Task AddAsync( + ulong userId, + long amount, + TxData? txData); + + Task AddAsync( + IUser user, + long amount, + TxData? txData); + + Task RemoveAsync( + ulong userId, + long amount, + TxData? txData); + + Task RemoveAsync( + IUser user, + long amount, + TxData? txData); + + Task> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Currency/ITxTracker.cs b/src/EllieBot/_common/Currency/ITxTracker.cs new file mode 100644 index 0000000..d7cad66 --- /dev/null +++ b/src/EllieBot/_common/Currency/ITxTracker.cs @@ -0,0 +1,9 @@ +using EllieBot.Services.Currency; + +namespace EllieBot.Services; + +public interface ITxTracker +{ + Task TrackAdd(long amount, TxData? txData); + Task TrackRemove(long amount, TxData? txData); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Currency/IWallet.cs b/src/EllieBot/_common/Currency/IWallet.cs new file mode 100644 index 0000000..39018e9 --- /dev/null +++ b/src/EllieBot/_common/Currency/IWallet.cs @@ -0,0 +1,40 @@ +namespace EllieBot.Services.Currency; + +public interface IWallet +{ + public ulong UserId { get; } + + public Task GetBalance(); + public Task Take(long amount, TxData? txData); + public Task Add(long amount, TxData? txData); + + public async Task Transfer( + long amount, + IWallet to, + TxData? txData) + { + if (amount <= 0) + throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be greater than 0."); + + if (txData is not null) + txData = txData with + { + OtherId = to.UserId + }; + + var succ = await Take(amount, txData); + + if (!succ) + return false; + + if (txData is not null) + txData = txData with + { + OtherId = UserId + }; + + await to.Add(amount, txData); + + return true; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Currency/TxData.cs b/src/EllieBot/_common/Currency/TxData.cs new file mode 100644 index 0000000..06dbab2 --- /dev/null +++ b/src/EllieBot/_common/Currency/TxData.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Services.Currency; + +public record class TxData( + string Type, + string Extra, + string? Note = "", + ulong? OtherId = null); \ No newline at end of file diff --git a/src/EllieBot/_common/DbService.cs b/src/EllieBot/_common/DbService.cs new file mode 100644 index 0000000..cdff91f --- /dev/null +++ b/src/EllieBot/_common/DbService.cs @@ -0,0 +1,15 @@ +#nullable disable +using Microsoft.EntityFrameworkCore; + +namespace EllieBot.Services; + +public abstract class DbService +{ + /// + /// Call this to apply all migrations + /// + public abstract Task SetupAsync(); + + public abstract DbContext CreateRawDbContext(string dbType, string connString); + public abstract DbContext GetDbContext(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/Deck.cs b/src/EllieBot/_common/Deck/Deck.cs new file mode 100644 index 0000000..3398d3c --- /dev/null +++ b/src/EllieBot/_common/Deck/Deck.cs @@ -0,0 +1,309 @@ +#nullable disable +namespace Ellie.Econ; + +public class Deck +{ + public enum CardSuit + { + Spades = 1, + Hearts = 2, + Diamonds = 3, + Clubs = 4 + } + + private static readonly Dictionary _cardNames = new() + { + { 1, "Ace" }, + { 2, "Two" }, + { 3, "Three" }, + { 4, "Four" }, + { 5, "Five" }, + { 6, "Six" }, + { 7, "Seven" }, + { 8, "Eight" }, + { 9, "Nine" }, + { 10, "Ten" }, + { 11, "Jack" }, + { 12, "Queen" }, + { 13, "King" } + }; + + private static Dictionary, bool>> handValues; + + public List CardPool { get; set; } + private readonly Random _r = new EllieRandom(); + + static Deck() + => InitHandValues(); + + /// + /// Creates a new instance of the BlackJackGame, this allows you to create multiple games running at one time. + /// + public Deck() + => RefillPool(); + + /// + /// Restart the game of blackjack. It will only refill the pool for now. Probably wont be used, unless you want to have + /// only 1 bjg running at one time, + /// then you will restart the same game every time. + /// + public void Restart() + => RefillPool(); + + /// + /// Removes all cards from the pool and refills the pool with all of the possible cards. NOTE: I think this is too + /// expensive. + /// We should probably make it so it copies another premade list with all the cards, or something. + /// + protected virtual void RefillPool() + { + CardPool = new(52); + //foreach suit + for (var j = 1; j < 14; j++) + // and number + for (var i = 1; i < 5; i++) + //generate a card of that suit and number and add it to the pool + + // the pool will go from ace of spades,hears,diamonds,clubs all the way to the king of spades. hearts, ... + CardPool.Add(new((CardSuit)i, j)); + } + + /// + /// Take a card from the pool, you either take it from the top if the deck is shuffled, or from a random place if the + /// deck is in the default order. + /// + /// A card from the pool + public Card Draw() + { + if (CardPool.Count == 0) + Restart(); + //you can either do this if your deck is not shuffled + + var num = _r.Next(0, CardPool.Count); + var c = CardPool[num]; + CardPool.RemoveAt(num); + return c; + + // if you want to shuffle when you fill, then take the first one + /* + Card c = cardPool[0]; + cardPool.RemoveAt(0); + return c; + */ + } + + /// + /// Shuffles the deck. Use this if you want to take cards from the top of the deck, instead of randomly. See DrawACard + /// method. + /// + private void Shuffle() + { + if (CardPool.Count <= 1) + return; + var orderedPool = CardPool.Shuffle(); + CardPool ??= orderedPool.ToList(); + } + + public override string ToString() + => string.Concat(CardPool.Select(c => c.ToString())) + Environment.NewLine; + + private static void InitHandValues() + { + bool HasPair(List cards) + { + return cards.GroupBy(card => card.Number).Count(group => group.Count() == 2) == 1; + } + + bool IsPair(List cards) + { + return cards.GroupBy(card => card.Number).Count(group => group.Count() == 3) == 0 && HasPair(cards); + } + + bool IsTwoPair(List cards) + { + return cards.GroupBy(card => card.Number).Count(group => group.Count() == 2) == 2; + } + + bool IsStraight(List cards) + { + if (cards.GroupBy(card => card.Number).Count() != cards.Count()) + return false; + var toReturn = cards.Max(card => card.Number) - cards.Min(card => card.Number) == 4; + if (toReturn || cards.All(c => c.Number != 1)) + return toReturn; + + var newCards = cards.Select(c => c.Number == 1 ? new(c.Suit, 14) : c).ToArray(); + return newCards.Max(card => card.Number) - newCards.Min(card => card.Number) == 4; + } + + bool HasThreeOfKind(List cards) + { + return cards.GroupBy(card => card.Number).Any(group => group.Count() == 3); + } + + bool IsThreeOfKind(List cards) + { + return HasThreeOfKind(cards) && !HasPair(cards); + } + + bool IsFlush(List cards) + { + return cards.GroupBy(card => card.Suit).Count() == 1; + } + + bool IsFourOfKind(List cards) + { + return cards.GroupBy(card => card.Number).Any(group => group.Count() == 4); + } + + bool IsFullHouse(List cards) + { + return HasPair(cards) && HasThreeOfKind(cards); + } + + bool HasStraightFlush(List cards) + { + return IsFlush(cards) && IsStraight(cards); + } + + bool IsRoyalFlush(List cards) + { + return cards.Min(card => card.Number) == 1 + && cards.Max(card => card.Number) == 13 + && HasStraightFlush(cards); + } + + bool IsStraightFlush(List cards) + { + return HasStraightFlush(cards) && !IsRoyalFlush(cards); + } + + handValues = new() + { + { "Royal Flush", IsRoyalFlush }, + { "Straight Flush", IsStraightFlush }, + { "Four Of A Kind", IsFourOfKind }, + { "Full House", IsFullHouse }, + { "Flush", IsFlush }, + { "Straight", IsStraight }, + { "Three Of A Kind", IsThreeOfKind }, + { "Two Pairs", IsTwoPair }, + { "A Pair", IsPair } + }; + } + + public static string GetHandValue(List cards) + { + if (handValues is null) + InitHandValues(); + + foreach (var kvp in handValues.Where(x => x.Value(cards))) + return kvp.Key; + return "High card " + (cards.FirstOrDefault(c => c.Number == 1)?.GetValueText() ?? cards.Max().GetValueText()); + } + + public class Card : IComparable + { + private static readonly IReadOnlyDictionary _suitToSuitChar = new Dictionary + { + { CardSuit.Diamonds, "♦" }, + { CardSuit.Clubs, "♣" }, + { CardSuit.Spades, "♠" }, + { CardSuit.Hearts, "♥" } + }; + + private static readonly IReadOnlyDictionary _suitCharToSuit = new Dictionary + { + { "♦", CardSuit.Diamonds }, + { "d", CardSuit.Diamonds }, + { "♣", CardSuit.Clubs }, + { "c", CardSuit.Clubs }, + { "♠", CardSuit.Spades }, + { "s", CardSuit.Spades }, + { "♥", CardSuit.Hearts }, + { "h", CardSuit.Hearts } + }; + + private static readonly IReadOnlyDictionary _numberCharToNumber = new Dictionary + { + { 'a', 1 }, + { '2', 2 }, + { '3', 3 }, + { '4', 4 }, + { '5', 5 }, + { '6', 6 }, + { '7', 7 }, + { '8', 8 }, + { '9', 9 }, + { 't', 10 }, + { 'j', 11 }, + { 'q', 12 }, + { 'k', 13 } + }; + + public CardSuit Suit { get; } + public int Number { get; } + + public string FullName + { + get + { + var str = string.Empty; + + if (Number is <= 10 and > 1) + str += "_" + Number; + else + str += GetValueText().ToLowerInvariant(); + return str + "_of_" + Suit.ToString().ToLowerInvariant(); + } + } + + private readonly string[] _regIndicators = + [ + "🇦", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:", ":eight:", ":nine:", ":keycap_ten:", + "🇯", "🇶", "🇰" + ]; + + public Card(CardSuit s, int cardNum) + { + Suit = s; + Number = cardNum; + } + + public string GetValueText() + => _cardNames[Number]; + + public override string ToString() + => _cardNames[Number] + " Of " + Suit; + + public int CompareTo(object obj) + { + if (obj is not Card card) + return 0; + return Number - card.Number; + } + + public static Card Parse(string input) + { + if (string.IsNullOrWhiteSpace(input)) + throw new ArgumentNullException(nameof(input)); + + if (input.Length != 2 + || !_numberCharToNumber.TryGetValue(input[0], out var n) + || !_suitCharToSuit.TryGetValue(input[1].ToString(), out var s)) + throw new ArgumentException("Invalid input", nameof(input)); + + return new(s, n); + } + + public string GetEmojiString() + { + var str = string.Empty; + + str += _regIndicators[Number - 1]; + str += _suitToSuitChar[Suit]; + + return str; + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/NewCard.cs b/src/EllieBot/_common/Deck/NewCard.cs new file mode 100644 index 0000000..4a091a4 --- /dev/null +++ b/src/EllieBot/_common/Deck/NewCard.cs @@ -0,0 +1,5 @@ +namespace Ellie.Econ; + +public abstract record class NewCard(TSuit Suit, TValue Value) + where TSuit : struct, Enum + where TValue : struct, Enum; \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/NewDeck.cs b/src/EllieBot/_common/Deck/NewDeck.cs new file mode 100644 index 0000000..a71406c --- /dev/null +++ b/src/EllieBot/_common/Deck/NewDeck.cs @@ -0,0 +1,54 @@ +namespace Ellie.Econ; + +public abstract class NewDeck + where TCard: NewCard + where TSuit : struct, Enum + where TValue : struct, Enum +{ + protected static readonly TSuit[] _suits = Enum.GetValues(); + protected static readonly TValue[] _values = Enum.GetValues(); + + public virtual int CurrentCount + => _cards.Count; + + public virtual int TotalCount { get; } + + protected readonly LinkedList _cards = new(); + public NewDeck() + { + TotalCount = _suits.Length * _values.Length; + } + + public virtual TCard? Draw() + { + var first = _cards.First; + if (first is not null) + { + _cards.RemoveFirst(); + return first.Value; + } + + return null; + } + + public virtual TCard? Peek(int x = 0) + { + var card = _cards.First; + for (var i = 0; i < x; i++) + { + card = card?.Next; + } + + return card?.Value; + } + + public virtual void Shuffle() + { + var cards = _cards.ToList(); + var newCards = cards.Shuffle(); + + _cards.Clear(); + foreach (var card in newCards) + _cards.AddFirst(card); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/Regular/MultipleRegularDeck/MultipleRegularDeck.cs b/src/EllieBot/_common/Deck/Regular/MultipleRegularDeck/MultipleRegularDeck.cs new file mode 100644 index 0000000..2a7e7df --- /dev/null +++ b/src/EllieBot/_common/Deck/Regular/MultipleRegularDeck/MultipleRegularDeck.cs @@ -0,0 +1,28 @@ +namespace Ellie.Econ; + +public class MultipleRegularDeck : NewDeck +{ + private int Decks { get; } + + public override int TotalCount { get; } + + public MultipleRegularDeck(int decks = 1) + { + if (decks < 1) + throw new ArgumentOutOfRangeException(nameof(decks), "Has to be more than 0"); + + Decks = decks; + TotalCount = base.TotalCount * decks; + + for (var i = 0; i < Decks; i++) + { + foreach (var suit in _suits) + { + foreach (var val in _values) + { + _cards.AddLast((RegularCard)Activator.CreateInstance(typeof(RegularCard), suit, val)!); + } + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/Regular/RegularCard.cs b/src/EllieBot/_common/Deck/Regular/RegularCard.cs new file mode 100644 index 0000000..337a1ff --- /dev/null +++ b/src/EllieBot/_common/Deck/Regular/RegularCard.cs @@ -0,0 +1,4 @@ +namespace Ellie.Econ; + +public sealed record class RegularCard(RegularSuit Suit, RegularValue Value) + : NewCard(Suit, Value); \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/Regular/RegularDeck.cs b/src/EllieBot/_common/Deck/Regular/RegularDeck.cs new file mode 100644 index 0000000..6997623 --- /dev/null +++ b/src/EllieBot/_common/Deck/Regular/RegularDeck.cs @@ -0,0 +1,15 @@ +namespace Ellie.Econ; + +public sealed class RegularDeck : NewDeck +{ + public RegularDeck() + { + foreach (var suit in _suits) + { + foreach (var val in _values) + { + _cards.AddLast((RegularCard)Activator.CreateInstance(typeof(RegularCard), suit, val)!); + } + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/Regular/RegularDeckExtensions.cs b/src/EllieBot/_common/Deck/Regular/RegularDeckExtensions.cs new file mode 100644 index 0000000..98c880c --- /dev/null +++ b/src/EllieBot/_common/Deck/Regular/RegularDeckExtensions.cs @@ -0,0 +1,56 @@ +namespace Ellie.Econ; + +public static class RegularDeckExtensions +{ + public static string GetEmoji(this RegularSuit suit) + => suit switch + { + RegularSuit.Hearts => "♥️", + RegularSuit.Spades => "♠️", + RegularSuit.Diamonds => "♦️", + _ => "♣️", + }; + + public static string GetEmoji(this RegularValue value) + => value switch + { + RegularValue.Ace => "🇦", + RegularValue.Two => "2️⃣", + RegularValue.Three => "3️⃣", + RegularValue.Four => "4️⃣", + RegularValue.Five => "5️⃣", + RegularValue.Six => "6️⃣", + RegularValue.Seven => "7️⃣", + RegularValue.Eight => "8️⃣", + RegularValue.Nine => "9️⃣", + RegularValue.Ten => "🔟", + RegularValue.Jack => "🇯", + RegularValue.Queen => "🇶", + _ => "🇰", + }; + + public static string GetEmoji(this RegularCard card) + => $"{card.Value.GetEmoji()} {card.Suit.GetEmoji()}"; + + public static string GetName(this RegularValue value) + => value.ToString(); + + public static string GetName(this RegularSuit suit) + => suit.ToString(); + + public static string GetName(this RegularCard card) + => $"{card.Value.ToString()} of {card.Suit.GetName()}"; +} + + + + + + + + + + + + + diff --git a/src/EllieBot/_common/Deck/Regular/RegularSuit.cs b/src/EllieBot/_common/Deck/Regular/RegularSuit.cs new file mode 100644 index 0000000..dc4167b --- /dev/null +++ b/src/EllieBot/_common/Deck/Regular/RegularSuit.cs @@ -0,0 +1,9 @@ +namespace Ellie.Econ; + +public enum RegularSuit +{ + Hearts, + Diamonds, + Clubs, + Spades +} \ No newline at end of file diff --git a/src/EllieBot/_common/Deck/Regular/RegularValue.cs b/src/EllieBot/_common/Deck/Regular/RegularValue.cs new file mode 100644 index 0000000..8aa9171 --- /dev/null +++ b/src/EllieBot/_common/Deck/Regular/RegularValue.cs @@ -0,0 +1,18 @@ +namespace Ellie.Econ; + +public enum RegularValue +{ + Ace = 1, + Two = 2, + Three = 3, + Four = 4, + Five = 5, + Six = 6, + Seven = 7, + Eight = 8, + Nine = 9, + Ten = 10, + Jack = 12, + Queen = 13, + King = 14, +} \ 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..f8fba27 --- /dev/null +++ b/src/EllieBot/_common/DoAsUserMessage.cs @@ -0,0 +1,154 @@ +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> GetReactionUsersAsync( + IEmote emoji, + int limit, + RequestOptions? options = null, + ReactionType type = ReactionType.Normal) + => _msg.GetReactionUsersAsync(emoji, limit, options, type); + + public IAsyncEnumerable> 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 Attachments => _msg.Attachments; + + public IReadOnlyCollection Embeds => _msg.Embeds; + + public IReadOnlyCollection Tags => _msg.Tags; + + public IReadOnlyCollection MentionedChannelIds => _msg.MentionedChannelIds; + + public IReadOnlyCollection MentionedRoleIds => _msg.MentionedRoleIds; + + public IReadOnlyCollection MentionedUserIds => _msg.MentionedUserIds; + + public MessageActivity Activity => _msg.Activity; + + public MessageApplication Application => _msg.Application; + + public MessageReference Reference => _msg.Reference; + + public IReadOnlyDictionary Reactions => _msg.Reactions; + + public IReadOnlyCollection Components => _msg.Components; + + public IReadOnlyCollection 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 Task ModifyAsync(Action 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 MessageResolvedData ResolvedData => _msg.ResolvedData; + + public IUserMessage ReferencedMessage => _msg.ReferencedMessage; + + public IMessageInteractionMetadata InteractionMetadata + => _msg.InteractionMetadata; +} \ 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..51d7cc6 --- /dev/null +++ b/src/EllieBot/_common/DownloadTracker.cs @@ -0,0 +1,38 @@ +#nullable disable +namespace EllieBot.Common; + +public class DownloadTracker : IEService +{ + private ConcurrentDictionary LastDownloads { get; } = new(); + private readonly SemaphoreSlim _downloadUsersSemaphore = new(1, 1); + + /// + /// Ensures all users on the specified guild were downloaded within the last hour. + /// + /// Guild to check and potentially download users from + /// Task representing download state + public async Task EnsureUsersDownloadedAsync(IGuild guild) + { +#if GLOBAL_ELLIE + 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/EllieModule.cs b/src/EllieBot/_common/EllieModule.cs new file mode 100644 index 0000000..ba52708 --- /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 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 GetUserInputAsync(ulong userId, ulong channelId, Func validate = null) + { + var userInputTask = new TaskCompletionSource(); + 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 : 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..bab013e --- /dev/null +++ b/src/EllieBot/_common/EllieTypeReader.cs @@ -0,0 +1,14 @@ +#nullable disable +namespace EllieBot.Common.TypeReaders; + +[MeansImplicitUse(ImplicitUseTargetFlags.Default | ImplicitUseTargetFlags.WithInheritors)] +public abstract class EllieTypeReader : TypeReader +{ + public abstract ValueTask> ReadAsync(ICommandContext ctx, string input); + + public override async Task ReadAsync( + ICommandContext ctx, + string input, + IServiceProvider services) + => await ReadAsync(ctx, input); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawColorGuess.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawColorGuess.cs new file mode 100644 index 0000000..8b95530 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawColorGuess.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Gambling.Betdraw; + +public enum BetdrawColorGuess +{ + Red, + Black +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawGame.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawGame.cs new file mode 100644 index 0000000..5ebcb6c --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawGame.cs @@ -0,0 +1,86 @@ +using Ellie.Econ; + +namespace EllieBot.Modules.Gambling.Betdraw; + +public sealed class BetdrawGame +{ + private static readonly EllieRandom _rng = new(); + private readonly RegularDeck _deck; + + private const decimal SINGLE_GUESS_MULTI = 2.075M; + private const decimal DOUBLE_GUESS_MULTI = 4.15M; + + public BetdrawGame() + { + _deck = new RegularDeck(); + } + + public BetdrawResult Draw(BetdrawValueGuess? val, BetdrawColorGuess? col, decimal amount) + { + if (val is null && col is null) + throw new ArgumentNullException(nameof(val)); + + var card = _deck.Peek(_rng.Next(0, 52))!; + + var realVal = (int)card.Value < 7 + ? BetdrawValueGuess.Low + : BetdrawValueGuess.High; + + var realCol = card.Suit is RegularSuit.Diamonds or RegularSuit.Hearts + ? BetdrawColorGuess.Red + : BetdrawColorGuess.Black; + + // if card is 7, autoloss + if (card.Value == RegularValue.Seven) + { + return new() + { + Won = 0M, + Multiplier = 0M, + ResultType = BetdrawResultType.Lose, + Card = card, + }; + } + + byte win = 0; + if (val is BetdrawValueGuess valGuess) + { + if (realVal != valGuess) + return new() + { + Won = 0M, + Multiplier = 0M, + ResultType = BetdrawResultType.Lose, + Card = card + }; + + ++win; + } + + if (col is BetdrawColorGuess colGuess) + { + if (realCol != colGuess) + return new() + { + Won = 0M, + Multiplier = 0M, + ResultType = BetdrawResultType.Lose, + Card = card + }; + + ++win; + } + + var multi = win == 1 + ? SINGLE_GUESS_MULTI + : DOUBLE_GUESS_MULTI; + + return new() + { + Won = amount * multi, + Multiplier = multi, + ResultType = BetdrawResultType.Win, + Card = card + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawResult.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawResult.cs new file mode 100644 index 0000000..a491985 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawResult.cs @@ -0,0 +1,11 @@ +using Ellie.Econ; + +namespace EllieBot.Modules.Gambling.Betdraw; + +public readonly struct BetdrawResult +{ + public decimal Won { get; init; } + public decimal Multiplier { get; init; } + public BetdrawResultType ResultType { get; init; } + public RegularCard Card { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawResultType.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawResultType.cs new file mode 100644 index 0000000..cc7ab51 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawResultType.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Gambling.Betdraw; + +public enum BetdrawResultType +{ + Win, + Lose +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betdraw/BetdrawValueGuess.cs b/src/EllieBot/_common/Gambling/Betdraw/BetdrawValueGuess.cs new file mode 100644 index 0000000..204cc46 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betdraw/BetdrawValueGuess.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Gambling.Betdraw; + +public enum BetdrawValueGuess +{ + High, + Low, +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betflip/BetflipGame.cs b/src/EllieBot/_common/Gambling/Betflip/BetflipGame.cs new file mode 100644 index 0000000..f704025 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betflip/BetflipGame.cs @@ -0,0 +1,33 @@ +namespace EllieBot.Modules.Gambling; + +public sealed class BetflipGame +{ + private readonly decimal _winMulti; + private static readonly EllieRandom _rng = new EllieRandom(); + + public BetflipGame(decimal winMulti) + { + _winMulti = winMulti; + } + + public BetflipResult Flip(byte guess, decimal amount) + { + var side = (byte)_rng.Next(0, 2); + if (side == guess) + { + return new BetflipResult() + { + Side = side, + Won = amount * _winMulti, + Multiplier = _winMulti + }; + } + + return new BetflipResult() + { + Side = side, + Won = 0, + Multiplier = 0, + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betflip/BetflipResult.cs b/src/EllieBot/_common/Gambling/Betflip/BetflipResult.cs new file mode 100644 index 0000000..e87f2f8 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betflip/BetflipResult.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Gambling; + +public readonly struct BetflipResult +{ + public decimal Won { get; init; } + public byte Side { get; init; } + public decimal Multiplier { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betroll/BetrollGame.cs b/src/EllieBot/_common/Gambling/Betroll/BetrollGame.cs new file mode 100644 index 0000000..7937538 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betroll/BetrollGame.cs @@ -0,0 +1,42 @@ +namespace EllieBot.Modules.Gambling; + +public sealed class BetrollGame +{ + private readonly (int WhenAbove, decimal MultiplyBy)[] _thresholdPairs; + private readonly EllieRandom _rng; + + public BetrollGame(IReadOnlyList<(int WhenAbove, decimal MultiplyBy)> pairs) + { + _thresholdPairs = pairs.OrderByDescending(x => x.WhenAbove).ToArray(); + _rng = new(); + } + + public BetrollResult Roll(decimal amount = 0) + { + var roll = _rng.Next(1, 101); + + for (var i = 0; i < _thresholdPairs.Length; i++) + { + ref var pair = ref _thresholdPairs[i]; + + if (pair.WhenAbove < roll) + { + return new() + { + Multiplier = pair.MultiplyBy, + Roll = roll, + Threshold = pair.WhenAbove, + Won = amount * pair.MultiplyBy + }; + } + } + + return new() + { + Multiplier = 0, + Roll = roll, + Threshold = -1, + Won = 0, + }; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Betroll/BetrollResult.cs b/src/EllieBot/_common/Gambling/Betroll/BetrollResult.cs new file mode 100644 index 0000000..b107f36 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Betroll/BetrollResult.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Gambling; + +public readonly struct BetrollResult +{ + public int Roll { get; init; } + public decimal Multiplier { get; init; } + public decimal Threshold { get; init; } + public decimal Won { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Rps/RpsGame.cs b/src/EllieBot/_common/Gambling/Rps/RpsGame.cs new file mode 100644 index 0000000..976a67a --- /dev/null +++ b/src/EllieBot/_common/Gambling/Rps/RpsGame.cs @@ -0,0 +1,75 @@ +namespace EllieBot.Modules.Gambling.Rps; + +public sealed class RpsGame +{ + private static readonly EllieRandom _rng = new EllieRandom(); + + private const decimal WIN_MULTI = 1.95m; + private const decimal DRAW_MULTI = 1m; + private const decimal LOSE_MULTI = 0m; + + public RpsGame() + { + + } + + public RpsResult Play(RpsPick pick, decimal amount) + { + var compPick = (RpsPick)_rng.Next(0, 3); + if (compPick == pick) + { + return new() + { + Won = amount * DRAW_MULTI, + Multiplier = DRAW_MULTI, + ComputerPick = compPick, + Result = RpsResultType.Draw, + }; + } + + if ((compPick == RpsPick.Paper && pick == RpsPick.Rock) + || (compPick == RpsPick.Rock && pick == RpsPick.Scissors) + || (compPick == RpsPick.Scissors && pick == RpsPick.Paper)) + { + return new() + { + Won = amount * LOSE_MULTI, + Multiplier = LOSE_MULTI, + Result = RpsResultType.Lose, + ComputerPick = compPick, + }; + } + + return new() + { + Won = amount * WIN_MULTI, + Multiplier = WIN_MULTI, + Result = RpsResultType.Win, + ComputerPick = compPick, + }; + } +} + +public enum RpsPick : byte +{ + Rock = 0, + Paper = 1, + Scissors = 2, +} + +public enum RpsResultType : byte +{ + Win, + Draw, + Lose +} + + + +public readonly struct RpsResult +{ + public decimal Won { get; init; } + public decimal Multiplier { get; init; } + public RpsResultType Result { get; init; } + public RpsPick ComputerPick { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Slot/SlotGame.cs b/src/EllieBot/_common/Gambling/Slot/SlotGame.cs new file mode 100644 index 0000000..83e92eb --- /dev/null +++ b/src/EllieBot/_common/Gambling/Slot/SlotGame.cs @@ -0,0 +1,116 @@ +namespace EllieBot.Modules.Gambling; + +//here is a payout chart +//https://lh6.googleusercontent.com/-i1hjAJy_kN4/UswKxmhrbPI/AAAAAAAAB1U/82wq_4ZZc-Y/DE6B0895-6FC1-48BE-AC4F-14D1B91AB75B.jpg +//thanks to judge for helping me with this +public class SlotGame +{ + private static readonly EllieRandom _rng = new EllieRandom(); + + public SlotResult Spin(decimal bet) + { + var rolls = new[] + { + (byte)_rng.Next(0, 6), + (byte)_rng.Next(0, 6), + (byte)_rng.Next(0, 6) + }; + + ref var a = ref rolls[0]; + ref var b = ref rolls[1]; + ref var c = ref rolls[2]; + + var multi = 0; + var winType = SlotWinType.None; + if (a == b && b == c) + { + if (a == 5) + { + winType = SlotWinType.TrippleJoker; + multi = 30; + } + else + { + winType = SlotWinType.TrippleNormal; + multi = 10; + } + } + else if (a == 5 && (b == 5 || c == 5) + || (b == 5 && c == 5)) + { + winType = SlotWinType.DoubleJoker; + multi = 4; + } + else if (a == 5 || b == 5 || c == 5) + { + winType = SlotWinType.SingleJoker; + multi = 1; + } + + return new() + { + Won = bet * multi, + WinType = winType, + Multiplier = multi, + Rolls = rolls, + }; + } +} + +public enum SlotWinType : byte +{ + None, + SingleJoker, + DoubleJoker, + TrippleNormal, + TrippleJoker, +} + +/* +var rolls = new[] + { + _rng.Next(default(byte), 6), + _rng.Next(default(byte), 6), + _rng.Next(default(byte), 6) + }; + + var multi = 0; + var winType = SlotWinType.None; + + ref var a = ref rolls[0]; + ref var b = ref rolls[1]; + ref var c = ref rolls[2]; + if (a == b && b == c) + { + if (a == 5) + { + winType = SlotWinType.TrippleJoker; + multi = 30; + } + else + { + winType = SlotWinType.TrippleNormal; + multi = 10; + } + } + else if (a == 5 && (b == 5 || c == 5) + || (b == 5 && c == 5)) + { + winType = SlotWinType.DoubleJoker; + multi = 4; + } + else if (rolls.Any(x => x == 5)) + { + winType = SlotWinType.SingleJoker; + multi = 1; + } + + return new() + { + Won = bet * multi, + WinType = winType, + Multiplier = multi, + Rolls = rolls, + }; + } +*/ \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Slot/SlotResult.cs b/src/EllieBot/_common/Gambling/Slot/SlotResult.cs new file mode 100644 index 0000000..d88a706 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Slot/SlotResult.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Gambling; + +public readonly struct SlotResult +{ + public decimal Multiplier { get; init; } + public byte[] Rolls { get; init; } + public decimal Won { get; init; } + public SlotWinType WinType { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Wof/LuLaResult.cs b/src/EllieBot/_common/Gambling/Wof/LuLaResult.cs new file mode 100644 index 0000000..cec8cca --- /dev/null +++ b/src/EllieBot/_common/Gambling/Wof/LuLaResult.cs @@ -0,0 +1,9 @@ +namespace EllieBot.Modules.Gambling; + +public readonly struct LuLaResult +{ + public int Index { get; init; } + public decimal Multiplier { get; init; } + public decimal Won { get; init; } + public IReadOnlyList Multipliers { get; init; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Gambling/Wof/WofGame.cs b/src/EllieBot/_common/Gambling/Wof/WofGame.cs new file mode 100644 index 0000000..0922271 --- /dev/null +++ b/src/EllieBot/_common/Gambling/Wof/WofGame.cs @@ -0,0 +1,34 @@ +namespace EllieBot.Modules.Gambling; + +public sealed class LulaGame +{ + private static readonly IReadOnlyList DEFAULT_MULTIPLIERS = new[] { 1.7M, 1.5M, 0.2M, 0.1M, 0.3M, 0.5M, 1.2M, 2.4M }; + + private readonly IReadOnlyList _multipliers; + private static readonly EllieRandom _rng = new(); + + public LulaGame(IReadOnlyList multipliers) + { + _multipliers = multipliers; + } + + public LulaGame() : this(DEFAULT_MULTIPLIERS) + { + } + + public LuLaResult Spin(long bet) + { + var result = _rng.Next(0, _multipliers.Count); + + var multi = _multipliers[result]; + var amount = bet * multi; + + return new() + { + Index = result, + Multiplier = multi, + Won = amount, + Multipliers = _multipliers.ToArray(), + }; + } +} \ 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 GetCurrentGuildIds(); + event Func JoinedGuild; + IReadOnlyCollection 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 + 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 cur, IFormatProvider format) + where T : INumber + => cur.ToString("C0", format); + + public static string N(T cur, CultureInfo culture, string currencySign) + where T : INumber + => 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..344be96 --- /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..81aaa64 --- /dev/null +++ b/src/EllieBot/_common/IPermissionChecker.cs @@ -0,0 +1,37 @@ +using OneOf; + +namespace EllieBot.Common; + +public interface IPermissionChecker +{ + Task CheckPermsAsync(IGuild guild, + IMessageChannel channel, + IUser author, + string module, + string? cmd); +} + +[GenerateOneOf] +public partial class PermCheckResult + : OneOfBase +{ + 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..1766577 --- /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 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..7274e60 --- /dev/null +++ b/src/EllieBot/_common/ImageUrls.cs @@ -0,0 +1,51 @@ +#nullable disable +using EllieBot.Common.Yml; +using Cloneable; + +namespace EllieBot.Common; + +[Cloneable] +public partial class ImageUrls : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 3; + + public CoinData Coins { get; set; } + public Uri[] Currency { get; set; } + public Uri[] Dice { get; set; } + public RategirlData Rategirl { get; set; } + public XpData Xp { get; set; } + + //new + public RipData Rip { get; set; } + public SlotData Slots { get; set; } + + public class RipData + { + public Uri Bg { get; set; } + public Uri Overlay { 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 RategirlData + { + public Uri Matrix { get; set; } + public Uri Dot { 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..89e2103 --- /dev/null +++ b/src/EllieBot/_common/Interaction/EllieInteraction.cs @@ -0,0 +1,164 @@ +namespace EllieBot; + +public abstract class EllieInteractionBase +{ + private readonly ulong _authorId; + private readonly Func _onAction; + private readonly bool _onlyAuthor; + public DiscordSocketClient Client { get; } + + private readonly TaskCompletionSource _interactionCompletedSource; + + private IUserMessage message = null!; + private readonly string _customId; + private readonly bool _singleUse; + + public EllieInteractionBase( + DiscordSocketClient client, + ulong authorId, + string customId, + Func onAction, + bool onlyAuthor, + bool singleUse = true) + { + _authorId = authorId; + _customId = customId; + _onAction = onAction; + _onlyAuthor = onlyAuthor; + _singleUse = singleUse; + _interactionCompletedSource = new(TaskCreationOptions.RunContinuationsAsynchronously); + + Client = client; + } + + public async Task RunAsync(IUserMessage msg) + { + message = msg; + + Client.InteractionCreated += OnInteraction; + if (_singleUse) + await Task.WhenAny(Task.Delay(30_000), _interactionCompletedSource.Task); + else + await Task.Delay(30_000); + Client.InteractionCreated -= OnInteraction; + + await msg.ModifyAsync(m => m.Components = new ComponentBuilder().Build()); + } + + private Task OnInteraction(SocketInteraction arg) + { + if (arg is not SocketMessageComponent smc) + return Task.CompletedTask; + + if (smc.Message.Id != message.Id) + return Task.CompletedTask; + + if (_onlyAuthor && smc.User.Id != _authorId) + return Task.CompletedTask; + + if (smc.Data.CustomId != _customId) + return Task.CompletedTask; + + _ = Task.Run(async () => + { + try + { + _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 sealed class EllieModalSubmitHandler +{ + private readonly ulong _authorId; + private readonly Func _onAction; + private readonly bool _onlyAuthor; + public DiscordSocketClient Client { get; } + + private readonly TaskCompletionSource _interactionCompletedSource; + + private IUserMessage message = null!; + private readonly string _customId; + + public EllieModalSubmitHandler( + DiscordSocketClient client, + ulong authorId, + string customId, + Func 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..115c417 --- /dev/null +++ b/src/EllieBot/_common/Interaction/EllieInteractionService.cs @@ -0,0 +1,77 @@ +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 onTrigger, + bool singleUse = true) + => new EllieButtonInteractionHandler(_client, + userId, + button, + onTrigger, + onlyAuthor: true, + singleUse: singleUse); + + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + Func onTrigger, + in T state, + bool singleUse = true) + => Create(userId, + button, + ((Func>)((data) + => smc => onTrigger(smc, data)))(state), + singleUse); + + public EllieInteractionBase Create( + ulong userId, + SelectMenuBuilder menu, + Func onTrigger, + bool singleUse = true) + => new EllieButtonSelectInteractionHandler(_client, + userId, + menu, + onTrigger, + onlyAuthor: true, + singleUse: singleUse); + + + /// + /// Create an interaction which opens a modal + /// + /// Id of the author + /// Button builder for the button that will open the modal + /// Modal + /// The function that will be called when the modal is submitted + /// Whether the button is single use + /// + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + ModalBuilder modal, + Func 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..967e91d --- /dev/null +++ b/src/EllieBot/_common/Interaction/IEllieInteractionService.cs @@ -0,0 +1,30 @@ +namespace EllieBot; + +public interface IEllieInteractionService +{ + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + Func onTrigger, + bool singleUse = true); + + public EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + Func onTrigger, + in T state, + bool singleUse = true); + + EllieInteractionBase Create( + ulong userId, + SelectMenuBuilder menu, + Func onTrigger, + bool singleUse = true); + + EllieInteractionBase Create( + ulong userId, + ButtonBuilder button, + ModalBuilder modal, + Func 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..3e65c1c --- /dev/null +++ b/src/EllieBot/_common/Interaction/Models/EllieButtonInteraction.cs @@ -0,0 +1,21 @@ +namespace EllieBot; + +public sealed class EllieButtonInteractionHandler : EllieInteractionBase +{ + public EllieButtonInteractionHandler( + DiscordSocketClient client, + ulong authorId, + ButtonBuilder button, + Func onAction, + bool onlyAuthor, + bool singleUse = true) + : base(client, authorId, button.CustomId, onAction, onlyAuthor, singleUse) + { + 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..cf54f9e --- /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 ellieInteractionBase + ) + { + var cb = new ComponentBuilder(); + + ellieInteractionBase.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 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..28167d6 --- /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 +{ + 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); +} \ 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..ef619a6 --- /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 +{ + 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..a724652 --- /dev/null +++ b/src/EllieBot/_common/Linq2DbExpressions.cs @@ -0,0 +1,16 @@ +#nullable disable +using LinqToDB; +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> 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/Marmalade/Common/Adapters/BehaviorAdapter.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/BehaviorAdapter.cs new file mode 100644 index 0000000..d76137f --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Adapters/BehaviorAdapter.cs @@ -0,0 +1,78 @@ +using EllieBot.Marmalade; + +[DIIgnore] +public sealed class BehaviorAdapter : ICustomBehavior +{ + private readonly WeakReference _canaryWr; + private readonly IMarmaladeStrings _strings; + private readonly IServiceProvider _services; + private readonly string _name; + + public string Name => _name; + + // unused + public int Priority + => 0; + + public BehaviorAdapter(WeakReference canaryWr, IMarmaladeStrings strings, IServiceProvider services) + { + _canaryWr = canaryWr; + _strings = strings; + _services = services; + + _name = canaryWr.TryGetTarget(out var canary) + ? $"canary/{canary.GetType().Name}" + : "unknown"; + } + + public async Task ExecPreCommandAsync(ICommandContext context, string moduleName, CommandInfo command) + { + if (!_canaryWr.TryGetTarget(out var canary)) + return false; + + return await canary.ExecPreCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services), + moduleName, + command.Name); + } + + public async Task ExecOnMessageAsync(IGuild? guild, IUserMessage msg) + { + if (!_canaryWr.TryGetTarget(out var canary)) + return false; + + return await canary.ExecOnMessageAsync(guild, msg); + } + + public async Task TransformInput( + IGuild guild, + IMessageChannel channel, + IUser user, + string input) + { + if (!_canaryWr.TryGetTarget(out var canary)) + return null; + + return await canary.ExecInputTransformAsync(guild, channel, user, input); + } + + public async Task ExecOnNoCommandAsync(IGuild? guild, IUserMessage msg) + { + if (!_canaryWr.TryGetTarget(out var canary)) + return; + + await canary.ExecOnNoCommandAsync(guild, msg); + } + + public async ValueTask ExecPostCommandAsync(ICommandContext context, string moduleName, string commandName) + { + if (!_canaryWr.TryGetTarget(out var canary)) + return; + + await canary.ExecPostCommandAsync(ContextAdapterFactory.CreateNew(context, _strings, _services), + moduleName, + commandName); + } + + public override string ToString() + => _name; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/ContextAdapterFactory.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/ContextAdapterFactory.cs new file mode 100644 index 0000000..38c1ad9 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Adapters/ContextAdapterFactory.cs @@ -0,0 +1,9 @@ +using EllieBot.Marmalade; + +internal class ContextAdapterFactory +{ + public static AnyContext CreateNew(ICommandContext context, IMarmaladeStrings strings, IServiceProvider services) + => context.Guild is null + ? new DmContextAdapter(context, strings, services) + : new GuildContextAdapter(context, strings, services); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/DmContextAdapter.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/DmContextAdapter.cs new file mode 100644 index 0000000..1f2d1cf --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Adapters/DmContextAdapter.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.DependencyInjection; +using EllieBot.Marmalade; + +public sealed class DmContextAdapter : DmContext +{ + public override IMarmaladeStrings Strings { get; } + public override IDMChannel Channel { get; } + public override IUserMessage Message { get; } + public override ISelfUser Bot { get; } + public override IUser User + => Message.Author; + + + private readonly IServiceProvider _services; + private readonly Lazy _botStrings; + private readonly Lazy _localization; + + public DmContextAdapter(ICommandContext ctx, IMarmaladeStrings strings, IServiceProvider services) + { + if (ctx is not { Channel: IDMChannel ch }) + { + throw new ArgumentException("Can't use non-dm context to create DmContextAdapter", nameof(ctx)); + } + + Strings = strings; + + _services = services; + + Channel = ch; + Message = ctx.Message; + Bot = ctx.Client.CurrentUser; + + + _botStrings = new(_services.GetRequiredService); + _localization = new(_services.GetRequiredService()); + } + + public override string GetText(string key, object[]? args = null) + { + var cultureInfo = _localization.Value.GetCultureInfo(default(ulong?)); + var output = Strings.GetText(key, cultureInfo, args ?? Array.Empty()); + if (!string.IsNullOrWhiteSpace(output)) + return output; + + return _botStrings.Value.GetText(key, cultureInfo, args); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/FilterAdapter.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/FilterAdapter.cs new file mode 100644 index 0000000..a23ceec --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Adapters/FilterAdapter.cs @@ -0,0 +1,33 @@ +using EllieBot.Marmalade; + +namespace Ellie.Marmalade.Adapters; + +public class FilterAdapter : PreconditionAttribute +{ + private readonly FilterAttribute _filterAttribute; + private readonly IMarmaladeStrings _strings; + + public FilterAdapter(FilterAttribute filterAttribute, + IMarmaladeStrings strings) + { + _filterAttribute = filterAttribute; + _strings = strings; + } + + public override async Task CheckPermissionsAsync( + ICommandContext context, + CommandInfo command, + IServiceProvider services) + { + var medusaContext = ContextAdapterFactory.CreateNew(context, + _strings, + services); + + var result = await _filterAttribute.CheckAsync(medusaContext); + + if (!result) + return PreconditionResult.FromError($"Precondition '{_filterAttribute.GetType().Name}' failed."); + + return PreconditionResult.FromSuccess(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/GuildContextAdapter.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/GuildContextAdapter.cs new file mode 100644 index 0000000..8b0c294 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Adapters/GuildContextAdapter.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.DependencyInjection; +using EllieBot.Marmalade; + +public sealed class GuildContextAdapter : GuildContext +{ + private readonly IServiceProvider _services; + private readonly ICommandContext _ctx; + private readonly Lazy _botStrings; + private readonly Lazy _localization; + + public override IMarmaladeStrings Strings { get; } + public override IGuild Guild { get; } + public override ITextChannel Channel { get; } + public override ISelfUser Bot { get; } + public override IUserMessage Message + => _ctx.Message; + + public override IGuildUser User { get; } + + public GuildContextAdapter(ICommandContext ctx, IMarmaladeStrings strings, IServiceProvider services) + { + if (ctx.Guild is not IGuild guild || ctx.Channel is not ITextChannel channel) + { + throw new ArgumentException("Can't use non-guild context to create GuildContextAdapter", nameof(ctx)); + } + + Strings = strings; + User = (IGuildUser)ctx.User; + Bot = ctx.Client.CurrentUser; + + _services = services; + _botStrings = new(_services.GetRequiredService); + _localization = new(_services.GetRequiredService()); + + (_ctx, Guild, Channel) = (ctx, guild, channel); + } + + public override string GetText(string key, object[]? args = null) + { + args ??= Array.Empty(); + + var cultureInfo = _localization.Value.GetCultureInfo(_ctx.Guild.Id); + var output = Strings.GetText(key, cultureInfo, args); + if (!string.IsNullOrWhiteSpace(output)) + return output; + + return _botStrings.Value.GetText(key, cultureInfo, args); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Adapters/ParamParserAdapter.cs b/src/EllieBot/_common/Marmalade/Common/Adapters/ParamParserAdapter.cs new file mode 100644 index 0000000..707abbb --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Adapters/ParamParserAdapter.cs @@ -0,0 +1,34 @@ +using EllieBot.Marmalade; + +public sealed class ParamParserAdapter : TypeReader +{ + private readonly ParamParser _parser; + private readonly IMarmaladeStrings _strings; + private readonly IServiceProvider _services; + + public ParamParserAdapter(ParamParser parser, + IMarmaladeStrings strings, + IServiceProvider services) + { + _parser = parser; + _strings = strings; + _services = services; + } + + public override async Task ReadAsync( + ICommandContext context, + string input, + IServiceProvider services) + { + var marmaladeContext = ContextAdapterFactory.CreateNew(context, + _strings, + _services); + + var result = await _parser.TryParseAsync(marmaladeContext, input); + + if(result.IsSuccess) + return Discord.Commands.TypeReaderResult.FromSuccess(result.Data); + + return Discord.Commands.TypeReaderResult.FromError(CommandError.Unsuccessful, "Invalid input"); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/CommandContextType.cs b/src/EllieBot/_common/Marmalade/Common/CommandContextType.cs new file mode 100644 index 0000000..c517510 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/CommandContextType.cs @@ -0,0 +1,27 @@ +namespace EllieBot.Marmalade; + +/// +/// Enum specifying in which context the command can be executed +/// +public enum CommandContextType +{ + /// + /// Command can only be executed in a guild + /// + Guild, + + /// + /// Command can only be executed in DMs + /// + Dm, + + /// + /// Command can be executed anywhere + /// + Any, + + /// + /// Command can be executed anywhere, and it doesn't require context to be passed to it + /// + Unspecified +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Config/IMarmaladeConfigService.cs b/src/EllieBot/_common/Marmalade/Common/Config/IMarmaladeConfigService.cs new file mode 100644 index 0000000..5cb14fc --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Config/IMarmaladeConfigService.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Marmalade; + +public interface IMarmaladeConfigService +{ + IReadOnlyCollection GetLoadedMarmalades(); + void AddLoadedMarmalade(string name); + void RemoveLoadedMarmalade(string name); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfig.cs b/src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfig.cs new file mode 100644 index 0000000..c14e893 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfig.cs @@ -0,0 +1,20 @@ +#nullable enable +using Cloneable; +using EllieBot.Common.Yml; + +namespace EllieBot.Marmalade; + +[Cloneable] +public sealed partial class MarmaladeConfig : ICloneable +{ + [Comment("""DO NOT CHANGE""")] + public int Version { get; set; } = 1; + + [Comment("""List of marmalades automatically loaded at startup""")] + public List? Loaded { get; set; } + + public MarmaladeConfig() + { + Loaded = new(); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfigService.cs b/src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfigService.cs new file mode 100644 index 0000000..43f3a26 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Config/MarmaladeConfigService.cs @@ -0,0 +1,45 @@ +using EllieBot.Common.Configs; + +namespace EllieBot.Marmalade; + +public sealed class MarmaladeConfigService : ConfigServiceBase, IMarmaladeConfigService +{ + private const string FILE_PATH = "data/marmalades/marmalade.yml"; + private static readonly TypedKey _changeKey = new("config.marmalade.updated"); + + public override string Name + => "marmalade"; + + public MarmaladeConfigService( + IConfigSeria serializer, + IPubSub pubSub) + : base(FILE_PATH, serializer, pubSub, _changeKey) + { + } + + public IReadOnlyCollection GetLoadedMarmalades() + => Data.Loaded?.ToList() ?? new List(); + + public void AddLoadedMarmalade(string name) + { + ModifyConfig(conf => + { + if (conf.Loaded is null) + conf.Loaded = new(); + + if(!conf.Loaded.Contains(name)) + conf.Loaded.Add(name); + }); + } + + public void RemoveLoadedMarmalade(string name) + { + ModifyConfig(conf => + { + if (conf.Loaded is null) + conf.Loaded = new(); + + conf.Loaded.Remove(name); + }); + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/MarmaladeAssemblyLoadContext.cs b/src/EllieBot/_common/Marmalade/Common/MarmaladeAssemblyLoadContext.cs new file mode 100644 index 0000000..f07f7b5 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/MarmaladeAssemblyLoadContext.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace EllieBot.Marmalade; + +public class MarmaladeAssemblyLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + + public MarmaladeAssemblyLoadContext(string folderPath) : base(isCollectible: true) + => _resolver = new(folderPath); + + // public Assembly MainAssembly { get; private set; } + + protected override Assembly? Load(AssemblyName assemblyName) + { + var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); + if (assemblyPath != null) + { + var assembly = LoadFromAssemblyPath(assemblyPath); + LoadDependencies(assembly); + return assembly; + } + + return null; + } + + public void LoadDependencies(Assembly assembly) + { + foreach (var reference in assembly.GetReferencedAssemblies()) + { + Load(reference); + } + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/MarmaladeIoCKernelModule.cs b/src/EllieBot/_common/Marmalade/Common/MarmaladeIoCKernelModule.cs new file mode 100644 index 0000000..e55ddff --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/MarmaladeIoCKernelModule.cs @@ -0,0 +1,74 @@ +using DryIoc; +using System.Reflection; +using System.Text.Json; + +namespace EllieBot.Marmalade; + +public interface IIocModule +{ + public string Name { get; } + public void Load(); + public void Unload(); +} + +public sealed class MarmaladeNinjectIocModule : IIocModule, IDisposable +{ + public string Name { get; } + private volatile bool isLoaded = false; + private readonly Dictionary _types; + private readonly IContainer _cont; + + public MarmaladeNinjectIocModule(IContainer cont, Assembly assembly, string name) + { + Name = name; + _cont = cont; + _types = assembly.GetExportedTypes() + .Where(t => t.IsClass) + .Where(t => t.GetCustomAttribute() is not null) + .ToDictionary(x => x, + type => type.GetInterfaces().ToArray()); + } + + public void Load() + { + if (isLoaded) + return; + + foreach (var (type, data) in _types) + { + var attribute = type.GetCustomAttribute()!; + + var reuse = attribute.Lifetime == Lifetime.Singleton + ? Reuse.Singleton + : Reuse.Transient; + + _cont.RegisterMany([type], reuse); + } + + isLoaded = true; + } + + public void Unload() + { + if (!isLoaded) + return; + + foreach (var type in _types.Keys) + { + _cont.Unregister(type); + } + + _types.Clear(); + + // in case the library uses System.Text.Json + var assembly = typeof(JsonSerializerOptions).Assembly; + var updateHandlerType = assembly.GetType("System.Text.Json.JsonSerializerOptionsUpdateHandler"); + var clearCacheMethod = updateHandlerType?.GetMethod("ClearCache", BindingFlags.Static | BindingFlags.Public); + clearCacheMethod?.Invoke(null, [null]); + + isLoaded = false; + } + + public void Dispose() + => _types.Clear(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/MarmaladeLoaderService.cs b/src/EllieBot/_common/Marmalade/Common/MarmaladeLoaderService.cs new file mode 100644 index 0000000..a17e05a --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/MarmaladeLoaderService.cs @@ -0,0 +1,916 @@ +using Discord.Commands.Builders; +using DryIoc; +using Microsoft.Extensions.DependencyInjection; +using Ellie.Common.Marmalade; +using Ellie.Marmalade.Adapters; +using EllieBot.Common.ModuleBehaviors; +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace EllieBot.Marmalade; + +// ReSharper disable RedundantAssignment +public sealed class MarmaladeLoaderService : IMarmaladeLoaderService, IReadyExecutor, IEService +{ + private readonly CommandService _cmdService; + private readonly IBehaviorHandler _behHandler; + private readonly IPubSub _pubSub; + private readonly IMarmaladeConfigService _marmaladeConfig; + private readonly IContainer _cont; + + private readonly ConcurrentDictionary _resolved = new(); + private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); + + private readonly TypedKey _loadKey = new("marmalade:load"); + private readonly TypedKey _unloadKey = new("marmalade:unload"); + + private readonly TypedKey _stringsReload = new("marmalade:reload_strings"); + + private const string BASE_DIR = "data/marmalades"; + + public MarmaladeLoaderService( + CommandService cmdService, + IContainer cont, + IBehaviorHandler behHandler, + IPubSub pubSub, + IMarmaladeConfigService marmaladeConfig) + { + _cmdService = cmdService; + _behHandler = behHandler; + _pubSub = pubSub; + _marmaladeConfig = marmaladeConfig; + _cont = cont; + + // has to be done this way to support this feature on sharded bots + _pubSub.Sub(_loadKey, async name => await InternalLoadAsync(name)); + _pubSub.Sub(_unloadKey, async name => await InternalUnloadAsync(name)); + + _pubSub.Sub(_stringsReload, async _ => await ReloadStringsInternal()); + } + + public IReadOnlyCollection GetAllMarmalades() + { + if (!Directory.Exists(BASE_DIR)) + return Array.Empty(); + + return Directory.GetDirectories(BASE_DIR) + .Select(x => Path.GetRelativePath(BASE_DIR, x)) + .ToArray(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public IReadOnlyCollection GetLoadedMarmalades(CultureInfo? culture) + { + var toReturn = new List(_resolved.Count); + foreach (var (name, resolvedData) in _resolved) + { + var canaries = new List(resolvedData.CanaryInfos.Count); + + foreach (var canaryInfos in resolvedData.CanaryInfos.Concat(resolvedData.CanaryInfos.SelectMany(x => x.Subcanaries))) + { + var commands = new List(); + + foreach (var command in canaryInfos.Commands) + { + commands.Add(new CanaryCommandStats(command.Aliases.First())); + } + + canaries.Add(new CanaryStats(canaryInfos.Name, canaryInfos.Instance.Prefix, commands)); + } + + toReturn.Add(new MarmaladeStats(name, resolvedData.Strings.GetDescription(culture), canaries)); + } + + return toReturn; + } + + public async Task OnReadyAsync() + { + foreach (var name in _marmaladeConfig.GetLoadedMarmalades()) + { + var result = await InternalLoadAsync(name); + if (result != MarmaladeLoadResult.Success) + Log.Warning("Unable to load '{MarmaladeName}' marmalade", name); + else + Log.Warning("Loaded marmalade '{MarmaladeName}'", name); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public async Task LoadMarmaladeAsync(string marmaladeName) + { + // try loading on this shard first to see if it works + var res = await InternalLoadAsync(marmaladeName); + if (res == MarmaladeLoadResult.Success) + { + // if it does publish it so that other shards can load the marmalade too + // this method will be ran twice on this shard but it doesn't matter as + // the second attempt will be ignored + await _pubSub.Pub(_loadKey, marmaladeName); + } + + return res; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public async Task UnloadMarmaladeAsync(string marmaladeName) + { + var res = await InternalUnloadAsync(marmaladeName); + if (res == MarmaladeUnloadResult.Success) + { + await _pubSub.Pub(_unloadKey, marmaladeName); + } + + return res; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public string[] GetCommandExampleArgs(string marmaladeName, string commandName, CultureInfo culture) + { + if (!_resolved.TryGetValue(marmaladeName, out var data)) + return Array.Empty(); + + return data.Strings.GetCommandStrings(commandName, culture).Args + ?? data.CanaryInfos + .SelectMany(x => x.Commands) + .FirstOrDefault(x => x.Aliases.Any(alias + => alias.Equals(commandName, StringComparison.InvariantCultureIgnoreCase))) + ?.OptionalStrings + .Args + ?? [string.Empty]; + } + + public Task ReloadStrings() + => _pubSub.Pub(_stringsReload, true); + + [MethodImpl(MethodImplOptions.NoInlining)] + private void ReloadStringsSync() + { + foreach (var resolved in _resolved.Values) + { + resolved.Strings.Reload(); + } + } + + private async Task ReloadStringsInternal() + { + await _lock.WaitAsync(); + try + { + ReloadStringsSync(); + } + finally + { + _lock.Release(); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public string GetCommandDescription(string marmaladeName, string commandName, CultureInfo culture) + { + if (!_resolved.TryGetValue(marmaladeName, out var data)) + return string.Empty; + + return data.Strings.GetCommandStrings(commandName, culture).Desc + ?? data.CanaryInfos + .SelectMany(x => x.Commands) + .FirstOrDefault(x => x.Aliases.Any(alias + => alias.Equals(commandName, StringComparison.InvariantCultureIgnoreCase))) + ?.OptionalStrings + .Desc + ?? string.Empty; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async ValueTask InternalLoadAsync(string name) + { + if (_resolved.ContainsKey(name)) + return MarmaladeLoadResult.AlreadyLoaded; + + var safeName = Uri.EscapeDataString(name); + + await _lock.WaitAsync(); + try + { + if (LoadAssemblyInternal(safeName, + out var ctx, + out var canaryData, + out var iocModule, + out var strings, + out var typeReaders)) + { + var moduleInfos = new List(); + + LoadTypeReadersInternal(typeReaders); + + foreach (var point in canaryData) + { + try + { + // initialize canary and subcanaries + await point.Instance.InitializeAsync(); + foreach (var sub in point.Subcanaries) + { + await sub.Instance.InitializeAsync(); + } + + var module = await LoadModuleInternalAsync(name, point, strings, iocModule); + moduleInfos.Add(module); + } + catch (Exception ex) + { + Log.Warning(ex, + "Error loading canary {CanaryName}", + point.Name); + } + } + + var execs = GetExecsInternal(canaryData, strings); + await _behHandler.AddRangeAsync(execs); + + _resolved[name] = new(LoadContext: ctx, + ModuleInfos: moduleInfos.ToImmutableArray(), + CanaryInfos: canaryData.ToImmutableArray(), + strings, + typeReaders, + execs) + { + IocModule = iocModule + }; + + + _marmaladeConfig.AddLoadedMarmalade(safeName); + return MarmaladeLoadResult.Success; + } + + return MarmaladeLoadResult.Empty; + } + catch (Exception ex) when (ex is FileNotFoundException or BadImageFormatException) + { + return MarmaladeLoadResult.NotFound; + } + catch (Exception ex) + { + Log.Error(ex, "An error occurred loading a marmalade"); + return MarmaladeLoadResult.UnknownError; + } + finally + { + _lock.Release(); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private IReadOnlyCollection GetExecsInternal( + IReadOnlyCollection canaryData, + IMarmaladeStrings strings) + { + var behs = new List(); + foreach (var canary in canaryData) + { + behs.Add(new BehaviorAdapter(new(canary.Instance), strings, _cont)); + + foreach (var sub in canary.Subcanaries) + { + behs.Add(new BehaviorAdapter(new(sub.Instance), strings, _cont)); + } + } + + + return behs; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void LoadTypeReadersInternal(Dictionary typeReaders) + { + var notAddedTypeReaders = new List(); + foreach (var (type, typeReader) in typeReaders) + { + // if type reader for this type already exists, it will not be replaced + if (_cmdService.TypeReaders.Contains(type)) + { + notAddedTypeReaders.Add(type); + continue; + } + + _cmdService.AddTypeReader(type, typeReader); + } + + // remove the ones that were not added + // to prevent them from being unloaded later + // as they didn't come from this marmalade + foreach (var toRemove in notAddedTypeReaders) + { + typeReaders.Remove(toRemove); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private bool LoadAssemblyInternal( + string safeName, + [NotNullWhen(true)] out WeakReference? ctxWr, + [NotNullWhen(true)] out IReadOnlyCollection? canaryData, + [NotNullWhen(true)] out IIocModule? iocModule, + out IMarmaladeStrings strings, + out Dictionary typeReaders) + { + ctxWr = null; + canaryData = null; + + var path = Path.GetFullPath($"{BASE_DIR}/{safeName}/{safeName}.dll"); + var dir = Path.GetFullPath($"{BASE_DIR}/{safeName}"); + + if (!Directory.Exists(dir)) + throw new DirectoryNotFoundException($"Marmalade folder not found: {dir}"); + + if (!File.Exists(path)) + throw new FileNotFoundException($"Marmalade dll not found: {path}"); + + strings = MarmaladeStrings.CreateDefault(dir); + var ctx = new MarmaladeAssemblyLoadContext(Path.GetDirectoryName(path)!); + var a = ctx.LoadFromAssemblyPath(Path.GetFullPath(path)); + ctx.LoadDependencies(a); + + // load services + iocModule = new MarmaladeNinjectIocModule(_cont, a, safeName); + iocModule.Load(); + + var sis = LoadCanariesFromAssembly(safeName, a); + typeReaders = LoadTypeReadersFromAssembly(a, strings); + + if (sis.Count == 0) + { + iocModule.Unload(); + return false; + } + + ctxWr = new(ctx); + canaryData = sis; + + return true; + } + + private static readonly Type _paramParserType = typeof(ParamParser<>); + + [MethodImpl(MethodImplOptions.NoInlining)] + private Dictionary LoadTypeReadersFromAssembly( + Assembly assembly, + IMarmaladeStrings strings) + { + var paramParsers = assembly.GetExportedTypes() + .Where(x => x.IsClass + && !x.IsAbstract + && x.BaseType is not null + && x.BaseType.IsGenericType + && x.BaseType.GetGenericTypeDefinition() == _paramParserType); + + var typeReaders = new Dictionary(); + foreach (var parserType in paramParsers) + { + var parserObj = ActivatorUtilities.CreateInstance(_cont, parserType); + + var targetType = parserType.BaseType!.GetGenericArguments()[0]; + var typeReaderInstance = (TypeReader)Activator.CreateInstance( + typeof(ParamParserAdapter<>).MakeGenericType(targetType), + args: [parserObj, strings, _cont])!; + + typeReaders.Add(targetType, typeReaderInstance); + } + + return typeReaders; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async Task LoadModuleInternalAsync( + string marmaladeName, + CanaryInfo canaryInfo, + IMarmaladeStrings strings, + IIocModule services) + { + var module = await _cmdService.CreateModuleAsync(canaryInfo.Instance.Prefix, + CreateModuleFactory(marmaladeName, canaryInfo, strings, services)); + + return module; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private Action CreateModuleFactory( + string marmaladeName, + CanaryInfo canaryInfo, + IMarmaladeStrings strings, + IIocModule iocModule) + => mb => + { + var m = mb.WithName(canaryInfo.Name); + + foreach (var f in canaryInfo.Filters) + { + m.AddPrecondition(new FilterAdapter(f, strings)); + } + + foreach (var cmd in canaryInfo.Commands) + { + m.AddCommand(cmd.Aliases.First(), + CreateCallback(cmd.ContextType, + new(canaryInfo), + new(cmd), + strings), + CreateCommandFactory(marmaladeName, cmd, strings)); + } + + foreach (var subInfo in canaryInfo.Subcanaries) + m.AddModule(subInfo.Instance.Prefix, CreateModuleFactory(marmaladeName, subInfo, strings, iocModule)); + }; + + private static readonly RequireContextAttribute _reqGuild = new RequireContextAttribute(ContextType.Guild); + private static readonly RequireContextAttribute _reqDm = new RequireContextAttribute(ContextType.DM); + + private Action CreateCommandFactory(string marmaladeName, CanaryCommandData cmd, IMarmaladeStrings strings) + => (cb) => + { + cb.AddAliases(cmd.Aliases.Skip(1).ToArray()); + + if (cmd.ContextType == CommandContextType.Guild) + cb.AddPrecondition(_reqGuild); + else if (cmd.ContextType == CommandContextType.Dm) + cb.AddPrecondition(_reqDm); + + foreach (var f in cmd.Filters) + cb.AddPrecondition(new FilterAdapter(f, strings)); + + foreach (var ubp in cmd.UserAndBotPerms) + { + if (ubp is user_permAttribute up) + { + if (up.GuildPerm is { } gp) + cb.AddPrecondition(new UserPermAttribute(gp)); + else if (up.ChannelPerm is { } cp) + cb.AddPrecondition(new UserPermAttribute(cp)); + } + else if (ubp is bot_permAttribute bp) + { + if (bp.GuildPerm is { } gp) + cb.AddPrecondition(new BotPermAttribute(gp)); + else if (bp.ChannelPerm is { } cp) + cb.AddPrecondition(new BotPermAttribute(cp)); + } + else if (ubp is bot_owner_onlyAttribute) + { + cb.AddPrecondition(new OwnerOnlyAttribute()); + } + } + + cb.WithPriority(cmd.Priority); + + // using summary to save method name + // method name is used to retrieve desc/usages + cb.WithRemarks($"marmalade///{marmaladeName}"); + cb.WithSummary(cmd.MethodInfo.Name.ToLowerInvariant()); + + foreach (var param in cmd.Parameters) + { + cb.AddParameter(param.Name, param.Type, CreateParamFactory(param)); + } + }; + + private Action CreateParamFactory(ParamData paramData) + => (pb) => + { + pb.WithIsMultiple(paramData.IsParams) + .WithIsOptional(paramData.IsOptional) + .WithIsRemainder(paramData.IsLeftover); + + if (paramData.IsOptional) + pb.WithDefault(paramData.DefaultValue); + }; + + [MethodImpl(MethodImplOptions.NoInlining)] + private Func CreateCallback( + CommandContextType contextType, + WeakReference canaryDataWr, + WeakReference canaryCommandDataWr, + IMarmaladeStrings strings) + => async ( + context, + parameters, + svcs, + _) => + { + if (!canaryCommandDataWr.TryGetTarget(out var cmdData) + || !canaryDataWr.TryGetTarget(out var canaryData)) + { + Log.Warning("Attempted to run an unloaded canary's command"); + return; + } + + var paramObjs = ParamObjs(contextType, cmdData, parameters, context, svcs, _cont, strings); + + try + { + var methodInfo = cmdData.MethodInfo; + if (methodInfo.ReturnType == typeof(Task) + || (methodInfo.ReturnType.IsGenericType + && methodInfo.ReturnType.GetGenericTypeDefinition() == typeof(Task<>))) + { + await (Task)methodInfo.Invoke(canaryData.Instance, paramObjs)!; + } + else if (methodInfo.ReturnType == typeof(ValueTask)) + { + await ((ValueTask)methodInfo.Invoke(canaryData.Instance, paramObjs)!).AsTask(); + } + else // if (methodInfo.ReturnType == typeof(void)) + { + methodInfo.Invoke(canaryData.Instance, paramObjs); + } + } + finally + { + paramObjs = null; + cmdData = null; + + canaryData = null; + } + }; + + [MethodImpl(MethodImplOptions.NoInlining)] + private static object[] ParamObjs( + CommandContextType contextType, + CanaryCommandData cmdData, + object[] parameters, + ICommandContext context, + IServiceProvider svcs, + IServiceProvider svcProvider, + IMarmaladeStrings strings) + { + var extraParams = contextType == CommandContextType.Unspecified ? 0 : 1; + extraParams += cmdData.InjectedParams.Count; + + var paramObjs = new object[parameters.Length + extraParams]; + + var startAt = 0; + if (contextType != CommandContextType.Unspecified) + { + paramObjs[0] = ContextAdapterFactory.CreateNew(context, strings, svcs); + + startAt = 1; + } + + for (var i = 0; i < cmdData.InjectedParams.Count; i++) + { + var svc = svcProvider.GetService(cmdData.InjectedParams[i]); + if (svc is null) + { + throw new ArgumentException($"Cannot inject a service of type {cmdData.InjectedParams[i]}"); + } + + paramObjs[i + startAt] = svc; + + svc = null; + } + + startAt += cmdData.InjectedParams.Count; + + for (var i = 0; i < parameters.Length; i++) + paramObjs[startAt + i] = parameters[i]; + + return paramObjs; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async Task InternalUnloadAsync(string name) + { + if (!_resolved.Remove(name, out var lsi)) + return MarmaladeUnloadResult.NotLoaded; + + await _lock.WaitAsync(); + try + { + UnloadTypeReaders(lsi.TypeReaders); + + foreach (var mi in lsi.ModuleInfos) + { + await _cmdService.RemoveModuleAsync(mi); + } + + await _behHandler.RemoveRangeAsync(lsi.Execs); + + await DisposeCanaryInstances(lsi); + + var lc = lsi.LoadContext; + var km = lsi.IocModule; + + lsi.IocModule.Unload(); + lsi.IocModule = null!; + + if (km is IDisposable d) + d.Dispose(); + + lsi = null; + + _marmaladeConfig.RemoveLoadedMarmalade(name); + return UnloadInternal(lc) + ? MarmaladeUnloadResult.Success + : MarmaladeUnloadResult.PossiblyUnable; + } + finally + { + _lock.Release(); + } + } + + private void UnloadTypeReaders(Dictionary valueTypeReaders) + { + foreach (var tr in valueTypeReaders) + { + _cmdService.TryRemoveTypeReader(tr.Key, false, out _); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private async Task DisposeCanaryInstances(ResolvedMarmalade marmalade) + { + foreach (var si in marmalade.CanaryInfos) + { + try + { + await si.Instance.DisposeAsync(); + foreach (var sub in si.Subcanaries) + { + await sub.Instance.DisposeAsync(); + } + } + catch (Exception ex) + { + Log.Warning(ex, + "Failed cleanup of Canary {CanaryName}. This marmalade might not unload correctly", + si.Instance.Name); + } + } + + // marmalades = null; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private bool UnloadInternal(WeakReference lsi) + { + UnloadContext(lsi); + GcCleanup(); + + return !lsi.TryGetTarget(out _); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void UnloadContext(WeakReference lsiLoadContext) + { + if (lsiLoadContext.TryGetTarget(out var ctx)) + { + ctx.Unload(); + } + } + + private void GcCleanup() + { + // cleanup + for (var i = 0; i < 10; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.WaitForFullGCComplete(); + GC.Collect(); + } + } + + private static readonly Type _canaryType = typeof(Canary); + + // [MethodImpl(MethodImplOptions.NoInlining)] + // private MarmaladeIoCKernelModule LoadMarmaladeServicesInternal(string name, Assembly a) + // => new MarmaladeIoCKernelModule(name, a); + + + [MethodImpl(MethodImplOptions.NoInlining)] + public IReadOnlyCollection LoadCanariesFromAssembly(string name, Assembly a) + { + // find all types in teh assembly + var types = a.GetExportedTypes(); + // canary is always a public non abstract class + var classes = types.Where(static x => x.IsClass + && (x.IsNestedPublic || x.IsPublic) + && !x.IsAbstract + && x.BaseType == _canaryType + && (x.DeclaringType is null || x.DeclaringType.IsAssignableTo(_canaryType))) + .ToList(); + + var topModules = new Dictionary(); + + foreach (var cl in classes) + { + if (cl.DeclaringType is not null) + continue; + + // get module data, and add it to the topModules dictionary + var module = GetModuleData(cl); + topModules.Add(cl, module); + } + + foreach (var c in classes) + { + if (c.DeclaringType is not Type dt) + continue; + + // if there is no top level module which this module is a child of + // just print a warning and skip it + if (!topModules.TryGetValue(dt, out var parentData)) + { + Log.Warning("Can't load submodule {SubName} because parent module {Name} does not exist", + c.Name, + dt.Name); + continue; + } + + GetModuleData(c, parentData); + } + + return topModules.Values.ToArray(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private CanaryInfo GetModuleData(Type type, CanaryInfo? parentData = null) + { + var filters = type.GetCustomAttributes(true) + .ToArray(); + + var instance = (Canary)ActivatorUtilities.CreateInstance(_cont, type); + + var module = new CanaryInfo(instance.Name, + parentData, + instance, + GetCommands(instance, type), + filters); + + if (parentData is not null) + parentData.Subcanaries.Add(module); + + return module; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private IReadOnlyCollection GetCommands(Canary instance, Type type) + { + var methodInfos = type + .GetMethods(BindingFlags.Instance + | BindingFlags.DeclaredOnly + | BindingFlags.Public) + .Where(static x => + { + if (x.GetCustomAttribute(true) is null) + return false; + + if (x.ReturnType.IsGenericType) + { + var genericType = x.ReturnType.GetGenericTypeDefinition(); + if (genericType == typeof(Task<>)) + return true; + + // if (genericType == typeof(ValueTask<>)) + // return true; + + Log.Warning("Method {MethodName} has an invalid return type: {ReturnType}", + x.Name, + x.ReturnType); + + return false; + } + + var succ = x.ReturnType == typeof(Task) + || x.ReturnType == typeof(ValueTask) + || x.ReturnType == typeof(void); + + if (!succ) + { + Log.Warning("Method {MethodName} has an invalid return type: {ReturnType}", + x.Name, + x.ReturnType); + } + + return succ; + }); + + + var cmds = new List(); + foreach (var method in methodInfos) + { + var filters = method.GetCustomAttributes(true).ToArray(); + var userAndBotPerms = method.GetCustomAttributes(true) + .ToArray(); + var prio = method.GetCustomAttribute(true)?.Priority ?? 0; + + var paramInfos = method.GetParameters(); + var cmdParams = new List(); + var diParams = new List(); + var cmdContext = CommandContextType.Unspecified; + var canInject = false; + for (var paramCounter = 0; paramCounter < paramInfos.Length; paramCounter++) + { + var pi = paramInfos[paramCounter]; + + var paramName = pi.Name ?? "unnamed"; + var isContext = paramCounter == 0 && pi.ParameterType.IsAssignableTo(typeof(AnyContext)); + + var leftoverAttribute = pi.GetCustomAttribute(true); + var hasDefaultValue = pi.HasDefaultValue; + var defaultValue = pi.DefaultValue; + var isLeftover = leftoverAttribute != null; + var isParams = pi.GetCustomAttribute() is not null; + var paramType = pi.ParameterType; + var isInjected = pi.GetCustomAttribute(true) is not null; + + if (isContext) + { + if (hasDefaultValue || leftoverAttribute != null || isParams) + throw new ArgumentException( + "IContext parameter cannot be optional, leftover, constant or params. " + + GetErrorPath(method, pi)); + + if (paramCounter != 0) + throw new ArgumentException($"IContext parameter has to be first. {GetErrorPath(method, pi)}"); + + canInject = true; + + if (paramType.IsAssignableTo(typeof(GuildContext))) + cmdContext = CommandContextType.Guild; + else if (paramType.IsAssignableTo(typeof(DmContext))) + cmdContext = CommandContextType.Dm; + else + cmdContext = CommandContextType.Any; + + continue; + } + + if (isInjected) + { + if (!canInject && paramCounter != 0) + throw new ArgumentException($"Parameters marked as [Injected] have to come after IContext"); + + canInject = true; + + diParams.Add(paramType); + continue; + } + + canInject = false; + + if (isParams) + { + if (hasDefaultValue) + throw new NotSupportedException("Params can't have const values at the moment. " + + GetErrorPath(method, pi)); + // if it's params, it means it's an array, and i only need a parser for the actual type, + // as the parser will run on each array element, it can't be null + paramType = paramType.GetElementType()!; + } + + // leftover can only be the last parameter. + if (isLeftover && paramCounter != paramInfos.Length - 1) + { + var path = GetErrorPath(method, pi); + Log.Error("Only one parameter can be marked [Leftover] and it has to be the last one. {Path} ", + path); + throw new ArgumentException("Leftover attribute error."); + } + + cmdParams.Add(new ParamData(paramType, paramName, hasDefaultValue, defaultValue, isLeftover, isParams)); + } + + + var cmdAttribute = method.GetCustomAttribute(true)!; + var aliases = cmdAttribute.Aliases; + if (aliases.Length == 0) + aliases = [method.Name.ToLowerInvariant()]; + + cmds.Add(new( + aliases, + method, + instance, + filters, + userAndBotPerms, + cmdContext, + diParams, + cmdParams, + new(cmdAttribute.desc, cmdAttribute.args), + prio + )); + } + + return cmds; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private string GetErrorPath(MethodInfo m, System.Reflection.ParameterInfo pi) + => $@"Module: {m.DeclaringType?.Name} +Command: {m.Name} +ParamName: {pi.Name} +ParamType: {pi.ParameterType.Name}"; +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Models/CanaryCommandData.cs b/src/EllieBot/_common/Marmalade/Common/Models/CanaryCommandData.cs new file mode 100644 index 0000000..8244576 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Models/CanaryCommandData.cs @@ -0,0 +1,44 @@ +using EllieBot.Marmalade; +using System.Reflection; + +namespace EllieBot.Marmalade; + +public sealed class CanaryCommandData +{ + public CanaryCommandData( + IReadOnlyCollection aliases, + MethodInfo methodInfo, + Canary module, + FilterAttribute[] filters, + MarmaladePermAttribute[] userAndBotPerms, + CommandContextType contextType, + IReadOnlyList injectedParams, + IReadOnlyList parameters, + CommandStrings strings, + int priority) + { + Aliases = aliases; + MethodInfo = methodInfo; + Module = module; + Filters = filters; + UserAndBotPerms = userAndBotPerms; + ContextType = contextType; + InjectedParams = injectedParams; + Parameters = parameters; + Priority = priority; + OptionalStrings = strings; + } + + public MarmaladePermAttribute[] UserAndBotPerms { get; set; } + + public CommandStrings OptionalStrings { get; set; } + + public IReadOnlyCollection Aliases { get; } + public MethodInfo MethodInfo { get; set; } + public Canary Module { get; set; } + public FilterAttribute[] Filters { get; set; } + public CommandContextType ContextType { get; } + public IReadOnlyList InjectedParams { get; } + public IReadOnlyList Parameters { get; } + public int Priority { get; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Models/CanaryData.cs b/src/EllieBot/_common/Marmalade/Common/Models/CanaryData.cs new file mode 100644 index 0000000..4ccb93d --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Models/CanaryData.cs @@ -0,0 +1,11 @@ +namespace EllieBot.Marmalade; + +public sealed record CanaryInfo( + string Name, + CanaryInfo? Parent, + Canary Instance, + IReadOnlyCollection Commands, + IReadOnlyCollection Filters) +{ + public List Subcanaries { get; set; } = new(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Models/ParamData.cs b/src/EllieBot/_common/Marmalade/Common/Models/ParamData.cs new file mode 100644 index 0000000..5b3e222 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Models/ParamData.cs @@ -0,0 +1,10 @@ +namespace EllieBot.Marmalade; + +public sealed record ParamData( + Type Type, + string Name, + bool IsOptional, + object? DefaultValue, + bool IsLeftover, + bool IsParams +); \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/Common/Models/ResolvedMarmalade.cs b/src/EllieBot/_common/Marmalade/Common/Models/ResolvedMarmalade.cs new file mode 100644 index 0000000..7441d90 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/Common/Models/ResolvedMarmalade.cs @@ -0,0 +1,15 @@ +using System.Collections.Immutable; + +namespace EllieBot.Marmalade; + +public sealed record ResolvedMarmalade( + WeakReference LoadContext, + IImmutableList ModuleInfos, + IImmutableList CanaryInfos, + IMarmaladeStrings Strings, + Dictionary TypeReaders, + IReadOnlyCollection Execs +) +{ + public required IIocModule IocModule { get; set; } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/IMarmaladeLoaderService.cs b/src/EllieBot/_common/Marmalade/IMarmaladeLoaderService.cs new file mode 100644 index 0000000..3a17339 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/IMarmaladeLoaderService.cs @@ -0,0 +1,24 @@ +using System.Globalization; + +namespace Ellie.Common.Marmalade; + +public interface IMarmaladeLoaderService +{ + Task LoadMarmaladeAsync(string marmaladeName); + Task UnloadMarmaladeAsync(string marmaladeName); + string GetCommandDescription(string marmaladeName, string commandName, CultureInfo culture); + string[] GetCommandExampleArgs(string marmaladeName, string commandName, CultureInfo culture); + Task ReloadStrings(); + IReadOnlyCollection GetAllMarmalades(); + IReadOnlyCollection GetLoadedMarmalades(CultureInfo? cultureInfo = null); +} + +public sealed record MarmaladeStats(string Name, + string? Description, + IReadOnlyCollection Canaries); + +public sealed record CanaryStats(string Name, + string? Prefix, + IReadOnlyCollection Commands); + +public sealed record CanaryCommandStats(string Name); \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/MarmaladeLoadResult.cs b/src/EllieBot/_common/Marmalade/MarmaladeLoadResult.cs new file mode 100644 index 0000000..826f4c5 --- /dev/null +++ b/src/EllieBot/_common/Marmalade/MarmaladeLoadResult.cs @@ -0,0 +1,10 @@ +namespace Ellie.Common.Marmalade; + +public enum MarmaladeLoadResult +{ + Success, + NotFound, + AlreadyLoaded, + Empty, + UnknownError, +} \ No newline at end of file diff --git a/src/EllieBot/_common/Marmalade/MarmaladeUnloadResult.cs b/src/EllieBot/_common/Marmalade/MarmaladeUnloadResult.cs new file mode 100644 index 0000000..f07569e --- /dev/null +++ b/src/EllieBot/_common/Marmalade/MarmaladeUnloadResult.cs @@ -0,0 +1,9 @@ +namespace Ellie.Common.Marmalade; + +public enum MarmaladeUnloadResult +{ + Success, + NotLoaded, + PossiblyUnable, + NotFound, +} \ 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..4ceeaaf --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IExecNoCommand.cs @@ -0,0 +1,19 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// Executed if no command was found for this message +/// +public interface IExecNoCommand : IBehavior +{ + /// + /// Executed at the end of the lifecycle if no command was found + /// → + /// → + /// → + /// [ | **] + /// + /// + /// + /// A task representing completion + 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..7b37a24 --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IExecOnMessage.cs @@ -0,0 +1,21 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// Implemented by modules to handle non-bot messages received +/// +public interface IExecOnMessage : IBehavior +{ + int Priority { get; } + + /// + /// Ran after a non-bot message was received + /// ** → + /// → + /// → + /// [ | ] + /// + /// Guild where the message was sent + /// The message that was received + /// Whether further processing of this message should be blocked + Task 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..ccb949c --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IExecPostCommand.cs @@ -0,0 +1,22 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// 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 marmalade system +/// +public interface IExecPostCommand : IBehavior +{ + /// + /// Executed after a command was successfully executed + /// → + /// → + /// → + /// [** | ] + /// + /// Command context + /// Module name + /// Command name + /// A task representing completion + 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..438cbd0 --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IExecPreCommand.cs @@ -0,0 +1,25 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// This interface's method is executed after a command was found but before it was executed. +/// Able to block further processing of a command +/// +public interface IExecPreCommand : IBehavior +{ + public int Priority { get; } + + /// + /// + /// Ran after a command was found but before execution. + /// + /// → + /// → + /// ** → + /// [ | ] + /// + /// Command context + /// Name of the module + /// Command info + /// Whether further processing of the command is blocked + Task 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..957e659 --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IInputTransformer.cs @@ -0,0 +1,25 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// Implemented by services which may transform input before a command is searched for +/// +public interface IInputTransformer : IBehavior +{ + /// + /// Ran after a non-bot message was received + /// -> + /// ** -> + /// -> + /// [ OR ] + /// + /// Guild + /// Channel in which the message was sent + /// User who sent the message + /// Content of the message + /// New input, if any, otherwise null + Task 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..f0a200b --- /dev/null +++ b/src/EllieBot/_common/ModuleBehaviors/IReadyExecutor.cs @@ -0,0 +1,13 @@ +namespace EllieBot.Common.ModuleBehaviors; + +/// +/// All services which need to execute something after +/// the bot is ready should implement this interface +/// +public interface IReadyExecutor : IBehavior +{ + /// + /// Executed when bot is ready + /// + public Task OnReadyAsync(); +} \ No newline at end of file diff --git a/src/EllieBot/_common/NinjectKernelExtensions.cs b/src/EllieBot/_common/NinjectKernelExtensions.cs new file mode 100644 index 0000000..36b4fa8 --- /dev/null +++ b/src/EllieBot/_common/NinjectKernelExtensions.cs @@ -0,0 +1,51 @@ +using DryIoc; + +namespace EllieBot.Extensions; + +public static class DryIocExtensions +{ + public static IContainer AddSingleton(this IContainer container) + where TImpl : TSvc + { + container.Register(Reuse.Singleton); + + return container; + } + + public static IContainer AddSingleton(this IContainer container, TImpl obj) + where TImpl : TSvc + { + container.RegisterInstance(obj); + + return container; + } + + public static IContainer AddSingleton(this IContainer container, Func factory) + where TImpl : TSvc + { + container.RegisterDelegate(factory, Reuse.Singleton); + + return container; + } + + public static IContainer AddSingleton(this IContainer container) + { + container.Register(Reuse.Singleton); + + return container; + } + + public static IContainer AddSingleton(this IContainer container, TImpl obj) + { + container.RegisterInstance(obj); + + return container; + } + + public static IContainer AddSingleton(this IContainer container, Func factory) + { + container.RegisterDelegate(factory); + + return container; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/OldCreds.cs b/src/EllieBot/_common/OldCreds.cs new file mode 100644 index 0000000..b06e15a --- /dev/null +++ b/src/EllieBot/_common/OldCreds.cs @@ -0,0 +1,45 @@ +#nullable disable +namespace EllieBot.Common; + +public class OldCreds +{ + public string Token { get; set; } = string.Empty; + public ulong[] OwnerIds { get; set; } = new ulong[1]; + public string LoLApiKey { get; set; } = string.Empty; + public string GoogleApiKey { get; set; } = string.Empty; + public string MashapeKey { get; set; } = string.Empty; + public string OsuApiKey { get; set; } = string.Empty; + public string CleverbotApiKey { get; set; } = string.Empty; + public string CarbonKey { get; set; } = string.Empty; + public int TotalShards { get; set; } = 1; + public string PatreonAccessToken { get; set; } = string.Empty; + public string PatreonCampaignId { get; set; } = "334038"; + public RestartConfig RestartCommand { get; set; } + + public string ShardRunCommand { get; set; } = string.Empty; + public string ShardRunArguments { get; set; } = string.Empty; + public int? ShardRunPort { get; set; } + public string MiningProxyUrl { get; set; } = string.Empty; + public string MiningProxyCreds { get; set; } = string.Empty; + + public string BotListToken { get; set; } = string.Empty; + public string TwitchClientId { get; set; } = string.Empty; + public string VotesToken { get; set; } = string.Empty; + public string VotesUrl { get; set; } = string.Empty; + public string RedisOptions { get; set; } = string.Empty; + public string LocationIqApiKey { get; set; } = string.Empty; + public string TimezoneDbApiKey { get; set; } = string.Empty; + public string CoinmarketcapApiKey { get; set; } = string.Empty; + + public class RestartConfig + { + public string Cmd { get; set; } + public string Args { get; set; } + + public RestartConfig(string cmd, string args) + { + Cmd = cmd; + Args = args; + } + } +} \ 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(string[]? args) + where T : IEllieCommandOptions, new() + => ParseFrom(new T(), args).Item1; + + public static (T, bool) ParseFrom(T options, string[]? args) + where T : IEllieCommandOptions + { + using var p = new Parser(x => + { + x.HelpWriter = null; + }); + var res = p.ParseArguments(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..9aa8a46 --- /dev/null +++ b/src/EllieBot/_common/Patronage/FeatureLimitKey.cs @@ -0,0 +1,7 @@ +namespace EllieBot.Modules.Patronage; + +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/FeatureQuotaStats.cs b/src/EllieBot/_common/Patronage/FeatureQuotaStats.cs new file mode 100644 index 0000000..02db5d3 --- /dev/null +++ b/src/EllieBot/_common/Patronage/FeatureQuotaStats.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Patronage; + +public readonly struct FeatureQuotaStats +{ + public (uint Cur, uint Max) Hourly { get; init; } + public (uint Cur, uint Max) Daily { get; init; } + public (uint Cur, uint Max) Monthly { 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..77fed4b --- /dev/null +++ b/src/EllieBot/_common/Patronage/IPatronageService.cs @@ -0,0 +1,56 @@ +using EllieBot.Db.Models; +using OneOf; + +namespace EllieBot.Modules.Patronage; + +/// +/// Manages patrons and provides access to their data +/// +public interface IPatronageService +{ + /// + /// Called when the payment is made. + /// Either as a single payment for that patron, + /// or as a recurring monthly donation. + /// + public event Func OnNewPatronPayment; + + /// + /// Called when the patron changes the pledge amount + /// (Patron old, Patron new) => Task + /// + public event Func OnPatronUpdated; + + /// + /// Called when the patron refunds the purchase or it's marked as fraud + /// + public event Func OnPatronRefunded; + + /// + /// Gets a Patron with the specified userId + /// + /// UserId for which to get the patron data for. + /// A patron with the specifeid userId + public Task GetPatronAsync(ulong userId); + + /// + /// Gets the quota statistic for the user/patron specified by the userId + /// + /// UserId of the user for which to get the quota statistic for + /// Quota stats for the specified user + Task GetUserQuotaStatistic(ulong userId); + + + Task TryGetFeatureLimitAsync(FeatureLimitKey key, ulong userId, int? defaultValue); + + ValueTask> TryIncrementQuotaCounterAsync( + ulong userId, + bool isSelf, + FeatureType featureType, + string featureName, + uint? maybeHourly, + uint? maybeDaily, + uint? maybeMonthly); + + PatronConfigData GetConfig(); +} \ 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; + +/// +/// Services implementing this interface are handling pledges/subscriptions/payments coming +/// from a payment platform. +/// +public interface ISubscriptionHandler +{ + /// + /// Get Current patrons in batches. + /// This will only return patrons who have their discord account connected + /// + /// Batched patrons + public IAsyncEnumerable> 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..a7c9d97 --- /dev/null +++ b/src/EllieBot/_common/Patronage/Patron.cs @@ -0,0 +1,38 @@ +namespace EllieBot.Modules.Patronage; + +public readonly struct Patron +{ + /// + /// Unique id assigned to this patron by the payment platform + /// + public string UniquePlatformUserId { get; init; } + + /// + /// Discord UserId to which this is connected to + /// + public ulong UserId { get; init; } + + /// + /// Amount the Patron is currently pledging or paid + /// + public int Amount { get; init; } + + /// + /// Current Tier of the patron + /// (do not question it in consumer classes, as the calculation should be always internal and may change) + /// + public PatronTier Tier { get; init; } + + /// + /// When was the last time this was paid + /// + public DateTime PaidAt { get; init; } + + /// + /// After which date does the user's Patronage benefit end + /// + 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..09ed100 --- /dev/null +++ b/src/EllieBot/_common/Patronage/PatronConfigData.cs @@ -0,0 +1,37 @@ +using EllieBot.Common.Yml; +using Cloneable; + +namespace EllieBot.Modules.Patronage; + +[Cloneable] +public partial class PatronConfigData : ICloneable +{ + [Comment("DO NOT CHANGE")] + public int Version { get; set; } = 2; + + [Comment("Whether the patronage feature is enabled")] + public bool IsEnabled { get; set; } + + [Comment("List of patron only features and relevant quota data")] + public FeatureQuotas Quotas { get; set; } + + public PatronConfigData() + { + Quotas = new(); + } + + public class FeatureQuotas + { + [Comment("Dictionary of feature names with their respective limits. Set to null for unlimited")] + public Dictionary> Features { get; set; } = new(); + + [Comment("Dictionary of commands with their respective quota data")] + public Dictionary?>> Commands { get; set; } = new(); + + [Comment("Dictionary of groups with their respective quota data")] + public Dictionary?>> Groups { get; set; } = new(); + + [Comment("Dictionary of modules with their respective quota data")] + public Dictionary?>> Modules { 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..b422b73 --- /dev/null +++ b/src/EllieBot/_common/Patronage/PatronExtensions.cs @@ -0,0 +1,39 @@ +namespace EllieBot.Modules.Patronage; + +public static class PatronExtensions +{ + public static string ToFullName(this PatronTier tier) + => tier switch + { + _ => $"Patron Tier {tier}", + }; + + public static string ToFullName(this QuotaPer per) + => per switch + { + QuotaPer.PerDay => "per day", + QuotaPer.PerHour => "per hour", + QuotaPer.PerMonth => "per month", + _ => "Unknown", + }; + + 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..b40017c --- /dev/null +++ b/src/EllieBot/_common/Patronage/QuotaLimit.cs @@ -0,0 +1,66 @@ +using EllieBot.Db.Models; + +namespace EllieBot.Modules.Patronage; + +/// +/// Represents information about why the user has triggered a quota limit +/// +public readonly struct QuotaLimit +{ + /// + /// Amount of usages reached, which is the limit + /// + public uint Quota { get; init; } + + /// + /// Which period is this quota limit for (hourly, daily, monthly, etc...) + /// + public QuotaPer QuotaPeriod { get; init; } + + /// + /// When does this quota limit reset + /// + public DateTime ResetsAt { get; init; } + + /// + /// Type of the feature this quota limit is for + /// + public FeatureType FeatureType { get; init; } + + /// + /// Name of the feature this quota limit is for + /// + public string Feature { get; init; } + + /// + /// Whether it is the user's own quota (true), or server owners (false) + /// + public bool IsOwnQuota { get; init; } +} + + +/// +/// Respresent information about the feature limit +/// +public readonly struct FeatureLimit +{ + + /// + /// Whether this limit comes from the patronage system + /// + public bool IsPatronLimit { get; init; } = false; + + /// + /// Maximum limit allowed + /// + public int? Quota { get; init; } = null; + + /// + /// Name of the limit + /// + public string Name { get; init; } = string.Empty; + + public FeatureLimit() + { + } +} \ 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..c6080ac --- /dev/null +++ b/src/EllieBot/_common/Patronage/QuotaPer.cs @@ -0,0 +1,8 @@ +namespace EllieBot.Modules.Patronage; + +public enum QuotaPer +{ + PerHour, + PerDay, + PerMonth, +} \ 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/Patronage/UserQuotaStats.cs b/src/EllieBot/_common/Patronage/UserQuotaStats.cs new file mode 100644 index 0000000..a9e33e4 --- /dev/null +++ b/src/EllieBot/_common/Patronage/UserQuotaStats.cs @@ -0,0 +1,25 @@ +namespace EllieBot.Modules.Patronage; + +public readonly struct UserQuotaStats +{ + private static readonly IReadOnlyDictionary _emptyDictionary + = new Dictionary(); + public PatronTier Tier { get; init; } + = PatronTier.None; + + public IReadOnlyDictionary Features { get; init; } + = _emptyDictionary; + + public IReadOnlyDictionary Commands { get; init; } + = _emptyDictionary; + + public IReadOnlyDictionary Groups { get; init; } + = _emptyDictionary; + + public IReadOnlyDictionary Modules { get; init; } + = _emptyDictionary; + + public UserQuotaStats() + { + } +} \ 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 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..c3f8f36 --- /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 Replacements { get; } + IReadOnlyDictionary RegexReplacements { get; } + + ValueTask Register(string token, Func> repFactory); + ValueTask Register(string token, Func> repFactory); + ValueTask Register(string token, Func> repFactory); + + ValueTask Register(string token, Func repFactory); + ValueTask Register(string token, Func repFactory); + ValueTask Register(string token, Func repFactory); + + ValueTask Register(Regex regex, Func repFactory); + ValueTask Register(Regex regex, Func repFactory); +} \ No newline at end of file diff --git a/src/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 ReplaceAsync(string input, ReplacementContext repCtx); + ValueTask 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..b321941 --- /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[]? Users { get; } + + private readonly List _overrides = new(); + private readonly HashSet _tokens = new(); + + public IReadOnlyList Overrides + => _overrides.AsReadOnly(); + + private readonly List _regexOverrides = new(); + private readonly HashSet _regexPatterns = new(); + + public IReadOnlyList RegexOverrides + => _regexOverrides.AsReadOnly(); + + public ReplacementContext(ICommandContext cmdContext) : this(cmdContext.Client as DiscordSocketClient, + cmdContext.Guild, + cmdContext.Channel, + cmdContext.User) + { + } + + public ReplacementContext( + DiscordSocketClient? client = null, + IGuild? guild = null, + IMessageChannel? channel = null, + params IUser[]? users) + { + Client = client; + Guild = guild; + Channel = channel; + Users = users; + } + + public ReplacementContext WithOverride(string key, Func> repFactory) + { + if (_tokens.Add(key)) + { + _overrides.Add(new(key, repFactory)); + } + + return this; + } + + public ReplacementContext WithOverride(string key, Func repFactory) + => WithOverride(key, () => new ValueTask(repFactory())); + + + public ReplacementContext WithOverride(Regex regex, Func> repFactory) + { + if (_regexPatterns.Add(regex.ToString())) + { + _regexOverrides.Add(new(regex, repFactory)); + } + + return this; + } + + public ReplacementContext WithOverride(Regex regex, Func repFactory) + => WithOverride(regex, (Match m) => new ValueTask(repFactory(m))); +} \ No newline at end of file diff --git a/src/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 InputTypes { get; } + public string Token { get; } + + private static readonly Func> _falllbackFunc = static () => default; + + public ReplacementInfo(string token, Delegate del) + { + _del = del; + InputTypes = del.GetMethodInfo().GetParameters().Select(x => x.ParameterType).ToArray().AsReadOnly(); + Token = token; + } + + public async Task GetValueAsync(params object?[]? objs) + => await (ValueTask)(_del.DynamicInvoke(objs) ?? _falllbackFunc); + + public override int GetHashCode() + => Token.GetHashCode(); + + public override bool Equals(object? obj) + => obj is ReplacementInfo ri && ri.Token == Token; +} + +public sealed class RegexReplacementInfo +{ + private readonly Delegate _del; + public IReadOnlyCollection InputTypes { get; } + + public Regex Regex { get; } + public string Pattern { get; } + + private static readonly Func> _falllbackFunc = static _ => default; + + public RegexReplacementInfo(Regex regex, Delegate del) + { + _del = del; + InputTypes = del.GetMethodInfo().GetParameters().Select(x => x.ParameterType).ToArray().AsReadOnly(); + Regex = regex; + Pattern = Regex.ToString(); + } + + public async Task GetValueAsync(Match m, params object?[]? objs) + => await ((Func>)(_del.DynamicInvoke(objs) ?? _falllbackFunc))(m); + + public override int GetHashCode() + => Regex.GetHashCode(); + + public override bool Equals(object? obj) + => obj is RegexReplacementInfo ri && ri.Pattern == Pattern; +} \ No newline at end of file diff --git a/src/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> _guids = new(); + + private readonly ConcurrentDictionary _defaultReplacements = new(); + private readonly ConcurrentDictionary _regexReplacements = new(); + + public IReadOnlyDictionary Replacements + => _defaultReplacements.AsReadOnly(); + + public IReadOnlyDictionary RegexReplacements + => _regexReplacements.AsReadOnly(); + + public ReplacementPatternStore() + { + WithClient(); + WithChannel(); + WithServer(); + WithUsers(); + WithDefault(); + WithRegex(); + } + + // private async ValueTask InternalReplace(string input, ReplacementContexta repCtx) + // { + // // multiple executions vs single execution per replacement + // var minIndex = -1; + // var index = -1; + // foreach (var rep in _replacements) + // { + // while ((index = input.IndexOf(rep.Key, StringComparison.InvariantCulture)) != -1 && index > minIndex) + // { + // var valueToInsert = await rep.Value(repCtx); + // input = input[..index] + valueToInsert +input[(index + rep.Key.Length)..]; + // minIndex = (index + valueToInsert.Length); + // } + // } + // + // return input; + // } + + private ValueTask InternalRegister(string token, Delegate repFactory) + { + if (!token.StartsWith('%') || !token.EndsWith('%')) + { + Log.Warning( + """ + Invalid replacement token: {Token} + Tokens have to start and end with a '%', ex: %mytoken% + """, + token); + return new(default(Guid?)); + } + + if (_defaultReplacements.TryAdd(token, new ReplacementInfo(token, repFactory))) + { + var guid = Guid.NewGuid(); + _guids[guid] = token; + return new(guid); + } + + return new(default(Guid?)); + } + + public ValueTask Register(string token, Func> repFactory) + => InternalRegister(token, repFactory); + + public ValueTask Register(string token, Func> repFactory) + => InternalRegister(token, repFactory); + + public ValueTask Register(string token, Func> repFactory) + => InternalRegister(token, repFactory); + + public ValueTask Register(string token, Func repFactory) + => InternalRegister(token, () => new ValueTask(repFactory())); + + public ValueTask Register(string token, Func repFactory) + => InternalRegister(token, (T1 a) => new ValueTask(repFactory(a))); + + public ValueTask Register(string token, Func repFactory) + => InternalRegister(token, (T1 a, T2 b) => new ValueTask(repFactory(a, b))); + + + private ValueTask InternalRegexRegister(Regex regex, Delegate repFactory) + { + var regexPattern = regex.ToString(); + if (!regexPattern.StartsWith('%') || !regexPattern.EndsWith('%')) + { + Log.Warning( + """ + Invalid replacement pattern: {Token} + Tokens have to start and end with a '%', ex: %mytoken% + """, + regex); + return new(default(Guid?)); + } + + if (_regexReplacements.TryAdd(regexPattern, new RegexReplacementInfo(regex, repFactory))) + { + var guid = Guid.NewGuid(); + _guids[guid] = regex; + return new(guid); + } + + return new(default(Guid?)); + } + + public ValueTask Register(Regex regex, Func repFactory) + => InternalRegexRegister(regex, () => (Match m) => new ValueTask(repFactory(m))); + + public ValueTask Register(Regex regex, Func repFactory) + => InternalRegexRegister(regex, (T1 a) => (Match m) => new ValueTask(repFactory(m, a))); + + public bool Unregister(Guid guid) + { + if (_guids.TryRemove(guid, out var pattern)) + { + return pattern.Match( + token => _defaultReplacements.TryRemove(token, out _), + regex => _regexReplacements.TryRemove(regex.ToString(), out _)); + } + + return false; + } +} \ No newline at end of file diff --git a/src/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs b/src/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs new file mode 100644 index 0000000..d42bba6 --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/ReplacementRegistrator.default.cs @@ -0,0 +1,113 @@ +using System.Text.RegularExpressions; + +namespace EllieBot.Common; + +public sealed partial class ReplacementPatternStore +{ + private static readonly Regex _rngRegex = new(@"%rng(?:(?(?:-)?\d+)-(?(?:-)?\d+))?%", + RegexOptions.Compiled); + + + private void WithDefault() + { + Register("%bot.time%", + static () + => DateTime.Now.ToString("HH:mm " + TimeZoneInfo.Local.StandardName.GetInitials())); + } + + private void WithClient() + { + Register("%bot.status%", static (DiscordSocketClient client) => client.Status.ToString()); + Register("%bot.latency%", static (DiscordSocketClient client) => client.Latency.ToString()); + Register("%bot.name%", static (DiscordSocketClient client) => client.CurrentUser.Username); + Register("%bot.fullname%", static (DiscordSocketClient client) => client.CurrentUser.ToString()); + Register("%bot.discrim%", static (DiscordSocketClient client) => client.CurrentUser.Discriminator); + Register("%bot.id%", static (DiscordSocketClient client) => client.CurrentUser.Id.ToString()); + Register("%bot.avatar%", + static (DiscordSocketClient client) => client.CurrentUser.RealAvatarUrl().ToString()); + + Register("%bot.mention%", static (DiscordSocketClient client) => client.CurrentUser.Mention); + + Register("%shard.servercount%", static (DiscordSocketClient c) => c.Guilds.Count.ToString()); + Register("%shard.usercount%", + static (DiscordSocketClient c) => c.Guilds.Sum(g => g.MemberCount).ToString()); + Register("%shard.id%", static (DiscordSocketClient c) => c.ShardId.ToString()); + } + + private void WithServer() + { + Register("%server%", static (IGuild g) => g.Name); + Register("%server.id%", static (IGuild g) => g.Id.ToString()); + Register("%server.name%", static (IGuild g) => g.Name); + Register("%server.icon%", static (IGuild g) => g.IconUrl); + Register("%server.members%", static (IGuild g) => (g 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[] 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..4d17213 --- /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 ReplaceAsync(SmartText input, ReplacementContext repCtx) + { + var reps = GetReplacementsForContext(repCtx); + var regReps = GetRegexReplacementsForContext(repCtx); + + var inputData = GetInputData(repCtx); + var rep = new Replacer(reps.Values, regReps.Values, inputData); + + return await rep.ReplaceAsync(input); + } + + public async ValueTask ReplaceAsync(string input, ReplacementContext repCtx) + { + var reps = GetReplacementsForContext(repCtx); + var regReps = GetRegexReplacementsForContext(repCtx); + + var inputData = GetInputData(repCtx); + var rep = new Replacer(reps.Values, regReps.Values, inputData); + + return await rep.ReplaceAsync(input); + } + + private object[] GetInputData(ReplacementContext repCtx) + { + var obj = new List(); + if (repCtx.Client is not null) + obj.Add(repCtx.Client); + + if (repCtx.Guild is not null) + obj.Add(repCtx.Guild); + + if (repCtx.Users is not null) + obj.Add(repCtx.Users); + + if (repCtx.Channel is not null) + obj.Add(repCtx.Channel); + + return obj.ToArray(); + } + + private IDictionary 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 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 GetOriginalReplacementsForContext(ReplacementContext repCtx) + { + var objs = new List(); + if (repCtx.Client is not null) + { + objs.Add(repCtx.Client); + } + + if (repCtx.Channel is not null) + { + objs.Add(repCtx.Channel); + } + + if (repCtx.Users is not null) + { + objs.Add(repCtx.Users); + } + + 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 GetOriginalRegexReplacementsForContext(ReplacementContext repCtx) + { + var objs = new List(); + if (repCtx.Client is not null) + { + objs.Add(repCtx.Client); + } + + if (repCtx.Channel is not null) + { + objs.Add(repCtx.Channel); + } + + if (repCtx.Users is not null) + { + objs.Add(repCtx.Users); + } + + 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..9b43331 --- /dev/null +++ b/src/EllieBot/_common/Replacements/Impl/Replacer.cs @@ -0,0 +1,138 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace EllieBot.Common; + +public sealed partial class Replacer +{ + private readonly IEnumerable _reps; + private readonly IEnumerable _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 reps, IEnumerable regexReps, object[] inputData) + { + _reps = reps; + _inputData = inputData; + _regexReps = regexReps; + } + + public async ValueTask 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 inputTypes) + { + if (inputTypes.Count == 0) + return null; + + var objs = new List(); + 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 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 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 ReplaceAsync(SmartPlainText plain) + => await ReplaceAsync(plain.Text); + + private async Task ReplaceAsync(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..f91f1ab --- /dev/null +++ b/src/EllieBot/_common/Sender/MessageSenderService.cs @@ -0,0 +1,57 @@ +using EllieBot.Common.Configs; +using System.Diagnostics.CodeAnalysis; + +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..a1dee4d --- /dev/null +++ b/src/EllieBot/_common/Sender/ResponseBuilder.PaginationSender.cs @@ -0,0 +1,153 @@ +namespace EllieBot.Extensions; + +public partial class ResponseBuilder +{ + public class PaginationSender + { + private const string BUTTON_LEFT = "BUTTON_LEFT"; + private const string BUTTON_RIGHT = "BUTTON_RIGHT"; + + private readonly SourcedPaginatedResponseBuilder _paginationBuilder; + private readonly ResponseBuilder _builder; + private readonly DiscordSocketClient _client; + private int currentPage; + + public PaginationSender( + SourcedPaginatedResponseBuilder 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); + + 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); + + return (leftBtnInter, maybeInter, rightBtnInter); + } + + 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); + + var (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(); + }); + } + + var (left, extra, right) = await GetInteractions(); + + 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 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..60783de --- /dev/null +++ b/src/EllieBot/_common/Sender/ResponseBuilder.cs @@ -0,0 +1,492 @@ +using EllieBot.Common.Configs; +using EllieBot.Db.Models; +using System.Collections.ObjectModel; + +namespace EllieBot.Extensions; + +public sealed partial class ResponseBuilder +{ + private ICommandContext? ctx; + private IMessageChannel? channel; + private string? plainText; + private IReadOnlyCollection? 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 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 SendAsync(bool ephemeral = false) + { + var model = await BuildAsync(ephemeral); + var sentMsg = await SendAsync(model); + + + return sentMsg; + } + + public async Task 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 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 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 Items(IReadOnlyCollection items) + => new SourcedPaginatedResponseBuilder(_builder) + .Items(items); + + public SourcedPaginatedResponseBuilder PageItems(Func>> items) + => new SourcedPaginatedResponseBuilder(_builder) + .PageItems(items); +} + +public sealed class SourcedPaginatedResponseBuilder : PaginatedResponseBuilder +{ + private IReadOnlyCollection? items; + + public Func, int, Task> PageFunc { get; private set; } = static delegate + { + return Task.FromResult(new()); + }; + + public Func>> ItemsFunc { get; set; } = static delegate + { + return Task.FromResult>(ReadOnlyCollection.Empty); + }; + + public Func>? 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 Items(IReadOnlyCollection col) + { + items = col; + Elems = col.Count; + ItemsFunc = (i) => Task.FromResult(items.Skip(i * ItemsPerPage).Take(ItemsPerPage).ToArray() as IReadOnlyCollection); + return this; + } + + public SourcedPaginatedResponseBuilder TotalElements(int i) + { + Elems = i; + return this; + } + + public SourcedPaginatedResponseBuilder PageItems(Func>> func) + { + Elems = null; + ItemsFunc = func; + return this; + } + + + public SourcedPaginatedResponseBuilder PageSize(int i) + { + ItemsPerPage = i; + return this; + } + + public SourcedPaginatedResponseBuilder CurrentPage(int i) + { + InitialPage = i; + return this; + } + + + public SourcedPaginatedResponseBuilder Page(Func, int, EmbedBuilder> pageFunc) + { + PageFunc = (xs, x) => Task.FromResult(pageFunc(xs, x)); + return this; + } + + public SourcedPaginatedResponseBuilder Page(Func, int, Task> pageFunc) + { + PageFunc = pageFunc; + return this; + } + + public SourcedPaginatedResponseBuilder AddFooter(bool addFooter = true) + { + AddPaginatedFooter = addFooter; + return this; + } + + public SourcedPaginatedResponseBuilder Ephemeral() + { + IsEphemeral = true; + return this; + } + + + public Task SendAsync() + { + var paginationSender = new ResponseBuilder.PaginationSender( + this, + _builder); + + return paginationSender.SendAsync(IsEphemeral); + } + + public SourcedPaginatedResponseBuilder Interaction(Func> func) + { + InteractionFunc = func; //async (i) => await func(i); + return this; + } + + public SourcedPaginatedResponseBuilder 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..4b468d2 --- /dev/null +++ b/src/EllieBot/_common/ServiceCollectionExtensions.cs @@ -0,0 +1,131 @@ +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(); + svcs.AddSingleton(); + } + else + { + svcs.AddSingleton(); + svcs.AddSingleton(); + } + + svcs.AddSingleton(); + + 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(Reuse.Singleton); + + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(); + svcs.AddSingleton(); + + 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.Connect(conf)); + cont.AddSingleton(); + cont.AddSingleton(); + } + else + { + cont.AddSingleton(); + cont.AddSingleton(); + } + + 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(_ => prov.GetRequiredService()); + svcs.RegisterDelegate(_ => prov.GetRequiredService()); + + 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() +#if GLOBAL_ELLIE + && !type.HasAttribute() +#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..c058887 --- /dev/null +++ b/src/EllieBot/_common/Services/CommandHandler.cs @@ -0,0 +1,433 @@ +#nullable disable +using EllieBot.Common.Configs; +using EllieBot.Common.ModuleBehaviors; +using EllieBot.Db; +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 = 750; + + private const float ONE_THOUSANDTH = 1.0f / 1000; + + public event Func CommandExecuted = delegate { return Task.CompletedTask; }; + public event Func CommandErrored = delegate { return Task.CompletedTask; }; + + //userid/msg count + public ConcurrentDictionary UserMessagesSent { get; } = new(); + + public ConcurrentHashSet 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 _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(_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_ELLIE + // 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(); + + 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(); + foreach (var pair in successfulPreconditions) + { + var parseResult = await pair.Key.ParseAsync(context, searchResult, pair.Value, services); + + if (parseResult.Error == CommandError.MultipleMatches) + { + IReadOnlyList 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..9cb4037 --- /dev/null +++ b/src/EllieBot/_common/Services/Currency/CurrencyService.cs @@ -0,0 +1,116 @@ +#nullable disable +using LinqToDB; +using LinqToDB.EntityFrameworkCore; +using EllieBot.Db; +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 GetWalletAsync(ulong userId, CurrencyType type = CurrencyType.Default) + { + if (type == CurrencyType.Default) + return Task.FromResult(new DefaultWallet(userId, _db)); + + throw new ArgumentOutOfRangeException(nameof(type)); + } + + public async Task AddBulkAsync( + IReadOnlyCollection 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 userIds, + long amount, + TxData txData, + CurrencyType type = CurrencyType.Default) + { + if (type == CurrencyType.Default) + { + await using var ctx = _db.GetDbContext(); + await ctx + .GetTable() + .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 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 RemoveAsync( + IUser user, + long amount, + TxData txData) + => await RemoveAsync(user.Id, amount, txData); + + public async Task> GetTopRichest(ulong ignoreId, int page = 0, int perPage = 9) + { + await using var uow = _db.GetDbContext(); + return await uow.Set().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 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 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 GetBalance() + { + await using var ctx = _db.GetDbContext(); + var userId = UserId; + return await ctx + .GetTable() + .Where(x => x.UserId == userId) + .Select(x => x.CurrencyAmount) + .FirstOrDefaultAsync(); + } + + public async Task 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() + .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() + .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() + .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() + .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 _gamblingTypes = new HashSet(new[] + { + "lula", + "betroll", + "betflip", + "blackjack", + "betdraw", + "slot", + }); + + private ConcurrentDictionary _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() + .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> GetAllAsync() + { + await using var ctx = _db.GetDbContext(); + return await ctx.Set() + .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..79e8e5a --- /dev/null +++ b/src/EllieBot/_common/Services/IBehaviourHandler.cs @@ -0,0 +1,17 @@ +#nullable disable +namespace EllieBot.Services; + +public interface IBehaviorHandler +{ + Task AddAsync(ICustomBehavior behavior); + Task AddRangeAsync(IEnumerable behavior); + Task RemoveAsync(ICustomBehavior behavior); + Task RemoveRangeAsync(IEnumerable behs); + + Task RunExecOnMessageAsync(SocketGuild guild, IUserMessage usrMsg); + Task RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg); + Task 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 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 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; + +/// +/// All services must implement this interface in order to be auto-discovered by the DI system +/// +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 Languages { get; } + + Task> GetVideoLinksByKeywordAsync(string keywords, int count = 1); + Task> GetVideoInfosByKeywordAsync(string keywords, int count = 1); + Task> GetPlaylistIdsByKeywordsAsync(string keywords, int count = 1); + Task> GetRelatedVideosAsync(string id, int count = 1, string user = null); + Task> GetPlaylistTracksAsync(string playlistId, int count = 50); + Task> GetVideoDurationsAsync(IEnumerable videoIds); + Task Translate(string sourceText, string sourceLanguage, string targetLanguage); + + Task ShortenUrl(string url); + Task 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> GetPokemonsAsync(); + Task> GetPokemonAbilitiesAsync(); + Task GetTriviaQuestionsAsync(); + Task> 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..3fa7c5e --- /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 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 +{ + /// + /// The author of the bot. + /// + string Author { get; } + + /// + /// The total amount of commands ran since startup. + /// + long CommandsRan { get; } + + /// + /// The amount of messages seen by the bot since startup. + /// + long MessageCounter { get; } + + /// + /// The rate of messages the bot sees every second. + /// + double MessagesPerSecond { get; } + + /// + /// The total amount of text channels the bot can see. + /// + long TextChannels { get; } + + /// + /// The total amount of voice channels the bot can see. + /// + long VoiceChannels { get; } + + /// + /// Gets for how long the bot has been up since startup. + /// + TimeSpan GetUptime(); + + /// + /// Gets a formatted string of how long the bot has been up since startup. + /// + /// The formatting separator. + string GetUptimeString(string separator = ", "); + + /// + /// Gets total amount of private memory currently in use by the bot, in Megabytes. + /// + 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 Features { get; init; } + public required IReadOnlyList Emojis { get; init; } + public required IReadOnlyList 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..aa8cbff --- /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 noCommandExecs; + private IReadOnlyCollection preCommandExecs; + private IReadOnlyCollection onMessageExecs; + private IReadOnlyCollection inputTransformers; + + private readonly SemaphoreSlim _customLock = new(1, 1); + private readonly List _customExecs = new(); + + public BehaviorHandler(IServiceProvider services) + { + _services = services; + } + + public void Initialize() + { + noCommandExecs = _services.GetServices().ToArray(); + preCommandExecs = _services.GetServices().OrderByDescending(x => x.Priority).ToArray(); + onMessageExecs = _services.GetServices().OrderByDescending(x => x.Priority).ToArray(); + inputTransformers = _services.GetServices().ToArray(); + } + + #region Add/Remove + + public async Task AddRangeAsync(IEnumerable execs) + { + await _customLock.WaitAsync(); + try + { + foreach (var exe in execs) + { + if (_customExecs.Contains(exe)) + continue; + + _customExecs.Add(exe); + } + } + finally + { + _customLock.Release(); + } + } + + public async Task AddAsync(ICustomBehavior behavior) + { + await _customLock.WaitAsync(); + try + { + if (_customExecs.Contains(behavior)) + return false; + + _customExecs.Add(behavior); + return true; + } + finally + { + _customLock.Release(); + } + } + + public async Task RemoveAsync(ICustomBehavior behavior) + { + await _customLock.WaitAsync(); + try + { + return _customExecs.Remove(behavior); + } + finally + { + _customLock.Release(); + } + } + + public async Task RemoveRangeAsync(IEnumerable behs) + { + await _customLock.WaitAsync(); + try + { + foreach(var beh in behs) + _customExecs.Remove(beh); + } + finally + { + _customLock.Release(); + } + } + + #endregion + + #region Running + + public async Task RunExecOnMessageAsync(SocketGuild guild, IUserMessage usrMsg) + { + async Task Exec(IReadOnlyCollection 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 RunPreCommandAsync(ICommandContext ctx, CommandInfo cmd) + { + async Task Exec(IReadOnlyCollection 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(IReadOnlyCollection 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 RunInputTransformersAsync(SocketGuild guild, IUserMessage usrMsg) + { + async Task Exec(IReadOnlyCollection 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 blacklist; + + private readonly TypedKey _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 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 GetBlacklist() + => blacklist; + + public void Reload(bool publish = true) + { + using var uow = _db.GetDbContext(); + var toPublish = uow.GetTable().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() + .InsertAsync(() => new() + { + ItemId = id, + Type = type, + }); + + if (type == BlacklistType.User) + { + await uow.GetTable() + .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() + .Where(bi => bi.ItemId == id && bi.Type == type) + .DeleteAsync(); + + Reload(); + } + + public async Task BlacklistUsers(IReadOnlyCollection toBlacklist) + { + await using var uow = _db.GetDbContext(); + var bc = uow.GetTable(); + await bc.BulkCopyAsync(toBlacklist.Select(uid => new BlacklistEntry + { + ItemId = uid, + Type = BlacklistType.User + })); + + var blList = toBlacklist.ToList(); + await uow.GetTable() + .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 GetCommandOptionHelpList(Type opt) + { + var strs = opt.GetProperties() + .Select(x => x.GetCustomAttributes(true).FirstOrDefault(a => a is OptionAttribute)) + .Where(x => x is not null) + .Cast() + .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 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(); + + 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() + .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() + .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 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() + .AsQueryable() + .FirstOrDefaultAsync(x => x.GuildId == guildId && commandName == x.Command); + + if (over is null) + { + uow.Set() + .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() + .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() + .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> GetAllOverrides(ulong guildId) + { + await using var uow = _db.GetDbContext(); + return await uow.Set() + .AsQueryable() + .AsNoTracking() + .Where(x => x.GuildId == guildId) + .ToListAsync(); + } + + public async Task 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..543f23c --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/FontProvider.cs @@ -0,0 +1,60 @@ +#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; } + + /// + /// Font used for .rip command + /// + public Font RipFont { get; } + + public List FallBackFonts { get; } + private readonly FontCollection _fonts; + + public FontProvider() + { + _fonts = new(); + + NotoSans = _fonts.Add("data/fonts/NotoSans-Bold.ttf"); + UniSans = _fonts.Add("data/fonts/Uni Sans.ttf"); + + FallBackFonts = new(); + + //FallBackFonts.Add(_fonts.Install("data/fonts/OpenSansEmoji.ttf")); + + // try loading some emoji and jap fonts on windows as fallback fonts + if (Environment.OSVersion.Platform == PlatformID.Win32NT) + { + try + { + var fontsfolder = Environment.GetFolderPath(Environment.SpecialFolder.Fonts); + FallBackFonts.Add(_fonts.Add(Path.Combine(fontsfolder, "seguiemj.ttf"))); + FallBackFonts.AddRange(_fonts.AddCollection(Path.Combine(fontsfolder, "msgothic.ttc"))); + FallBackFonts.AddRange(_fonts.AddCollection(Path.Combine(fontsfolder, "segoe.ttc"))); + } + catch { } + } + + // any fonts present in data/fonts should be added as fallback fonts + // this will allow support for special characters when drawing text + foreach (var font in Directory.GetFiles(@"data/fonts")) + { + if (font.EndsWith(".ttf")) + FallBackFonts.Add(_fonts.Add(font)); + else if (font.EndsWith(".ttc")) + FallBackFonts.AddRange(_fonts.AddCollection(font)); + } + + RipFont = NotoSans.CreateFont(20, FontStyle.Bold); + DottyFont = FallBackFonts.First(x => x.Name == "dotty"); + } +} \ 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..8c8ff32 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/IImageCache.cs @@ -0,0 +1,17 @@ +namespace EllieBot.Services; + +public interface IImageCache +{ + Task GetHeadsImageAsync(); + Task GetTailsImageAsync(); + Task GetCurrencyImageAsync(); + Task GetXpBackgroundImageAsync(); + Task GetRategirlBgAsync(); + Task GetRategirlDotAsync(); + Task GetDiceAsync(int num); + Task GetSlotEmojiAsync(int number); + Task GetSlotBgAsync(); + Task GetRipBgAsync(); + Task GetRipOverlayAsync(); + Task 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..821d717 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/ImagesConfig.cs @@ -0,0 +1,19 @@ +using EllieBot.Common.Configs; + +namespace EllieBot.Services; + +public sealed class ImagesConfig : ConfigServiceBase +{ + private const string PATH = "data/images.yml"; + + private static readonly TypedKey _changeKey = + new("config.images.updated"); + + public override string Name + => "images"; + + public ImagesConfig(IConfigSeria serializer, IPubSub pubSub) + : base(PATH, serializer, pubSub, _changeKey) + { + } +} \ 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 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, IEService +{ + private readonly IReadOnlyList _guilds; + + public StartingGuildsService(DiscordSocketClient client) + => _guilds = client.Guilds.Select(x => x.Id).ToList(); + + public IEnumerator 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..ef7ea80 --- /dev/null +++ b/src/EllieBot/_common/Services/Impl/StatsService.cs @@ -0,0 +1,222 @@ +#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 => + { + _ = Task.Run(() => + { + if (c is ITextChannel) + Interlocked.Increment(ref textChannels); + else if (c is IVoiceChannel) + Interlocked.Increment(ref voiceChannels); + }); + + return Task.CompletedTask; + }; + + _client.ChannelDestroyed += c => + { + _ = Task.Run(() => + { + if (c is ITextChannel) + Interlocked.Decrement(ref textChannels); + else if (c is IVoiceChannel) + Interlocked.Decrement(ref voiceChannels); + }); + + return Task.CompletedTask; + }; + + _client.GuildAvailable += g => + { + _ = Task.Run(() => + { + var tc = g.Channels.Count(cx => cx is ITextChannel); + var vc = g.Channels.Count - tc; + Interlocked.Add(ref textChannels, tc); + Interlocked.Add(ref voiceChannels, vc); + }); + return Task.CompletedTask; + }; + + _client.JoinedGuild += g => + { + _ = Task.Run(() => + { + var tc = g.Channels.Count(cx => cx is ITextChannel); + var vc = g.Channels.Count - tc; + Interlocked.Add(ref textChannels, tc); + Interlocked.Add(ref voiceChannels, vc); + }); + return Task.CompletedTask; + }; + + _client.GuildUnavailable += g => + { + _ = Task.Run(() => + { + var tc = g.Channels.Count(cx => cx is ITextChannel); + var vc = g.Channels.Count - tc; + Interlocked.Add(ref textChannels, -tc); + Interlocked.Add(ref voiceChannels, -vc); + }); + + return Task.CompletedTask; + }; + + _client.LeftGuild += g => + { + _ = Task.Run(() => + { + var tc = g.Channels.Count(cx => cx is ITextChannel); + var vc = g.Channels.Count - tc; + Interlocked.Add(ref textChannels, -tc); + Interlocked.Add(ref voiceChannels, -vc); + }); + + return Task.CompletedTask; + }; + } + + private void InitializeChannelCount() + { + var guilds = _client.Guilds; + textChannels = guilds.Sum(static g => g.Channels.Count(static cx => cx is ITextChannel)); + voiceChannels = guilds.Sum(static g => g.Channels.Count(static cx => cx is IVoiceChannel)); + } + + public async Task OnReadyAsync() + { + InitializeChannelCount(); + + using var timer = new PeriodicTimer(TimeSpan.FromHours(1)); + do + { + if (string.IsNullOrWhiteSpace(_creds.BotListToken)) + continue; + + try + { + using var http = _httpFactory.CreateClient(); + using var content = new FormUrlEncodedContent(new Dictionary + { + { "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, + }; + } +} diff --git a/src/EllieBot/_common/Services/Impl/YtdlOperation.cs b/src/EllieBot/_common/Services/Impl/YtdlOperation.cs new file mode 100644 index 0000000..f302ab7 --- /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 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 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 +{ + /// + /// Used as failsafe in case response key doesn't exist in the selected or default language. + /// + 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; + +/// +/// Loads strings from the local default filepath +/// +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> GetResponseStrings() + { + var outputDict = new Dictionary>(); + foreach (var file in Directory.GetFiles(_responsesPath)) + { + try + { + var langDict = JsonConvert.DeserializeObject>(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> GetCommandStrings() + { + var deserializer = new DeserializerBuilder().Build(); + + var outputDict = new Dictionary>(); + foreach (var file in Directory.GetFiles(_commandsPath)) + { + try + { + var text = File.ReadAllText(file); + var langDict = deserializer.Deserialize>(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> responseStrings; + private IReadOnlyDictionary> 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..952f402 --- /dev/null +++ b/src/EllieBot/_common/Settings/BotConfigService.cs @@ -0,0 +1,73 @@ +#nullable disable +using EllieBot.Common.Configs; +using SixLabors.ImageSharp.PixelFormats; + +namespace EllieBot.Services; + +/// +/// Settings service for bot-wide configuration. +/// +public sealed class BotConfigService : ConfigServiceBase +{ + private const string FILE_PATH = "data/bot.yml"; + private static readonly TypedKey _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; + }); + } +} \ 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..c32b7ca --- /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; + +/// +/// Custom setting value parsers for types which don't have them by default +/// +public static class ConfigParsers +{ + /// + /// Default string parser. Passes input to output and returns true. + /// + 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(string input, out T output) + where T : struct + => Enum.TryParse(input, true, out output); +} + +public static class ConfigPrinters +{ + public static string ToString(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..bdf76d8 --- /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; + +/// +/// Base service for all settings services +/// +/// Type of the settings +public abstract class ConfigServiceBase : IConfigService + where TSettings : ICloneable, 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 _changeKey; + + protected TSettings data; + + private readonly Dictionary> _propSetters = new(); + private readonly Dictionary> _propSelectors = new(); + private readonly Dictionary> _propPrinters = new(); + private readonly Dictionary _propComments = new(); + + /// + /// Initialized an instance of + /// + /// Path to the file where the settings are serialized/deserialized to and from + /// Serializer which will be used + /// Pubsub implementation for signaling when settings are updated + /// Key used to signal changed event + protected ConfigServiceBase( + string filePath, + IConfigSeria serializer, + IPubSub pubSub, + TypedKey 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; + } + + /// + /// Loads data from disk. If file doesn't exist, it will be created with default values + /// + protected void Load() + { + // if file is deleted, regenerate it with default values + if (!File.Exists(_filePath)) + { + data = new(); + Save(); + } + + data = _serializer.Deserialize(File.ReadAllText(_filePath)); + } + + /// + /// Loads new data and publishes the new state + /// + public void Reload() + { + Load(); + _pubSub.Pub(_changeKey, data); + } + + /// + /// Doesn't do anything by default. This method will be executed after + /// is reloaded from or new data is recieved + /// from the publish event + /// + protected virtual void OnStateUpdate() + { + } + + private void Save() + { + var strData = _serializer.Serialize(data); + File.WriteAllText(_filePath, strData); + } + + protected void AddParsedProp( + string key, + Expression> selector, + SettingParser parser, + Func printer, + Func? 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()?.Comment; + } + + private Func Magic( + Expression> selector, + SettingParser parser, + Func 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(); + + 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 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 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..69ac966 --- /dev/null +++ b/src/EllieBot/_common/Settings/IConfigService.cs @@ -0,0 +1,46 @@ +#nullable disable +namespace EllieBot.Services; + +/// +/// Interface that all services which deal with configs should implement +/// +public interface IConfigService +{ + /// + /// Name of the config + /// + public string Name { get; } + + /// + /// Loads new data and publishes the new state + /// + void Reload(); + + /// + /// Gets the list of props you can set + /// + /// List of props + IReadOnlyList GetSettableProps(); + + /// + /// Gets the value of the specified property + /// + /// Prop name + /// Value of the prop + string GetSetting(string prop); + + /// + /// Gets the value of the specified property + /// + /// Prop name + /// Value of the prop + string GetComment(string prop); + + /// + /// Sets the value of the specified property + /// + /// Property to set + /// Value to set the property to + /// Success + 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..06a8e75 --- /dev/null +++ b/src/EllieBot/_common/Settings/SettingParser.cs @@ -0,0 +1,8 @@ +#nullable disable +namespace EllieBot.Services; + +/// +/// Delegate which describes a parser which can convert string input into given data type +/// +/// Data type to convert string to +public delegate bool SettingParser(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(); + + 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(); + + if (arr is null) + return new SmartPlainText(input); + + arr!.NormalizeFields(); + return arr; + } + + var obj = root.ToObject(); + + 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 +{ + public bool IsSuccess + => _result.IsSuccess; + + public IReadOnlyCollection Values + => _result.Values; + + private readonly Discord.Commands.TypeReaderResult _result; + + private TypeReaderResult(in Discord.Commands.TypeReaderResult result) + => _result = result; + + public static implicit operator TypeReaderResult(in Discord.Commands.TypeReaderResult result) + => new(result); + + public static implicit operator Discord.Commands.TypeReaderResult(in TypeReaderResult wrapper) + => wrapper._result; +} + +public static class TypeReaderResult +{ + public static TypeReaderResult FromError(CommandError error, string reason) + => Discord.Commands.TypeReaderResult.FromError(error, reason); + + public static TypeReaderResult FromSuccess(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 +{ + public override ValueTask> ReadAsync(ICommandContext ctx, string input) + { + if (!Emote.TryParse(input, out var emote)) + return new(TypeReaderResult.FromError(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 +{ + private readonly ITimezoneService _gts; + + public GuildDateTimeTypeReader(ITimezoneService gts) + => _gts = gts; + + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + var gdt = Parse(context.Guild.Id, input); + if (gdt is null) + { + return new(TypeReaderResult.FromError(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 +{ + private readonly DiscordSocketClient _client; + + public GuildTypeReader(DiscordSocketClient client) + => _client = client; + + public override ValueTask> 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(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 +{ + public override async ValueTask> ReadAsync(ICommandContext ctx, string input) + { + if (ctx.Guild is null) + return TypeReaderResult.FromError(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(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 +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + if (kwum.TryParse(input, out var val)) + return new(TypeReaderResult.FromSuccess(val)); + + return new(TypeReaderResult.FromError(CommandError.ParseFailed, "Input is not a valid kwum")); + } +} + +public sealed class SmartTextTypeReader : EllieTypeReader +{ + public override ValueTask> 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( + @"^(?:(?\d)mo)?(?:(?\d{1,2})w)?(?:(?\d{1,2})d)?(?:(?\d{1,4})h)?(?:(?\d{1,5})m)?(?:(?\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(); + + 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 +{ + private readonly CommandService _cmds; + + public ModuleTypeReader(CommandService cmds) + => _cmds = cmds; + + public override ValueTask> 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(CommandError.ParseFailed, "No such module found.")); + + return new(TypeReaderResult.FromSuccess(module)); + } +} + +public sealed class ModuleOrExprTypeReader : EllieTypeReader +{ + private readonly CommandService _cmds; + + public ModuleOrExprTypeReader(CommandService cmds) + => _cmds = cmds; + + public override ValueTask> 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(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; + +/// +/// Used instead of bool for more flexible keywords for true/false only in the permission module +/// +public sealed class PermissionActionTypeReader : EllieTypeReader +{ + public override ValueTask> 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(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 +{ + public override ValueTask> 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(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 +{ + public override ValueTask> ReadAsync(ICommandContext context, string input) + { + if (string.IsNullOrWhiteSpace(input)) + return new(TypeReaderResult.FromError(CommandError.Unsuccessful, "Input is empty.")); + try + { + var time = StoopidTime.FromInput(input); + return new(TypeReaderResult.FromSuccess(time)); + } + catch (Exception ex) + { + return new(TypeReaderResult.FromError(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..7c04bea --- /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 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() + where T : Attribute + => _baseDescriptor.GetCustomAttribute(); + + public IObjectDescriptor Read(object target) + { + var comment = _baseDescriptor.GetCustomAttribute(); + 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 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..e2cfd97 --- /dev/null +++ b/src/EllieBot/_common/Yml/MultilineScalarFlowStyleEmitter.cs @@ -0,0 +1,36 @@ +#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(); + 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(); + 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(); + 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..7f90d78 --- /dev/null +++ b/src/EllieBot/_common/_Extensions/CommandContextExtensions.cs @@ -0,0 +1,30 @@ +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); +} \ 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..58a9abd --- /dev/null +++ b/src/EllieBot/_common/_Extensions/DbExtensions.cs @@ -0,0 +1,11 @@ +using Microsoft.EntityFrameworkCore; +using EllieBot.Db; +using EllieBot.Db.Models; + +namespace EllieBot.Extensions; + +public static class DbExtensions +{ + public static DiscordUser GetOrCreateUser(this DbContext ctx, IUser original, Func, IQueryable>? 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..27341ad --- /dev/null +++ b/src/EllieBot/_common/_Extensions/Extensions.cs @@ -0,0 +1,237 @@ +using System.Diagnostics; +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)://(?[^\s/$.?#].[^\s]*)$", RegexOptions.Compiled); + + /// + /// Converts to + /// + /// The to convert. + /// The . + public static DateOnly ToDateOnly(this DateTime dateTime) + => DateOnly.FromDateTime(dateTime); + + /// + /// Determines if is before today + /// + /// The to check. + /// True if is before today. + 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); + + /// + /// Generates a string in the format HHH:mm if timespan is >= 2m. + /// Generates a string in the format 00:mm:ss if timespan is less than 2m. + /// + /// Timespan to convert to string + /// Formatted duration string + 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); + + + /// + /// First 10 characters of teh bot token. + /// + 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/// 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/// 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> 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(this T any, JsonSerializerOptions? options = null) + => JsonSerializer.Serialize(any, options); + + public static Stream ToStream(this IEnumerable bytes, bool canWrite = false) + { + var ms = new MemoryStream(bytes as byte[] ?? bytes.ToArray(), canWrite); + ms.Seek(0, SeekOrigin.Begin); + return ms; + } + + public static IEnumerable GetRoles(this IGuildUser user) + => user.RoleIds.Select(r => user.Guild.GetRole(r)).Where(r => r is not null); + + public static void Lap(this Stopwatch sw, string checkpoint) + { + Log.Information("Checkpoint {CheckPoint}: {Time}ms", checkpoint, sw.Elapsed.TotalMilliseconds); + sw.Restart(); + } +} \ 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 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 ToStreamAsync(this Image 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? FindNode(this LinkedList list, Func 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..3e0f703 --- /dev/null +++ b/src/EllieBot/_common/_Extensions/NumberExtensions.cs @@ -0,0 +1,7 @@ +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); +} \ 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..a0cd408 --- /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 Merge(this IEnumerable> images) + => images.Merge(out _); + + public static Image Merge(this IEnumerable> images, out IImageFormat format) + { + format = PngFormat.Instance; + + void DrawFrame(IList> imgArray, Image 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(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(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