From 2dc42919dfdf82045fc3a16b118f3e3a331c097a Mon Sep 17 00:00:00 2001 From: Mahdi Bahrami Date: Sat, 14 Dec 2024 10:32:59 +0330 Subject: [PATCH] Better VS Code compatibility (#272) * enable swift-format * add vscode-related configs * temporarily update tests CI to the other PR's branch * run swift-format * Revert "temporarily update tests CI to the other PR's branch" This reverts commit 75e8138ed7ca9e317a2182103dc18cfbb348f4b4. * commit launch.json as well --- .github/workflows/test.yml | 1 + .gitignore | 1 - .sourcekit-lsp/config.json | 6 + .swift-format | 62 ++ .swiftformatignore | 1 + .vscode/extensions.json | 10 + .vscode/launch.json | 170 +++++ .vscode/settings.json | 81 +++ Lambdas/AutoFaqs/AutoFaqsHandler.swift | 11 +- Lambdas/AutoFaqs/S3AutoFaqsRepository.swift | 19 +- Lambdas/AutoPings/AutoPingsHandler.swift | 17 +- Lambdas/AutoPings/S3AutoPingsRepository.swift | 31 +- Lambdas/Faqs/FaqsHandler.swift | 9 +- Lambdas/Faqs/S3FaqsRepository.swift | 19 +- .../GHHooks/+Rendering/+LeafRenderer.swift | 15 +- .../GHHooks/+Rendering/+RenderClient.swift | 1 - Lambdas/GHHooks/Authenticator.swift | 23 +- Lambdas/GHHooks/Constants.swift | 4 +- Lambdas/GHHooks/Errors.swift | 1 + .../GHHooks/EventHandler/EventHandler.swift | 58 +- .../GHHooks/EventHandler/HandlerContext.swift | 2 +- .../EventHandler/Handlers/+String.swift | 8 +- .../EventHandler/Handlers/DocsIssuer.swift | 21 +- .../EventHandler/Handlers/IssueHandler.swift | 24 +- .../EventHandler/Handlers/PRCoinGiver.swift | 52 +- .../EventHandler/Handlers/PRHandler.swift | 29 +- .../Handlers/ProjectBoardHandler.swift | 6 +- .../EventHandler/Handlers/ReleaseMaker.swift | 186 +++--- .../Handlers/ReleaseReporter.swift | 93 +-- .../Handlers/TicketReporter.swift | 19 +- Lambdas/GHHooks/EventHandler/Requester.swift | 41 +- Lambdas/GHHooks/Extensions/+Issue.Label.swift | 4 +- .../GHHooks/Extensions/+SemanticVersion.swift | 2 +- .../GHHooks/Extensions/Embed+Equtable.swift | 43 +- .../GHHooks/Extensions/String+Document.swift | 51 +- Lambdas/GHHooks/GHHooksHandler.swift | 63 +- .../MessageLookupRepo/DynamoMessageRepo.swift | 44 +- .../MessageLookupRepo/MessageLookupRepo.swift | 1 - Lambdas/GHOAuth/Constants.swift | 2 +- Lambdas/GHOAuth/Models/GHOAuthPayload.swift | 3 +- Lambdas/GHOAuth/OAuthLambda.swift | 158 +++-- Lambdas/GitHubAPI/+Client.swift | 1 + Lambdas/GitHubAPI/+Repository.swift | 1 - Lambdas/GitHubAPI/+User.swift | 1 - Lambdas/GitHubAPI/Changes.swift | 1 - Lambdas/GitHubAPI/Events+Action.swift | 1 - Lambdas/GitHubAPI/GHMiddleware.swift | 63 +- Lambdas/GitHubAPI/Verifier.swift | 4 +- Lambdas/GitHubAPI/uiName+.swift | 1 - Lambdas/LambdasShared/+APIGatewayV2.swift | 7 +- Lambdas/LambdasShared/SecretsRetriever.swift | 50 +- Lambdas/Sponsors/GithubWebhookPayload.swift | 2 +- Lambdas/Sponsors/SponsorType.swift | 8 +- Lambdas/Sponsors/SponsorsLambda.swift | 78 +-- Lambdas/Users/CoinEntryRepository.swift | 2 +- Lambdas/Users/DynamoUserRepository.swift | 9 +- Lambdas/Users/InternalUsersService.swift | 7 +- Lambdas/Users/UsersHandler.swift | 39 +- Package.swift | 6 +- Sources/Models/AutoFaqsRequest.swift | 1 - Sources/Models/AutoPingsRequest.swift | 3 +- Sources/Models/CoinEntry.swift | 2 +- Sources/Models/CoinResponse.swift | 1 - Sources/Models/FaqsRequest.swift | 1 - Sources/Models/S3AutoPingItems.swift | 9 +- Sources/Models/UserRequest.swift | 1 - Sources/Penny/+Array.swift | 5 +- Sources/Penny/+Logger.swift | 68 +- Sources/Penny/+Rendering/+LeafRenderer.swift | 5 +- Sources/Penny/+Rendering/RenderModels.swift | 1 - Sources/Penny/+String.swift | 7 +- Sources/Penny/BotStateManager.swift | 21 +- Sources/Penny/CommandsManager.swift | 70 +- Sources/Penny/Constants.swift | 102 +-- Sources/Penny/EvolutionChecker.swift | 177 ++--- Sources/Penny/HandlerContext.swift | 2 +- Sources/Penny/Handlers/+Expression.swift | 15 +- Sources/Penny/Handlers/AuditLogHandler.swift | 80 ++- Sources/Penny/Handlers/CoinFinder.swift | 80 ++- Sources/Penny/Handlers/EventHandler.swift | 15 +- .../Penny/Handlers/InteractionHandler.swift | 615 ++++++++++-------- .../Penny/Handlers/MessageDeleteHandler.swift | 147 +++-- Sources/Penny/Handlers/MessageHandler.swift | 139 ++-- .../ReactionHandler/ReactionCache.swift | 74 ++- .../ReactionHandler/ReactionHandler.swift | 106 +-- Sources/Penny/MainService/MainService.swift | 4 +- Sources/Penny/MainService/PennyService.swift | 26 +- Sources/Penny/Penny.swift | 7 +- Sources/Penny/SOChecker.swift | 39 +- .../AutoFaqsService/AutoFaqsService.swift | 4 +- .../DefaultAutoFaqsService.swift | 55 +- .../AutoPingsService/AutoPingsService.swift | 4 +- .../DefaultPingsService.swift | 55 +- .../CachesService/CachesService.swift | 1 - .../CachesService/CachesStorage.swift | 45 +- .../CachesService/DefaultCachesService.swift | 2 +- .../CachesService/S3CachesRepository.swift | 15 +- .../DiscordService/DiscordService.swift | 237 ++++--- .../DefaultEvolutionService.swift | 20 +- .../FaqsService/DefaultFaqsService.swift | 39 +- .../Services/FaqsService/FaqsService.swift | 2 +- .../DefaultSOService.swift | 26 +- .../DefaultSwiftReleasesService.swift | 19 +- .../SwiftReleasesService.swift | 1 - Sources/Penny/SwiftReleasesChecker.swift | 29 +- Sources/Rendering/+LeafRenderer.swift | 5 +- Sources/Rendering/GHLeafSource.swift | 7 +- Sources/Rendering/LeafEncoder.swift | 65 +- Sources/Rendering/RawTag.swift | 1 - Sources/Shared/+String.swift | 3 +- Sources/Shared/Discord+ui.swift | 11 +- Sources/Shared/ServiceError.swift | 2 +- .../UsersService/DefaultUsersService.swift | 129 ++-- Tests/PennyTests/Fake/AnyBox.swift | 3 +- Tests/PennyTests/Fake/EventKey.swift | 172 ++++- .../PennyTests/Fake/FakeAutoFaqsService.swift | 23 +- Tests/PennyTests/Fake/FakeCacheService.swift | 2 +- .../PennyTests/Fake/FakeClientTransport.swift | 8 +- Tests/PennyTests/Fake/FakeDiscordClient.swift | 17 +- Tests/PennyTests/Fake/FakeFaqsService.swift | 11 +- Tests/PennyTests/Fake/FakeMainService.swift | 98 +-- Tests/PennyTests/Fake/FakeManager.swift | 29 +- .../Fake/FakeMessageLookupRepo.swift | 9 +- Tests/PennyTests/Fake/FakePingsService.swift | 17 +- .../Fake/FakeProposalsService.swift | 7 +- .../PennyTests/Fake/FakeResponseStorage.swift | 33 +- Tests/PennyTests/Fake/FakeSOService.swift | 1 + Tests/PennyTests/Fake/FakeUsersService.swift | 17 +- Tests/PennyTests/Fake/TestData.swift | 20 +- Tests/PennyTests/Tests/+XCTest.swift | 6 +- Tests/PennyTests/Tests/CoinHandlerTests.swift | 160 ++--- Tests/PennyTests/Tests/GHHooksTests.swift | 502 +++++++------- .../Tests/GatewayProcessingTests.swift | 233 ++++--- Tests/PennyTests/Tests/LeafRenderTests.swift | 212 +++--- Tests/PennyTests/Tests/OtherTests.swift | 21 +- scripts/format.bash | 31 + 136 files changed, 3559 insertions(+), 2346 deletions(-) create mode 100644 .sourcekit-lsp/config.json create mode 100644 .swift-format create mode 100644 .swiftformatignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100755 scripts/format.bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e8fb6e33..ed2c53f9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,6 +15,7 @@ jobs: with_tsan: false with_api_check: false with_deps_submission: true + with_linting: true cloudformation-lint: name: Check CloudFormation diff --git a/.gitignore b/.gitignore index 81783859..677caa2b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,3 @@ DerivedData/ .vscode/launch.json .devcontainer Sources/DBMigration/Data -.vscode/ diff --git a/.sourcekit-lsp/config.json b/.sourcekit-lsp/config.json new file mode 100644 index 00000000..457abfc3 --- /dev/null +++ b/.sourcekit-lsp/config.json @@ -0,0 +1,6 @@ +{ + "backgroundIndexing": true, + "backgroundPreparationMode": "build", + "maxCoresPercentageToUseForBackgroundIndexing": 0.7, + "experimentalFeatures": ["on-type-formatting"] +} diff --git a/.swift-format b/.swift-format new file mode 100644 index 00000000..4e9954f6 --- /dev/null +++ b/.swift-format @@ -0,0 +1,62 @@ +{ + "version": 1, + "indentation": { + "spaces": 4 + }, + "tabWidth": 4, + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "spacesAroundRangeFormationOperators": false, + "indentConditionalCompilationBlocks": false, + "indentSwitchCaseLabels": false, + "lineBreakAroundMultilineExpressionChainComponents": false, + "lineBreakBeforeControlFlowKeywords": false, + "lineBreakBeforeEachArgument": true, + "lineBreakBeforeEachGenericRequirement": true, + "lineLength": 120, + "maximumBlankLines": 1, + "respectsExistingLineBreaks": true, + "prioritizeKeepingFunctionOutputTogether": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": false, + "AlwaysUseLowerCamelCase": false, + "AmbiguousTrailingClosureOverload": true, + "BeginDocumentationCommentWithOneLineSummary": false, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": false, + "NeverUseForceTry": false, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": true, + "NoAssignmentInExpressions": true, + "NoBlockComments": true, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": true, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReplaceForEachWithForLoop": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "UseEarlyExits": false, + "UseExplicitNilCheckInConditions": false, + "UseLetInEveryBoundCaseVariable": false, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": false, + "UseSynthesizedInitializer": false, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false + } +} diff --git a/.swiftformatignore b/.swiftformatignore new file mode 100644 index 00000000..3ecc6c71 --- /dev/null +++ b/.swiftformatignore @@ -0,0 +1 @@ +Lambdas/GitHubAPI/GeneratedSources/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..7c100293 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "sswg.swift-lang", + "foxundermoon.shell-format", + "esbenp.prettier-vscode", + "usernamehw.errorlens", + "redhat.vscode-yaml", + "francisco.html-leaf" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..ff42a18f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,170 @@ +{ + "configurations": [ + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Debug Penny", + "program": "${workspaceFolder:penny-bot}/.build/debug/Penny", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Debug Penny", + "env": { + "DEPLOYMENT_ENVIRONMENT": "local" + } + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Release Penny", + "program": "${workspaceFolder:penny-bot}/.build/release/Penny", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Release Penny", + "env": { + "DEPLOYMENT_ENVIRONMENT": "local" + } + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Debug UsersLambda", + "program": "${workspaceFolder:penny-bot}/.build/debug/UsersLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Debug UsersLambda" + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Release UsersLambda", + "program": "${workspaceFolder:penny-bot}/.build/release/UsersLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Release UsersLambda" + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Debug SponsorsLambda", + "program": "${workspaceFolder:penny-bot}/.build/debug/SponsorsLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Debug SponsorsLambda" + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Release SponsorsLambda", + "program": "${workspaceFolder:penny-bot}/.build/release/SponsorsLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Release SponsorsLambda" + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Debug GHOAuthLambda", + "program": "${workspaceFolder:penny-bot}/.build/debug/GHOAuthLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Debug GHOAuthLambda" + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Release GHOAuthLambda", + "program": "${workspaceFolder:penny-bot}/.build/release/GHOAuthLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Release GHOAuthLambda" + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Debug GHHooksLambda", + "program": "${workspaceFolder:penny-bot}/.build/debug/GHHooksLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Debug GHHooksLambda" + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Release GHHooksLambda", + "program": "${workspaceFolder:penny-bot}/.build/release/GHHooksLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Release GHHooksLambda" + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Debug FaqsLambda", + "program": "${workspaceFolder:penny-bot}/.build/debug/FaqsLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Debug FaqsLambda" + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Release FaqsLambda", + "program": "${workspaceFolder:penny-bot}/.build/release/FaqsLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Release FaqsLambda" + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Debug AutoPingsLambda", + "program": "${workspaceFolder:penny-bot}/.build/debug/AutoPingsLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Debug AutoPingsLambda" + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Release AutoPingsLambda", + "program": "${workspaceFolder:penny-bot}/.build/release/AutoPingsLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Release AutoPingsLambda" + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Debug AutoFaqsLambda", + "program": "${workspaceFolder:penny-bot}/.build/debug/AutoFaqsLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Debug AutoFaqsLambda" + }, + { + "type": "lldb", + "request": "launch", + "sourceLanguages": ["swift"], + "name": "Release AutoFaqsLambda", + "program": "${workspaceFolder:penny-bot}/.build/release/AutoFaqsLambda", + "args": [], + "cwd": "${workspaceFolder:penny-bot}", + "preLaunchTask": "swift: Build Release AutoFaqsLambda" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..fd99883d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,81 @@ +{ + "files.autoSave": "afterDelay", + "debug.onTaskErrors": "abort", + "git.openRepositoryInParentFolders": "always", + "editor.unicodeHighlight.nonBasicASCII": false, + "files.associations": { + "*.swift": "swift" + }, + "workbench.editor.enablePreview": false, + "swift.sourcekit-lsp.trace.server": "messages", + "markdown.validate.enabled": true, + "diffEditor.codeLens": true, + "editor.stickyScroll.enabled": true, + "editor.stickyScroll.maxLineCount": 8, + "json.maxItemsComputed": 1000000, + "editor.foldingMaximumRegions": 65000, + "git.confirmSync": false, + "cSpell.ignorePaths": [ + "vscode-extension", + ".git/objects", + ".vscode", + ".vscode-insiders", + ".build", + ".*-build", + ".swiftpm" + ], + "yaml.maxItemsComputed": 1000000, + "yaml.format.enable": true, + "editor.rulers": [120], + "editor.minimap.enabled": false, + "editor.wordWrapColumn": 100, + "githubPullRequests.pullBranch": "never", + "errorLens.statusBarColorsEnabled": true, + "errorLens.statusBarIconsEnabled": true, + "errorLens.scrollbarHackEnabled": true, + "errorLens.delayMode": "debounce", + "errorLens.delay": 500, + "errorLens.editorHoverPartsEnabled": { + "messageEnabled": true, + "sourceCodeEnabled": true, + "buttonsEnabled": true + }, + "editor.unicodeHighlight.invisibleCharacters": false, + "editor.unicodeHighlight.ambiguousCharacters": false, + "editor.largeFileOptimizations": false, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "files.insertFinalNewline": true, + "editor.indentSize": "tabSize", + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "editor.formatOnType": true, + "[swift]": { + "editor.defaultFormatter": "sswg.swift-lang", + "editor.detectIndentation": false, + "editor.tabSize": 4, + "editor.insertSpaces": true + }, + "terminal.integrated.scrollback": 10000, + "swift.buildArguments": ["--force-resolved-versions"], + "swift.additionalTestArguments": ["--disable-xctest"], + "[yaml]": { + "editor.insertSpaces": true, + "editor.tabSize": 2, + "editor.defaultFormatter": "redhat.vscode-yaml" + }, + "[shellscript]": { + "editor.defaultFormatter": "foxundermoon.shell-format" + }, + "[dotenv]": { + "editor.defaultFormatter": null + }, + "[ignore]": { + "editor.defaultFormatter": null + }, + "[dockerfile]": { + "editor.defaultFormatter": null + }, + "[properties]": { + "editor.defaultFormatter": null + } +} diff --git a/Lambdas/AutoFaqs/AutoFaqsHandler.swift b/Lambdas/AutoFaqs/AutoFaqsHandler.swift index 93a66470..c7f472e6 100644 --- a/Lambdas/AutoFaqs/AutoFaqsHandler.swift +++ b/Lambdas/AutoFaqs/AutoFaqsHandler.swift @@ -1,15 +1,16 @@ -import AWSLambdaRuntime import AWSLambdaEvents +import AWSLambdaRuntime import AsyncHTTPClient +import LambdasShared +import Models +import Shared +import SotoCore + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import SotoCore -import Models -import Shared -import LambdasShared @main struct AutoFaqsHandler: LambdaHandler { diff --git a/Lambdas/AutoFaqs/S3AutoFaqsRepository.swift b/Lambdas/AutoFaqs/S3AutoFaqsRepository.swift index aae404ac..7509db4b 100644 --- a/Lambdas/AutoFaqs/S3AutoFaqsRepository.swift +++ b/Lambdas/AutoFaqs/S3AutoFaqsRepository.swift @@ -1,10 +1,11 @@ +import Models import SotoS3 + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import Models package struct S3AutoFaqsRepository { @@ -45,7 +46,10 @@ package struct S3AutoFaqsRepository { let request = S3.GetObjectRequest(bucket: bucket, key: key) response = try await s3.getObject(request, logger: logger) } catch { - logger.error("Cannot retrieve the file from the bucket. If this is the first time, manually create a file named '\(self.key)' in bucket '\(self.bucket)' and set its content to empty json ('{}'). This has not been automated to reduce the chance of data loss", metadata: ["error": "\(error)"]) + logger.error( + "Cannot retrieve the file from the bucket. If this is the first time, manually create a file named '\(self.key)' in bucket '\(self.bucket)' and set its content to empty json ('{}'). This has not been automated to reduce the chance of data loss", + metadata: ["error": "\(error)"] + ) throw error } @@ -57,10 +61,13 @@ package struct S3AutoFaqsRepository { do { return try decoder.decode([String: String].self, from: body) } catch { - logger.error("Cannot find any data in the bucket", metadata: [ - "response-body": .string(String(buffer: body)), - "error": "\(error)" - ]) + logger.error( + "Cannot find any data in the bucket", + metadata: [ + "response-body": .string(String(buffer: body)), + "error": "\(error)", + ] + ) return [String: String]() } } diff --git a/Lambdas/AutoPings/AutoPingsHandler.swift b/Lambdas/AutoPings/AutoPingsHandler.swift index 4a517c38..35e5da37 100644 --- a/Lambdas/AutoPings/AutoPingsHandler.swift +++ b/Lambdas/AutoPings/AutoPingsHandler.swift @@ -1,21 +1,22 @@ -import AWSLambdaRuntime import AWSLambdaEvents +import AWSLambdaRuntime import AsyncHTTPClient +import LambdasShared +import Models +import Shared +import SotoCore + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import SotoCore -import Models -import Shared -import LambdasShared @main struct AutoPingsHandler: LambdaHandler { typealias Event = APIGatewayV2Request typealias Output = APIGatewayV2Response - + let awsClient: AWSClient let pingsRepo: S3AutoPingsRepository @@ -27,7 +28,7 @@ struct AutoPingsHandler: LambdaHandler { self.awsClient = AWSClient(httpClient: httpClient) self.pingsRepo = S3AutoPingsRepository(awsClient: self.awsClient, logger: context.logger) } - + func handle( _ event: APIGatewayV2Request, context: LambdaContext @@ -92,7 +93,7 @@ struct AutoPingsHandler: LambdaHandler { ) ) } - + return APIGatewayV2Response(status: .ok, content: newItems) } } diff --git a/Lambdas/AutoPings/S3AutoPingsRepository.swift b/Lambdas/AutoPings/S3AutoPingsRepository.swift index df0ec996..724db088 100644 --- a/Lambdas/AutoPings/S3AutoPingsRepository.swift +++ b/Lambdas/AutoPings/S3AutoPingsRepository.swift @@ -1,13 +1,14 @@ +import Models import SotoS3 + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import Models package struct S3AutoPingsRepository { - + let s3: S3 let logger: Logger let bucket = "penny-auto-pings-lambda" @@ -20,7 +21,7 @@ package struct S3AutoPingsRepository { self.s3 = S3(client: awsClient, region: .euwest1) self.logger = logger } - + package func insert( expressions: [S3AutoPingItems.Expression], forDiscordID id: UserSnowflake @@ -47,18 +48,21 @@ package struct S3AutoPingsRepository { try await self.save(items: all) return all } - + package func getAll() async throws -> S3AutoPingItems { let response: S3.GetObjectOutput - + do { let request = S3.GetObjectRequest(bucket: bucket, key: key) response = try await s3.getObject(request, logger: logger) } catch { - logger.error("Cannot retrieve the file from the bucket. If this is the first time, manually create a file named '\(self.key)' in bucket '\(self.bucket)' and set its content to empty json ('{}'). This has not been automated to reduce the chance of data loss", metadata: ["error": "\(error)"]) + logger.error( + "Cannot retrieve the file from the bucket. If this is the first time, manually create a file named '\(self.key)' in bucket '\(self.bucket)' and set its content to empty json ('{}'). This has not been automated to reduce the chance of data loss", + metadata: ["error": "\(error)"] + ) throw error } - + let body = try await response.body.collect(upTo: 1 << 24) if body.readableBytes == 0 { logger.error("Cannot find any data in the bucket") @@ -67,14 +71,17 @@ package struct S3AutoPingsRepository { do { return try decoder.decode(S3AutoPingItems.self, from: body) } catch { - logger.error("Cannot find any data in the bucket", metadata: [ - "response-body": .string(String(buffer: body)), - "error": "\(error)" - ]) + logger.error( + "Cannot find any data in the bucket", + metadata: [ + "response-body": .string(String(buffer: body)), + "error": "\(error)", + ] + ) return S3AutoPingItems() } } - + package func save(items: S3AutoPingItems) async throws { let data = try encoder.encode(items) let putObjectRequest = S3.PutObjectRequest( diff --git a/Lambdas/Faqs/FaqsHandler.swift b/Lambdas/Faqs/FaqsHandler.swift index bc20ad1e..686b76ad 100644 --- a/Lambdas/Faqs/FaqsHandler.swift +++ b/Lambdas/Faqs/FaqsHandler.swift @@ -1,14 +1,15 @@ -import AWSLambdaRuntime import AWSLambdaEvents +import AWSLambdaRuntime import AsyncHTTPClient +import LambdasShared +import Models +import SotoCore + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import SotoCore -import Models -import LambdasShared @main struct FaqsHandler: LambdaHandler { diff --git a/Lambdas/Faqs/S3FaqsRepository.swift b/Lambdas/Faqs/S3FaqsRepository.swift index 586579df..70cb1074 100644 --- a/Lambdas/Faqs/S3FaqsRepository.swift +++ b/Lambdas/Faqs/S3FaqsRepository.swift @@ -1,10 +1,11 @@ +import Models import SotoS3 + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import Models package struct S3FaqsRepository { @@ -45,7 +46,10 @@ package struct S3FaqsRepository { let request = S3.GetObjectRequest(bucket: bucket, key: key) response = try await s3.getObject(request, logger: logger) } catch { - logger.error("Cannot retrieve the file from the bucket. If this is the first time, manually create a file named '\(self.key)' in bucket '\(self.bucket)' and set its content to empty json ('{}'). This has not been automated to reduce the chance of data loss", metadata: ["error": "\(error)"]) + logger.error( + "Cannot retrieve the file from the bucket. If this is the first time, manually create a file named '\(self.key)' in bucket '\(self.bucket)' and set its content to empty json ('{}'). This has not been automated to reduce the chance of data loss", + metadata: ["error": "\(error)"] + ) throw error } @@ -57,10 +61,13 @@ package struct S3FaqsRepository { do { return try decoder.decode([String: String].self, from: body) } catch { - logger.error("Cannot find any data in the bucket", metadata: [ - "response-body": .string(String(buffer: body)), - "error": "\(error)" - ]) + logger.error( + "Cannot find any data in the bucket", + metadata: [ + "response-body": .string(String(buffer: body)), + "error": "\(error)", + ] + ) return [String: String]() } } diff --git a/Lambdas/GHHooks/+Rendering/+LeafRenderer.swift b/Lambdas/GHHooks/+Rendering/+LeafRenderer.swift index b1df42d1..30d424fc 100644 --- a/Lambdas/GHHooks/+Rendering/+LeafRenderer.swift +++ b/Lambdas/GHHooks/+Rendering/+LeafRenderer.swift @@ -1,7 +1,8 @@ +import AsyncHTTPClient +import Logging import NIOCore import Rendering -import Logging -import AsyncHTTPClient + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -13,10 +14,12 @@ extension LeafRenderer { try LeafRenderer( subDirectory: "GHHooksLambda", httpClient: httpClient, - extraSources: [DocsLeafSource( - httpClient: httpClient, - logger: logger - )], + extraSources: [ + DocsLeafSource( + httpClient: httpClient, + logger: logger + ) + ], logger: logger, on: httpClient.eventLoopGroup.next() ) diff --git a/Lambdas/GHHooks/+Rendering/+RenderClient.swift b/Lambdas/GHHooks/+Rendering/+RenderClient.swift index d923ecdb..7d096a4a 100644 --- a/Lambdas/GHHooks/+Rendering/+RenderClient.swift +++ b/Lambdas/GHHooks/+Rendering/+RenderClient.swift @@ -25,4 +25,3 @@ extension RenderClient { ) } } - diff --git a/Lambdas/GHHooks/Authenticator.swift b/Lambdas/GHHooks/Authenticator.swift index 9a87729a..234d8a3b 100644 --- a/Lambdas/GHHooks/Authenticator.swift +++ b/Lambdas/GHHooks/Authenticator.swift @@ -1,9 +1,4 @@ import AsyncHTTPClient -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif import GitHubAPI import JWTKit import LambdasShared @@ -12,6 +7,12 @@ import OpenAPIAsyncHTTPClient import OpenAPIRuntime import Shared +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + actor Authenticator { private let secretsRetriever: SecretsRetriever private let httpClient: HTTPClient @@ -36,7 +37,7 @@ actor Authenticator { func generateAccessToken(forceRefreshToken: Bool = false) async throws -> String { try await self.queue.process(queueKey: "default") { if !forceRefreshToken, - let cachedAccessToken = await self.cachedAccessToken + let cachedAccessToken = await self.cachedAccessToken { return cachedAccessToken.token } else { @@ -50,12 +51,14 @@ actor Authenticator { } private func createAccessToken(client: Client) async throws -> InstallationToken { - let response = try await client.apps_create_installation_access_token(.init( - path: .init(installation_id: Constants.GitHub.installationID) - )) + let response = try await client.apps_create_installation_access_token( + .init( + path: .init(installation_id: Constants.GitHub.installationID) + ) + ) if case let .created(created) = response, - case let .json(json) = created.body + case let .json(json) = created.body { return json } else { diff --git a/Lambdas/GHHooks/Constants.swift b/Lambdas/GHHooks/Constants.swift index 9afee001..18b546ec 100644 --- a/Lambdas/GHHooks/Constants.swift +++ b/Lambdas/GHHooks/Constants.swift @@ -7,11 +7,11 @@ enum Constants { enum GitHub { /// The user id of Penny. - static let userID = 139480971 + static let userID = 139_480_971 /// The app-id of Penny. static let appID = 360798 /// The installation-id of Penny for Vapor org. - static let installationID = 39698047 + static let installationID = 39_698_047 } enum Channels: ChannelSnowflake, CaseIterable { diff --git a/Lambdas/GHHooks/Errors.swift b/Lambdas/GHHooks/Errors.swift index 880ddcc9..66798623 100644 --- a/Lambdas/GHHooks/Errors.swift +++ b/Lambdas/GHHooks/Errors.swift @@ -1,4 +1,5 @@ import AWSLambdaEvents + #if canImport(FoundationEssentials) import FoundationEssentials #else diff --git a/Lambdas/GHHooks/EventHandler/EventHandler.swift b/Lambdas/GHHooks/EventHandler/EventHandler.swift index 89bb9c15..c9fbf76a 100644 --- a/Lambdas/GHHooks/EventHandler/EventHandler.swift +++ b/Lambdas/GHHooks/EventHandler/EventHandler.swift @@ -15,7 +15,7 @@ struct EventHandler: Sendable { case .push: try await withThrowingAccumulatingVoidTaskGroup(tasks: [ { try await DocsIssuer(context: context).handle() }, - { try await PRCoinGiver(context: context).handle() } + { try await PRCoinGiver(context: context).handle() }, ]) case .ping: try await onPing() @@ -31,43 +31,49 @@ struct EventHandler: Sendable { func onPing() async throws { try await context.discordClient.createMessage( channelId: Constants.Channels.botLogs.id, - payload: .init(embeds: [.init( - title: "Ping events should not reach here", - description: """ - Ping events must be handled immediately, even before any body-decoding happens. - Action: \(context.event.action ?? "") - Repo: \(context.event.repository?.name ?? "") - """, - color: .red - )]) + payload: .init(embeds: [ + .init( + title: "Ping events should not reach here", + description: """ + Ping events must be handled immediately, even before any body-decoding happens. + Action: \(context.event.action ?? "") + Repo: \(context.event.repository?.name ?? "") + """, + color: .red + ) + ]) ).guardSuccess() } func onSponsorship() async throws { try await context.discordClient.createMessage( channelId: Constants.Channels.botLogs.id, - payload: .init(embeds: [.init( - title: "Got Sponsorship payload. Check the logs!", - description: """ - Action: \(context.event.action ?? "") - Repo: \(context.event.repository?.name ?? "") - """, - color: .yellow - )]) + payload: .init(embeds: [ + .init( + title: "Got Sponsorship payload. Check the logs!", + description: """ + Action: \(context.event.action ?? "") + Repo: \(context.event.repository?.name ?? "") + """, + color: .yellow + ) + ]) ).guardSuccess() } func onDefault() async throws { try await context.discordClient.createMessage( channelId: Constants.Channels.botLogs.id, - payload: .init(embeds: [.init( - title: "Received UNHANDLED event \(context.eventName)", - description: """ - Action: \(context.event.action ?? "") - Repo: \(context.event.repository?.name ?? "") - """, - color: .red - )]) + payload: .init(embeds: [ + .init( + title: "Received UNHANDLED event \(context.eventName)", + description: """ + Action: \(context.event.action ?? "") + Repo: \(context.event.repository?.name ?? "") + """, + color: .red + ) + ]) ).guardSuccess() } } diff --git a/Lambdas/GHHooks/EventHandler/HandlerContext.swift b/Lambdas/GHHooks/EventHandler/HandlerContext.swift index 7dd8b0a8..49d84689 100644 --- a/Lambdas/GHHooks/EventHandler/HandlerContext.swift +++ b/Lambdas/GHHooks/EventHandler/HandlerContext.swift @@ -1,10 +1,10 @@ import AsyncHTTPClient import DiscordBM import GitHubAPI +import Logging import OpenAPIRuntime import Rendering import Shared -import Logging struct HandlerContext: Sendable { let eventName: GHEvent.Kind diff --git a/Lambdas/GHHooks/EventHandler/Handlers/+String.swift b/Lambdas/GHHooks/EventHandler/Handlers/+String.swift index 85ae1282..ff2613fd 100644 --- a/Lambdas/GHHooks/EventHandler/Handlers/+String.swift +++ b/Lambdas/GHHooks/EventHandler/Handlers/+String.swift @@ -11,12 +11,12 @@ extension String? { } } -extension StringProtocol where Self: Sendable, SubSequence == Substring { +extension StringProtocol where Self: Sendable, SubSequence == Substring { func isPrimaryOrReleaseBranch(repo: Repository) -> Bool { - let result = repo.primaryBranch == self || - self.isSuffixedWithStableOrPartialStableSemVer + let result = repo.primaryBranch == self || self.isSuffixedWithStableOrPartialStableSemVer Logger(label: "StringProtocol.isPrimaryOrReleaseBranch").debug( - "Checking branch status for 'isPrimaryOrReleaseBranch'", metadata: [ + "Checking branch status for 'isPrimaryOrReleaseBranch'", + metadata: [ "branch": .stringConvertible(self), "isPrimaryOrReleaseBranch": .stringConvertible(result), ] diff --git a/Lambdas/GHHooks/EventHandler/Handlers/DocsIssuer.swift b/Lambdas/GHHooks/EventHandler/Handlers/DocsIssuer.swift index 350fc9a5..d54fa715 100644 --- a/Lambdas/GHHooks/EventHandler/Handlers/DocsIssuer.swift +++ b/Lambdas/GHHooks/EventHandler/Handlers/DocsIssuer.swift @@ -30,7 +30,7 @@ struct DocsIssuer { return } guard let branch = self.event.ref.extractHeadBranchFromRef(), - branch.isPrimaryOrReleaseBranch(repo: repo) + branch.isPrimaryOrReleaseBranch(repo: repo) else { return } for pr in try await self.getPRsRelatedToCommit() { @@ -44,10 +44,11 @@ struct DocsIssuer { let files = try await getPRFiles(number: pr.number) /// PR must contain file changes for files that are in the `docs` directory. /// Otherwise there is nothing to be translated and there is no need for a new issue. - guard files.contains(where: { file in - file.filename.hasPrefix("docs/") && - [.added, .modified].contains(file.status) - }) else { + guard + files.contains(where: { file in + file.filename.hasPrefix("docs/") && [.added, .modified].contains(file.status) + }) + else { self.logger.debug( "Will not file issue for docs push PR because no docs files are added or modified", metadata: ["number": .stringConvertible(pr.number)] @@ -90,10 +91,12 @@ struct DocsIssuer { owner: self.repo.owner.login, repo: self.repo.name ), - body: .json(.init( - title: .case1("Translation needed for #\(number)"), - body: description - )) + body: .json( + .init( + title: .case1("Translation needed for #\(number)"), + body: description + ) + ) ).created } } diff --git a/Lambdas/GHHooks/EventHandler/Handlers/IssueHandler.swift b/Lambdas/GHHooks/EventHandler/Handlers/IssueHandler.swift index 6bbeede1..8cb2c341 100644 --- a/Lambdas/GHHooks/EventHandler/Handlers/IssueHandler.swift +++ b/Lambdas/GHHooks/EventHandler/Handlers/IssueHandler.swift @@ -94,7 +94,7 @@ struct IssueHandler: Sendable { embedIssue: Issue? = nil, embedRepo: Repository? = nil ) async throws -> TicketReporter { - return try TicketReporter( + try TicketReporter( context: self.context, embed: await self.createReportEmbed( issue: embedIssue, @@ -102,7 +102,7 @@ struct IssueHandler: Sendable { ), createdAt: self.issue.created_at, repoID: self.repo.id, - number: self.issue.number, + number: self.issue.number, authorID: self.issue.user.requireValue().id ) } @@ -118,13 +118,14 @@ struct IssueHandler: Sendable { let issueLink = issue.html_url - let body = issue.body.map { body -> String in - body.formatMarkdown( - maxVisualLength: 256, - hardLimit: 2_048, - trailingTextMinLength: 96 - ) - } ?? "" + let body = + issue.body.map { body -> String in + body.formatMarkdown( + maxVisualLength: 256, + hardLimit: 2_048, + trailingTextMinLength: 96 + ) + } ?? "" let description = try await context.renderClient .ticketReport(title: issue.title, body: body) @@ -140,8 +141,9 @@ struct IssueHandler: Sendable { var iconURL = member?.uiAvatarURL ?? user.avatar_url var footer = "By \(authorName)" - if let verb = status.closedByVerb, - let closedBy = try await self.maybeGetClosedByUser() { + if let verb = status.closedByVerb, + let closedBy = try await self.maybeGetClosedByUser() + { if closedBy.id == issue.user?.id { /// The same person opened and closed the issue. footer = "Filed & \(verb) by \(authorName)" diff --git a/Lambdas/GHHooks/EventHandler/Handlers/PRCoinGiver.swift b/Lambdas/GHHooks/EventHandler/Handlers/PRCoinGiver.swift index c38eeb1d..f382f2c5 100644 --- a/Lambdas/GHHooks/EventHandler/Handlers/PRCoinGiver.swift +++ b/Lambdas/GHHooks/EventHandler/Handlers/PRCoinGiver.swift @@ -22,7 +22,7 @@ struct PRCoinGiver { func handle() async throws { guard let branch = self.event.ref.extractHeadBranchFromRef(), - branch.isPrimaryOrReleaseBranch(repo: repo) + branch.isPrimaryOrReleaseBranch(repo: repo) else { return } let prs = try await getPRsRelatedToCommit() if prs.isEmpty { return } @@ -32,16 +32,20 @@ struct PRCoinGiver { ) for pr in try await getPRsRelatedToCommit() { let user = try pr.user.requireValue() - if pr.merged_at == nil || - codeOwners.contains(user: user) { + if pr.merged_at == nil || codeOwners.contains(user: user) { continue } - guard let member = try await context.requester.getDiscordMember( - githubID: "\(user.id)" - ), let discordID = member.user?.id else { - logger.debug("Found no Discord member for the GitHub user", metadata: [ - "pr": "\(pr)", - ]) + guard + let member = try await context.requester.getDiscordMember( + githubID: "\(user.id)" + ), let discordID = member.user?.id + else { + logger.debug( + "Found no Discord member for the GitHub user", + metadata: [ + "pr": "\(pr)" + ] + ) continue } @@ -49,14 +53,16 @@ struct PRCoinGiver { if member.roles.contains(Constants.Roles.core.id) { continue } let amount = 5 - let coinResponse = try await context.usersService.postCoin(with: .init( - amount: amount, - /// GuildID because this is automated. - fromDiscordID: Snowflake(Constants.guildID), - toDiscordID: discordID, - source: .github, - reason: .prMerge - )) + let coinResponse = try await context.usersService.postCoin( + with: .init( + amount: amount, + /// GuildID because this is automated. + fromDiscordID: Snowflake(Constants.guildID), + toDiscordID: discordID, + source: .github, + reason: .prMerge + ) + ) try await context.discordClient.createMessage( channelId: Constants.Channels.thanks.id, @@ -65,16 +71,16 @@ struct PRCoinGiver { embeds: [ .init( description: """ - Thanks for your contribution in [**\(pr.title)**](\(pr.html_url)). - You now have \(amount) more \(Constants.ServerEmojis.coin.emoji) for a total of \(coinResponse.newCoinCount) \(Constants.ServerEmojis.coin.emoji)! - """, + Thanks for your contribution in [**\(pr.title)**](\(pr.html_url)). + You now have \(amount) more \(Constants.ServerEmojis.coin.emoji) for a total of \(coinResponse.newCoinCount) \(Constants.ServerEmojis.coin.emoji)! + """, color: .blue ), .init( description: """ - Want coins too? Link your GitHub account to take credit for your contributions. - Try `/github link` for more info, it's private. - """, + Want coins too? Link your GitHub account to take credit for your contributions. + Try `/github link` for more info, it's private. + """, color: .green ), ] diff --git a/Lambdas/GHHooks/EventHandler/Handlers/PRHandler.swift b/Lambdas/GHHooks/EventHandler/Handlers/PRHandler.swift index fc39d771..09739518 100644 --- a/Lambdas/GHHooks/EventHandler/Handlers/PRHandler.swift +++ b/Lambdas/GHHooks/EventHandler/Handlers/PRHandler.swift @@ -1,16 +1,17 @@ import AsyncHTTPClient import DiscordBM -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif import GitHubAPI import Markdown import NIOCore import NIOFoundationCompat import SwiftSemver +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + struct PRHandler { let context: HandlerContext let action: PullRequest.Action @@ -41,7 +42,8 @@ struct PRHandler { try await self.onClosed() case .edited, .converted_to_draft, .dequeued, .enqueued, .locked, .ready_for_review, .reopened, .unlocked: try await self.onEdited() - case .assigned, .auto_merge_disabled, .auto_merge_enabled, .demilestoned, .labeled, .milestoned, .review_request_removed, .review_requested, .synchronize, .unassigned, .unlabeled, .submitted: + case .assigned, .auto_merge_disabled, .auto_merge_enabled, .demilestoned, .labeled, .milestoned, + .review_request_removed, .review_requested, .synchronize, .unassigned, .unlabeled, .submitted: break } } @@ -89,13 +91,14 @@ struct PRHandler { func createReportEmbed() async throws -> Embed { let prLink = self.pr.html_url - let body = self.pr.body.map { body -> String in - body.formatMarkdown( - maxVisualLength: 256, - hardLimit: 2_048, - trailingTextMinLength: 96 - ) - } ?? "" + let body = + self.pr.body.map { body -> String in + body.formatMarkdown( + maxVisualLength: 256, + hardLimit: 2_048, + trailingTextMinLength: 96 + ) + } ?? "" let description = try await context.renderClient.ticketReport(title: self.pr.title, body: body) diff --git a/Lambdas/GHHooks/EventHandler/Handlers/ProjectBoardHandler.swift b/Lambdas/GHHooks/EventHandler/Handlers/ProjectBoardHandler.swift index 9d52b597..d469b936 100644 --- a/Lambdas/GHHooks/EventHandler/Handlers/ProjectBoardHandler.swift +++ b/Lambdas/GHHooks/EventHandler/Handlers/ProjectBoardHandler.swift @@ -237,12 +237,12 @@ extension [ProjectCard] { } } -private extension Issue { - var isClosed: Bool { +extension Issue { + fileprivate var isClosed: Bool { self.state == "closed" } - var hasAssignees: Bool { + fileprivate var hasAssignees: Bool { !(self.assignees ?? []).isEmpty } } diff --git a/Lambdas/GHHooks/EventHandler/Handlers/ReleaseMaker.swift b/Lambdas/GHHooks/EventHandler/Handlers/ReleaseMaker.swift index 419747f2..30a4d8c3 100644 --- a/Lambdas/GHHooks/EventHandler/Handlers/ReleaseMaker.swift +++ b/Lambdas/GHHooks/EventHandler/Handlers/ReleaseMaker.swift @@ -1,11 +1,12 @@ -import DiscordBM import AsyncHTTPClient +import DiscordBM +import GitHubAPI +import Logging +import Markdown import NIOCore import NIOFoundationCompat -import GitHubAPI import SwiftSemver -import Markdown -import Logging + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -15,10 +16,12 @@ import Foundation struct ReleaseMaker { enum Configuration { - static let repositoryIDDenyList: Set = [/*postgres-nio:*/ 150622661] + /// The postgres-nio repository ID. + static let repositoryIDDenyList: Set = [150_622_661] /// Needs the Penny installation to be installed on the org, /// which is not possible without making Penny app public. - static let organizationIDAllowList: Set = [/*vapor:*/ 17364220] + /// The Vapor organization ID. + static let organizationIDAllowList: Set = [17_364_220] static let releaseNoticePrefix = "**These changes are now available in" } @@ -59,10 +62,10 @@ struct ReleaseMaker { func handle() async throws { guard !Configuration.repositoryIDDenyList.contains(repo.id), - Configuration.organizationIDAllowList.contains(repo.owner.id), - let mergedBy = pr.merged_by, - pr.base.ref.isPrimaryOrReleaseBranch(repo: repo), - let bump = pr.knownLabels.first?.toBump() + Configuration.organizationIDAllowList.contains(repo.owner.id), + let mergedBy = pr.merged_by, + pr.base.ref.isPrimaryOrReleaseBranch(repo: repo), + let bump = pr.knownLabels.first?.toBump() else { return } let previousRelease = try await getLastRelease() @@ -135,15 +138,19 @@ struct ReleaseMaker { ) if case let .ok(ok) = response, - case let .json(json) = ok.body { + case let .json(json) = ok.body + { return json } - logger.warning("Could not find a 'latest' release", metadata: [ - "owner": .string(repo.owner.login), - "name": .string(repo.name), - "response": "\(response)", - ]) + logger.warning( + "Could not find a 'latest' release", + metadata: [ + "owner": .string(repo.owner.login), + "name": .string(repo.name), + "response": "\(response)", + ] + ) return nil } @@ -164,15 +171,17 @@ struct ReleaseMaker { owner: repo.owner.login, repo: repo.name ), - body: .json(.init( - tag_name: newVersion, - target_commitish: pr.base.ref, - name: "\(newVersion) - \(pr.title)", - body: body, - draft: false, - prerelease: isPrerelease, - make_latest: isPrerelease ? ._false : ._true - )) + body: .json( + .init( + tag_name: newVersion, + target_commitish: pr.base.ref, + name: "\(newVersion) - \(pr.title)", + body: body, + draft: false, + prerelease: isPrerelease, + make_latest: isPrerelease ? ._false : ._true + ) + ) ).created.body.json } @@ -186,10 +195,14 @@ struct ReleaseMaker { ).ok.body.json if (current.body ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) - .hasPrefix(Configuration.releaseNoticePrefix) { - logger.debug("Pull request doesn't need to be updated with release notice", metadata: [ - "current": "\(current)" - ]) + .hasPrefix(Configuration.releaseNoticePrefix) + { + logger.debug( + "Pull request doesn't need to be updated with release notice", + metadata: [ + "current": "\(current)" + ] + ) return } let updated = try await context.githubClient.pulls_update( @@ -198,27 +211,30 @@ struct ReleaseMaker { repo: repo.name, pull_number: number ), - body: .json(.init( - body: """ - \(Configuration.releaseNoticePrefix) [\(release.tag_name)](\(release.html_url))** + body: .json( + .init( + body: """ + \(Configuration.releaseNoticePrefix) [\(release.tag_name)](\(release.html_url))** - \(current.body ?? "") - """ - )) + \(current.body ?? "") + """ + ) + ) ).ok.body.json - logger.debug("Updated a pull request with a release notice", metadata: [ - "before": "\(current)", - "after": "\(updated)" - ]) + logger.debug( + "Updated a pull request with a release notice", + metadata: [ + "before": "\(current)", + "after": "\(updated)", + ] + ) } - /** - - A user who appears in a given repo's code owners file should NOT be credited as either an author or reviewer for a release in that repo (but can still be credited for releasing it). - - The user who authored the PR should be credited unless they are a code owner. Such a credit should be prominent and - as the GitHub changelog generator does - include a notation if it's that user's first merged PR. - - Any users who reviewed the PR (even if they requested changes or did a comments-only review without approving) should also be credited unless they are code owners. Such a credit should be less prominent than the author credit, something like a "thanks to ... for helping to review this release" - - The release author (user who merged the PR) should always be credited in a release, even if they're a code owner. This credit should be the least prominent, maybe even just a footnote (since it will pretty much always be a owner/maintainer). - */ + /// - A user who appears in a given repo's code owners file should NOT be credited as either an author or reviewer for a release in that repo (but can still be credited for releasing it). + /// - The user who authored the PR should be credited unless they are a code owner. Such a credit should be prominent and - as the GitHub changelog generator does - include a notation if it's that user's first merged PR. + /// - Any users who reviewed the PR (even if they requested changes or did a comments-only review without approving) should also be credited unless they are code owners. Such a credit should be less prominent than the author credit, something like a "thanks to ... for helping to review this release" + /// - The release author (user who merged the PR) should always be credited in a release, even if they're a code owner. This credit should be the least prominent, maybe even just a footnote (since it will pretty much always be a owner/maintainer). func makeReleaseBody( mergedBy: NullableUser, previousVersion: String, @@ -274,27 +290,30 @@ struct ReleaseMaker { } func isNewContributor(codeOwners: CodeOwners, existingContributors: Set) -> Bool { - pr.author_association != .OWNER && - !pr.user.isBot && - !codeOwners.contains(user: pr.user) && - !existingContributors.contains(pr.user.id) + pr.author_association != .OWNER && !pr.user.isBot && !codeOwners.contains(user: pr.user) + && !existingContributors.contains(pr.user.id) } func getReviews() async throws -> [PullRequestReview] { let response = try await context.githubClient.pulls_list_reviews( - .init(path: .init( - owner: repo.owner.login, - repo: repo.name, - pull_number: number - )) + .init( + path: .init( + owner: repo.owner.login, + repo: repo.name, + pull_number: number + ) + ) ) guard case let .ok(ok) = response, - case let .json(json) = ok.body + case let .json(json) = ok.body else { - logger.warning("Could not find reviews", metadata: [ - "response": "\(response)" - ]) + logger.warning( + "Could not find reviews", + metadata: [ + "response": "\(response)" + ] + ) return [] } @@ -320,9 +339,12 @@ struct ReleaseMaker { } func getExistingContributorIDs(page: Int) async throws -> (ids: [Int], hasNext: Bool) { - logger.debug("Will fetch current contributors", metadata: [ - "page": .stringConvertible(page) - ]) + logger.debug( + "Will fetch current contributors", + metadata: [ + "page": .stringConvertible(page) + ] + ) let response = try await context.githubClient.repos_list_contributors( path: .init( @@ -336,28 +358,36 @@ struct ReleaseMaker { ) if case let .ok(ok) = response, - case let .json(json) = ok.body { + case let .json(json) = ok.body + { /// Example of a `link` header: `; rel="prev", ; rel="next", ; rel="last", ; rel="first"` /// If the header contains `rel="next"` then we'll have a next page to fetch. - let hasNext = switch ok.headers.Link { - case let .case1(string): - string.contains(#"rel="next""#) - case let .case2(strings): - strings.contains { $0.contains(#"rel="next""#) } - case .none: - false - } + let hasNext = + switch ok.headers.Link { + case let .case1(string): + string.contains(#"rel="next""#) + case let .case2(strings): + strings.contains { $0.contains(#"rel="next""#) } + case .none: + false + } let ids = json.compactMap(\.id) - logger.debug("Fetched some contributors", metadata: [ - "page": .stringConvertible(page), - "count": .stringConvertible(ids.count) - ]) + logger.debug( + "Fetched some contributors", + metadata: [ + "page": .stringConvertible(page), + "count": .stringConvertible(ids.count), + ] + ) return (ids, hasNext) } else { - logger.error("Error when fetching contributors but will continue", metadata: [ - "page": .stringConvertible(page), - "response": "\(response)" - ]) + logger.error( + "Error when fetching contributors but will continue", + metadata: [ + "page": .stringConvertible(page), + "response": "\(response)", + ] + ) return ([], false) } } diff --git a/Lambdas/GHHooks/EventHandler/Handlers/ReleaseReporter.swift b/Lambdas/GHHooks/EventHandler/Handlers/ReleaseReporter.swift index c2aea651..90e7810b 100644 --- a/Lambdas/GHHooks/EventHandler/Handlers/ReleaseReporter.swift +++ b/Lambdas/GHHooks/EventHandler/Handlers/ReleaseReporter.swift @@ -1,7 +1,8 @@ -import GitHubAPI +import Algorithms import DiscordBM +import GitHubAPI import Logging -import Algorithms + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -55,13 +56,17 @@ struct ReleaseReporter { ).ok.body.json if let releaseIdx = json.firstIndex(where: { $0.name == release.tag_name }), - json.count > releaseIdx { + json.count > releaseIdx + { return json[releaseIdx + 1].name } else { - logger.warning("No previous tag found. Will just return the first tag", metadata: [ - "tags": "\(json)", - "release": "\(release)" - ]) + logger.warning( + "No previous tag found. Will just return the first tag", + metadata: [ + "tags": "\(json)", + "release": "\(release)", + ] + ) return json.first?.name } } @@ -118,13 +123,14 @@ struct ReleaseReporter { } func sendToDiscord(pr: SimplePullRequest) async throws { - let body = pr.body.map { body -> String in - body.trimmingReleaseNoticeFromBody().formatMarkdown( - maxVisualLength: 256, - hardLimit: 2_048, - trailingTextMinLength: 96 - ) - } ?? "" + let body = + pr.body.map { body -> String in + body.trimmingReleaseNoticeFromBody().formatMarkdown( + maxVisualLength: 256, + hardLimit: 2_048, + trailingTextMinLength: 96 + ) + } ?? "" let description = try await context.renderClient.ticketReport(title: pr.title, body: body) @@ -146,44 +152,49 @@ struct ReleaseReporter { let commitCount = commitCount > 10 ? "More Than 10" : "\(commitCount)" let description = """ - ### \(commitCount) Changes, Including: + ### \(commitCount) Changes, Including: - \(prDescriptions) - """.formatMarkdown( - maxVisualLength: 256, - hardLimit: 2_048, - trailingTextMinLength: 96 - ) - try await sendToDiscord(description: description) - } - - func sendToDiscordWithRelease() async throws { - let description = release.body.map { body -> String in - let preferredContent = body.contentsOfHeading( - named: "What's Changed" - ) ?? body - let formatted = preferredContent.formatMarkdown( + \(prDescriptions) + """.formatMarkdown( maxVisualLength: 256, hardLimit: 2_048, trailingTextMinLength: 96 ) - return formatted.isEmpty ? "" : ">>> \(formatted)" - } ?? "" + try await sendToDiscord(description: description) + } + + func sendToDiscordWithRelease() async throws { + let description = + release.body.map { body -> String in + let preferredContent = + body.contentsOfHeading( + named: "What's Changed" + ) ?? body + let formatted = preferredContent.formatMarkdown( + maxVisualLength: 256, + hardLimit: 2_048, + trailingTextMinLength: 96 + ) + return formatted.isEmpty ? "" : ">>> \(formatted)" + } ?? "" try await sendToDiscord(description: description) } func sendToDiscord(description: String) async throws { let fullName = repo.full_name.urlPathEncoded() - let image = "https://opengraph.githubassets.com/\(UUID().uuidString)/\(fullName)/releases/tag/\(release.tag_name)" - - try await self.sendToDiscord(embed: .init( - title: "[\(repo.uiName)] Release \(release.tag_name)".unicodesPrefix(256), - description: description, - url: release.html_url, - color: .cyan, - image: .init(url: .exact(image)) - )) + let image = + "https://opengraph.githubassets.com/\(UUID().uuidString)/\(fullName)/releases/tag/\(release.tag_name)" + + try await self.sendToDiscord( + embed: .init( + title: "[\(repo.uiName)] Release \(release.tag_name)".unicodesPrefix(256), + description: description, + url: release.html_url, + color: .cyan, + image: .init(url: .exact(image)) + ) + ) } func sendToDiscord(embed: Embed) async throws { diff --git a/Lambdas/GHHooks/EventHandler/Handlers/TicketReporter.swift b/Lambdas/GHHooks/EventHandler/Handlers/TicketReporter.swift index 18ed6235..3a2df53b 100644 --- a/Lambdas/GHHooks/EventHandler/Handlers/TicketReporter.swift +++ b/Lambdas/GHHooks/EventHandler/Handlers/TicketReporter.swift @@ -1,5 +1,6 @@ import DiscordBM import Shared + #if canImport(FoundationEssentials) import struct FoundationEssentials.Date #else @@ -10,7 +11,8 @@ import struct Foundation.Date struct TicketReporter { enum Configuration { - static let userIDDenyList: Set = [ /* dependabot[bot]: */ 49_699_333] + /// The dependabot[bot] user ID. + static let userIDDenyList: Set = [49_699_333] } private static let ticketQueue = SerialProcessor() @@ -75,9 +77,12 @@ struct TicketReporter { number: number ) messageID = MessageSnowflake(repoMessageID) - context.logger.debug("Got message ID from Repo", metadata: [ - "messageID": "\(messageID)" - ]) + context.logger.debug( + "Got message ID from Repo", + metadata: [ + "messageID": "\(messageID)" + ] + ) } catch let error as DynamoMessageRepo.Errors where error == .unavailable { context.logger.debug("Message is unavailable to edit") return @@ -128,14 +133,14 @@ struct TicketReporter { } } -private extension Constants.Channels { - static func reportingChannel(repoID: Int, createdAt: Date) -> Self { +extension Constants.Channels { + fileprivate static func reportingChannel(repoID: Int, createdAt: Date) -> Self { /// The change to use `.documentation` was made only after this timestamp. if createdAt.timeIntervalSince1970 < 1_696_067_000 { return .issuesAndPRs } else { switch repoID { - case 64560805: + case 64_560_805: return .documentation default: return .issuesAndPRs diff --git a/Lambdas/GHHooks/EventHandler/Requester.swift b/Lambdas/GHHooks/EventHandler/Requester.swift index 646ed5bf..3d8927d1 100644 --- a/Lambdas/GHHooks/EventHandler/Requester.swift +++ b/Lambdas/GHHooks/EventHandler/Requester.swift @@ -1,8 +1,8 @@ +import AsyncHTTPClient import DiscordBM import GitHubAPI -import AsyncHTTPClient -import Shared import Logging +import Shared /// A shared place for requests that more than 1 place uses. struct Requester: Sendable { @@ -43,31 +43,38 @@ extension Requester { let response = try await self.httpClient.execute(request, timeout: .seconds(5)) let body = try await response.body.collect(upTo: 1 << 16) guard response.status == .ok else { - logger.warning("Can't find code owners of repo", metadata: [ - "responseBody": "\(body)", - "response": "\(response)" - ]) + logger.warning( + "Can't find code owners of repo", + metadata: [ + "responseBody": "\(body)", + "response": "\(response)", + ] + ) return CodeOwners(value: []) } let text = String(buffer: body) let parsed = parseCodeOwners(text: text) - logger.debug("Parsed code owners", metadata: [ - "text": .string(text), - "parsed": .stringConvertible(parsed) - ]) + logger.debug( + "Parsed code owners", + metadata: [ + "text": .string(text), + "parsed": .stringConvertible(parsed), + ] + ) return parsed } /// Returns code owner names all lowercased. func parseCodeOwners(text: String) -> CodeOwners { - let codeOwners: [String] = text - /// split into lines + let codeOwners: [String] = + text + /// split into lines .split(omittingEmptySubsequences: true, whereSeparator: \.isNewline) - /// trim leading whitespace per line + /// trim leading whitespace per line .map { $0.trimmingPrefix(while: \.isWhitespace) } - /// remove whole-line comments + /// remove whole-line comments .filter { !$0.starts(with: "#") } - /// remove partial-line comments + /// remove partial-line comments .compactMap { $0.split( separator: "#", @@ -75,7 +82,7 @@ extension Requester { omittingEmptySubsequences: true ).first } - /// split lines on whitespace, dropping first character, and combine to single list + /// split lines on whitespace, dropping first character, and combine to single list .flatMap { line -> [Substring] in line.split( omittingEmptySubsequences: true, @@ -130,7 +137,7 @@ struct CodeOwners: CustomStringConvertible { } func union(_ other: Set) -> CodeOwners { - return CodeOwners( + CodeOwners( lowercasedValue: self.value.union( other.map({ $0.lowercased() }) ) diff --git a/Lambdas/GHHooks/Extensions/+Issue.Label.swift b/Lambdas/GHHooks/Extensions/+Issue.Label.swift index 60fffb35..48d04549 100644 --- a/Lambdas/GHHooks/Extensions/+Issue.Label.swift +++ b/Lambdas/GHHooks/Extensions/+Issue.Label.swift @@ -18,8 +18,8 @@ extension Issue { } } -private extension Issue.labelsPayloadPayload { - var name: String? { +extension Issue.labelsPayloadPayload { + fileprivate var name: String? { switch self { case let .case1(string): return string diff --git a/Lambdas/GHHooks/Extensions/+SemanticVersion.swift b/Lambdas/GHHooks/Extensions/+SemanticVersion.swift index 7e2d085f..ca286d2c 100644 --- a/Lambdas/GHHooks/Extensions/+SemanticVersion.swift +++ b/Lambdas/GHHooks/Extensions/+SemanticVersion.swift @@ -93,7 +93,7 @@ extension SemanticVersion { } } } - + return version } } diff --git a/Lambdas/GHHooks/Extensions/Embed+Equtable.swift b/Lambdas/GHHooks/Extensions/Embed+Equtable.swift index 24bd37b0..2ed3ea9b 100644 --- a/Lambdas/GHHooks/Extensions/Embed+Equtable.swift +++ b/Lambdas/GHHooks/Extensions/Embed+Equtable.swift @@ -2,64 +2,47 @@ import DiscordModels extension Embed: @retroactive Equatable { public static func == (lhs: Embed, rhs: Embed) -> Bool { - lhs.title == rhs.title && - lhs.type == rhs.type && - lhs.description == rhs.description && - lhs.url == rhs.url && - lhs.timestamp?.date == rhs.timestamp?.date && - lhs.color == rhs.color && - lhs.footer == rhs.footer && - lhs.image == rhs.image && - lhs.thumbnail == rhs.thumbnail && - lhs.video == rhs.video && - lhs.provider == rhs.provider && - lhs.author == rhs.author && - lhs.fields == rhs.fields + lhs.title == rhs.title && lhs.type == rhs.type && lhs.description == rhs.description && lhs.url == rhs.url + && lhs.timestamp?.date == rhs.timestamp?.date && lhs.color == rhs.color && lhs.footer == rhs.footer + && lhs.image == rhs.image && lhs.thumbnail == rhs.thumbnail && lhs.video == rhs.video + && lhs.provider == rhs.provider && lhs.author == rhs.author && lhs.fields == rhs.fields } } extension Embed.Media: @retroactive Equatable { public static func == (lhs: Embed.Media, rhs: Embed.Media) -> Bool { - lhs.url.asString == rhs.url.asString && - lhs.proxy_url ?== rhs.proxy_url && - lhs.height ?== rhs.height && - lhs.width ?== rhs.width + lhs.url.asString == rhs.url.asString && lhs.proxy_url ?== rhs.proxy_url && lhs.height ?== rhs.height + && lhs.width ?== rhs.width } } extension Embed.Footer: @retroactive Equatable { public static func == (lhs: Embed.Footer, rhs: Embed.Footer) -> Bool { - lhs.icon_url?.asString == rhs.icon_url?.asString && - lhs.text == rhs.text && - lhs.proxy_icon_url ?== rhs.proxy_icon_url + lhs.icon_url?.asString == rhs.icon_url?.asString && lhs.text == rhs.text && lhs.proxy_icon_url + ?== rhs.proxy_icon_url } } extension Embed.Provider: @retroactive Equatable { public static func == (lhs: Embed.Provider, rhs: Embed.Provider) -> Bool { - lhs.url == rhs.url && - lhs.name == rhs.name + lhs.url == rhs.url && lhs.name == rhs.name } } extension Embed.Author: @retroactive Equatable { public static func == (lhs: Embed.Author, rhs: Embed.Author) -> Bool { - lhs.url == rhs.url && - lhs.icon_url?.asString == rhs.icon_url?.asString && - lhs.name == rhs.name && - lhs.proxy_icon_url ?== rhs.proxy_icon_url + lhs.url == rhs.url && lhs.icon_url?.asString == rhs.icon_url?.asString && lhs.name == rhs.name + && lhs.proxy_icon_url ?== rhs.proxy_icon_url } } extension Embed.Field: @retroactive Equatable { public static func == (lhs: Embed.Field, rhs: Embed.Field) -> Bool { - lhs.name == rhs.name && - lhs.value == rhs.value && - lhs.inline ?== rhs.inline + lhs.name == rhs.name && lhs.value == rhs.value && lhs.inline ?== rhs.inline } } -infix operator ?==: ComparisonPrecedence +infix operator ?== : ComparisonPrecedence /// Returns `true` if any of the values is `nil`, or if both values are the same. Otherwise `false`. /// This is used for the fields that even if we send `nil` for, Discord might populate them itself. diff --git a/Lambdas/GHHooks/Extensions/String+Document.swift b/Lambdas/GHHooks/Extensions/String+Document.swift index 05f81e35..9237fba1 100644 --- a/Lambdas/GHHooks/Extensions/String+Document.swift +++ b/Lambdas/GHHooks/Extensions/String+Document.swift @@ -4,7 +4,7 @@ extension String { /// Formats markdown in a way that looks decent on both Discord and GitHub at the same time. /// /// If you want to know why something is being done, comment out those lines and run the tests. - /// + /// /// Or, better yet, nag the code author to add more comments to things that are complicated and confusing. func formatMarkdown( maxVisualLength: Int, @@ -13,13 +13,17 @@ extension String { ) -> String { assert(maxVisualLength > 0, "Maximum visual length must be greater than zero (got \(maxVisualLength)).") assert(hardLimit > 0, "Hard length limit must be greater than zero (got \(hardLimit)).") - assert(hardLimit >= maxVisualLength, "maxVisualLength '\(maxVisualLength)' can't be more than hardLimit '\(hardLimit)'.") + assert( + hardLimit >= maxVisualLength, + "maxVisualLength '\(maxVisualLength)' can't be more than hardLimit '\(hardLimit)'." + ) /// Interpret urls like GitHub does. /// For example GitHub changes `https://github.com/vapor/penny-bot/issues/99` to /// `[vapor/penny-bot#99](https://github.com/vapor/penny-bot/issues/99)` which /// ends up looking like a blue `vapor/penny-bot#99` text linked to the url. - let regex = #/ + let regex = + #/ https://(?:www\.)?github\.com /(?[A-Za-z0-9](?:[A-Za-z0-9\-]*[A-Za-z0-9])?) /(?[A-Za-z0-9.\-_]+) @@ -31,16 +35,16 @@ extension String { /// `match.output` is a `Range` which means it's `..<`. /// So the character at `index == upperBound` is not part of the match. if match.range.upperBound < self.endIndex, - /// `offsetBy: 2` is guaranteed to exist because the string must contain - /// `github` based on the regex above, so it has more length than 3. - match.range.lowerBound > self.index(self.startIndex, offsetBy: 2) { + /// `offsetBy: 2` is guaranteed to exist because the string must contain + /// `github` based on the regex above, so it has more length than 3. + match.range.lowerBound > self.index(self.startIndex, offsetBy: 2) + { /// All 3 indexes below are guaranteed to exist based on the range check above. let before = self.index(before: match.range.lowerBound) let beforeBefore = self.index(before: before) /// Is surrounded like `STR` in `](STR)` or not. - let isSurroundedInSomePuncs = self[beforeBefore] == "]" && - self[before] == "(" && - self[match.range.upperBound] == ")" + let isSurroundedInSomePuncs = + self[beforeBefore] == "]" && self[before] == "(" && self[match.range.upperBound] == ")" /// If it's surrounded in the punctuations above, it /// might already be a link, so don't manipulate it. if isSurroundedInSomePuncs { return current } @@ -57,7 +61,8 @@ extension String { guard var markup2 = emptyLinksRemover.visit(markup1) else { return "" } var didRemoveMarkdownElement = false - var (remaining, prefixed) = markup2 + var (remaining, prefixed) = + markup2 .format(options: .default) .trimmingForMarkdown() .markdownUnicodesPrefix(maxVisualLength) @@ -79,7 +84,8 @@ extension String { didRemoveMarkdownElement = didRemoveMarkdownElement || paragraphRemover.didModify markup2 = markup /// Update `prefixed` - prefixed = markup2 + prefixed = + markup2 .format(options: .default) .trimmingForMarkdown() .markdownUnicodesPrefix(maxVisualLength) @@ -90,10 +96,12 @@ extension String { /// If the final block element is a heading, remove it (cosmetics again) var document3 = Document(parsing: prefixed) if let last = document3.blockChildren.suffix(1).first, - last is Heading { + last is Heading + { didRemoveMarkdownElement = true document3 = Document(document3.blockChildren.dropLast()) - prefixed = document3 + prefixed = + document3 .format(options: .default) .trimmingForMarkdown() .markdownUnicodesPrefix(maxVisualLength) @@ -155,18 +163,18 @@ extension String { } } -private extension MarkupFormatter.Options { +extension MarkupFormatter.Options { /// It's safe but apparently the underlying type doesn't declare a proper conditional Sendable conformance. - static nonisolated(unsafe) let `default` = Self() + fileprivate static nonisolated(unsafe) let `default` = Self() } private struct HTMLAndImageRemover: MarkupRewriter { func visitHTMLBlock(_ html: HTMLBlock) -> (any Markup)? { - return nil + nil } func visitImage(_ image: Image) -> (any Markup)? { - return nil + nil } } @@ -299,9 +307,9 @@ private struct TextElementUnicodePrefixRewriter: MarkupRewriter { } } -private extension StringProtocol { +extension StringProtocol { /// The line is worthless and can be trimmed. - var isWorthlessLineForTrim: Bool { + fileprivate var isWorthlessLineForTrim: Bool { self.allSatisfy({ $0.isWhitespace || $0.isPunctuation }) } } @@ -331,8 +339,9 @@ private struct HeadingFinder: MarkupWalker { } else { if let heading = markup as? Heading { if let firstChild = heading.children.first(where: { _ in true }), - let text = firstChild as? Text, - Self.fold(text.string) == name { + let text = firstChild as? Text, + Self.fold(text.string) == name + { self.started = true } } diff --git a/Lambdas/GHHooks/GHHooksHandler.swift b/Lambdas/GHHooks/GHHooksHandler.swift index 78686622..d43e826f 100644 --- a/Lambdas/GHHooks/GHHooksHandler.swift +++ b/Lambdas/GHHooks/GHHooksHandler.swift @@ -1,14 +1,15 @@ -import AWSLambdaRuntime import AWSLambdaEvents +import AWSLambdaRuntime import AsyncHTTPClient -import SotoCore import DiscordHTTP -import GitHubAPI -import Rendering import DiscordUtilities -import Logging +import GitHubAPI import LambdasShared +import Logging +import Rendering import Shared +import SotoCore + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -86,22 +87,29 @@ struct GHHooksHandler: LambdaHandler { channelId: Constants.Channels.botLogs.id, payload: .init( content: DiscordUtils.mention(id: Constants.botDevUserID), - embeds: [.init( - title: "GHHooks lambda top-level error", - description: "\(error)".unicodesPrefix(2_048), - color: .red - )], - files: [.init( - data: ByteBuffer(string: "\(error)"), - filename: "error" - )], + embeds: [ + .init( + title: "GHHooks lambda top-level error", + description: "\(error)".unicodesPrefix(2_048), + color: .red + ) + ], + files: [ + .init( + data: ByteBuffer(string: "\(error)"), + filename: "error" + ) + ], attachments: [.init(index: 0, filename: "error")] ) ).guardSuccess() } catch { - logger.error("DiscordClient logging error", metadata: [ - "error": "\(error)" - ]) + logger.error( + "DiscordClient logging error", + metadata: [ + "error": "\(error)" + ] + ) } throw error } @@ -111,14 +119,18 @@ struct GHHooksHandler: LambdaHandler { _ request: APIGatewayV2Request, context: LambdaContext ) async throws -> APIGatewayV2Response { - logger.debug("Got request", metadata: [ - "request": "\(request)" - ]) + logger.debug( + "Got request", + metadata: [ + "request": "\(request)" + ] + ) try await verifyWebhookSignature(request: request) guard let _eventName = request.headers.first(name: "x-github-event"), - let eventName = GHEvent.Kind(rawValue: _eventName) else { + let eventName = GHEvent.Kind(rawValue: _eventName) + else { throw Errors.headerNotFound(name: "x-gitHub-event", headers: request.headers) } @@ -133,9 +145,12 @@ struct GHHooksHandler: LambdaHandler { let event = try request.decodeWithISO8601(as: GHEvent.self) logger.debug("Event id: '\(eventName).\(event.action ?? "")'") - logger.trace("Decoded event", metadata: [ - "event": "\(event)" - ]) + logger.trace( + "Decoded event", + metadata: [ + "event": "\(event)" + ] + ) let apiBaseURL = try requireEnvVar("API_BASE_URL") try await EventHandler( diff --git a/Lambdas/GHHooks/MessageLookupRepo/DynamoMessageRepo.swift b/Lambdas/GHHooks/MessageLookupRepo/DynamoMessageRepo.swift index fa9490af..6c4e9707 100644 --- a/Lambdas/GHHooks/MessageLookupRepo/DynamoMessageRepo.swift +++ b/Lambdas/GHHooks/MessageLookupRepo/DynamoMessageRepo.swift @@ -1,5 +1,5 @@ -import SotoDynamoDB import Atomics +import SotoDynamoDB struct DynamoMessageRepo: MessageLookupRepo { @@ -83,10 +83,13 @@ struct DynamoMessageRepo: MessageLookupRepo { private func get(id: String) async throws -> Item { let requestID = Self.idGenerator.loadThenWrappingIncrement(ordering: .relaxed) - logger.trace("Will get an item", metadata: [ - "id": .string(id), - "repo-request-id": .stringConvertible(requestID), - ]) + logger.trace( + "Will get an item", + metadata: [ + "id": .string(id), + "repo-request-id": .stringConvertible(requestID), + ] + ) let query = DynamoDB.QueryInput( expressionAttributeValues: [":v1": .s(id)], keyConditionExpression: "id = :v1", @@ -98,10 +101,13 @@ struct DynamoMessageRepo: MessageLookupRepo { type: Item.self, logger: self.logger ) - logger.debug("Got some items", metadata: [ - "items": "\(result.items ?? [])", - "repo-request-id": .stringConvertible(requestID), - ]) + logger.debug( + "Got some items", + metadata: [ + "items": "\(result.items ?? [])", + "repo-request-id": .stringConvertible(requestID), + ] + ) guard let item = result.items?.first else { throw Errors.notFound } @@ -110,10 +116,13 @@ struct DynamoMessageRepo: MessageLookupRepo { private func save(item: Item) async throws { let requestID = Self.idGenerator.loadThenWrappingIncrement(ordering: .relaxed) - logger.debug("Will save an item", metadata: [ - "item": "\(item)", - "repo-request-id": .stringConvertible(requestID), - ]) + logger.debug( + "Will save an item", + metadata: [ + "item": "\(item)", + "repo-request-id": .stringConvertible(requestID), + ] + ) let input = DynamoDB.UpdateItemCodableInput( key: ["id"], tableName: self.tableName, @@ -125,8 +134,11 @@ struct DynamoMessageRepo: MessageLookupRepo { logger: self.logger ) - logger.trace("Item did save", metadata: [ - "repo-request-id": .stringConvertible(requestID) - ]) + logger.trace( + "Item did save", + metadata: [ + "repo-request-id": .stringConvertible(requestID) + ] + ) } } diff --git a/Lambdas/GHHooks/MessageLookupRepo/MessageLookupRepo.swift b/Lambdas/GHHooks/MessageLookupRepo/MessageLookupRepo.swift index daf70698..d7116336 100644 --- a/Lambdas/GHHooks/MessageLookupRepo/MessageLookupRepo.swift +++ b/Lambdas/GHHooks/MessageLookupRepo/MessageLookupRepo.swift @@ -1,4 +1,3 @@ - protocol MessageLookupRepo: Sendable { func getMessageID(repoID: Int, number: Int) async throws -> String func markAsUnavailable(repoID: Int, number: Int) async throws diff --git a/Lambdas/GHOAuth/Constants.swift b/Lambdas/GHOAuth/Constants.swift index da9f4c7e..39a60cee 100644 --- a/Lambdas/GHOAuth/Constants.swift +++ b/Lambdas/GHOAuth/Constants.swift @@ -3,7 +3,7 @@ import DiscordBM enum Constants { enum Channels: ChannelSnowflake { case botLogs = "1067060193982156880" - + var id: ChannelSnowflake { self.rawValue } diff --git a/Lambdas/GHOAuth/Models/GHOAuthPayload.swift b/Lambdas/GHOAuth/Models/GHOAuthPayload.swift index 8e9f3340..8a60f9d4 100644 --- a/Lambdas/GHOAuth/Models/GHOAuthPayload.swift +++ b/Lambdas/GHOAuth/Models/GHOAuthPayload.swift @@ -1,5 +1,6 @@ import DiscordBM import JWTKit + #if canImport(FoundationEssentials) import struct FoundationEssentials.Date #else @@ -19,7 +20,7 @@ package struct GHOAuthPayload: JWTPayload { package init(discordID: UserSnowflake, interactionToken: String) { self.discordID = discordID self.interactionToken = interactionToken - self.expiration = .init(value: Date().addingTimeInterval(10 * 60)) // 10 minutes + self.expiration = .init(value: Date().addingTimeInterval(10 * 60)) // 10 minutes } package func verify(using algorithm: some JWTAlgorithm) async throws { diff --git a/Lambdas/GHOAuth/OAuthLambda.swift b/Lambdas/GHOAuth/OAuthLambda.swift index 88da01e6..9579bd60 100644 --- a/Lambdas/GHOAuth/OAuthLambda.swift +++ b/Lambdas/GHOAuth/OAuthLambda.swift @@ -1,18 +1,19 @@ -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif -import AsyncHTTPClient -import AWSLambdaRuntime import AWSLambdaEvents -import SotoCore +import AWSLambdaRuntime +import AsyncHTTPClient import DiscordBM -import Models import JWTKit import LambdasShared -import Shared import Logging +import Models +import Shared +import SotoCore + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif @main struct GHOAuthHandler: LambdaHandler { @@ -82,10 +83,13 @@ struct GHOAuthHandler: LambdaHandler { return .init(statusCode: .badRequest, body: "Missing state query parameter") } - logger.trace("Got code and state", metadata: [ - "code": .string(code), - "state": .string(state), - ]) + logger.trace( + "Got code and state", + metadata: [ + "code": .string(code), + "state": .string(state), + ] + ) let jwt: GHOAuthPayload @@ -93,10 +97,13 @@ struct GHOAuthHandler: LambdaHandler { do { jwt = try await jwtKeys.verify(state, as: GHOAuthPayload.self) } catch { - logger.error("Error during state verification", metadata: [ - "error": "\(String(reflecting: error))", - "state": .string(event.queryStringParameters?["state"] ?? "") - ]) + logger.error( + "Error during state verification", + metadata: [ + "error": "\(String(reflecting: error))", + "state": .string(event.queryStringParameters?["state"] ?? ""), + ] + ) await logErrorToDiscord("Error verifying state") return .init(statusCode: .badRequest, body: "Error verifying state") } @@ -105,18 +112,23 @@ struct GHOAuthHandler: LambdaHandler { do { try await discordClient.updateOriginalInteractionResponse( token: jwt.interactionToken, - payload: .init(embeds: [.init( - description: description, - color: color - )]) + payload: .init(embeds: [ + .init( + description: description, + color: color + ) + ]) ).guardSuccess() } catch { await logErrorToDiscord( "Received Discord error while updating interaction: \(String(reflecting: error))" ) - logger.error("Received Discord error while updating interaction", metadata: [ - "error": "\(String(reflecting: error))" - ]) + logger.error( + "Received Discord error while updating interaction", + metadata: [ + "error": "\(String(reflecting: error))" + ] + ) } } @@ -131,9 +143,12 @@ struct GHOAuthHandler: LambdaHandler { do { accessToken = try await getGHAccessToken(code: code) } catch { - logger.error("Error getting access token", metadata: [ - "error": "\(String(reflecting: error))" - ]) + logger.error( + "Error getting access token", + metadata: [ + "error": "\(String(reflecting: error))" + ] + ) return await failure("Error getting access token") } @@ -142,21 +157,27 @@ struct GHOAuthHandler: LambdaHandler { do { user = try await getGHUser(accessToken: accessToken) } catch { - logger.error("Error getting user ID", metadata: [ - "error": "\(String(reflecting: error))", - "accessToken": .string(accessToken) - ]) + logger.error( + "Error getting user ID", + metadata: [ + "error": "\(String(reflecting: error))", + "accessToken": .string(accessToken), + ] + ) return await failure("Error getting user") } do { try await userService.linkGitHubID(discordID: jwt.discordID, toGitHubID: "\(user.id)") } catch { - logger.error("Error linking user to GitHub account", metadata: [ - "jwt": "\(jwt)", - "githubID": .stringConvertible(user.id), - "error": .string(String(reflecting: error)) - ]) + logger.error( + "Error linking user to GitHub account", + metadata: [ + "jwt": "\(jwt)", + "githubID": .stringConvertible(user.id), + "error": .string(String(reflecting: error)), + ] + ) return await failure("Error linking user") } @@ -165,8 +186,8 @@ struct GHOAuthHandler: LambdaHandler { await updateInteraction( color: .green, description: """ - Successfully linked your GitHub account with username: [\(user.login)](\(url)) - """ + Successfully linked your GitHub account with username: [\(user.login)](\(url)) + """ ) return .init(statusCode: .ok, body: "Account linking successful, you can return to Discord now.") @@ -185,23 +206,26 @@ struct GHOAuthHandler: LambdaHandler { request.method = .POST request.headers = [ "Accept": "application/json", - "Content-Type": "application/json" + "Content-Type": "application/json", ] let requestBody = try jsonEncoder.encode([ "client_id": clientID, "client_secret": clientSecret, - "code": code + "code": code, ]) request.body = .bytes(requestBody) let response = try await client.execute(request, timeout: .seconds(5)) let body = try await response.body.collect(upTo: 1 << 22) - logger.debug("Got access token response", metadata: [ - "status": .stringConvertible(response.status), - "headers": .stringConvertible(response.headers), - "body": .string(String(buffer: body)) - ]) + logger.debug( + "Got access token response", + metadata: [ + "status": .stringConvertible(response.status), + "headers": .stringConvertible(response.headers), + "body": .string(String(buffer: body)), + ] + ) guard response.status == .ok else { throw Errors.httpRequestFailed(response: response, body: String(buffer: body)) @@ -222,18 +246,21 @@ struct GHOAuthHandler: LambdaHandler { "Accept": "application/vnd.github+json", "Authorization": "Bearer \(accessToken)", "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "Penny/1.0.0 (https://github.com/vapor/penny-bot)" + "User-Agent": "Penny/1.0.0 (https://github.com/vapor/penny-bot)", ] logger.trace("Will send get-GH-user request", metadata: ["accessToken": .string(accessToken)]) let response = try await client.execute(request, timeout: .seconds(5)) let body = try await response.body.collect(upTo: 1024 * 1024) - logger.debug("Got user with access token response", metadata: [ - "status": .stringConvertible(response.status), - "headers": .stringConvertible(response.headers), - "body": .string(String(buffer: body)) - ]) + logger.debug( + "Got user with access token response", + metadata: [ + "status": .stringConvertible(response.status), + "headers": .stringConvertible(response.headers), + "body": .string(String(buffer: body)), + ] + ) guard response.status == .ok else { throw Errors.httpRequestFailed(response: response, body: String(buffer: body)) @@ -250,19 +277,24 @@ struct GHOAuthHandler: LambdaHandler { do { try await discordClient.createMessage( channelId: Constants.Channels.botLogs.id, - payload: .init(embeds: [.init( - description: """ - Error in GHHooks Lambda: - - >>> \(error) - """, - color: .red - )]) + payload: .init(embeds: [ + .init( + description: """ + Error in GHHooks Lambda: + + >>> \(error) + """, + color: .red + ) + ]) ).guardSuccess() } catch { - logger.warning("Received Discord error while logging", metadata: [ - "error": "\(String(reflecting: error))" - ]) + logger.warning( + "Received Discord error while logging", + metadata: [ + "error": "\(String(reflecting: error))" + ] + ) } } } diff --git a/Lambdas/GitHubAPI/+Client.swift b/Lambdas/GitHubAPI/+Client.swift index cba51e21..d851f256 100644 --- a/Lambdas/GitHubAPI/+Client.swift +++ b/Lambdas/GitHubAPI/+Client.swift @@ -1,5 +1,6 @@ import AsyncHTTPClient import Logging + import struct NIOCore.TimeAmount extension Client { diff --git a/Lambdas/GitHubAPI/+Repository.swift b/Lambdas/GitHubAPI/+Repository.swift index 561454d6..d2143b40 100644 --- a/Lambdas/GitHubAPI/+Repository.swift +++ b/Lambdas/GitHubAPI/+Repository.swift @@ -1,4 +1,3 @@ - extension Repository { package var primaryBranch: String { self.master_branch ?? "main" diff --git a/Lambdas/GitHubAPI/+User.swift b/Lambdas/GitHubAPI/+User.swift index 3ec41faf..4c985d2b 100644 --- a/Lambdas/GitHubAPI/+User.swift +++ b/Lambdas/GitHubAPI/+User.swift @@ -1,4 +1,3 @@ - extension User { package var isBot: Bool { self._type == "Bot" diff --git a/Lambdas/GitHubAPI/Changes.swift b/Lambdas/GitHubAPI/Changes.swift index f8b6dce2..1df4ed0e 100644 --- a/Lambdas/GitHubAPI/Changes.swift +++ b/Lambdas/GitHubAPI/Changes.swift @@ -1,4 +1,3 @@ - package struct Changes: Sendable, Codable { package let new_repository: Repository? package let new_issue: Issue? diff --git a/Lambdas/GitHubAPI/Events+Action.swift b/Lambdas/GitHubAPI/Events+Action.swift index 957ff0c4..60ad20fa 100644 --- a/Lambdas/GitHubAPI/Events+Action.swift +++ b/Lambdas/GitHubAPI/Events+Action.swift @@ -1,4 +1,3 @@ - extension PullRequest { /// https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads#pull_request package enum Action: String, Codable { diff --git a/Lambdas/GitHubAPI/GHMiddleware.swift b/Lambdas/GitHubAPI/GHMiddleware.swift index c2f5b64b..efa0fb16 100644 --- a/Lambdas/GitHubAPI/GHMiddleware.swift +++ b/Lambdas/GitHubAPI/GHMiddleware.swift @@ -1,14 +1,15 @@ +import Atomics +import HTTPTypes +import Logging +import NIOCore +import OpenAPIAsyncHTTPClient +import OpenAPIRuntime + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import OpenAPIRuntime -import Atomics -import Logging -import HTTPTypes -import NIOCore -import OpenAPIAsyncHTTPClient /// Adds some headers to all requests. struct GHMiddleware: ClientMiddleware { @@ -71,29 +72,36 @@ struct GHMiddleware: ClientMiddleware { let requestID = Self.idGenerator.loadThenWrappingIncrement(ordering: .relaxed) - logger.debug("Will send request to GitHub", metadata: [ - "request": "\(request)", - "baseURL": .stringConvertible(baseURL), - "operationID": .string(operationID), - "requestID": .stringConvertible(requestID), - ]) + logger.debug( + "Will send request to GitHub", + metadata: [ + "request": "\(request)", + "baseURL": .stringConvertible(baseURL), + "operationID": .string(operationID), + "requestID": .stringConvertible(requestID), + ] + ) do { let (response, body) = try await next(request, body, baseURL) let collectedBody = try await body?.collect(upTo: 1 << 28, using: Self.allocator) - logger.debug("Got response from GitHub", metadata: [ - "response": .string(response.debugDescription), - "requestID": .stringConvertible(requestID), - ]) + logger.debug( + "Got response from GitHub", + metadata: [ + "response": .string(response.debugDescription), + "requestID": .stringConvertible(requestID), + ] + ) /// If this is not _the_ retry, /// and if the authorization is retriable, /// and if the response status is `401 Unauthorized`, /// then retry the request with a force-refreshed token. if !isRetry, - authorization.isRetriable, - response.status == .unauthorized { + authorization.isRetriable, + response.status == .unauthorized + { logger.warning("Got 401 from GitHub. Will retry the request with a fresh token") return try await intercept( &request, @@ -107,18 +115,21 @@ struct GHMiddleware: ClientMiddleware { return (response, collectedBody.map { HTTPBody($0.readableBytesView) }) } } catch { - logger.error("Got error from GitHub", metadata: [ - "error": "\(error)", - "requestID": .stringConvertible(requestID), - ]) + logger.error( + "Got error from GitHub", + metadata: [ + "error": "\(error)", + "requestID": .stringConvertible(requestID), + ] + ) throw error } } } -private extension HTTPFields { - mutating func addOrReplace(name: HTTPField.Name, value: String) { +extension HTTPFields { + fileprivate mutating func addOrReplace(name: HTTPField.Name, value: String) { let header = HTTPField(name: name, value: value) if let existingIdx = self.firstIndex( where: { $0.name == name } @@ -130,6 +141,6 @@ private extension HTTPFields { } } -private extension HTTPField.Name { - static let xGitHubAPIVersion = Self("X-GitHub-Api-Version")! +extension HTTPField.Name { + fileprivate static let xGitHubAPIVersion = Self("X-GitHub-Api-Version")! } diff --git a/Lambdas/GitHubAPI/Verifier.swift b/Lambdas/GitHubAPI/Verifier.swift index 4c7e68f5..fd21eb75 100644 --- a/Lambdas/GitHubAPI/Verifier.swift +++ b/Lambdas/GitHubAPI/Verifier.swift @@ -1,4 +1,5 @@ import Crypto + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -37,7 +38,6 @@ package enum Verifier { extension Sequence where Element == UInt8 { /// Returns a hex-encoded `String` buffer from an array of bytes. func toHexDigest() -> String { - return self.map { String(format: "%02x", $0) }.joined(separator: "") + self.map { String(format: "%02x", $0) }.joined(separator: "") } } - diff --git a/Lambdas/GitHubAPI/uiName+.swift b/Lambdas/GitHubAPI/uiName+.swift index f11509d4..480d3f0f 100644 --- a/Lambdas/GitHubAPI/uiName+.swift +++ b/Lambdas/GitHubAPI/uiName+.swift @@ -1,4 +1,3 @@ - extension Repository { /// If it's a Vapor repository, use the raw repo name like `postgres-nio`. /// Otherwise use more of the repo name, like `community/stripe` for `vapor-community/stripe`. diff --git a/Lambdas/LambdasShared/+APIGatewayV2.swift b/Lambdas/LambdasShared/+APIGatewayV2.swift index da8ff387..0fbcf14e 100644 --- a/Lambdas/LambdasShared/+APIGatewayV2.swift +++ b/Lambdas/LambdasShared/+APIGatewayV2.swift @@ -1,6 +1,7 @@ import AWSLambdaEvents -import HTTPTypes import Crypto +import HTTPTypes + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -16,7 +17,7 @@ private let iso8601jsonDecoder: JSONDecoder = { private let jsonEncoder = JSONEncoder() extension APIGatewayV2Request { - + package func decode(as type: D.Type = D.self) throws -> D { guard let body = self.body else { throw APIGatewayErrors.emptyBody(self) @@ -53,7 +54,7 @@ extension APIGatewayV2Response { package struct GatewayFailure: Encodable { var reason: String - + package init(reason: String) { self.reason = reason } diff --git a/Lambdas/LambdasShared/SecretsRetriever.swift b/Lambdas/LambdasShared/SecretsRetriever.swift index 7d0b59aa..51c9c50c 100644 --- a/Lambdas/LambdasShared/SecretsRetriever.swift +++ b/Lambdas/LambdasShared/SecretsRetriever.swift @@ -1,7 +1,8 @@ -import SotoCore -import SotoSecretsManager import Logging import Shared +import SotoCore +import SotoSecretsManager + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -34,14 +35,20 @@ package actor SecretsRetriever { } package func getSecret(arnEnvVarKey: String) async throws -> String { - logger.trace("Get secret start", metadata: [ - "arnEnvVarKey": .string(arnEnvVarKey) - ]) + logger.trace( + "Get secret start", + metadata: [ + "arnEnvVarKey": .string(arnEnvVarKey) + ] + ) let secret = try await queue.process(queueKey: arnEnvVarKey) { if let cached = await getCache(key: arnEnvVarKey) { - logger.debug("Will return cached secret", metadata: [ - "arnEnvVarKey": .string(arnEnvVarKey) - ]) + logger.debug( + "Will return cached secret", + metadata: [ + "arnEnvVarKey": .string(arnEnvVarKey) + ] + ) return cached } else { let value = try await self.getSecretFromAWS(arnEnvVarKey: arnEnvVarKey) @@ -49,17 +56,23 @@ package actor SecretsRetriever { return value } } - logger.trace("Get secret done", metadata: [ - "arnEnvVarKey": .string(arnEnvVarKey) - ]) + logger.trace( + "Get secret done", + metadata: [ + "arnEnvVarKey": .string(arnEnvVarKey) + ] + ) return secret } /// Gets a secret directly from AWS. private func getSecretFromAWS(arnEnvVarKey: String) async throws -> String { - logger.trace("Retrieving secret from AWS", metadata: [ - "arnEnvVarKey": .string(arnEnvVarKey) - ]) + logger.trace( + "Retrieving secret from AWS", + metadata: [ + "arnEnvVarKey": .string(arnEnvVarKey) + ] + ) let arn = try requireEnvVar(arnEnvVarKey) let secret = try await secretsManager.getSecretValue( .init(secretId: arn), @@ -68,9 +81,12 @@ package actor SecretsRetriever { guard let secret = secret.secretString else { throw Errors.secretNotFound(arn: arn) } - logger.trace("Got secret from AWS", metadata: [ - "arnEnvVarKey": .string(arnEnvVarKey) - ]) + logger.trace( + "Got secret from AWS", + metadata: [ + "arnEnvVarKey": .string(arnEnvVarKey) + ] + ) return secret } diff --git a/Lambdas/Sponsors/GithubWebhookPayload.swift b/Lambdas/Sponsors/GithubWebhookPayload.swift index 86051d9c..2ea21307 100644 --- a/Lambdas/Sponsors/GithubWebhookPayload.swift +++ b/Lambdas/Sponsors/GithubWebhookPayload.swift @@ -9,7 +9,7 @@ package struct GitHubWebhookPayload: Codable { let sponsorship: Sponsorship let sender: Sender let changes: Changes? - + enum ActionType: String { case created case cancelled diff --git a/Lambdas/Sponsors/SponsorType.swift b/Lambdas/Sponsors/SponsorType.swift index 52def7af..d9d733d9 100644 --- a/Lambdas/Sponsors/SponsorType.swift +++ b/Lambdas/Sponsors/SponsorType.swift @@ -3,7 +3,7 @@ import DiscordModels enum SponsorType: String { case sponsor = "sponsor" case backer = "backer" - + var roleID: RoleSnowflake { switch self { case .sponsor: @@ -12,9 +12,9 @@ enum SponsorType: String { return "431921695524126722" } } - + var channelID: ChannelSnowflake { - return "633345683012976640" + "633345683012976640" } var discordColor: DiscordColor { @@ -25,7 +25,7 @@ enum SponsorType: String { return .green } } - + package static func `for`(sponsorshipAmount: Int) throws -> SponsorType { switch sponsorshipAmount { case 500...9900: return .backer diff --git a/Lambdas/Sponsors/SponsorsLambda.swift b/Lambdas/Sponsors/SponsorsLambda.swift index e0eb1a7a..9b0f1478 100644 --- a/Lambdas/Sponsors/SponsorsLambda.swift +++ b/Lambdas/Sponsors/SponsorsLambda.swift @@ -1,11 +1,12 @@ -import AsyncHTTPClient -import AWSLambdaRuntime import AWSLambdaEvents -import SotoCore -import NIOHTTP1 +import AWSLambdaRuntime +import AsyncHTTPClient import DiscordBM import LambdasShared +import NIOHTTP1 import Shared +import SotoCore + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -58,14 +59,14 @@ struct SponsorsHandler: LambdaHandler { } do { context.logger.debug("Received sponsorship event") - + // Try updating the GitHub Readme with the new sponsor try await requestReadmeWorkflowTrigger(on: event) - + // Decode GitHub Webhook Response context.logger.debug("Decoding GitHub Payload") let payload = try event.decode(as: GitHubWebhookPayload.self) - + // Look for the user in the DB context.logger.debug("Looking for user in the DB") let newSponsorID = payload.sender.id @@ -81,16 +82,16 @@ struct SponsorsHandler: LambdaHandler { body: "Error: no user found with GitHub ID \(newSponsorID)" ) } - + // TODO: Create gh user let discordID = user.discordID - + // Get role ID based on sponsorship tier let role = try SponsorType.for(sponsorshipAmount: payload.sponsorship.tier.monthlyPriceInCents) - + // Do different stuff depending on what happened to the sponsorship let actionType = GitHubWebhookPayload.ActionType(rawValue: payload.action)! - + context.logger.debug("Managing Discord roles") switch actionType { @@ -110,7 +111,9 @@ struct SponsorsHandler: LambdaHandler { break case .tierChanged: guard let changes = payload.changes else { - context.logger.error("Error: GitHub returned 'tier_changed' event but no 'changes' data in the payload") + context.logger.error( + "Error: GitHub returned 'tier_changed' event but no 'changes' data in the payload" + ) return APIGatewayV2Response( statusCode: .ok, body: "Error: GitHub returned 'tier_changed' event but no 'changes' data in the payload" @@ -118,7 +121,8 @@ struct SponsorsHandler: LambdaHandler { } // This means that the user downgraded from a sponsor role to a backer role if try SponsorType.for(sponsorshipAmount: changes.tier.from.monthlyPriceInCents) == .sponsor, - role == .backer { + role == .backer + { try await removeRole(from: discordID, role: .sponsor) } case .pendingCancellation: @@ -137,9 +141,7 @@ struct SponsorsHandler: LambdaHandler { } } - /** - Removes a role from the selected Discord user. - */ + /// Removes a role from the selected Discord user. private func removeRole(from discordID: UserSnowflake, role: SponsorType) async throws { // Try removing role from user do { @@ -153,9 +155,11 @@ struct SponsorsHandler: LambdaHandler { case let .some(error): switch error { case let .jsonError(jsonError) - where [.invalidRole, .unknownRole].contains(jsonError.code): + where [.invalidRole, .unknownRole].contains(jsonError.code): /// This is fine - logger.debug("User \(discordID) probably didn't have the \(role.rawValue) role in the first place, to be deleted") + logger.debug( + "User \(discordID) probably didn't have the \(role.rawValue) role in the first place, to be deleted" + ) default: throw error } @@ -169,10 +173,8 @@ struct SponsorsHandler: LambdaHandler { ) } } - - /** - Adds a new Discord role to the selected user, depending on the sponsorship tier they selected (**sponsor**, **backer**). - */ + + /// Adds a new Discord role to the selected user, depending on the sponsorship tier they selected (**sponsor**, **backer**). private func addRole(to discordID: UserSnowflake, role: SponsorType) async throws { do { // Try adding role to new sponsor @@ -190,19 +192,20 @@ struct SponsorsHandler: LambdaHandler { } } - /** - Sends a message welcoming the user in the new channel and giving them a coin. - */ + /// Sends a message welcoming the user in the new channel and giving them a coin. private func sendMessage(to discordID: UserSnowflake, role: SponsorType) async throws { do { // Try sending message to new sponsor try await discordClient.createMessage( // Always send message to backer channel only channelId: SponsorType.backer.channelID, - payload: .init(embeds: [.init( - description: "Welcome \(DiscordUtils.mention(id: discordID)), our new \(DiscordUtils.mention(id: role.roleID))", - color: role.discordColor - )]) + payload: .init(embeds: [ + .init( + description: + "Welcome \(DiscordUtils.mention(id: discordID)), our new \(DiscordUtils.mention(id: role.roleID))", + color: role.discordColor + ) + ]) ).guardSuccess() logger.info("Successfully sent message to user \(discordID).") } catch { @@ -217,10 +220,7 @@ struct SponsorsHandler: LambdaHandler { try await secretsRetriever.getSecret(arnEnvVarKey: "GH_WORKFLOW_TOKEN_ARN") } - /** - Sends a request to GitHub to trigger the workflow that is going to update the repository readme file with the new sponsor. - - returns The response status of the request - */ + /// Sends a request to GitHub to trigger the workflow that is going to update the repository readme file with the new sponsor. private func requestReadmeWorkflowTrigger(on event: APIGatewayV2Request) async throws { // Create request to trigger workflow let url = "https://api.github.com/repos/vapor/vapor/actions/workflows/sponsors.yml/dispatches" @@ -232,17 +232,19 @@ struct SponsorsHandler: LambdaHandler { triggerActionRequest.headers.add(contentsOf: [ "Accept": "application/vnd.github+json", "Authorization": "Bearer \(workflowToken)", - "User-Agent": "Penny/1.0.0 (https://github.com/vapor/penny-bot)" + "User-Agent": "Penny/1.0.0 (https://github.com/vapor/penny-bot)", ]) - + triggerActionRequest.body = .bytes(ByteBuffer(string: #"{"ref":"main"}"#)) - + // Send request to trigger workflow and read response let githubResponse = try await httpClient.execute(triggerActionRequest, timeout: .seconds(10)) - + guard 200..<300 ~= githubResponse.status.code else { let body = try await githubResponse.body.collect(upTo: 1024 * 1024) - logger.error("GitHub did not run workflow with error code: \(githubResponse.status.code) and body: \(String(buffer: body))") + logger.error( + "GitHub did not run workflow with error code: \(githubResponse.status.code) and body: \(String(buffer: body))" + ) throw Errors.runWorkflowError( message: "GitHub did not run workflow with error code: \(githubResponse.status.code)" ) diff --git a/Lambdas/Users/CoinEntryRepository.swift b/Lambdas/Users/CoinEntryRepository.swift index 3797971f..76bea3c9 100644 --- a/Lambdas/Users/CoinEntryRepository.swift +++ b/Lambdas/Users/CoinEntryRepository.swift @@ -1,6 +1,6 @@ -import SotoDynamoDB import Logging import Models +import SotoDynamoDB struct DynamoCoinEntryRepository { let db: DynamoDB diff --git a/Lambdas/Users/DynamoUserRepository.swift b/Lambdas/Users/DynamoUserRepository.swift index 5d41d2eb..59717b1a 100644 --- a/Lambdas/Users/DynamoUserRepository.swift +++ b/Lambdas/Users/DynamoUserRepository.swift @@ -1,10 +1,11 @@ +import Models import SotoDynamoDB + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import Models struct DynamoUserRepository { @@ -30,7 +31,7 @@ struct DynamoUserRepository { _ = try await db.putItem(input, logger: self.logger) } - func updateUser(_ user: DynamoDBUser) async throws -> Void { + func updateUser(_ user: DynamoDBUser) async throws { var user = user if (user.githubID ?? "").isEmpty { @@ -55,7 +56,7 @@ struct DynamoUserRepository { limit: 1, tableName: self.tableName ) - + return try await queryUser(with: query) } @@ -67,7 +68,7 @@ struct DynamoUserRepository { limit: 1, tableName: self.tableName ) - + return try await queryUser(with: query) } diff --git a/Lambdas/Users/InternalUsersService.swift b/Lambdas/Users/InternalUsersService.swift index 4a964842..d0c085b8 100644 --- a/Lambdas/Users/InternalUsersService.swift +++ b/Lambdas/Users/InternalUsersService.swift @@ -1,10 +1,11 @@ +import Models import SotoDynamoDB + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import Models package struct InternalUsersService { private let userRepo: DynamoUserRepository @@ -31,7 +32,7 @@ package struct InternalUsersService { freshUser user: DynamoDBUser ) async throws -> DynamoDBUser { try await coinEntryRepo.createCoinEntry(coinEntry) - + var user = user user.coinCount += coinEntry.amount try await userRepo.updateUser(user) @@ -53,7 +54,7 @@ package struct InternalUsersService { return newUser } } - + /// Returns nil if user does not exist. package func getUser(githubID: String) async throws -> DynamoDBUser? { try await userRepo.getUser(githubID: githubID) diff --git a/Lambdas/Users/UsersHandler.swift b/Lambdas/Users/UsersHandler.swift index 872c1240..ef23bbd1 100644 --- a/Lambdas/Users/UsersHandler.swift +++ b/Lambdas/Users/UsersHandler.swift @@ -1,15 +1,16 @@ -import AWSLambdaRuntime import AWSLambdaEvents +import AWSLambdaRuntime import AsyncHTTPClient +import LambdasShared +import Models +import Shared +import SotoCore + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import SotoCore -import Models -import Shared -import LambdasShared @main struct UsersHandler: LambdaHandler { @@ -28,7 +29,7 @@ struct UsersHandler: LambdaHandler { self.internalService = InternalUsersService(awsClient: awsClient, logger: context.logger) self.logger = context.logger } - + func handle(_ event: APIGatewayV2Request, context: LambdaContext) async -> APIGatewayV2Response { do { let request = try event.decode(as: UserRequest.self) @@ -45,17 +46,20 @@ struct UsersHandler: LambdaHandler { return try await handleUnlinkGitHubRequest(discordID: discordID) } } catch { - context.logger.error("Received error while handling request", metadata: [ - "event": "\(event)", - "error": .string(String(reflecting: error)) - ]) + context.logger.error( + "Received error while handling request", + metadata: [ + "event": "\(event)", + "error": .string(String(reflecting: error)), + ] + ) return APIGatewayV2Response( status: .badRequest, content: GatewayFailure(reason: "Error: \(error)") ) } } - + func handleAddUserRequest( entry: UserRequest.CoinEntryRequest ) async throws -> APIGatewayV2Response { @@ -76,14 +80,17 @@ struct UsersHandler: LambdaHandler { newCoinCount: newUser.coinCount ) - logger.debug("Added coins", metadata: [ - "entry": "\(entry)", - "coinResponse": "\(coinResponse)" - ]) + logger.debug( + "Added coins", + metadata: [ + "entry": "\(entry)", + "coinResponse": "\(coinResponse)", + ] + ) return APIGatewayV2Response(status: .ok, content: coinResponse) } - + func handleGetOrCreateUserRequest(discordID: UserSnowflake) async throws -> APIGatewayV2Response { let user = try await internalService.getOrCreateUser(discordID: discordID) return APIGatewayV2Response(status: .ok, content: user) diff --git a/Package.swift b/Package.swift index 955c0f38..5cb47b95 100644 --- a/Package.swift +++ b/Package.swift @@ -221,11 +221,11 @@ var upcomingFeaturesSwiftSettings: [SwiftSetting] { [ /// https://github.com/apple/swift-evolution/blob/main/proposals/0335-existential-any.md /// Require `any` for existential types. - .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("ExistentialAny"), /// https://github.com/apple/swift-evolution/blob/main/proposals/0413-typed-throws.md /// Enable the full potential of typed throws. - .enableUpcomingFeature("FullTypedThrows"), + .enableUpcomingFeature("FullTypedThrows"), ] } @@ -235,7 +235,7 @@ var targetsSwiftSettings: [SwiftSetting] { .unsafeFlags( ["-Xllvm", "-vectorize-slp=false"], .when(platforms: [.linux], configuration: .release) - ), + ) ] } diff --git a/Sources/Models/AutoFaqsRequest.swift b/Sources/Models/AutoFaqsRequest.swift index 0ea3a23b..66d57826 100644 --- a/Sources/Models/AutoFaqsRequest.swift +++ b/Sources/Models/AutoFaqsRequest.swift @@ -1,4 +1,3 @@ - package enum AutoFaqsRequest: Codable { case all case add(expression: String, value: String) diff --git a/Sources/Models/AutoPingsRequest.swift b/Sources/Models/AutoPingsRequest.swift index 7262c6f3..1db309cf 100644 --- a/Sources/Models/AutoPingsRequest.swift +++ b/Sources/Models/AutoPingsRequest.swift @@ -1,8 +1,7 @@ - package struct AutoPingsRequest: Codable { package let discordID: UserSnowflake package let expressions: [S3AutoPingItems.Expression] - + package init( discordID: UserSnowflake, expressions: [S3AutoPingItems.Expression] diff --git a/Sources/Models/CoinEntry.swift b/Sources/Models/CoinEntry.swift index 72ab5418..b6d2e767 100644 --- a/Sources/Models/CoinEntry.swift +++ b/Sources/Models/CoinEntry.swift @@ -30,7 +30,7 @@ package struct CoinEntry: Sendable, Codable { package let amount: Int package let source: Source package let reason: Reason - + package init( id: UUID = UUID(), fromUserID: UUID, diff --git a/Sources/Models/CoinResponse.swift b/Sources/Models/CoinResponse.swift index c02298c7..956e8be6 100644 --- a/Sources/Models/CoinResponse.swift +++ b/Sources/Models/CoinResponse.swift @@ -1,4 +1,3 @@ - package struct CoinResponse: Sendable, Codable { package let sender: UserSnowflake package let receiver: UserSnowflake diff --git a/Sources/Models/FaqsRequest.swift b/Sources/Models/FaqsRequest.swift index 2cbd1dc1..81800e29 100644 --- a/Sources/Models/FaqsRequest.swift +++ b/Sources/Models/FaqsRequest.swift @@ -1,4 +1,3 @@ - package enum FaqsRequest: Codable { case all case add(name: String, value: String) diff --git a/Sources/Models/S3AutoPingItems.swift b/Sources/Models/S3AutoPingItems.swift index e630ed10..d5589000 100644 --- a/Sources/Models/S3AutoPingItems.swift +++ b/Sources/Models/S3AutoPingItems.swift @@ -1,6 +1,5 @@ - package struct S3AutoPingItems: Sendable, Codable { - + package enum Expression: Sendable, Codable, RawRepresentable, Hashable { package enum Kind: String, CaseIterable { @@ -32,14 +31,14 @@ package struct S3AutoPingItems: Sendable, Codable { case contains(String) /// Exact match (with some insensitivity, such as case-insensitivity) case matches(String) - + package var kind: Kind { switch self { case .contains: return .containment case .matches: return .exactMatch } } - + package var innerValue: String { switch self { case let .contains(contain): @@ -62,7 +61,7 @@ package struct S3AutoPingItems: Sendable, Codable { /// Important for the Codable conformance. /// Changing the implementation might result in breaking the repository. - package init? (rawValue: String) { + package init?(rawValue: String) { if rawValue.hasPrefix("C-") { self = .contains(String(rawValue.dropFirst(2))) } else if rawValue.hasPrefix("T-") { diff --git a/Sources/Models/UserRequest.swift b/Sources/Models/UserRequest.swift index 6a16d7da..25d9f1c5 100644 --- a/Sources/Models/UserRequest.swift +++ b/Sources/Models/UserRequest.swift @@ -1,4 +1,3 @@ - package enum UserRequest: Sendable, Codable { case addCoin(CoinEntryRequest) case getOrCreateUser(discordID: UserSnowflake) diff --git a/Sources/Penny/+Array.swift b/Sources/Penny/+Array.swift index 0927ead7..040cf01b 100644 --- a/Sources/Penny/+Array.swift +++ b/Sources/Penny/+Array.swift @@ -1,6 +1,5 @@ - extension Array { - func divided( + func asyncDivided( _ isInLhs: (Element) async throws -> Bool ) async rethrows -> (lhs: [Element], rhs: [Element]) { var lhs = [Element]() @@ -23,7 +22,7 @@ extension Array { ) rethrows -> (lhs: ArraySlice, rhs: ArraySlice) { var copy = self let firstOfRhs = try copy.partition(by: isInLhs) - return (copy[firstOfRhs ..< copy.endIndex], copy[copy.startIndex ..< firstOfRhs]) + return (copy[firstOfRhs.. Logger.Message, - response: (any LoggableHTTPResponse)?, - metadata: @autoclosure () -> Logger.Metadata? = nil, - source: @autoclosure () -> String? = nil, - file: String = #fileID, function: String = #function, line: UInt = #line) { + func report( + _ message: @autoclosure () -> Logger.Message, + response: (any LoggableHTTPResponse)?, + metadata: @autoclosure () -> Logger.Metadata? = nil, + source: @autoclosure () -> String? = nil, + file: String = #fileID, + function: String = #function, + line: UInt = #line + ) { let message = message() let metadata = metadata() ?? .init() let source = source() @@ -32,27 +36,39 @@ extension Logger { "_status": "\(String(describing: response?.status))", "_body": "\(String(buffer: response?.body ?? .init()))", ].merging(metadata, uniquingKeysWith: { a, _ in a }), - source: source, file: file, function: function, line: line) + source: source, + file: file, + function: function, + line: line + ) self.log( level: .debug, message, metadata: [ - "_response": "\(String(describing: response))", + "_response": "\(String(describing: response))" ].merging(metadata, uniquingKeysWith: { a, _ in a }), - source: source, file: file, function: function, line: line) + source: source, + file: file, + function: function, + line: line + ) } - - func report(_ message: @autoclosure () -> Logger.Message, - error: any Error, - metadata: @autoclosure () -> Logger.Metadata? = nil, - source: @autoclosure () -> String? = nil, - file: String = #fileID, function: String = #function, line: UInt = #line) { + + func report( + _ message: @autoclosure () -> Logger.Message, + error: any Error, + metadata: @autoclosure () -> Logger.Metadata? = nil, + source: @autoclosure () -> String? = nil, + file: String = #fileID, + function: String = #function, + line: UInt = #line + ) { let message = message() var metadata = metadata() ?? .init() let source = source() - + var loggable: (any LoggableHTTPResponse)? - + if let error = error as? any LoggableHTTPResponse { loggable = error } else if let error = error as? DiscordHTTPError { @@ -66,9 +82,17 @@ extension Logger { default: break } } - + if let loggable { - self.report(message, response: loggable, metadata: metadata, source: source, file: file, function: function, line: line) + self.report( + message, + response: loggable, + metadata: metadata, + source: source, + file: file, + function: function, + line: line + ) } else { metadata["_error"] = "\(error)" self.error(message, metadata: metadata, source: source, file: file, function: function, line: line) diff --git a/Sources/Penny/+Rendering/+LeafRenderer.swift b/Sources/Penny/+Rendering/+LeafRenderer.swift index 41810eed..73d66bda 100644 --- a/Sources/Penny/+Rendering/+LeafRenderer.swift +++ b/Sources/Penny/+Rendering/+LeafRenderer.swift @@ -1,7 +1,8 @@ -import LeafKit -import NIO import AsyncHTTPClient +import LeafKit import Logging +import NIO + #if canImport(FoundationEssentials) import FoundationEssentials #else diff --git a/Sources/Penny/+Rendering/RenderModels.swift b/Sources/Penny/+Rendering/RenderModels.swift index 05fdb9ad..74e59058 100644 --- a/Sources/Penny/+Rendering/RenderModels.swift +++ b/Sources/Penny/+Rendering/RenderModels.swift @@ -1,4 +1,3 @@ - struct AutoPingsContext: Codable { struct Commands: Codable { diff --git a/Sources/Penny/+String.swift b/Sources/Penny/+String.swift index 23afce1c..6d0580f9 100644 --- a/Sources/Penny/+String.swift +++ b/Sources/Penny/+String.swift @@ -1,10 +1,11 @@ +import Models + #if canImport(FoundationEssentials) import FoundationEssentials import struct Foundation.CharacterSet #else import Foundation #endif -import Models /// `StringProtocol` is basically either `String` or `Substring`. extension StringProtocol { @@ -77,8 +78,8 @@ extension Array where Element: StringProtocol { } } -private extension Character { - var isWhitespaceOrNewline: Bool { +extension Character { + fileprivate var isWhitespaceOrNewline: Bool { self.isWhitespace || self.isNewline } } diff --git a/Sources/Penny/BotStateManager.swift b/Sources/Penny/BotStateManager.swift index 39daa118..de754e1b 100644 --- a/Sources/Penny/BotStateManager.swift +++ b/Sources/Penny/BotStateManager.swift @@ -1,11 +1,12 @@ import DiscordBM +import Logging +import ServiceLifecycle + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import Logging -import ServiceLifecycle /** When we update Penny, AWS waits a few minutes before taking down the old Penny instance to @@ -81,7 +82,7 @@ actor BotStateManager: Service { private func cancelIfCachePopulationTakesTooLong() async { guard (try? await Task.sleep(for: .seconds(120))) != nil else { - return /// Somewhere else cancelled the Task + return/// Somewhere else cancelled the Task } if !canRespond { await startAllowingResponses() @@ -97,14 +98,14 @@ actor BotStateManager: Service { } return canRespond } - + private func checkIfItsASignal(event: Gateway.Event) async { guard case let .messageCreate(message) = event.data, - message.channel_id == Constants.Channels.botLogs.id, - let author = message.author, - author.id == Constants.botId, - let otherId = message.content.split(whereSeparator: \.isWhitespace).last, - otherId != "\(self.id)" + message.channel_id == Constants.Channels.botLogs.id, + let author = message.author, + author.id == Constants.botId, + let otherId = message.content.split(whereSeparator: \.isWhitespace).last, + otherId != "\(self.id)" else { return } if StateManagerSignal.shutdown.isInMessage(message.content) { @@ -126,7 +127,7 @@ actor BotStateManager: Service { self.canRespond = false guard (try? await Task.sleep(for: disableDuration)) != nil else { - return /// Somewhere else cancelled the Task + return/// Somewhere else cancelled the Task } await startAllowingResponses() diff --git a/Sources/Penny/CommandsManager.swift b/Sources/Penny/CommandsManager.swift index 2f953032..ad86f0d8 100644 --- a/Sources/Penny/CommandsManager.swift +++ b/Sources/Penny/CommandsManager.swift @@ -1,6 +1,6 @@ import DiscordBM -import Models import Logging +import Models struct CommandsManager { let context: HandlerContext @@ -50,7 +50,7 @@ enum SlashCommand: String, CaseIterable { var options: [ApplicationCommand.Option]? { switch self { - case .github: + case .github: return GitHubSubCommand.allCases.map { subCommand in ApplicationCommand.Option( type: .subCommand, @@ -87,18 +87,20 @@ enum SlashCommand: String, CaseIterable { ) } case .howManyCoins: - return [.init( - type: .user, - name: "member", - description: "The member to check their coin count" - )] + return [ + .init( + type: .user, + name: "member", + description: "The member to check their coin count" + ) + ] case .howManyCoinsApp: return nil } } var dmPermission: Bool { - return false + false } var type: ApplicationCommand.Kind? { @@ -165,13 +167,15 @@ enum AutoPingsSubCommand: String, CaseIterable { case .add, .bulkRemove, .test: return [Self.expressionModeOption] case .remove: - return [.init( - type: .string, - name: "expression", - description: "What expression to remove", - required: true, - autocomplete: true - )] + return [ + .init( + type: .string, + name: "expression", + description: "What expression to remove", + required: true, + autocomplete: true + ) + ] case .help, .list: return [] } @@ -222,16 +226,18 @@ enum FaqsSubCommand: String, CaseIterable { name: "ephemeral", description: "If True, the response will only be visible to you", required: false - ) + ), ] case .remove, .edit, .rename: - return [.init( - type: .string, - name: "name", - description: "The name of the command", - required: true, - autocomplete: true - )] + return [ + .init( + type: .string, + name: "name", + description: "The name of the command", + required: true, + autocomplete: true + ) + ] case .add: return [] } @@ -272,16 +278,18 @@ enum AutoFaqsSubCommand: String, CaseIterable { name: "ephemeral", description: "If True, the response will only be visible to you", required: false - ) + ), ] case .remove, .edit, .rename: - return [.init( - type: .string, - name: "expression", - description: "The matching expression of the answer", - required: true, - autocomplete: true - )] + return [ + .init( + type: .string, + name: "expression", + description: "The matching expression of the answer", + required: true, + autocomplete: true + ) + ] case .add: return [] } diff --git a/Sources/Penny/Constants.swift b/Sources/Penny/Constants.swift index 18a7b9ad..1a10d1a0 100644 --- a/Sources/Penny/Constants.swift +++ b/Sources/Penny/Constants.swift @@ -1,10 +1,11 @@ +import DiscordBM +import Logging + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import DiscordBM -import Logging enum Constants { @@ -21,14 +22,17 @@ enum Constants { case "prod": self = .prod default: Logger(label: "Environment.init").critical( - "Invalid deployment environment env var provided", metadata: [ + "Invalid deployment environment env var provided", + metadata: [ "value": .string(value ?? "") ] ) - fatalError(""" - Invalid deployment environment env var provided: '\(value ?? "")'. - Set 'DEPLOYMENT_ENVIRONMENT' to 'local' for local developments. - """) + fatalError( + """ + Invalid deployment environment env var provided: '\(value ?? "")'. + Set 'DEPLOYMENT_ENVIRONMENT' to 'local' for local developments. + """ + ) } } } @@ -36,11 +40,13 @@ enum Constants { if let value = ProcessInfo.processInfo.environment[key] { return value } else { - fatalError(""" - Set an environment value for key '\(key)'. - In tests you usually can set dummy values. - For a local run, 'BOT_TOKEN' is required. - """) + fatalError( + """ + Set an environment value for key '\(key)'. + In tests you usually can set dummy values. + For a local run, 'BOT_TOKEN' is required. + """ + ) } } static let vaporGuildId: GuildSnowflake = "431917998102675485" @@ -116,24 +122,28 @@ enum Constants { /// Must not send thanks-responses to these channels. /// Instead send to the #thanks channel. - static let thanksResponseDenyList: Set = Set([ - Channels.welcome, - Channels.news, - Channels.publications, - Channels.release, - Channels.jobs, - Channels.status, - ].map(\.id)) - - static let announcementChannels: Set = Set([ - Channels.news, - Channels.publications, - Channels.release, - Channels.jobs, - Channels.stackOverflow, - Channels.issuesAndPRs, - Channels.evolution, - ].map(\.id)) + static let thanksResponseDenyList: Set = Set( + [ + Channels.welcome, + Channels.news, + Channels.publications, + Channels.release, + Channels.jobs, + Channels.status, + ].map(\.id) + ) + + static let announcementChannels: Set = Set( + [ + Channels.news, + Channels.publications, + Channels.release, + Channels.jobs, + Channels.stackOverflow, + Channels.issuesAndPRs, + Channels.evolution, + ].map(\.id) + ) } enum Roles: RoleSnowflake { @@ -145,7 +155,7 @@ enum Constants { case automationDev = "1031520606434381824" case moderator = "431920836631592980" case core = "431919254372089857" - + static let elevatedPublicCommandsAccess: [Roles] = [ .nitroBooster, .backer, @@ -165,18 +175,22 @@ enum Constants { .core, ] - static let elevatedRestrictedCommandsAccessSet: Set = Set([ - Roles.contributor, - Roles.maintainer, - Roles.moderator, - Roles.automationDev, - Roles.core, - ].map(\.rawValue)) - - static let moderators: Set = Set([ - Roles.automationDev, - Roles.moderator, - Roles.core - ].map(\.rawValue)) + static let elevatedRestrictedCommandsAccessSet: Set = Set( + [ + Roles.contributor, + Roles.maintainer, + Roles.moderator, + Roles.automationDev, + Roles.core, + ].map(\.rawValue) + ) + + static let moderators: Set = Set( + [ + Roles.automationDev, + Roles.moderator, + Roles.core, + ].map(\.rawValue) + ) } } diff --git a/Sources/Penny/EvolutionChecker.swift b/Sources/Penny/EvolutionChecker.swift index 3a72e4fe..ef9fe65f 100644 --- a/Sources/Penny/EvolutionChecker.swift +++ b/Sources/Penny/EvolutionChecker.swift @@ -1,9 +1,10 @@ +import DiscordModels +import EvolutionMetadataModel import Logging -import ServiceLifecycle import Markdown -import DiscordModels import Models -import EvolutionMetadataModel +import ServiceLifecycle + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -42,7 +43,7 @@ actor EvolutionChecker: Service { if Task.isCancelled { return } do { try await self.check() - try await Task.sleep(for: .seconds(60 * 15)) /// 15 mins + try await Task.sleep(for: .seconds(60 * 15))/// 15 mins } catch { logger.report("Couldn't check proposals", error: error) try await Task.sleep(for: .seconds(60 * 5)) @@ -92,11 +93,13 @@ actor EvolutionChecker: Service { self.storage.queuedProposals[existingIdx].proposal = new logger.debug("A new proposal will be delayed", metadata: ["id": .string(new.id)]) } else { - self.storage.queuedProposals.append(.init( - firstKnownStateBeforeQueue: nil, - updatedAt: Date(), - proposal: new - )) + self.storage.queuedProposals.append( + .init( + firstKnownStateBeforeQueue: nil, + updatedAt: Date(), + proposal: new + ) + ) logger.debug("A new proposal was queued", metadata: ["id": .string(new.id)]) } } @@ -118,18 +121,26 @@ actor EvolutionChecker: Service { ) { self.storage.queuedProposals[existingIdx].updatedAt = Date() self.storage.queuedProposals[existingIdx].proposal = updated - logger.debug("An updated proposal will be delayed", metadata: [ - "id": .string(updated.id) - ]) + logger.debug( + "An updated proposal will be delayed", + metadata: [ + "id": .string(updated.id) + ] + ) } else { - self.storage.queuedProposals.append(.init( - firstKnownStateBeforeQueue: previousState, - updatedAt: Date(), - proposal: updated - )) - logger.debug("An updated proposal was queued", metadata: [ - "id": .string(updated.id) - ]) + self.storage.queuedProposals.append( + .init( + firstKnownStateBeforeQueue: previousState, + updatedAt: Date(), + proposal: updated + ) + ) + logger.debug( + "An updated proposal was queued", + metadata: [ + "id": .string(updated.id) + ] + ) } } @@ -190,9 +201,10 @@ actor EvolutionChecker: Service { let summary = makeSummary(proposal: proposal) - let upcomingFeatureFlag = proposal.upcomingFeatureFlag.map { - "\n**Upcoming Feature Flag:** \($0.flag)" - } ?? "" + let upcomingFeatureFlag = + proposal.upcomingFeatureFlag.map { + "\n**Upcoming Feature Flag:** \($0.flag)" + } ?? "" let authors = proposal.authors .filter(\.isRealPerson) @@ -218,23 +230,25 @@ actor EvolutionChecker: Service { if let current = stateDescription { status = """ - **Status: \(current)** - """ + **Status: \(current)** + """ } return .init( - embeds: [.init( - title: title.unicodesPrefix(256), - description: """ - > \(summary) - \(status) - \(upcomingFeatureFlag) - \(authorsString) - \(reviewManagersString) - """.replaceTripleNewlinesWithDoubleNewlines(), - url: proposalLink, - color: proposal.status.color - )], + embeds: [ + .init( + title: title.unicodesPrefix(256), + description: """ + > \(summary) + \(status) + \(upcomingFeatureFlag) + \(authorsString) + \(reviewManagersString) + """.replaceTripleNewlinesWithDoubleNewlines(), + url: proposalLink, + color: proposal.status.color + ) + ], components: await makeComponents(proposal: proposal) ) } @@ -250,9 +264,10 @@ actor EvolutionChecker: Service { let summary = makeSummary(proposal: proposal) - let upcomingFeatureFlag = proposal.upcomingFeatureFlag.map { - "\n**Upcoming Feature Flag:** \($0.flag)" - } ?? "" + let upcomingFeatureFlag = + proposal.upcomingFeatureFlag.map { + "\n**Upcoming Feature Flag:** \($0.flag)" + } ?? "" let authors = proposal.authors .filter(\.isRealPerson) @@ -276,26 +291,29 @@ actor EvolutionChecker: Service { var status = "" if let previous = previousState.UIDescription, - let new = stateDescription { + let new = stateDescription + { status = """ - **Status:** \(previous) -> **\(new)** - """ + **Status:** \(previous) -> **\(new)** + """ } return .init( - embeds: [.init( - title: title.unicodesPrefix(256), - description: """ - > \(summary) - \(status) - \(upcomingFeatureFlag) - \(authorsString) - \(reviewManagersString) - """.replaceTripleNewlinesWithDoubleNewlines(), - url: proposalLink, - color: proposal.status.color - )], + embeds: [ + .init( + title: title.unicodesPrefix(256), + description: """ + > \(summary) + \(status) + \(upcomingFeatureFlag) + \(authorsString) + \(reviewManagersString) + """.replaceTripleNewlinesWithDoubleNewlines(), + url: proposalLink, + color: proposal.status.color + ) + ], components: await makeComponents(proposal: proposal) ) } @@ -305,10 +323,12 @@ actor EvolutionChecker: Service { if let discussion = proposal.discussions.last { buttons[0].components.append( - .button(.init( - label: "\(discussion.name.capitalized) Post", - url: discussion.link - )) + .button( + .init( + label: "\(discussion.name.capitalized) Post", + url: discussion.link + ) + ) ) } @@ -341,7 +361,8 @@ actor EvolutionChecker: Service { logger.warning("Edited Markup was nil", metadata: ["proposal": "\(proposal)"]) } let newSummary = newMarkup?.format() ?? proposal.summary - return newSummary + return + newSummary .replacing("\n", with: " ") .sanitized() .unicodesPrefix(2_048) @@ -352,23 +373,24 @@ actor EvolutionChecker: Service { } func getCachedDataForCachesStorage() -> Storage { - return self.storage + self.storage } } -private extension String { - func sanitized() -> String { +extension String { + fileprivate func sanitized() -> String { self.trimmingCharacters(in: .whitespacesAndNewlines) - .replacing(#"\/"#, with: "/") /// Un-escape + .replacing(#"\/"#, with: "/") + /// Un-escape } } -private extension Proposal.Person { - var isRealPerson: Bool { +extension Proposal.Person { + fileprivate var isRealPerson: Bool { !["", "TBD", "N/A"].contains(self.name.sanitized()) } - func makeStringForDiscord() -> String { + fileprivate func makeStringForDiscord() -> String { let link = link.sanitized() let name = name.sanitized() if link.isEmpty { @@ -387,8 +409,9 @@ struct LinkRepairer: MarkupRewriter { func visitLink(_ link: Link) -> (any Markup)? { if let dest = link.destination?.trimmingCharacters(in: .whitespaces), - !dest.hasPrefix("https://"), - dest.hasSuffix(".md") { + !dest.hasPrefix("https://"), + dest.hasSuffix(".md") + { /// It's a relative .md link like "0400-init-accessors.md". /// We make it absolute. var link = link @@ -420,8 +443,8 @@ struct QueuedProposal: Sendable, Codable { } // MARK: - +Proposal -private extension Proposal.Status { - var color: DiscordColor { +extension Proposal.Status { + fileprivate var color: DiscordColor { switch self { case .accepted: return .green case .acceptedWithRevisions: return .green(scheme: .dark) @@ -437,7 +460,7 @@ private extension Proposal.Status { } } - var UIDescription: String? { + fileprivate var UIDescription: String? { switch self { case .accepted: return "Accepted" case .acceptedWithRevisions: return "Accepted With Revisions" @@ -453,7 +476,7 @@ private extension Proposal.Status { } } - var titleDescription: String? { + fileprivate var titleDescription: String? { switch self { case .activeReview: return "In Active Review" default: return self.UIDescription @@ -461,14 +484,14 @@ private extension Proposal.Status { } } -private extension Collection { - var nilIfEmpty: Self? { +extension Collection { + fileprivate var nilIfEmpty: Self? { self.isEmpty ? nil : self } } -private extension String { - func replaceTripleNewlinesWithDoubleNewlines() -> String { +extension String { + fileprivate func replaceTripleNewlinesWithDoubleNewlines() -> String { self.replacingOccurrences(of: "\n\n\n", with: "\n\n") } } diff --git a/Sources/Penny/HandlerContext.swift b/Sources/Penny/HandlerContext.swift index ab84d710..c33d9db5 100644 --- a/Sources/Penny/HandlerContext.swift +++ b/Sources/Penny/HandlerContext.swift @@ -1,6 +1,6 @@ import Rendering -import Shared import ServiceLifecycle +import Shared final class HandlerContext: Sendable { /// Contain references to this class so need to be initialized separately diff --git a/Sources/Penny/Handlers/+Expression.swift b/Sources/Penny/Handlers/+Expression.swift index e989742a..e8a6c7e8 100644 --- a/Sources/Penny/Handlers/+Expression.swift +++ b/Sources/Penny/Handlers/+Expression.swift @@ -1,5 +1,5 @@ -import Models import DiscordBM +import Models extension Collection { /// Make sure the list in not empty before using this function. @@ -27,20 +27,21 @@ extension Collection { if elements.isEmpty { return nil } else { - let list = elements + let list = + elements .map(\.innerValue) .sorted() .makeExpressionListItems() return """ - - **\(kind.UIDescription)** - \(list) - """ + - **\(kind.UIDescription)** + \(list) + """ } } } -private extension [String] { - func makeExpressionListItems() -> String { +extension [String] { + fileprivate func makeExpressionListItems() -> String { self.enumerated().map { idx, text -> String in let escaped = DiscordUtils.escapingSpecialCharacters(text) return " - \(escaped)" diff --git a/Sources/Penny/Handlers/AuditLogHandler.swift b/Sources/Penny/Handlers/AuditLogHandler.swift index d9fb63b9..384bc38c 100644 --- a/Sources/Penny/Handlers/AuditLogHandler.swift +++ b/Sources/Penny/Handlers/AuditLogHandler.swift @@ -1,13 +1,14 @@ import DiscordBM +import Logging +import Models +import NIOCore +import NIOFoundationCompat + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import Logging -import NIOCore -import NIOFoundationCompat -import Models struct AuditLogHandler { let event: AuditLog.Entry @@ -30,27 +31,31 @@ struct AuditLogHandler { switch event.action { case .memberBanAdd: guard let userId = event.user_id.map({ UserSnowflake($0) }), - let targetId = event.target_id.map({ UserSnowflake($0) }) else { + let targetId = event.target_id.map({ UserSnowflake($0) }) + else { logger.error("User id or target id unavailable in memberBanAdd") return } await discordService.sendMessage( channelId: Constants.Channels.modLogs.id, payload: .init( - embeds: [.init( - title: "A user was banned", - description: """ - By: \(DiscordUtils.mention(id: userId)) - Banned User: \(DiscordUtils.mention(id: targetId)) - Reason: \(event.reason ?? "") - """, - color: .purple - )] + embeds: [ + .init( + title: "A user was banned", + description: """ + By: \(DiscordUtils.mention(id: userId)) + Banned User: \(DiscordUtils.mention(id: targetId)) + Reason: \(event.reason ?? "") + """, + color: .purple + ) + ] ) ) case let .messageDelete(channelId, count): guard let userId = event.user_id.map({ UserSnowflake($0) }), - let targetId = event.target_id.map({ UserSnowflake($0) }) else { + let targetId = event.target_id.map({ UserSnowflake($0) }) + else { logger.error("User id or target id unavailable in messageDelete") return } @@ -67,21 +72,24 @@ struct AuditLogHandler { await discordService.sendMessage( channelId: Constants.Channels.modLogs.id, payload: .init( - embeds: [.init( - title: "A message was deleted", - description: """ - By: \(DiscordUtils.mention(id: userId)) - From: \(DiscordUtils.mention(id: targetId)) - Count: \(count) - In: \(DiscordUtils.mention(id: channelId)) - """, - color: .purple - )] + embeds: [ + .init( + title: "A message was deleted", + description: """ + By: \(DiscordUtils.mention(id: userId)) + From: \(DiscordUtils.mention(id: targetId)) + Count: \(count) + In: \(DiscordUtils.mention(id: channelId)) + """, + color: .purple + ) + ] ) ) case let .messageBulkDelete(count): guard let userId = event.user_id.map({ UserSnowflake($0) }), - let targetId = event.target_id.map({ UserSnowflake($0) }) else { + let targetId = event.target_id.map({ UserSnowflake($0) }) + else { logger.error("User id or target id unavailable in messageBulkDelete") return } @@ -98,15 +106,17 @@ struct AuditLogHandler { await discordService.sendMessage( channelId: Constants.Channels.modLogs.id, payload: .init( - embeds: [.init( - title: "Messages were bulk-deleted", - description: """ - By: \(DiscordUtils.mention(id: userId)) - From: \(DiscordUtils.mention(id: targetId)) - Count: \(count) - """, - color: .purple - )] + embeds: [ + .init( + title: "Messages were bulk-deleted", + description: """ + By: \(DiscordUtils.mention(id: userId)) + From: \(DiscordUtils.mention(id: targetId)) + Count: \(count) + """, + color: .purple + ) + ] ) ) default: diff --git a/Sources/Penny/Handlers/CoinFinder.swift b/Sources/Penny/Handlers/CoinFinder.swift index 2f505ec5..e3d8a252 100644 --- a/Sources/Penny/Handlers/CoinFinder.swift +++ b/Sources/Penny/Handlers/CoinFinder.swift @@ -1,28 +1,30 @@ +import DiscordBM + #if canImport(FoundationEssentials) import FoundationEssentials import struct Foundation.CharacterSet #else import Foundation #endif -import DiscordBM struct CoinFinder { enum Configuration { /// All coin signs must be lowercased. /// Add a test when you add a coin sign. - static let coinSigns = [ - Constants.ServerEmojis.coin.emoji, - Constants.ServerEmojis.love.emoji, - "🚀", "🎉", "💯", "🪙", - "thx", "thanks", "thank you", - "thanks a lot", "thanks a bunch", "thanks so much", - "thank you a lot", "thank you a bunch", "thank you so much", - "thanks for the help", "thanks for your help", - "+= 1", "+ 1" - ] - + Constants.emojiSkins.map { "🙌\($0)" } - + Constants.emojiSkins.map { "🙏\($0)" } + static let coinSigns = + [ + Constants.ServerEmojis.coin.emoji, + Constants.ServerEmojis.love.emoji, + "🚀", "🎉", "💯", "🪙", + "thx", "thanks", "thank you", + "thanks a lot", "thanks a bunch", "thanks so much", + "thank you a lot", "thank you a bunch", "thank you so much", + "thanks for the help", "thanks for your help", + "+= 1", "+ 1", + ] + + Constants.emojiSkins.map { "🙌\($0)" } + + Constants.emojiSkins.map { "🙏\($0)" } /// Two or more of these characters, like `++` or `++++++++++++`. static let twoOrMore_coinSigns: [Character] = ["+"] @@ -50,10 +52,11 @@ struct CoinFinder { } // Lowercased for case-insensitive coin-sign checking. - var text = text + var text = + text .lowercased() - /// Punctuations can be problematic if someone sticks it to the end of a coin sign, like - /// "@Penny thanks, ..." or "@Penny thanks!" + /// Punctuations can be problematic if someone sticks it to the end of a coin sign, like + /// "@Penny thanks, ..." or "@Penny thanks!" .removingOccurrences(of: undesiredCharacterSet) for mentionedUser in mentionedUsers { @@ -74,7 +77,8 @@ struct CoinFinder { for line in lines { if finalUsers.count == Configuration.maxUsers { break } - let components = line + let components = + line .split(whereSeparator: \.isWhitespace) .filter({ !$0.isIgnorable }) let enumeratedComponents = components.enumerated() @@ -86,8 +90,9 @@ struct CoinFinder { /// Turns `<@ID>`s to `ID`. let user = user.dropFirst(2).dropLast() if !usersWithNewCoins.contains(where: { $0.rawValue.elementsEqual(user) }), - !excludedUsers.contains(where: { $0.rawValue.elementsEqual(user) }), - !finalUsers.contains(where: { $0.rawValue.elementsEqual(user) }) { + !excludedUsers.contains(where: { $0.rawValue.elementsEqual(user) }), + !finalUsers.contains(where: { $0.rawValue.elementsEqual(user) }) + { usersWithNewCoins.append(UserSnowflake(String(user))) } } @@ -114,7 +119,8 @@ struct CoinFinder { // If there were no users found so far, we try to check if // the message starts with @s and ends in a coin sign. if usersWithNewCoins.isEmpty, - components.isSuffixedWithCoinSign { + components.isSuffixedWithCoinSign + { for component in components { if isUserMention(component) { append(user: component) @@ -127,7 +133,8 @@ struct CoinFinder { // If there were no users found so far, we try to check if // the message starts with a coin sign and ends in @s. if usersWithNewCoins.isEmpty, - components.isPrefixedWithCoinSign { + components.isPrefixedWithCoinSign + { for component in components.reversed() { if isUserMention(component) { append(user: component) @@ -147,12 +154,14 @@ struct CoinFinder { // a coin sign in a proper place. // It would mean that someone has replied to another one and thanked them. if let repliedUser = repliedUser, - !excludedUsers.contains(repliedUser), - !finalUsers.contains(repliedUser) { + !excludedUsers.contains(repliedUser), + !finalUsers.contains(repliedUser) + { // At the beginning of the first line. if let firstLine = lines.first { - let components = firstLine + let components = + firstLine .split(whereSeparator: \.isWhitespace) .filter({ !$0.isEmpty }) if components.isPrefixedWithCoinSign { @@ -162,7 +171,8 @@ struct CoinFinder { // At the end of the last line, only if there are no other users that get any coins. if finalUsers.isEmpty, let lastLine = lines.last { - let components = lastLine + let components = + lastLine .split(whereSeparator: \.isWhitespace) .filter({ !$0.isEmpty }) if components.isSuffixedWithCoinSign { @@ -178,9 +188,8 @@ struct CoinFinder { let stringNoSurroundings = string.dropFirst(2).dropLast() /// `.hasPrefix()` is for a better performance. /// Should remove a lot of no-match strings, much faster than the containment check. - return string.hasPrefix("<@") && - string.hasSuffix(">") && - mentionedUsers.contains(where: { $0.rawValue.elementsEqual(stringNoSurroundings) }) + return string.hasPrefix("<@") && string.hasSuffix(">") + && mentionedUsers.contains(where: { $0.rawValue.elementsEqual(stringNoSurroundings) }) } } @@ -192,14 +201,14 @@ private let splitSigns = CoinFinder.Configuration.coinSigns.map { /// It's safe but apparently the underlying type doesn't declare a proper conditional Sendable conformance. private let reversedSplitSigns = splitSigns.map { $0.reversed() } -private extension Sequence { - var isPrefixedWithCoinSign: Bool { - return splitSigns.contains { +extension Sequence { + fileprivate var isPrefixedWithCoinSign: Bool { + splitSigns.contains { self.starts(with: $0) } || self.isPrefixedWithOtherCoinSigns } - var isSuffixedWithCoinSign: Bool { + fileprivate var isSuffixedWithCoinSign: Bool { let reversedElements = self.reversed() return reversedSplitSigns.contains { reversedElements.starts(with: $0) @@ -210,14 +219,13 @@ private extension Sequence { private var isPrefixedWithOtherCoinSigns: Bool { self.first(where: { _ in true }).map { element in CoinFinder.Configuration.twoOrMore_coinSigns.contains { sign in - element.underestimatedCount > 1 && - element.allSatisfy({ sign == $0 }) + element.underestimatedCount > 1 && element.allSatisfy({ sign == $0 }) } } == true } } -private extension Substring { +extension Substring { private static let ignorable = Set(["", "and", "&"]) /// These strings are considered neutral, and in the logic above we can ignore them. @@ -225,7 +233,7 @@ private extension Substring { /// NOTE: The logic in `CoinHandler`, intentionally adds spaces after and before each /// user-mention. That means we _need_ to remove empty strings to neutralize those /// intentional spaces. - var isIgnorable: Bool { + fileprivate var isIgnorable: Bool { Self.ignorable.contains(self.lowercased()) } } diff --git a/Sources/Penny/Handlers/EventHandler.swift b/Sources/Penny/Handlers/EventHandler.swift index 9d3e1db0..9ea664b4 100644 --- a/Sources/Penny/Handlers/EventHandler.swift +++ b/Sources/Penny/Handlers/EventHandler.swift @@ -5,17 +5,20 @@ struct EventHandler: GatewayEventHandler { let event: Gateway.Event let context: HandlerContext let logger = Logger(label: "EventHandler") - + func onEventHandlerStart() async -> Bool { let canRespond = await context.botStateManager.canRespond(to: event) if !canRespond { - logger.debug("BotStateManager doesn't allow responding to event", metadata: [ - "event": "\(event)" - ]) + logger.debug( + "BotStateManager doesn't allow responding to event", + metadata: [ + "event": "\(event)" + ] + ) } return canRespond } - + func onMessageCreate(_ message: Gateway.MessageCreate) async { await MessageHandler(event: message, context: context).handle() } @@ -47,7 +50,7 @@ struct EventHandler: GatewayEventHandler { func onInteractionCreate(_ interaction: Interaction) async { await InteractionHandler(event: interaction, context: context).handle() } - + func onMessageReactionAdd(_ reaction: Gateway.MessageReactionAdd) async { await ReactionHandler(event: reaction, context: context).handle() } diff --git a/Sources/Penny/Handlers/InteractionHandler.swift b/Sources/Penny/Handlers/InteractionHandler.swift index adca041f..99a634f8 100644 --- a/Sources/Penny/Handlers/InteractionHandler.swift +++ b/Sources/Penny/Handlers/InteractionHandler.swift @@ -1,7 +1,8 @@ import DiscordBM +import JWTKit import Logging import Models -import JWTKit + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -22,12 +23,11 @@ struct InteractionHandler { let event: Interaction let context: HandlerContext var logger = Logger(label: "InteractionHandler") - + private let oops = "Oopsie Woopsie... Something went wrong :(" - + typealias InteractionOption = Interaction.ApplicationCommand.Option - init(event: Interaction, context: HandlerContext) { self.event = event self.context = context @@ -41,7 +41,7 @@ struct InteractionHandler { await jwtKeys.add(ecdsa: privateKey) return jwtKeys } - + func handle() async { switch event.data { case let .applicationCommand(data) where event.type == .applicationCommand: @@ -89,8 +89,8 @@ struct InteractionHandler { } // MARK: - makeResponseForModal -private extension InteractionHandler { - func makeResponseForModal( +extension InteractionHandler { + fileprivate func makeResponseForModal( modal: Interaction.ModalSubmit, modalId: ModalID ) async throws -> any Response { @@ -110,7 +110,7 @@ private extension InteractionHandler { return "The list you sent seems to be empty." } - let (existingExpressions, newExpressions) = try await allExpressions.divided { + let (existingExpressions, newExpressions) = try await allExpressions.asyncDivided { try await context.pingsService.exists( expression: $0, forDiscordID: discordId @@ -120,22 +120,27 @@ private extension InteractionHandler { let tooShorts = newExpressions.filter({ $0.innerValue.unicodeScalars.count < 3 }) if !tooShorts.isEmpty { return """ - Some texts are less than 3 letters, which is not acceptable: - \(tooShorts.makeExpressionListForDiscord()) - """ + Some texts are less than 3 letters, which is not acceptable: + \(tooShorts.makeExpressionListForDiscord()) + """ } let current = try await context.pingsService.get(discordID: discordId) - let limit = await context.discordService.memberHasRolesForElevatedPublicCommandsAccess( - member: member - ) ? Configuration.autoPingsMaxLimit : Configuration.autoPingsLowLimit + let limit = + await context.discordService.memberHasRolesForElevatedPublicCommandsAccess( + member: member + ) ? Configuration.autoPingsMaxLimit : Configuration.autoPingsLowLimit if newExpressions.count + current.count > limit { - logger.error("Someone hit their expressions count limit", metadata: [ - "limit": .stringConvertible(limit), - "current": .stringConvertible(current), - "new": .stringConvertible(newExpressions), - ]) - return "You currently have \(current.count) expressions and you want to add \(newExpressions.count) more, but you have a limit of \(limit) expressions." + logger.error( + "Someone hit their expressions count limit", + metadata: [ + "limit": .stringConvertible(limit), + "current": .stringConvertible(current), + "new": .stringConvertible(newExpressions), + ] + ) + return + "You currently have \(current.count) expressions and you want to add \(newExpressions.count) more, but you have a limit of \(limit) expressions." } discardingResult { @@ -150,10 +155,10 @@ private extension InteractionHandler { if !newExpressions.isEmpty { components.append( - """ - Successfully added the followings to your pings-list: - \(newExpressions.makeExpressionListForDiscord()) - """ + """ + Successfully added the followings to your pings-list: + \(newExpressions.makeExpressionListForDiscord()) + """ ) } @@ -178,7 +183,7 @@ private extension InteractionHandler { return "The list you sent seems to be empty." } - let (existingExpressions, newExpressions) = try await allExpressions.divided { + let (existingExpressions, newExpressions) = try await allExpressions.asyncDivided { try await context.pingsService.exists( expression: $0, forDiscordID: discordId @@ -224,7 +229,8 @@ private extension InteractionHandler { .requireTextInput() if let _text = textInput.value?.trimmingCharacters(in: .whitespaces), - !_text.isEmpty { + !_text.isEmpty + { let dividedExpressions = _text.divideIntoAutoPingsExpressions(mode: mode) let divided = message.divideForPingCommandExactMatchChecking() @@ -238,33 +244,33 @@ private extension InteractionHandler { } var response = """ - The message is: + The message is: - > \(message) + > \(message) - And the entered texts are: + And the entered texts are: - > \(_text) + > \(_text) - """ + """ if dividedExpressions.isEmpty { response += "The texts you entered seem like an empty list to me." } else { response += """ - The identified expressions are: - \(dividedExpressions.makeExpressionListForDiscord()) + The identified expressions are: + \(dividedExpressions.makeExpressionListForDiscord()) - """ + """ if triggeredExpressions.isEmpty { response += "The message won't trigger any of the expressions above." } else { response += """ - The message will trigger these expressions: - \(triggeredExpressions.makeExpressionListForDiscord()) - """ + The message will trigger these expressions: + \(triggeredExpressions.makeExpressionListForDiscord()) + """ } } @@ -284,25 +290,25 @@ private extension InteractionHandler { if currentExpressions.isEmpty { return """ - You pings-list is empty. - Either use the `texts` field, or add some expressions. - """ + You pings-list is empty. + Either use the `texts` field, or add some expressions. + """ } else { var response = """ - The message is: + The message is: - > \(message) + > \(message) - """ + """ if triggeredExpressions.isEmpty { response += "The message won't trigger any of your expressions." } else { response += """ - The message will trigger these ping expressions: - \(triggeredExpressions.makeExpressionListForDiscord()) - """ + The message will trigger these ping expressions: + \(triggeredExpressions.makeExpressionListForDiscord()) + """ } return response @@ -324,47 +330,46 @@ private extension InteractionHandler { if name.contains("\n") { let nameNoNewLines = name.replacing("\n", with: " ") return """ - The name cannot contain new lines. You can try '\(nameNoNewLines)' instead. + The name cannot contain new lines. You can try '\(nameNoNewLines)' instead. - Value: - > \(newValue) - """ + Value: + > \(newValue) + """ } if name.unicodeScalars.count > Configuration.faqsNameMaxLength { return """ - Name cannot be more than \(Configuration.faqsNameMaxLength) characters. + Name cannot be more than \(Configuration.faqsNameMaxLength) characters. - Value: - > \(newValue) - """ + Value: + > \(newValue) + """ } let all = try await context.faqsService.getAll() if let similar = all.first(where: { - $0.key.heavyFolded().filter({ !$0.isWhitespace }) == name && - $0.key != name + $0.key.heavyFolded().filter({ !$0.isWhitespace }) == name && $0.key != name })?.key { return """ - The entered name '\(DiscordUtils.escapingSpecialCharacters(name))' is too similar to another name '\(DiscordUtils.escapingSpecialCharacters(similar))' while not being equal. - This will cause ambiguity for users. + The entered name '\(DiscordUtils.escapingSpecialCharacters(name))' is too similar to another name '\(DiscordUtils.escapingSpecialCharacters(similar))' while not being equal. + This will cause ambiguity for users. - Value: - \(newValue) - """ + Value: + \(newValue) + """ } if let value = all[name] { return """ - A FAQ with name '\(name)' already exists. Please remove it first. + A FAQ with name '\(name)' already exists. Please remove it first. - Value: - \(newValue) + Value: + \(newValue) - Old value: - \(value) - """ + Old value: + \(value) + """ } if name.isEmpty || newValue.isEmpty { @@ -372,20 +377,23 @@ private extension InteractionHandler { } /// The response of this command is ephemeral so members feel free to add faqs. /// We will log this action so we can know if something malicious is happening. - logger.notice("Will add a FAQ", metadata: [ - "name": .string(name), - "value": .string(newValue), - ]) + logger.notice( + "Will add a FAQ", + metadata: [ + "name": .string(name), + "value": .string(newValue), + ] + ) discardingResult { try await context.faqsService.insert(name: name, value: newValue) } return """ - Added a new FAQ with name '\(name)': + Added a new FAQ with name '\(name)': - \(newValue) - """ + \(newValue) + """ case let .edit(nameHash, _): guard let name = try await context.faqsService.getName(hash: nameHash) else { logger.warning( @@ -404,20 +412,23 @@ private extension InteractionHandler { } /// The response of this command is ephemeral so members feel free to add faqs. /// We will log this action so we can know if something malicious is happening. - logger.notice("Will edit a FAQ", metadata: [ - "name": .string(name), - "value": .string(newValue), - ]) + logger.notice( + "Will edit a FAQ", + metadata: [ + "name": .string(name), + "value": .string(newValue), + ] + ) discardingResult { try await context.faqsService.insert(name: name, value: newValue) } return """ - Edited a FAQ with name '\(name)': + Edited a FAQ with name '\(name)': - \(newValue) - """ + \(newValue) + """ case let .rename(nameHash, _): guard let oldName = try await context.faqsService.getName(hash: nameHash) else { logger.warning( @@ -443,10 +454,13 @@ private extension InteractionHandler { } /// The response of this command is ephemeral so members feel free to add faqs. /// We will log this action so we can know if something malicious is happening. - logger.notice("Will rename a FAQ", metadata: [ - "name": .string(name), - "value": .string(value), - ]) + logger.notice( + "Will rename a FAQ", + metadata: [ + "name": .string(name), + "value": .string(value), + ] + ) discardingResult { try await context.faqsService.insert(name: name, value: value) @@ -454,10 +468,10 @@ private extension InteractionHandler { } return """ - Renamed a FAQ from '\(oldName)' to '\(name)': + Renamed a FAQ from '\(oldName)' to '\(name)': - \(value) - """ + \(value) + """ } case let .autoFaqs(autoFaqsMode): switch autoFaqsMode { @@ -474,47 +488,46 @@ private extension InteractionHandler { if expression.contains("\n") { let expNoNewLines = expression.replacing("\n", with: " ") return """ - The expression cannot contain new lines. You can try '\(expNoNewLines)' instead. + The expression cannot contain new lines. You can try '\(expNoNewLines)' instead. - Value: - > \(newValue) - """ + Value: + > \(newValue) + """ } if expression.unicodeScalars.count > Configuration.autoFaqsNameMaxLength { return """ - Expression cannot be more than \(Configuration.faqsNameMaxLength) characters. + Expression cannot be more than \(Configuration.faqsNameMaxLength) characters. - Value: - > \(newValue) - """ + Value: + > \(newValue) + """ } let all = try await context.faqsService.getAll() if let similar = all.first(where: { - $0.key.heavyFolded().filter({ !$0.isWhitespace }) == expression && - $0.key != expression + $0.key.heavyFolded().filter({ !$0.isWhitespace }) == expression && $0.key != expression })?.key { return """ - The entered expression '\(DiscordUtils.escapingSpecialCharacters(expression))' is too similar to another expression '\(DiscordUtils.escapingSpecialCharacters(similar))' while not being equal. - This will cause ambiguity for users. + The entered expression '\(DiscordUtils.escapingSpecialCharacters(expression))' is too similar to another expression '\(DiscordUtils.escapingSpecialCharacters(similar))' while not being equal. + This will cause ambiguity for users. - Value: - \(newValue) - """ + Value: + \(newValue) + """ } if let value = all[expression] { return """ - A Auto-FAQ with expression '\(expression)' already exists. Please remove it first. + A Auto-FAQ with expression '\(expression)' already exists. Please remove it first. - Value: - \(newValue) + Value: + \(newValue) - Old value: - \(value) - """ + Old value: + \(value) + """ } if expression.isEmpty || newValue.isEmpty { @@ -522,10 +535,13 @@ private extension InteractionHandler { } /// The response of this command is ephemeral so members feel free to add faqs. /// We will log this action so we can know if something malicious is happening. - logger.notice("Will add an Auto-FAQ", metadata: [ - "expression": .string(expression), - "value": .string(newValue), - ]) + logger.notice( + "Will add an Auto-FAQ", + metadata: [ + "expression": .string(expression), + "value": .string(newValue), + ] + ) discardingResult { try await context.autoFaqsService.insert( @@ -535,14 +551,16 @@ private extension InteractionHandler { } return """ - Added a new Auto-FAQ with expression '\(expression)': + Added a new Auto-FAQ with expression '\(expression)': - \(newValue) - """ + \(newValue) + """ case let .edit(expressionHash, _): - guard let expression = try await context.autoFaqsService.getName( - hash: expressionHash - ) else { + guard + let expression = try await context.autoFaqsService.getName( + hash: expressionHash + ) + else { logger.warning( "This should be very rare ... an expression doesn't exist anymore to edit", metadata: ["expressionHash": .stringConvertible(expressionHash)] @@ -559,10 +577,13 @@ private extension InteractionHandler { } /// The response of this command is ephemeral so members feel free to add faqs. /// We will log this action so we can know if something malicious is happening. - logger.notice("Will edit an Auto-FAQ", metadata: [ - "expression": .string(expression), - "value": .string(newValue), - ]) + logger.notice( + "Will edit an Auto-FAQ", + metadata: [ + "expression": .string(expression), + "value": .string(newValue), + ] + ) discardingResult { try await context.autoFaqsService.insert( @@ -572,23 +593,27 @@ private extension InteractionHandler { } return """ - Edited an Auto-FAQ with expression '\(expression)': + Edited an Auto-FAQ with expression '\(expression)': - \(newValue) - """ + \(newValue) + """ case let .rename(expressionHash, _): - guard let oldExpression = try await context.autoFaqsService.getName( - hash: expressionHash - ) else { + guard + let oldExpression = try await context.autoFaqsService.getName( + hash: expressionHash + ) + else { logger.warning( "This should be very rare ... an expression doesn't exist anymore to edit", metadata: ["expressionHash": .stringConvertible(expressionHash)] ) return "The expression no longer exists!" } - guard let value = try await context.autoFaqsService.get( - expression: oldExpression - ) else { + guard + let value = try await context.autoFaqsService.get( + expression: oldExpression + ) + else { logger.warning( "This should be very rare ... an expression doesn't have a value anymore", metadata: ["expressionHash": .stringConvertible(expressionHash)] @@ -605,10 +630,13 @@ private extension InteractionHandler { } /// The response of this command is ephemeral so members feel free to add faqs. /// We will log this action so we can know if something malicious is happening. - logger.notice("Will rename an Auto-FAQ", metadata: [ - "expression": .string(expression), - "value": .string(value), - ]) + logger.notice( + "Will rename an Auto-FAQ", + metadata: [ + "expression": .string(expression), + "value": .string(value), + ] + ) discardingResult { try await context.autoFaqsService.insert( @@ -619,10 +647,10 @@ private extension InteractionHandler { } return """ - Renamed an Auto-FAQ from '\(oldExpression)' to '\(expression)': + Renamed an Auto-FAQ from '\(oldExpression)' to '\(expression)': - \(value) - """ + \(value) + """ } } } @@ -646,9 +674,9 @@ private extension InteractionHandler { } // MARK: - makeResponseForApplicationCommand -private extension InteractionHandler { +extension InteractionHandler { /// Returns `nil` if no response is supposed to be sent to user. - func makeResponseForApplicationCommand( + fileprivate func makeResponseForApplicationCommand( command: SlashCommand, data: Interaction.ApplicationCommand ) async -> (any Response)? { @@ -673,8 +701,8 @@ private extension InteractionHandler { return oops } } - - func handleGitHubCommand(options: [InteractionOption]) async throws -> (any Response)? { + + fileprivate func handleGitHubCommand(options: [InteractionOption]) async throws -> (any Response)? { let discordID = try (event.member?.user).requireValue().id let first = try options.first.requireValue() let subcommand = try GitHubSubCommand(rawValue: first.name).requireValue() @@ -682,7 +710,7 @@ private extension InteractionHandler { switch subcommand { case .link: let jwt = GHOAuthPayload( - discordID: discordID, + discordID: discordID, interactionToken: event.token ) let signers = try await makeJWTSigners() @@ -690,14 +718,14 @@ private extension InteractionHandler { let state = try await signers.sign(jwt) let url = "https://github.com/login/oauth/authorize?client_id=\(clientID)&state=\(state)" return """ - Click the link below to authorize Vapor: + Click the link below to authorize Vapor: - > This is a one-time authorization so Penny can confirm you own the GitHub account. - > Penny doesn't do anything else with its authorization, and immediately discards its access token as visible in [Penny's open-source code](https://github.com/vapor/penny-bot/blob/main/Lambdas/GHOAuth/OAuthLambda.swift). - > Feel free to revoke Penny's access from your GitHub account afterwards. + > This is a one-time authorization so Penny can confirm you own the GitHub account. + > Penny doesn't do anything else with its authorization, and immediately discards its access token as visible in [Penny's open-source code](https://github.com/vapor/penny-bot/blob/main/Lambdas/GHOAuth/OAuthLambda.swift). + > Feel free to revoke Penny's access from your GitHub account afterwards. - [**Authorize**](\(url)) - """ + [**Authorize**](\(url)) + """ case .unlink: let response = try await context.usersService.getGitHubName(of: discordID) switch response { @@ -721,8 +749,8 @@ private extension InteractionHandler { } } } - - func handlePingsCommand(options: [InteractionOption]) async throws -> (any Response)? { + + fileprivate func handlePingsCommand(options: [InteractionOption]) async throws -> (any Response)? { let discordId = try (event.member?.user).requireValue().id let first = try options.first.requireValue() let subcommand = try AutoPingsSubCommand(rawValue: first.name).requireValue() @@ -769,20 +797,23 @@ private extension InteractionHandler { return "You have not set any expressions to be pinged for." } else { return """ - Your expressions - \(items.makeExpressionListForDiscord()) - """ + Your expressions + \(items.makeExpressionListForDiscord()) + """ } case .remove: - let expressionInput = try first + let expressionInput = + try first .requireOption(named: "expression") .requireString() guard let hash = Int(expressionInput) else { return "Malformed expression value: '\(expressionInput)'" } - guard let expression = try await context.pingsService.getExpression( - hash: hash - ) else { + guard + let expression = try await context.pingsService.getExpression( + hash: hash + ) + else { return "Could not find any expression matching your input" } discardingResult { @@ -793,9 +824,9 @@ private extension InteractionHandler { } return """ - Successfully removed the followings from your pings-list: - \([expression].makeExpressionListForDiscord()) - """ + Successfully removed the followings from your pings-list: + \([expression].makeExpressionListForDiscord()) + """ case .add: let mode = try self.requireExpressionMode(first.options) let modalId = ModalID.autoPings(.add, mode) @@ -811,19 +842,22 @@ private extension InteractionHandler { } } - func handleFaqsCommand(options: [InteractionOption]) async throws -> (any Response)? { + fileprivate func handleFaqsCommand(options: [InteractionOption]) async throws -> (any Response)? { let first = try options.first.requireValue() let subcommand = try FaqsSubCommand(rawValue: first.name).requireValue() switch subcommand { case .get: var ephemeralOverride: Bool? if let option = first.option(named: "ephemeral"), - case let .bool(bool) = option.value { + case let .bool(bool) = option.value + { ephemeralOverride = bool } - guard await sendAcknowledgement( - isEphemeral: ephemeralOverride ?? false - ) else { return nil } + guard + await sendAcknowledgement( + isEphemeral: ephemeralOverride ?? false + ) + else { return nil } case .remove: /// This is ephemeral so members feel free to remove stuff, /// but we will log this action so we can know if something malicious is happening. @@ -849,9 +883,11 @@ private extension InteractionHandler { .requireOption(named: "name") .requireString() let member = try event.member.requireValue() - guard await context.discordService.memberHasRolesForElevatedRestrictedCommandsAccess( - member: member - ) else { + guard + await context.discordService.memberHasRolesForElevatedRestrictedCommandsAccess( + member: member + ) + else { let rolesString = Constants.Roles .elevatedRestrictedCommandsAccess .map(\.rawValue) @@ -862,10 +898,13 @@ private extension InteractionHandler { guard let value = try await context.faqsService.get(name: name) else { return "No FAQ with name '\(name)' exists at all" } - logger.warning("Will remove a FAQ", metadata: [ - "name": .string(name), - "value": .string(value), - ]) + logger.warning( + "Will remove a FAQ", + metadata: [ + "name": .string(name), + "value": .string(value), + ] + ) discardingResult { try await context.faqsService.remove(name: name) @@ -909,19 +948,22 @@ private extension InteractionHandler { } } - func handleAutoFaqsCommand(options: [InteractionOption]) async throws -> (any Response)? { + fileprivate func handleAutoFaqsCommand(options: [InteractionOption]) async throws -> (any Response)? { let first = try options.first.requireValue() let subcommand = try AutoFaqsSubCommand(rawValue: first.name).requireValue() switch subcommand { case .get: var ephemeralOverride: Bool? if let option = first.option(named: "ephemeral"), - case let .bool(bool) = option.value { + case let .bool(bool) = option.value + { ephemeralOverride = bool } - guard await sendAcknowledgement( - isEphemeral: ephemeralOverride ?? false - ) else { return nil } + guard + await sendAcknowledgement( + isEphemeral: ephemeralOverride ?? false + ) + else { return nil } case .remove: /// This is ephemeral so members feel free to remove stuff, /// but we will log this action so we can know if something malicious is happening. @@ -947,9 +989,11 @@ private extension InteractionHandler { .requireOption(named: "expression") .requireString() let member = try event.member.requireValue() - guard await context.discordService.memberHasRolesForElevatedRestrictedCommandsAccess( - member: member - ) else { + guard + await context.discordService.memberHasRolesForElevatedRestrictedCommandsAccess( + member: member + ) + else { let rolesString = Constants.Roles .elevatedRestrictedCommandsAccess .map(\.rawValue) @@ -957,15 +1001,20 @@ private extension InteractionHandler { .joined(separator: " ") return "You don't have access to this command; it is only available to \(rolesString)" } - guard let value = try await context.autoFaqsService.get( - expression: expression - ) else { + guard + let value = try await context.autoFaqsService.get( + expression: expression + ) + else { return "No Auto-FAQ with expression '\(expression)' exists at all" } - logger.warning("Will remove an Auto-FAQ", metadata: [ - "expression": .string(expression), - "value": .string(value), - ]) + logger.warning( + "Will remove an Auto-FAQ", + metadata: [ + "expression": .string(expression), + "value": .string(value), + ] + ) discardingResult { try await context.autoFaqsService.remove(expression: expression) @@ -1001,10 +1050,12 @@ private extension InteractionHandler { return accessLevelError } if try await context.autoFaqsService.get(expression: expression) != nil { - let modalId = ModalID.autoFaqs(.rename( - expressionHash: expression.hash, - expression: expression - )) + let modalId = ModalID.autoFaqs( + .rename( + expressionHash: expression.hash, + expression: expression + ) + ) return modalId.makeModal() } else { return "No Auto-FAQ with expression '\(expression)' exists at all" @@ -1013,7 +1064,7 @@ private extension InteractionHandler { } /// Returns a `String` if there is an access-levelerror. Otherwise `nil`. - func faqsCommandAccessLevelErrorIfNeeded() async throws -> String? { + fileprivate func faqsCommandAccessLevelErrorIfNeeded() async throws -> String? { if await context.discordService.memberHasRolesForElevatedRestrictedCommandsAccess( member: try event.member.requireValue() ) { @@ -1031,7 +1082,7 @@ private extension InteractionHandler { } } - func makeResponseForAutocomplete( + fileprivate func makeResponseForAutocomplete( command: SlashCommand, data: Interaction.ApplicationCommand ) async -> Payloads.InteractionResponse.Autocomplete { @@ -1050,16 +1101,20 @@ private extension InteractionHandler { ) } } catch { - logger.report("Autocomplete generation error", error: error, metadata: [ - "command": .string(command.rawValue) - ]) + logger.report( + "Autocomplete generation error", + error: error, + metadata: [ + "command": .string(command.rawValue) + ] + ) return Payloads.InteractionResponse.Autocomplete( choices: [.init(name: "Failure", value: .string(self.oops))] ) } } - func handleAutoPingsAutocomplete( + fileprivate func handleAutoPingsAutocomplete( data: Interaction.ApplicationCommand ) async throws -> Payloads.InteractionResponse.Autocomplete { let first = try (data.options?.first).requireValue() @@ -1073,30 +1128,33 @@ private extension InteractionHandler { let all = try await context.pingsService.get(discordID: userId) let queried: ArraySlice if foldedName.isEmpty { - queried = ArraySlice(all - .sorted { $0.innerValue > $1.innerValue } - .sorted { $0.kind.priority > $1.kind.priority } - .prefix(25) + queried = ArraySlice( + all + .sorted { $0.innerValue > $1.innerValue } + .sorted { $0.kind.priority > $1.kind.priority } + .prefix(25) ) } else { - queried = all + queried = + all .filter { $0.innerValue.heavyFolded().contains(foldedName) } .sorted { $0.innerValue > $1.innerValue } .sorted { $0.kind.priority > $1.kind.priority } .prefix(25) } - - return .init(choices: queried.map { expression in - let name = "\(expression.kind.UIDescription) - \(expression.innerValue)" - return .init( - name: name.unicodesPrefix(100), - value: .string("\(expression.hashValue)") - ) - }) + return .init( + choices: queried.map { expression in + let name = "\(expression.kind.UIDescription) - \(expression.innerValue)" + return .init( + name: name.unicodesPrefix(100), + value: .string("\(expression.hashValue)") + ) + } + ) } - func handleFaqsAutocomplete( + fileprivate func handleFaqsAutocomplete( data: Interaction.ApplicationCommand ) async throws -> Payloads.InteractionResponse.Autocomplete { let first = try (data.options?.first).requireValue() @@ -1110,7 +1168,8 @@ private extension InteractionHandler { if foldedName.isEmpty { queried = ArraySlice(all.sorted { $0 > $1 }.prefix(25)) } else { - queried = all + queried = + all .filter { $0.heavyFolded().contains(foldedName) } .sorted { $0 > $1 } .prefix(25) @@ -1125,7 +1184,7 @@ private extension InteractionHandler { ) } - func handleAutoFaqsAutocomplete( + fileprivate func handleAutoFaqsAutocomplete( data: Interaction.ApplicationCommand ) async throws -> Payloads.InteractionResponse.Autocomplete { let first = try (data.options?.first).requireValue() @@ -1139,7 +1198,8 @@ private extension InteractionHandler { if foldedExpression.isEmpty { queried = ArraySlice(all.sorted { $0 > $1 }.prefix(25)) } else { - queried = all + queried = + all .filter { $0.heavyFolded().contains(foldedExpression) } .sorted { $0 > $1 } .prefix(25) @@ -1154,24 +1214,26 @@ private extension InteractionHandler { ) } - func requireExpressionMode(_ options: [InteractionOption]?) throws -> Expression.Kind { - let optionValue = try options + fileprivate func requireExpressionMode(_ options: [InteractionOption]?) throws -> Expression.Kind { + let optionValue = + try options .requireValue() .requireOption(named: "mode") .requireString() return try Expression.Kind(rawValue: optionValue).requireValue() } - - func handleHowManyCoinsAppCommand() async throws -> String { + + fileprivate func handleHowManyCoinsAppCommand() async throws -> String { guard case let .applicationCommand(data) = event.data, - let userID = data.target_id else { + let userID = data.target_id + else { logger.error("Coin-count command could not find appropriate data") return oops } return try await getCoinCount(of: UserSnowflake(userID)) } - - func handleHowManyCoinsCommand(options: [InteractionOption]) async throws -> String { + + fileprivate func handleHowManyCoinsCommand(options: [InteractionOption]) async throws -> String { let userID: String if let userOption = options.first?.value?.asString { userID = userOption @@ -1182,12 +1244,12 @@ private extension InteractionHandler { } return try await getCoinCount(of: UserSnowflake(userID)) } - - func getCoinCount(of discordID: UserSnowflake) async throws -> String { + + fileprivate func getCoinCount(of discordID: UserSnowflake) async throws -> String { let coinCount = try await context.usersService.getCoinCount(of: discordID) return "\(DiscordUtils.mention(id: discordID)) has \(coinCount) \(Constants.ServerEmojis.coin.emoji)!" } - + /// Returns `true` if the acknowledgement was successfully sent private func sendAcknowledgement(isEphemeral: Bool) async -> Bool { await context.discordService.respondToInteraction( @@ -1196,21 +1258,25 @@ private extension InteractionHandler { payload: .deferredChannelMessageWithSource(isEphemeral: isEphemeral) ) } - + private func sendInteractionResolveFailure() async { await context.discordService.respondToInteraction( id: event.id, token: event.token, - payload: .channelMessageWithSource(.init( - embeds: [.init( - description: "Failed to resolve the interaction :(", - color: .purple - )], - flags: [.ephemeral] - )) + payload: .channelMessageWithSource( + .init( + embeds: [ + .init( + description: "Failed to resolve the interaction :(", + color: .purple + ) + ], + flags: [.ephemeral] + ) + ) ) } - + private func respond( with response: any Response, shouldEdit: Bool, @@ -1290,7 +1356,7 @@ private enum ModalID { } } - init? (customIdPart part: String) { + init?(customIdPart part: String) { if part == "add" { self = .add } else if part.hasPrefix("edit-"), let hash = Int(part.dropFirst(5)) { @@ -1337,7 +1403,7 @@ private enum ModalID { } } - init? (customIdPart part: String) { + init?(customIdPart part: String) { if part == "add" { self = .add } else if part.hasPrefix("edit-"), let hash = Int(part.dropFirst(5)) { @@ -1402,7 +1468,8 @@ private enum ModalID { style: .paragraph, label: "Enter the ping-expressions", required: false, - placeholder: "Leave empty to test your own expressions. Example: vapor, fluent, swift, websocket kit, your-name" + placeholder: + "Leave empty to test your own expressions. Example: vapor, fluent, swift, websocket kit, your-name" ) return [message, texts] } @@ -1425,9 +1492,9 @@ private enum ModalID { min_length: 3, required: true, placeholder: """ - Example: - How to set your working directory: - """ + Example: + How to set your working directory: + """ ) return [name, value] case .edit(_, let value): @@ -1438,10 +1505,11 @@ private enum ModalID { min_length: 3, required: true, value: value, - placeholder: value == nil ? """ - Example: - How to set your working directory: - """ : nil + placeholder: value == nil + ? """ + Example: + How to set your working directory: + """ : nil ) return [value] case .rename(_, let name): @@ -1476,9 +1544,9 @@ private enum ModalID { min_length: 3, required: true, placeholder: """ - Example: - Update your package dependencies! - """ + Example: + Update your package dependencies! + """ ) return [expression, value] case .edit(_, let value): @@ -1489,10 +1557,11 @@ private enum ModalID { min_length: 3, required: true, value: value, - placeholder: value == nil ? """ - Example: - Update your package dependencies! - """ : nil + placeholder: value == nil + ? """ + Example: + Update your package dependencies! + """ : nil ) return [value] case .rename(_, let expression): @@ -1525,26 +1594,29 @@ extension ModalID: RawRepresentable { } } - init? (rawValue: String) { + init?(rawValue: String) { var split = rawValue.split(separator: ";") if split.isEmpty { return nil } switch split.removeFirst() { case "auto-pings": guard split.count == 2, - let autoPingsMode = AutoPingsMode(rawValue: String(split[0])), - let expressionMode = Expression.Kind(rawValue: String(split[1])) else { + let autoPingsMode = AutoPingsMode(rawValue: String(split[0])), + let expressionMode = Expression.Kind(rawValue: String(split[1])) + else { return nil } self = .autoPings(autoPingsMode, expressionMode) case "faqs": guard split.count == 1, - let faqsMode = FaqsMode(customIdPart: String(split[0])) else { + let faqsMode = FaqsMode(customIdPart: String(split[0])) + else { return nil } self = .faqs(faqsMode) case "auto-faqs": guard split.count == 1, - let autoFaqsMode = AutoFaqsMode(customIdPart: String(split[0])) else { + let autoFaqsMode = AutoFaqsMode(customIdPart: String(split[0])) + else { return nil } self = .autoFaqs(autoFaqsMode) @@ -1554,8 +1626,8 @@ extension ModalID: RawRepresentable { } } -private extension String { - func divideIntoAutoPingsExpressions(mode: Expression.Kind) -> [Expression] { +extension String { + fileprivate func divideIntoAutoPingsExpressions(mode: Expression.Kind) -> [Expression] { self.split(whereSeparator: { $0 == "," || $0.isNewline }) .map(String.init) .map({ $0.heavyFolded() }) @@ -1584,17 +1656,26 @@ private protocol Response { extension String: Response { func makeResponse(isEphemeral: Bool) -> Payloads.InteractionResponse { - .channelMessageWithSource(.init(embeds: [.init( - description: String(self.unicodesPrefix(4_000)), - color: .purple - )], flags: isEphemeral ? [.ephemeral] : nil)) + .channelMessageWithSource( + .init( + embeds: [ + .init( + description: String(self.unicodesPrefix(4_000)), + color: .purple + ) + ], + flags: isEphemeral ? [.ephemeral] : nil + ) + ) } func makeEditPayload() -> Payloads.EditWebhookMessage { - .init(embeds: [.init( - description: String(self.unicodesPrefix(4_000)), - color: .purple - )]) + .init(embeds: [ + .init( + description: String(self.unicodesPrefix(4_000)), + color: .purple + ) + ]) } var isEditable: Bool { true } diff --git a/Sources/Penny/Handlers/MessageDeleteHandler.swift b/Sources/Penny/Handlers/MessageDeleteHandler.swift index 22ba0c0d..9aee711b 100644 --- a/Sources/Penny/Handlers/MessageDeleteHandler.swift +++ b/Sources/Penny/Handlers/MessageDeleteHandler.swift @@ -1,13 +1,14 @@ import DiscordBM +import Logging +import Models +import NIOCore +import NIOFoundationCompat + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import Logging -import NIOCore -import NIOFoundationCompat -import Models struct MessageDeleteHandler { let context: HandlerContext @@ -24,30 +25,41 @@ struct MessageDeleteHandler { messageId: MessageSnowflake, in channelId: ChannelSnowflake ) async throws { - guard let messages = await discordService.getDeletedMessageWithEditions( - id: messageId, - channelId: channelId - ) else { - logger.warning("Could not find any saved messages for a deleted message", metadata: [ - "messageId": .string(messageId.rawValue), - "channelId": .string(channelId.rawValue) - ]) + guard + let messages = await discordService.getDeletedMessageWithEditions( + id: messageId, + channelId: channelId + ) + else { + logger.warning( + "Could not find any saved messages for a deleted message", + metadata: [ + "messageId": .string(messageId.rawValue), + "channelId": .string(channelId.rawValue), + ] + ) return } guard let author = messages.last?.author else { - logger.error("Cannot find author of a deleted message", metadata: [ - "messageId": .string(messageId.rawValue), - "channelId": .string(channelId.rawValue), - "messages": .string("\(messages)") - ]) + logger.error( + "Cannot find author of a deleted message", + metadata: [ + "messageId": .string(messageId.rawValue), + "channelId": .string(channelId.rawValue), + "messages": .string("\(messages)"), + ] + ) return } if try await discordService.userIsModerator(userId: author.id) { - logger.debug("User is a moderator so won't report message deletion", metadata: [ - "messageId": .string(messageId.rawValue), - "channelId": .string(channelId.rawValue), - "messages": .string("\(messages)") - ]) + logger.debug( + "User is a moderator so won't report message deletion", + metadata: [ + "messageId": .string(messageId.rawValue), + "channelId": .string(channelId.rawValue), + "messages": .string("\(messages)"), + ] + ) return } await discordService.sendMessage( @@ -60,8 +72,8 @@ struct MessageDeleteHandler { } } -private extension Payloads.CreateMessage { - init( +extension Payloads.CreateMessage { + fileprivate init( messages: [Gateway.MessageCreate], author: DiscordUser ) { @@ -94,37 +106,44 @@ private extension Payloads.CreateMessage { let jsonData = (try? JSONEncoder().encode(messages)) ?? Data() self.init( - embeds: [.init( - title: "Deleted Message in \(DiscordUtils.mention(id: lastMessage.channel_id))", - description: DiscordUtils - .escapingSpecialCharacters(lastMessage.content) - .quotedMarkdown(), - timestamp: lastMessage.timestamp.date, - color: .red, - footer: .init( - text: "From \(member?.uiName ?? author.uiName)", - icon_url: avatarURL.map { .exact($0) } - ), - fields: fields - )], - files: [.init( - data: ByteBuffer(data: jsonData), - filename: attachmentName - )], - attachments: [.init( - index: 0, - filename: attachmentName - )] + embeds: [ + .init( + title: "Deleted Message in \(DiscordUtils.mention(id: lastMessage.channel_id))", + description: + DiscordUtils + .escapingSpecialCharacters(lastMessage.content) + .quotedMarkdown(), + timestamp: lastMessage.timestamp.date, + color: .red, + footer: .init( + text: "From \(member?.uiName ?? author.uiName)", + icon_url: avatarURL.map { .exact($0) } + ), + fields: fields + ) + ], + files: [ + .init( + data: ByteBuffer(data: jsonData), + filename: attachmentName + ) + ], + attachments: [ + .init( + index: 0, + filename: attachmentName + ) + ] ) } } /// Unused for now: -private extension Gateway.MessageCreate { +extension Gateway.MessageCreate { /// Hash of the deterministic content of the message. /// For example doesn't include IDs which will change even if messages are the same. - var partialHash: Int { + fileprivate var partialHash: Int { let author = self.author?.id.rawValue.hashValue ?? 0 let content = self.content.hashValue let attachments = self.attachments.map(\.filename.hashValue).reduce(into: 0, ^=) @@ -132,25 +151,20 @@ private extension Gateway.MessageCreate { let type = self.type.rawValue.hashValue let member = self.member?.user?.id.rawValue.hashValue ?? 0 - return author ^ - content ^ - attachments ^ - embeds ^ - type ^ - member + return author ^ content ^ attachments ^ embeds ^ type ^ member } } -private extension [Gateway.MessageCreate] { +extension [Gateway.MessageCreate] { /// Hash of the deterministic content of the messages. /// For example doesn't include IDs which will change even if messages are the same. - var partialHash: Int { + fileprivate var partialHash: Int { self.map(\.partialHash).reduce(into: 0, ^=) } } -private extension Embed { - var partialHash: Int { +extension Embed { + fileprivate var partialHash: Int { let title = self.title?.hashValue ?? 0 let type = self.type?.rawValue.hashValue ?? 0 let description = self.description?.hashValue ?? 0 @@ -161,20 +175,11 @@ private extension Embed { let video = self.video?.height.hashValue ?? 0 let provider = self.provider?.name.hashValue ?? 0 let author = self.author?.name.hashValue ?? 0 - let fields = self.fields?.map { - $0.name.hashValue ^ $0.value.hashValue - }.reduce(into: 0, ^=) ?? 0 + let fields = + self.fields?.map { + $0.name.hashValue ^ $0.value.hashValue + }.reduce(into: 0, ^=) ?? 0 - return title ^ - type ^ - description ^ - url ^ - footer ^ - image ^ - thumbnail ^ - video ^ - provider ^ - author ^ - fields + return title ^ type ^ description ^ url ^ footer ^ image ^ thumbnail ^ video ^ provider ^ author ^ fields } } diff --git a/Sources/Penny/Handlers/MessageHandler.swift b/Sources/Penny/Handlers/MessageHandler.swift index 767c832a..22ea0ed3 100644 --- a/Sources/Penny/Handlers/MessageHandler.swift +++ b/Sources/Penny/Handlers/MessageHandler.swift @@ -12,7 +12,7 @@ struct MessageHandler { self.context = context self.logger[metadataKey: "event"] = "\(event)" } - + func handle() async { let isBot = event.author?.bot == true @@ -26,7 +26,7 @@ struct MessageHandler { /// Check for bot messages like Penny's own messages too await publishAnnouncementMessages() } - + func checkForNewCoins() async { guard let author = event.author else { logger.error("Cannot find author of the message") @@ -37,7 +37,7 @@ struct MessageHandler { text: event.content, repliedUser: event.referenced_message?.value.author?.id, mentionedUsers: event.mentions.map(\.id), - excludedUsers: [author.id] // Can't give yourself a coin + excludedUsers: [author.id] // Can't give yourself a coin ) let usersWithNewCoins = coinHandler.findUsers() @@ -45,7 +45,7 @@ struct MessageHandler { var successfulResponses = [String]() successfulResponses.reserveCapacity(usersWithNewCoins.count) - + for receiver in usersWithNewCoins { let coinRequest = UserRequest.CoinEntryRequest( amount: 1, @@ -56,15 +56,20 @@ struct MessageHandler { ) do { let response = try await context.usersService.postCoin(with: coinRequest) - let responseString = "\(DiscordUtils.mention(id: response.receiver)) now has \(response.newCoinCount) \(Constants.ServerEmojis.coin.emoji)!" + let responseString = + "\(DiscordUtils.mention(id: response.receiver)) now has \(response.newCoinCount) \(Constants.ServerEmojis.coin.emoji)!" successfulResponses.append(responseString) } catch { - logger.report("UsersService failed", error: error, metadata: [ - "request": "\(coinRequest)" - ]) + logger.report( + "UsersService failed", + error: error, + metadata: [ + "request": "\(coinRequest)" + ] + ) } } - + if successfulResponses.isEmpty { // Definitely there were some coin requests that failed. await self.respondToThanks( @@ -77,9 +82,12 @@ struct MessageHandler { let finalResponse = successfulResponses.joined(separator: "\n") // Discord doesn't like embed-descriptions with more than 4_000 content length. if finalResponse.unicodeScalars.count > 4_000 { - logger.warning("Can't send the full thanks-response", metadata: [ - "full": .string(finalResponse) - ]) + logger.warning( + "Can't send the full thanks-response", + metadata: [ + "full": .string(finalResponse) + ] + ) await self.respondToThanks( with: "Coins were granted to a lot of members!", isFailureMessage: false @@ -100,7 +108,7 @@ struct MessageHandler { /// Like server boosts. func checkForGuildSubscriptionCoins() async { guard Self.messageGuildSubscriptionTypes.contains(event.type) else { return } - + guard let author = event.author else { logger.error("Cannot find author of the message") return @@ -119,17 +127,21 @@ struct MessageHandler { let response = try await context.usersService.postCoin(with: coinRequest) await self.respondToThanks( with: """ - Thanks for the Server Boost \(Constants.ServerEmojis.love.emoji)! - You now have \(amount) more \(Constants.ServerEmojis.coin.emoji) for a total of \(response.newCoinCount) \(Constants.ServerEmojis.coin.emoji)! - """, + Thanks for the Server Boost \(Constants.ServerEmojis.love.emoji)! + You now have \(amount) more \(Constants.ServerEmojis.coin.emoji) for a total of \(response.newCoinCount) \(Constants.ServerEmojis.coin.emoji)! + """, overrideChannelId: Constants.Channels.thanks.id, isFailureMessage: false, userToExplicitlyMention: author.id ) } catch { - logger.report("UsersService failed for server boost thanks", error: error, metadata: [ - "request": "\(coinRequest)" - ]) + logger.report( + "UsersService failed for server boost thanks", + error: error, + metadata: [ + "request": "\(coinRequest)" + ] + ) /// Don't send a message at all in case of failure } } @@ -155,18 +167,22 @@ struct MessageHandler { }).map(\.value) for value in matches { - guard await context.autoFaqsService.canRespond( - receiverID: author.id, - faqHash: value.hash - ) else { continue } + guard + await context.autoFaqsService.canRespond( + receiverID: author.id, + faqHash: value.hash + ) + else { continue } await context.discordService.sendMessage( channelId: event.channel_id, payload: .init( - embeds: [.init( - title: "🤖 Automated Answer", - description: value, - color: .blue - )], + embeds: [ + .init( + title: "🤖 Automated Answer", + description: value, + color: .blue + ) + ], message_reference: .init( message_id: event.id, channel_id: event.channel_id, @@ -182,8 +198,8 @@ struct MessageHandler { let content = event.content if content.isEmpty { return } guard let guildId = event.guild_id, - let author = event.author, - let member = event.member + let author = event.author, + let member = event.member else { return } let expUsersDict: [S3AutoPingItems.Expression: Set] @@ -231,44 +247,49 @@ struct MessageHandler { } /// Identify if this could be a test message by the bot-dev. - let mightBeATestMessage = userId == Constants.botDevUserId - && event.channel_id == Constants.Channels.botLogs.id - + let mightBeATestMessage = + userId == Constants.botDevUserId + && event.channel_id == Constants.Channels.botLogs.id + if !mightBeATestMessage { /// Don't `@` someone for their own message. if userId == author.id { continue } } let authorName = makeAuthorName(nick: member.nick, user: author) - let iconURLEndpoint = member.avatar.map { avatar in - CDNEndpoint.guildMemberAvatar( - guildId: guildId, - userId: author.id, - avatar: avatar - ) - } ?? author.avatar.map { avatar in - CDNEndpoint.userAvatar( - userId: author.id, - avatar: avatar - ) - } + let iconURLEndpoint = + member.avatar.map { avatar in + CDNEndpoint.guildMemberAvatar( + guildId: guildId, + userId: author.id, + avatar: avatar + ) + } + ?? author.avatar.map { avatar in + CDNEndpoint.userAvatar( + userId: author.id, + avatar: avatar + ) + } await context.discordService.sendDM( userId: Snowflake(userId), payload: .init( - embeds: [.init( - description: """ - There is a new message in \(channelLink) that might be of interest to you. + embeds: [ + .init( + description: """ + There is a new message in \(channelLink) that might be of interest to you. - Triggered by: - \(words.makeExpressionListForDiscord()) + Triggered by: + \(words.makeExpressionListForDiscord()) - >>> \(content.unicodesPrefix(256)) - """, - color: .blue, - footer: .init( - text: "By \(authorName)", - icon_url: (iconURLEndpoint?.url).map { .exact($0) } + >>> \(content.unicodesPrefix(256)) + """, + color: .blue, + footer: .init( + text: "By \(authorName)", + icon_url: (iconURLEndpoint?.url).map { .exact($0) } + ) ) - )], + ], components: [[.button(.init(label: "Open Message", url: messageLink))]] ) ) @@ -296,7 +317,7 @@ struct MessageHandler { return username } } - + static func triggersPing( dividedForExactMatchChecking: [[Substring]], foldedForContainmentChecking: String, @@ -310,7 +331,7 @@ struct MessageHandler { return foldedForContainmentChecking.contains(contain) } } - + private func respondToThanks( with response: String, overrideChannelId channelId: ChannelSnowflake? = nil, diff --git a/Sources/Penny/Handlers/ReactionHandler/ReactionCache.swift b/Sources/Penny/Handlers/ReactionHandler/ReactionCache.swift index f5dc5391..1a468e34 100644 --- a/Sources/Penny/Handlers/ReactionHandler/ReactionCache.swift +++ b/Sources/Penny/Handlers/ReactionHandler/ReactionCache.swift @@ -1,6 +1,7 @@ -import Logging -import DiscordBM import Collections +import DiscordBM +import Logging + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -68,7 +69,7 @@ actor ReactionCache { private var storage = Storage() let logger = Logger(label: "ReactionCache") - init() { } + init() {} /// This is to prevent spams. In case someone removes their reaction and /// reacts again, we should not give coins to message's author anymore. @@ -95,11 +96,13 @@ actor ReactionCache { messageId: MessageSnowflake, context: HandlerContext ) async -> Bool { - guard let message = await self.getMessage( - channelId: channelId, - messageId: messageId, - discordService: context.discordService - ) else { + guard + let message = await self.getMessage( + channelId: channelId, + messageId: messageId, + discordService: context.discordService + ) + else { return false } if message.author?.bot ?? false { return false } @@ -107,16 +110,20 @@ actor ReactionCache { let calendar = Calendar.utc let now = Date() guard let aWeekAgo = calendar.date(byAdding: .weekOfMonth, value: -1, to: now) else { - logger.error("Could not find the past-week date", metadata: [ - "now": .stringConvertible(now.timeIntervalSince1970) - ]) + logger.error( + "Could not find the past-week date", + metadata: [ + "now": .stringConvertible(now.timeIntervalSince1970) + ] + ) return true } - let inPastWeek = calendar.compare( - message.timestamp.date, - to: aWeekAgo, - toGranularity: .minute - ) == .orderedDescending + let inPastWeek = + calendar.compare( + message.timestamp.date, + to: aWeekAgo, + toGranularity: .minute + ) == .orderedDescending return inPastWeek } @@ -126,14 +133,19 @@ actor ReactionCache { messageId: MessageSnowflake, discordService: DiscordService ) async -> AnyMessage? { - guard let message = await discordService.getChannelMessage( - channelId: channelId, - messageId: messageId - ) else { - logger.error("ReactionCache could not find a message's author id", metadata: [ - "channelId": .stringConvertible(channelId), - "messageId": .stringConvertible(messageId), - ]) + guard + let message = await discordService.getChannelMessage( + channelId: channelId, + messageId: messageId + ) + else { + logger.error( + "ReactionCache could not find a message's author id", + metadata: [ + "channelId": .stringConvertible(channelId), + "messageId": .stringConvertible(messageId), + ] + ) return nil } return message @@ -179,7 +191,7 @@ actor ReactionCache { if let existing = storage.forcedInThanksChannelMessages[receiverMessageId] { return .forcedInThanksChannel(existing) } else if let existing = storage.normalThanksMessages[ - [AnySnowflake(channelId), AnySnowflake(receiverMessageId)] + [AnySnowflake(channelId), AnySnowflake(receiverMessageId)] ] { return .normal(existing) } else { @@ -196,8 +208,8 @@ actor ReactionCache { } } -private extension Calendar { - static let utc: Calendar = { +extension Calendar { + fileprivate static let utc: Calendar = { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = .init(identifier: "UTC")! return calendar @@ -217,9 +229,10 @@ extension Emoji: @retroactive Hashable { return name1 == name2 default: Logger(label: "Emoji:Hashable.==").warning( - "Emojis didn't have id and name!", metadata: [ + "Emojis didn't have id and name!", + metadata: [ "lhs": "\(lhs)", - "rhs": "\(rhs)" + "rhs": "\(rhs)", ] ) return false @@ -238,7 +251,8 @@ extension Emoji: @retroactive Hashable { hasher.combine(name) } else { Logger(label: "Emoji:Hashable.hash(into:)").warning( - "Emoji didn't have id and name!", metadata: [ + "Emoji didn't have id and name!", + metadata: [ "emoji": "\(self)" ] ) diff --git a/Sources/Penny/Handlers/ReactionHandler/ReactionHandler.swift b/Sources/Penny/Handlers/ReactionHandler/ReactionHandler.swift index 87e904ba..3f19bb4d 100644 --- a/Sources/Penny/Handlers/ReactionHandler/ReactionHandler.swift +++ b/Sources/Penny/Handlers/ReactionHandler/ReactionHandler.swift @@ -1,6 +1,7 @@ import DiscordBM import Logging import Models + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -10,18 +11,19 @@ import Foundation struct ReactionHandler { enum Configuration { - static let coinSignEmojis: Set = [ - Constants.ServerEmojis.love.name, - Constants.ServerEmojis.vapor.name, - Constants.ServerEmojis.coin.name, - Constants.ServerEmojis.doge.name, - "🪙", "🚀", - "❤️", "💙", "💜", "🤍", "🤎", "🖤", "💛", "💚", "🧡", - "🩷", "🩶", "🩵", "💗", "💕", "😍", "😻", "🎉", "💯", - ] - + Constants.emojiSkins.map { "🙌\($0)" } - + Constants.emojiSkins.map { "🙏\($0)" } - + Constants.emojiSkins.flatMap { s in Constants.emojiGenders.map { g in "🙇\(s)\(g)" } } + static let coinSignEmojis: Set = + [ + Constants.ServerEmojis.love.name, + Constants.ServerEmojis.vapor.name, + Constants.ServerEmojis.coin.name, + Constants.ServerEmojis.doge.name, + "🪙", "🚀", + "❤️", "💙", "💜", "🤍", "🤎", "🖤", "💛", "💚", "🧡", + "🩷", "🩶", "🩵", "💗", "💕", "😍", "😻", "🎉", "💯", + ] + + Constants.emojiSkins.map { "🙌\($0)" } + + Constants.emojiSkins.map { "🙏\($0)" } + + Constants.emojiSkins.flatMap { s in Constants.emojiGenders.map { g in "🙇\(s)\(g)" } } } let event: Gateway.MessageReactionAdd @@ -36,21 +38,21 @@ struct ReactionHandler { self.context = context self.logger[metadataKey: "event"] = "\(event)" } - + func handle() async { guard let member = event.member, - let user = member.user, - user.bot != true, - let emojiName = event.emoji.name, - Configuration.coinSignEmojis.contains(emojiName), - await cache.canGiveCoin( + let user = member.user, + user.bot != true, + let emojiName = event.emoji.name, + Configuration.coinSignEmojis.contains(emojiName), + await cache.canGiveCoin( fromSender: user.id, toAuthorOfMessage: event.message_id, emoji: event.emoji, reactionKind: event.type - ), - let receiverId = event.message_author_id, - user.id != receiverId + ), + let receiverId = event.message_author_id, + user.id != receiverId else { return } /// Super reactions give more coins, otherwise only 1 coin @@ -63,7 +65,7 @@ struct ReactionHandler { source: .discord, reason: .userProvided ) - + var response: CoinResponse? do { response = try await context.usersService.postCoin(with: coinRequest) @@ -71,13 +73,15 @@ struct ReactionHandler { logger.report("Error when posting coins", error: error) response = nil } - - guard await cache.messageCanBeRespondedTo( - channelId: event.channel_id, - messageId: event.message_id, - context: context - ) else { return } - + + guard + await cache.messageCanBeRespondedTo( + channelId: event.channel_id, + messageId: event.message_id, + context: context + ) + else { return } + guard let response = response else { await respond( with: "Oops. Something went wrong! Please try again later", @@ -87,7 +91,7 @@ struct ReactionHandler { ) return } - + let senderName = member.nick ?? user.global_name ?? user.username if let toEdit = await cache.messageToEditIfAvailable( in: event.channel_id, @@ -101,7 +105,8 @@ struct ReactionHandler { let count = info.totalCoinCount + amount await editResponse( messageId: info.pennyResponseMessageId, - with: "\(names) gave \(count) \(Constants.ServerEmojis.coin.emoji) to \(DiscordUtils.mention(id: response.receiver)), who now has \(response.newCoinCount) \(Constants.ServerEmojis.coin.emoji)!", + with: + "\(names) gave \(count) \(Constants.ServerEmojis.coin.emoji) to \(DiscordUtils.mention(id: response.receiver)), who now has \(response.newCoinCount) \(Constants.ServerEmojis.coin.emoji)!", forcedInThanksChannel: false, amount: amount, senderName: senderName @@ -110,29 +115,32 @@ struct ReactionHandler { var newNames = info.senderUsers newNames.append(senderName) let names = newNames.joined(separator: ", ", lastSeparator: " & ") - let link = "https://discord.com/channels/\(Constants.vaporGuildId.rawValue)/\(info.originalChannelId.rawValue)/\(event.message_id.rawValue)" + let link = + "https://discord.com/channels/\(Constants.vaporGuildId.rawValue)/\(info.originalChannelId.rawValue)/\(event.message_id.rawValue)" let count = info.totalCoinCount + amount await editResponse( messageId: info.pennyResponseMessageId, - with: "\(names) gave \(count) \(Constants.ServerEmojis.coin.emoji) to \(DiscordUtils.mention(id: response.receiver)), who now has \(response.newCoinCount) \(Constants.ServerEmojis.coin.emoji)! (\(link))", + with: + "\(names) gave \(count) \(Constants.ServerEmojis.coin.emoji) to \(DiscordUtils.mention(id: response.receiver)), who now has \(response.newCoinCount) \(Constants.ServerEmojis.coin.emoji)! (\(link))", forcedInThanksChannel: true, amount: amount, senderName: senderName ) } } else { - let coinCountDescription = amount == 1 ? - "a \(Constants.ServerEmojis.coin.emoji)" : - "\(amount) \(Constants.ServerEmojis.coin.emoji)" + let coinCountDescription = + amount == 1 + ? "a \(Constants.ServerEmojis.coin.emoji)" : "\(amount) \(Constants.ServerEmojis.coin.emoji)" await respond( - with: "\(senderName) gave \(coinCountDescription) to \(DiscordUtils.mention(id: response.receiver)), who now has \(response.newCoinCount) \(Constants.ServerEmojis.coin.emoji)!", + with: + "\(senderName) gave \(coinCountDescription) to \(DiscordUtils.mention(id: response.receiver)), who now has \(response.newCoinCount) \(Constants.ServerEmojis.coin.emoji)!", amount: amount, senderName: senderName, isFailureMessage: false ) } } - + /// `senderName` only should be included if its not a error-response. private func respond( with response: String, @@ -148,11 +156,12 @@ struct ReactionHandler { ) do { if let senderName, - let decoded = try apiResponse?.decode() { + let decoded = try apiResponse?.decode() + { /// If it's a thanks message that was sent to `#thanks` instead of the original /// channel, then we need to inform the cache. - let sentToThanksChannelInstead = decoded.channel_id == Constants.Channels.thanks.id && - decoded.channel_id != event.channel_id + let sentToThanksChannelInstead = + decoded.channel_id == Constants.Channels.thanks.id && decoded.channel_id != event.channel_id await cache.didRespond( originalChannelId: event.channel_id, to: event.message_id, @@ -170,7 +179,7 @@ struct ReactionHandler { ) } } - + /// `senderName` only should be included if its not a error-response. private func editResponse( messageId: MessageSnowflake, @@ -183,15 +192,18 @@ struct ReactionHandler { messageId: messageId, channelId: forcedInThanksChannel ? Constants.Channels.thanks.id : event.channel_id, payload: .init( - embeds: [.init( - description: response, - color: .purple - )] + embeds: [ + .init( + description: response, + color: .purple + ) + ] ) ) do { if let senderName, - let decoded = try apiResponse?.decode() { + let decoded = try apiResponse?.decode() + { await cache.didRespond( originalChannelId: event.channel_id, to: event.message_id, diff --git a/Sources/Penny/MainService/MainService.swift b/Sources/Penny/MainService/MainService.swift index 83a808c0..86255ab1 100644 --- a/Sources/Penny/MainService/MainService.swift +++ b/Sources/Penny/MainService/MainService.swift @@ -1,7 +1,7 @@ -import DiscordBM -import SotoCore import AsyncHTTPClient +import DiscordBM import NIOCore +import SotoCore protocol MainService: Sendable { func bootstrapLoggingSystem(httpClient: HTTPClient) async throws diff --git a/Sources/Penny/MainService/PennyService.swift b/Sources/Penny/MainService/PennyService.swift index aab21dfb..a865880f 100644 --- a/Sources/Penny/MainService/PennyService.swift +++ b/Sources/Penny/MainService/PennyService.swift @@ -1,20 +1,20 @@ +import AsyncHTTPClient import DiscordBM import DiscordLogger -import AsyncHTTPClient -import NIOCore -import SotoCore -import Shared import Logging -import ServiceLifecycle +import NIOCore import NIOPosix +import ServiceLifecycle +import Shared +import SotoCore struct PennyService: MainService { func bootstrapLoggingSystem(httpClient: HTTPClient) async throws { // Discord-logging is disabled in debug based on the logger configuration, // so we can just use an invalid url - let webhookURL = Constants.deploymentEnvironment == .prod ? - Constants.loggingWebhookURL : - "https://discord.com/api/webhooks/1066284436045439037/dSs4nFhjpxcOh6HWD_" + let webhookURL = + Constants.deploymentEnvironment == .prod + ? Constants.loggingWebhookURL : "https://discord.com/api/webhooks/1066284436045439037/dSs4nFhjpxcOh6HWD_" DiscordGlobalConfiguration.logManager = await DiscordLogManager( httpClient: httpClient, @@ -29,7 +29,7 @@ struct PennyService: MainService { mentions: [ .warning: .user(Constants.botDevUserId), .error: .user(Constants.botDevUserId), - .critical: .user(Constants.botDevUserId) + .critical: .user(Constants.botDevUserId), ], extraMetadata: [.warning, .error, .critical], disabledLogLevels: [.debug, .trace], @@ -53,7 +53,7 @@ struct PennyService: MainService { let clientConfiguration = ClientConfiguration( cachingBehavior: .custom( apiEndpoints: [ - .listApplicationCommands: .seconds(60 * 60) /// 1 hour + .listApplicationCommands: .seconds(60 * 60)/// 1 hour ], apiEndpointsDefaultTTL: .seconds(5) ) @@ -74,7 +74,7 @@ struct PennyService: MainService { .guildMessages, .messageContent, .guildMessageReactions, - .guildModeration + .guildModeration, ] ) } @@ -142,7 +142,7 @@ struct PennyService: MainService { reactionCache: reactionCache ) ) - + let context = HandlerContext( backgroundProcessor: backgroundProcessor, usersService: usersService, @@ -211,7 +211,7 @@ struct PennyService: MainService { botStateManagerWrappedService, evolutionCheckerWrappedService, soCheckerWrappedService, - swiftReleasesCheckerWrappedService + swiftReleasesCheckerWrappedService, ], logger: Logger(label: "ServiceGroup") ) diff --git a/Sources/Penny/Penny.swift b/Sources/Penny/Penny.swift index 321411b0..958dcb90 100644 --- a/Sources/Penny/Penny.swift +++ b/Sources/Penny/Penny.swift @@ -1,13 +1,14 @@ -import NIOPosix -import NIOCore import AsyncHTTPClient +import NIOCore +import NIOPosix import Shared import SotoS3 @main struct Penny { static func main() async throws { - let success = NIOSingletons + let success = + NIOSingletons .unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor() print("*** Tried to install singleton Posix ELG as Concurrency global executor. Success: \(success)***") diff --git a/Sources/Penny/SOChecker.swift b/Sources/Penny/SOChecker.swift index 075a77e0..572f9c89 100644 --- a/Sources/Penny/SOChecker.swift +++ b/Sources/Penny/SOChecker.swift @@ -1,7 +1,8 @@ -import Logging -import ServiceLifecycle import DiscordBM +import Logging import Markdown +import ServiceLifecycle + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -9,7 +10,7 @@ import Foundation #endif actor SOChecker: Service { - + struct Storage: Sendable, Codable { var lastCheckDate: Date? } @@ -33,7 +34,8 @@ actor SOChecker: Service { /// Waits forever: let (stream, _) = AsyncStream.makeStream(of: Void.self) await stream.first { _ in true } - return /// Just in case + return + /// Just in case } if Task.isCancelled { return } do { @@ -41,7 +43,8 @@ actor SOChecker: Service { } catch { logger.report("Couldn't check SO questions", error: error) } - try await Task.sleep(for: .seconds(60 * 5)) /// 5 mins + try await Task.sleep(for: .seconds(60 * 5)) + /// 5 mins try await self.run() } @@ -53,16 +56,18 @@ actor SOChecker: Service { for question in questions { await discordService.sendMessage( channelId: Constants.Channels.stackOverflow.id, - payload: .init(embeds: [.init( - title: question.title.htmlDecoded().unicodesPrefix(256), - url: question.link, - timestamp: Date(timeIntervalSince1970: Double(question.creationDate)), - color: .mint, - footer: .init( - text: "By \(question.owner.displayName)", - icon_url: question.owner.profileImage.map { .exact($0) } + payload: .init(embeds: [ + .init( + title: question.title.htmlDecoded().unicodesPrefix(256), + url: question.link, + timestamp: Date(timeIntervalSince1970: Double(question.creationDate)), + color: .mint, + footer: .init( + text: "By \(question.owner.displayName)", + icon_url: question.owner.profileImage.map { .exact($0) } + ) ) - )]) + ]) ) } } @@ -72,13 +77,13 @@ actor SOChecker: Service { } func getCachedDataForCachesStorage() -> Storage { - return self.storage + self.storage } } // MARK: +String -private extension String { - func htmlDecoded() -> String { +extension String { + fileprivate func htmlDecoded() -> String { Document(parsing: self).format() } } diff --git a/Sources/Penny/Services/AutoFaqsService/AutoFaqsService.swift b/Sources/Penny/Services/AutoFaqsService/AutoFaqsService.swift index 95a8cf00..1397b12b 100644 --- a/Sources/Penny/Services/AutoFaqsService/AutoFaqsService.swift +++ b/Sources/Penny/Services/AutoFaqsService/AutoFaqsService.swift @@ -1,6 +1,6 @@ -import Models -import DiscordModels import AsyncHTTPClient +import DiscordModels +import Models protocol AutoFaqsService: Sendable { func insert(expression: String, value: String) async throws diff --git a/Sources/Penny/Services/AutoFaqsService/DefaultAutoFaqsService.swift b/Sources/Penny/Services/AutoFaqsService/DefaultAutoFaqsService.swift index 28de94e5..70da115b 100644 --- a/Sources/Penny/Services/AutoFaqsService/DefaultAutoFaqsService.swift +++ b/Sources/Penny/Services/AutoFaqsService/DefaultAutoFaqsService.swift @@ -1,15 +1,16 @@ +import AsyncHTTPClient +import Collections +import DiscordModels +import Logging +import Models +import NIOHTTP1 +import Shared + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import DiscordModels -import Models -import Collections -import AsyncHTTPClient -import Logging -import NIOHTTP1 -import Shared actor DefaultAutoFaqsService: AutoFaqsService { @@ -137,26 +138,34 @@ actor DefaultAutoFaqsService: AutoFaqsService { logger.trace("HTTP head", metadata: ["response": "\(response)"]) guard 200..<300 ~= response.status.code else { - let collected = try? await response.body.collect(upTo: 1 << 16) /// 64 KiB + let collected = try? await response.body.collect(upTo: 1 << 16) + /// 64 KiB let body = collected.map { String(buffer: $0) } ?? "nil" - logger.error("Faqs-service failed", metadata: [ - "status": "\(response.status)", - "headers": "\(response.headers)", - "body": "\(body)", - ]) + logger.error( + "Faqs-service failed", + metadata: [ + "status": "\(response.status)", + "headers": "\(response.headers)", + "body": "\(body)", + ] + ) throw ServiceError.badStatus(response.status) } - let body = try await response.body.collect(upTo: 1 << 24) /// 16 MiB + let body = try await response.body.collect(upTo: 1 << 24) + /// 16 MiB let items = try decoder.decode([String: String].self, from: body) freshenCache(items) resetItemsTask?.cancel() } private func freshenCache(_ new: [String: String]) { - logger.trace("Will refresh auto-faqs cache", metadata: [ - "new": .stringConvertible(new) - ]) + logger.trace( + "Will refresh auto-faqs cache", + metadata: [ + "new": .stringConvertible(new) + ] + ) self._cachedItems = new self._cachedFoldedItems = Dictionary( new.map({ ($0.key.superHeavyFolded(), $0.value) }), @@ -197,10 +206,12 @@ actor DefaultAutoFaqsService: AutoFaqsService { } func canRespond(receiverID: UserSnowflake, faqHash: Int) -> Bool { - self.responseRateLimiter.canRespond(to: .init( - receiverID: receiverID, - faqHash: faqHash - )) + self.responseRateLimiter.canRespond( + to: .init( + receiverID: receiverID, + faqHash: faqHash + ) + ) } func consumeCachesStorageData(_ storage: ResponseRateLimiter) { @@ -208,6 +219,6 @@ actor DefaultAutoFaqsService: AutoFaqsService { } func getCachedDataForCachesStorage() -> ResponseRateLimiter { - return self.responseRateLimiter + self.responseRateLimiter } } diff --git a/Sources/Penny/Services/AutoPingsService/AutoPingsService.swift b/Sources/Penny/Services/AutoPingsService/AutoPingsService.swift index ba2431ea..01f05356 100644 --- a/Sources/Penny/Services/AutoPingsService/AutoPingsService.swift +++ b/Sources/Penny/Services/AutoPingsService/AutoPingsService.swift @@ -1,6 +1,6 @@ -import Models -import DiscordBM import AsyncHTTPClient +import DiscordBM +import Models protocol AutoPingsService: Sendable { typealias Expression = S3AutoPingItems.Expression diff --git a/Sources/Penny/Services/AutoPingsService/DefaultPingsService.swift b/Sources/Penny/Services/AutoPingsService/DefaultPingsService.swift index e8db7720..fb84a6b2 100644 --- a/Sources/Penny/Services/AutoPingsService/DefaultPingsService.swift +++ b/Sources/Penny/Services/AutoPingsService/DefaultPingsService.swift @@ -1,14 +1,15 @@ +import AsyncHTTPClient +import DiscordBM +import Logging +import Models +import NIOHTTP1 +import Shared + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import Models -import AsyncHTTPClient -import Logging -import DiscordBM -import NIOHTTP1 -import Shared actor DefaultPingsService: AutoPingsService { @@ -16,7 +17,7 @@ actor DefaultPingsService: AutoPingsService { let httpClient: HTTPClient var logger = Logger(label: "DefaultPingsService") - + /// Use `getAll()` to retrieve. var _cachedItems: S3AutoPingItems? /// `[ExpressionHash: Expression]` @@ -42,7 +43,7 @@ actor DefaultPingsService: AutoPingsService { ) async throws -> Bool { try await self.getAll().items[expression]?.contains(id) ?? false } - + func insert( _ expressions: [Expression], forDiscordID id: UserSnowflake @@ -53,7 +54,7 @@ actor DefaultPingsService: AutoPingsService { pingRequest: .init(discordID: id, expressions: expressions) ) } - + func remove( _ expressions: [Expression], forDiscordID id: UserSnowflake @@ -64,7 +65,7 @@ actor DefaultPingsService: AutoPingsService { pingRequest: .init(discordID: id, expressions: expressions) ) } - + func get(discordID id: UserSnowflake) async throws -> [Expression] { try await self.getAll() .items @@ -121,29 +122,37 @@ actor DefaultPingsService: AutoPingsService { logger: self.logger ) logger.trace("HTTP head", metadata: ["response": "\(response)"]) - + guard 200..<300 ~= response.status.code else { - let collected = try? await response.body.collect(upTo: 1 << 16) /// 64 KiB + let collected = try? await response.body.collect(upTo: 1 << 16) + /// 64 KiB let body = collected.map { String(buffer: $0) } ?? "nil" - logger.error( "Pings-service failed", metadata: [ - "status": "\(response.status)", - "headers": "\(response.headers)", - "body": "\(body)", - ]) + logger.error( + "Pings-service failed", + metadata: [ + "status": "\(response.status)", + "headers": "\(response.headers)", + "body": "\(body)", + ] + ) throw ServiceError.badStatus(response.status) } - - let body = try await response.body.collect(upTo: 1 << 24) /// 16 MiB + + let body = try await response.body.collect(upTo: 1 << 24) + /// 16 MiB let items = try decoder.decode(S3AutoPingItems.self, from: body) freshenCache(items) resetItemsTask?.cancel() return items } - + private func freshenCache(_ new: S3AutoPingItems) { - logger.trace("Will refresh auto-pings cache", metadata: [ - "new": .stringConvertible(new.items) - ]) + logger.trace( + "Will refresh auto-pings cache", + metadata: [ + "new": .stringConvertible(new.items) + ] + ) self._cachedItems = new self._cachedExpressionsHashTable = Dictionary( uniqueKeysWithValues: new.items.map({ ($0.key.hashValue, $0.key) }) diff --git a/Sources/Penny/Services/CachesService/CachesService.swift b/Sources/Penny/Services/CachesService/CachesService.swift index 87d0b3a0..7e2bbb94 100644 --- a/Sources/Penny/Services/CachesService/CachesService.swift +++ b/Sources/Penny/Services/CachesService/CachesService.swift @@ -1,4 +1,3 @@ - protocol CachesService: Sendable { func getCachedInfoFromRepositoryAndPopulateServices() async func gatherCachedInfoAndSaveToRepository() async diff --git a/Sources/Penny/Services/CachesService/CachesStorage.swift b/Sources/Penny/Services/CachesService/CachesStorage.swift index d3ea37a0..16bf73d3 100644 --- a/Sources/Penny/Services/CachesService/CachesStorage.swift +++ b/Sources/Penny/Services/CachesService/CachesStorage.swift @@ -1,5 +1,5 @@ -import Models import Logging +import Models struct CachesStorage: Sendable, Codable { @@ -17,7 +17,7 @@ struct CachesStorage: Sendable, Codable { var swiftReleasesData: SwiftReleasesChecker.Storage? var autoFaqsResponseRateLimiter: DefaultAutoFaqsService.ResponseRateLimiter? - init() { } + init() {} static func makeFromCachedData(context: Context) async -> CachesStorage { var storage = CachesStorage() @@ -46,23 +46,32 @@ struct CachesStorage: Sendable, Codable { await context.autoFaqsService.consumeCachesStorageData(autoFaqsResponseRateLimiter) } - let reactionCacheDataCounts = reactionCacheData.map { data in - [data.givenCoins.count, - data.normalThanksMessages.count, - data.forcedInThanksChannelMessages.count] - } ?? [] - let evolutionCheckerDataCounts = evolutionCheckerData.map { data in - [data.previousProposals.count, - data.queuedProposals.count] - } ?? [] + let reactionCacheDataCounts = + reactionCacheData.map { data in + [ + data.givenCoins.count, + data.normalThanksMessages.count, + data.forcedInThanksChannelMessages.count, + ] + } ?? [] + let evolutionCheckerDataCounts = + evolutionCheckerData.map { data in + [ + data.previousProposals.count, + data.queuedProposals.count, + ] + } ?? [] let autoFaqsResponseRateLimiterCounts = [autoFaqsResponseRateLimiter?.count ?? 0] - Logger(label: "CachesStorage").notice("Recovered the cached stuff", metadata: [ - "reactionCache_counts": .stringConvertible(reactionCacheDataCounts), - "evolutionChecker_counts": .stringConvertible(evolutionCheckerDataCounts), - "soChecker_isNotNil": .stringConvertible(soCheckerData != nil), - "releasesChecker_isNotEmpty": .stringConvertible(swiftReleasesData?.currentReleases.isEmpty == false), - "autoFaqsLimiter_counts": .stringConvertible(autoFaqsResponseRateLimiterCounts), - ]) + Logger(label: "CachesStorage").notice( + "Recovered the cached stuff", + metadata: [ + "reactionCache_counts": .stringConvertible(reactionCacheDataCounts), + "evolutionChecker_counts": .stringConvertible(evolutionCheckerDataCounts), + "soChecker_isNotNil": .stringConvertible(soCheckerData != nil), + "releasesChecker_isNotEmpty": .stringConvertible(swiftReleasesData?.currentReleases.isEmpty == false), + "autoFaqsLimiter_counts": .stringConvertible(autoFaqsResponseRateLimiterCounts), + ] + ) } } diff --git a/Sources/Penny/Services/CachesService/DefaultCachesService.swift b/Sources/Penny/Services/CachesService/DefaultCachesService.swift index 6cb8af1a..1880c63d 100644 --- a/Sources/Penny/Services/CachesService/DefaultCachesService.swift +++ b/Sources/Penny/Services/CachesService/DefaultCachesService.swift @@ -1,5 +1,5 @@ -import Logging import AsyncHTTPClient +import Logging import SotoS3 actor DefaultCachesService: CachesService { diff --git a/Sources/Penny/Services/CachesService/S3CachesRepository.swift b/Sources/Penny/Services/CachesService/S3CachesRepository.swift index 981e4768..73b8fcb5 100644 --- a/Sources/Penny/Services/CachesService/S3CachesRepository.swift +++ b/Sources/Penny/Services/CachesService/S3CachesRepository.swift @@ -1,4 +1,5 @@ import SotoS3 + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -24,7 +25,8 @@ struct S3CachesRepository { let request = S3.GetObjectRequest(bucket: bucket, key: key) let response = try await s3.getObject(request, logger: logger) - let body = try await response.body.collect(upTo: 1 << 24) /// 16 MiB + let body = try await response.body.collect(upTo: 1 << 24) + /// 16 MiB if body.readableBytes == 0 { logger.error("Cannot find any data in the bucket") return CachesStorage() @@ -33,10 +35,13 @@ struct S3CachesRepository { do { return try decoder.decode(CachesStorage.self, from: body) } catch { - logger.error("Cannot find any data in the bucket", metadata: [ - "response-body": .string(String(buffer: body)), - "error": "\(error)" - ]) + logger.error( + "Cannot find any data in the bucket", + metadata: [ + "response-body": .string(String(buffer: body)), + "error": "\(error)", + ] + ) return CachesStorage() } } diff --git a/Sources/Penny/Services/DiscordService/DiscordService.swift b/Sources/Penny/Services/DiscordService/DiscordService.swift index e0d6f96c..0b585d0b 100644 --- a/Sources/Penny/Services/DiscordService/DiscordService.swift +++ b/Sources/Penny/Services/DiscordService/DiscordService.swift @@ -8,7 +8,7 @@ actor DiscordService { case cantGetGuild case cantFindChannel } - + private let discordClient: any DiscordClient private let cache: DiscordCache private let backgroundProcessor: BackgroundProcessor @@ -23,19 +23,22 @@ actor DiscordService { logger.error("Cannot get cached vapor guild", metadata: ["guilds": "\(guilds)"]) throw Error.cantGetGuild } - + /// This could cause problems so we need to somehow keep an eye on it. /// `Array.count` is O(1) so this is fine. if guild.members.count < 1_000 { - logger.critical("Vapor guild only has \(guild.members.count) members?!", metadata: [ - "guild": "\(guild)" - ]) + logger.critical( + "Vapor guild only has \(guild.members.count) members?!", + metadata: [ + "guild": "\(guild)" + ] + ) } - + return guild } } - + init( discordClient: any DiscordClient, cache: DiscordCache, @@ -45,29 +48,32 @@ actor DiscordService { self.cache = cache self.backgroundProcessor = backgroundProcessor } - + func sendDM(userId: UserSnowflake, payload: Payloads.CreateMessage) async { guard let dmChannelId = await getDMChannelId(userId: userId) else { return } - + do { let response = try await discordClient.createMessage( channelId: dmChannelId, payload: payload ) - + switch response.asError() { case let .jsonError(jsonError) - where jsonError.code == .cannotSendMessagesToThisUser: + where jsonError.code == .cannotSendMessagesToThisUser: /// Try to let them know Penny can't DM them. if usersAlreadyWarnedAboutClosedDMS.insert(userId).inserted { - - logger.warning("Could not send DM, will try to let them know", metadata: [ - "userId": .stringConvertible(userId), - "dmChannelId": .stringConvertible(dmChannelId), - "payload": "\(payload)", - "jsonError": "\(jsonError)" - ]) - + + logger.warning( + "Could not send DM, will try to let them know", + metadata: [ + "userId": .stringConvertible(userId), + "dmChannelId": .stringConvertible(dmChannelId), + "payload": "\(payload)", + "jsonError": "\(jsonError)", + ] + ) + self.backgroundProcessor.process { let userMention = DiscordUtils.mention(id: userId) /// Make it wait 1 to 10 minutes so it's not too @@ -77,37 +83,47 @@ actor DiscordService { channelId: Constants.Channels.thanks.id, payload: .init( content: userMention, - embeds: [.init( - description: """ - I tried to DM you but couldn't. Please open your DMs to me. + embeds: [ + .init( + description: """ + I tried to DM you but couldn't. Please open your DMs to me. - You can allow Vapor server members to DM you by going into your `Server Settings` (tap Vapor server name), then choosing `Allow Direct Messages`. + You can allow Vapor server members to DM you by going into your `Server Settings` (tap Vapor server name), then choosing `Allow Direct Messages`. - On Desktop, this option is under the `Privacy Settings` menu. - """, - color: .purple - )] + On Desktop, this option is under the `Privacy Settings` menu. + """, + color: .purple + ) + ] ) ) } } case .jsonError, .badStatusCode: - logger.report("Couldn't send DM", response: response, metadata: [ - "userId": .stringConvertible(userId), - "dmChannelId": .stringConvertible(dmChannelId), - "payload": "\(payload)" - ]) + logger.report( + "Couldn't send DM", + response: response, + metadata: [ + "userId": .stringConvertible(userId), + "dmChannelId": .stringConvertible(dmChannelId), + "payload": "\(payload)", + ] + ) case .none: break } } catch { - logger.report("Couldn't send DM", error: error, metadata: [ - "userId": .stringConvertible(userId), - "dmChannelId": .stringConvertible(dmChannelId), - "payload": "\(payload)" - ]) + logger.report( + "Couldn't send DM", + error: error, + metadata: [ + "userId": .stringConvertible(userId), + "dmChannelId": .stringConvertible(dmChannelId), + "payload": "\(payload)", + ] + ) } } - + private func getDMChannelId(userId: UserSnowflake) async -> ChannelSnowflake? { if let existing = dmChannels[userId] { return existing @@ -124,7 +140,7 @@ actor DiscordService { } } } - + @discardableResult func sendMessage( channelId: ChannelSnowflake, @@ -138,14 +154,18 @@ actor DiscordService { try response.guardSuccess() return response } catch { - logger.report("Couldn't send a message", error: error, metadata: [ - "channelId": "\(channelId)", - "payload": "\(payload)" - ]) + logger.report( + "Couldn't send a message", + error: error, + metadata: [ + "channelId": "\(channelId)", + "payload": "\(payload)", + ] + ) return nil } } - + /// Sends thanks response to the specified channel if Penny has the required permissions, /// otherwise sends to the `#thanks` channel. /// - Parameters: @@ -183,10 +203,12 @@ actor DiscordService { channelId: channelId, payload: .init( content: content, - embeds: [.init( - description: response, - color: .purple - )], + embeds: [ + .init( + description: response, + color: .purple + ) + ], message_reference: .init( message_id: messageId, channel_id: channelId, @@ -201,20 +223,23 @@ actor DiscordService { logger.debug("Won't report a failure to users") return nil } - let link = "https://discord.com/channels/\(Constants.vaporGuildId.rawValue)/\(channelId.rawValue)/\(messageId.rawValue)" + let link = + "https://discord.com/channels/\(Constants.vaporGuildId.rawValue)/\(channelId.rawValue)/\(messageId.rawValue)" return await self.sendMessage( channelId: Constants.Channels.thanks.id, payload: .init( content: content, - embeds: [.init( - description: "\(response) (\(link))", - color: .purple - )] + embeds: [ + .init( + description: "\(response) (\(link))", + color: .purple + ) + ] ) ) } } - + @discardableResult func editMessage( messageId: MessageSnowflake, @@ -230,15 +255,19 @@ actor DiscordService { try response.guardSuccess() return response } catch { - logger.report("Couldn't edit a message", error: error, metadata: [ - "messageId": .stringConvertible(messageId), - "channelId": .stringConvertible(channelId), - "payload": "\(payload)" - ]) + logger.report( + "Couldn't edit a message", + error: error, + metadata: [ + "messageId": .stringConvertible(messageId), + "channelId": .stringConvertible(channelId), + "payload": "\(payload)", + ] + ) return nil } } - + /// Returns whether or not the response has been successfully sent. @discardableResult func respondToInteraction( @@ -254,15 +283,19 @@ actor DiscordService { ).guardSuccess() return true } catch { - logger.report("Couldn't send interaction response", error: error, metadata: [ - "id": .stringConvertible(id), - "token": .string(token), - "payload": "\(payload)" - ]) + logger.report( + "Couldn't send interaction response", + error: error, + metadata: [ + "id": .stringConvertible(id), + "token": .string(token), + "payload": "\(payload)", + ] + ) return false } } - + func editInteraction( token: String, payload: Payloads.EditWebhookMessage @@ -273,25 +306,33 @@ actor DiscordService { payload: payload ).guardSuccess() } catch { - logger.report("Couldn't send interaction edit", error: error, metadata: [ - "token": .string(token), - "payload": "\(payload)" - ]) + logger.report( + "Couldn't send interaction edit", + error: error, + metadata: [ + "token": .string(token), + "payload": "\(payload)", + ] + ) } } - + func overwriteCommands(_ commands: [Payloads.ApplicationCommandCreate]) async { do { try await discordClient .bulkSetApplicationCommands(payload: commands) .guardSuccess() } catch { - logger.report("Couldn't overwrite application commands", error: error, metadata: [ - "commands": "\(commands)" - ]) + logger.report( + "Couldn't overwrite application commands", + error: error, + metadata: [ + "commands": "\(commands)" + ] + ) } } - + func getCommands() async -> [ApplicationCommand] { do { return try await discordClient.listApplicationCommands().decode() @@ -307,7 +348,8 @@ actor DiscordService { channelId: ChannelSnowflake ) async -> [Gateway.MessageCreate]? { guard let messages = await cache.deletedMessages[channelId]?[id], - !messages.isEmpty else { + !messages.isEmpty + else { return nil } return messages @@ -318,7 +360,8 @@ actor DiscordService { messageId: MessageSnowflake ) async -> AnyMessage? { if let fromCache = await cache.messages[channelId]? - .first(where: { $0.id == messageId }) { + .first(where: { $0.id == messageId }) + { return .init(fromCache) } do { @@ -328,10 +371,14 @@ actor DiscordService { ).decode() return .init(message) } catch { - logger.report("Couldn't get channel message", error: error, metadata: [ - "channelId": .string(channelId.rawValue), - "messageId": .string(messageId.rawValue) - ]) + logger.report( + "Couldn't get channel message", + error: error, + metadata: [ + "channelId": .string(channelId.rawValue), + "messageId": .string(messageId.rawValue), + ] + ) return nil } } @@ -348,11 +395,15 @@ actor DiscordService { payload: payload ).guardSuccess() } catch { - logger.report("Couldn't create thread from message", error: error, metadata: [ - "channelId": .stringConvertible(channelId), - "messageId": .stringConvertible(messageId), - "payload": .string("\(payload)") - ]) + logger.report( + "Couldn't create thread from message", + error: error, + metadata: [ + "channelId": .stringConvertible(channelId), + "messageId": .stringConvertible(messageId), + "payload": .string("\(payload)"), + ] + ) } } @@ -366,13 +417,17 @@ actor DiscordService { messageId: messageId ).guardSuccess() } catch { - logger.report("Couldn't crosspost message", error: error, metadata: [ - "channelId": .stringConvertible(channelId), - "messageId": .stringConvertible(messageId), - ]) + logger.report( + "Couldn't crosspost message", + error: error, + metadata: [ + "channelId": .stringConvertible(channelId), + "messageId": .stringConvertible(messageId), + ] + ) } } - + func userHasReadAccess( userId: UserSnowflake, channelId: ChannelSnowflake diff --git a/Sources/Penny/Services/EvolutionService/DefaultEvolutionService.swift b/Sources/Penny/Services/EvolutionService/DefaultEvolutionService.swift index cf920de8..39638f4a 100644 --- a/Sources/Penny/Services/EvolutionService/DefaultEvolutionService.swift +++ b/Sources/Penny/Services/EvolutionService/DefaultEvolutionService.swift @@ -1,15 +1,16 @@ +import AsyncHTTPClient +import EvolutionMetadataModel +import Models +import NIOCore + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import AsyncHTTPClient -import Models -import EvolutionMetadataModel -import NIOCore struct DefaultEvolutionService: EvolutionService { - + enum Errors: Error, CustomStringConvertible { case emptyProposals @@ -33,7 +34,8 @@ struct DefaultEvolutionService: EvolutionService { .init(url: "https://download.swift.org/swift-evolution/v1/evolution.json"), deadline: .now() + .seconds(15) ) - let buffer = try await response.body.collect(upTo: 1 << 25) /// 32 MiB + let buffer = try await response.body.collect(upTo: 1 << 25) + /// 32 MiB let evolution = try decoder.decode(EvolutionMetadata.self, from: buffer) if evolution.proposals.isEmpty { throw Errors.emptyProposals @@ -47,14 +49,16 @@ struct DefaultEvolutionService: EvolutionService { /// to /// https://raw.githubusercontent.com/apple/swift-evolution/main/proposals/0401-remove-property-wrapper-isolation.md /// to get the raw content of the file instead of the GitHub web page. - let link = link + let link = + link .replacing("github.com", with: "raw.githubusercontent.com") .replacing("/blob/", with: "/") let response = try await httpClient.execute( .init(url: link), deadline: .now() + .seconds(15) ) - let buffer = try await response.body.collect(upTo: 1 << 25) /// 32 MiB + let buffer = try await response.body.collect(upTo: 1 << 25) + /// 32 MiB let content = String(buffer: buffer) return content } diff --git a/Sources/Penny/Services/FaqsService/DefaultFaqsService.swift b/Sources/Penny/Services/FaqsService/DefaultFaqsService.swift index eb259124..4e33ebaf 100644 --- a/Sources/Penny/Services/FaqsService/DefaultFaqsService.swift +++ b/Sources/Penny/Services/FaqsService/DefaultFaqsService.swift @@ -1,13 +1,14 @@ +import AsyncHTTPClient +import Logging +import Models +import NIOHTTP1 +import Shared + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import Models -import AsyncHTTPClient -import Logging -import NIOHTTP1 -import Shared actor DefaultFaqsService: FaqsService { @@ -84,26 +85,34 @@ actor DefaultFaqsService: FaqsService { logger.trace("HTTP head", metadata: ["response": "\(response)"]) guard 200..<300 ~= response.status.code else { - let collected = try? await response.body.collect(upTo: 1 << 16) /// 64 KiB + let collected = try? await response.body.collect(upTo: 1 << 16) + /// 64 KiB let body = collected.map { String(buffer: $0) } ?? "nil" - logger.error("Faqs-service failed", metadata: [ - "status": "\(response.status)", - "headers": "\(response.headers)", - "body": "\(body)", - ]) + logger.error( + "Faqs-service failed", + metadata: [ + "status": "\(response.status)", + "headers": "\(response.headers)", + "body": "\(body)", + ] + ) throw ServiceError.badStatus(response.status) } - let body = try await response.body.collect(upTo: 1 << 24) /// 16 MiB + let body = try await response.body.collect(upTo: 1 << 24) + /// 16 MiB let items = try decoder.decode([String: String].self, from: body) freshenCache(items) resetItemsTask?.cancel() } private func freshenCache(_ new: [String: String]) { - logger.trace("Will refresh faqs cache", metadata: [ - "new": .stringConvertible(new) - ]) + logger.trace( + "Will refresh faqs cache", + metadata: [ + "new": .stringConvertible(new) + ] + ) self._cachedItems = new self._cachedNamesHashTable = Dictionary( uniqueKeysWithValues: new.map({ ($0.key.hash, $0.key) }) diff --git a/Sources/Penny/Services/FaqsService/FaqsService.swift b/Sources/Penny/Services/FaqsService/FaqsService.swift index 28fb9a0e..d4c2dad5 100644 --- a/Sources/Penny/Services/FaqsService/FaqsService.swift +++ b/Sources/Penny/Services/FaqsService/FaqsService.swift @@ -1,5 +1,5 @@ -import Models import AsyncHTTPClient +import Models protocol FaqsService: Sendable { func insert(name: String, value: String) async throws diff --git a/Sources/Penny/Services/StackoverflowService /DefaultSOService.swift b/Sources/Penny/Services/StackoverflowService /DefaultSOService.swift index db9e1088..53160f44 100644 --- a/Sources/Penny/Services/StackoverflowService /DefaultSOService.swift +++ b/Sources/Penny/Services/StackoverflowService /DefaultSOService.swift @@ -1,6 +1,7 @@ import AsyncHTTPClient -import NIOCore import Logging +import NIOCore + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -18,7 +19,8 @@ struct DefaultSOService: SOService { let queries: KeyValuePairs = [ "site": "stackoverflow", "tagged": "vapor", - "nottagged": "laravel", /// Don't be a "laravel" question + "nottagged": "laravel", + /// Don't be a "laravel" question "page": "1", "sort": "creation", "order": "desc", @@ -29,15 +31,19 @@ struct DefaultSOService: SOService { let url = "https://api.stackexchange.com/2.3/search/advanced" + queries.makeForURLQueryUnchecked() let request = HTTPClientRequest(url: url) let response = try await httpClient.execute(request, deadline: .now() + .seconds(15)) - let buffer = try await response.body.collect(upTo: 1 << 25) /// 32 MiB + let buffer = try await response.body.collect(upTo: 1 << 25) + /// 32 MiB guard 200..<300 ~= response.status.code else { let body = String(buffer: buffer) - logger.error("SO-service failed", metadata: [ - "status": "\(response.status)", - "headers": "\(response.headers)", - "body": "\(body)", - ]) + logger.error( + "SO-service failed", + metadata: [ + "status": "\(response.status)", + "headers": "\(response.headers)", + "body": "\(body)", + ] + ) throw ServiceError.badStatus(response.status) } @@ -49,10 +55,10 @@ struct DefaultSOService: SOService { } } -private extension KeyValuePairs { +extension KeyValuePairs { /// Doesn't do url-query encoding. /// Assumes the values are already safe. - func makeForURLQueryUnchecked() -> String { + fileprivate func makeForURLQueryUnchecked() -> String { "?" + self.map { "\($0)=\($1)" }.joined(separator: "&") } } diff --git a/Sources/Penny/Services/SwiftReleasesService/DefaultSwiftReleasesService.swift b/Sources/Penny/Services/SwiftReleasesService/DefaultSwiftReleasesService.swift index 44b4e440..96764f89 100644 --- a/Sources/Penny/Services/SwiftReleasesService/DefaultSwiftReleasesService.swift +++ b/Sources/Penny/Services/SwiftReleasesService/DefaultSwiftReleasesService.swift @@ -1,6 +1,7 @@ import AsyncHTTPClient -import NIOCore import Logging +import NIOCore + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -16,15 +17,19 @@ struct DefaultSwiftReleasesService: SwiftReleasesService { let url = "https://www.swift.org/api/v1/install/releases.json" let request = HTTPClientRequest(url: url) let response = try await httpClient.execute(request, deadline: .now() + .seconds(15)) - let buffer = try await response.body.collect(upTo: 1 << 25) /// 32 MiB + let buffer = try await response.body.collect(upTo: 1 << 25) + /// 32 MiB guard 200..<300 ~= response.status.code else { let body = String(buffer: buffer) - logger.error("SwiftReleases-service failed", metadata: [ - "status": "\(response.status)", - "headers": "\(response.headers)", - "body": "\(body)", - ]) + logger.error( + "SwiftReleases-service failed", + metadata: [ + "status": "\(response.status)", + "headers": "\(response.headers)", + "body": "\(body)", + ] + ) throw ServiceError.badStatus(response.status) } diff --git a/Sources/Penny/Services/SwiftReleasesService/SwiftReleasesService.swift b/Sources/Penny/Services/SwiftReleasesService/SwiftReleasesService.swift index cdc3123b..05eb9508 100644 --- a/Sources/Penny/Services/SwiftReleasesService/SwiftReleasesService.swift +++ b/Sources/Penny/Services/SwiftReleasesService/SwiftReleasesService.swift @@ -1,4 +1,3 @@ - protocol SwiftReleasesService: Sendable { func listReleases() async throws -> [SwiftOrgRelease] } diff --git a/Sources/Penny/SwiftReleasesChecker.swift b/Sources/Penny/SwiftReleasesChecker.swift index ad421dfe..568157b3 100644 --- a/Sources/Penny/SwiftReleasesChecker.swift +++ b/Sources/Penny/SwiftReleasesChecker.swift @@ -1,7 +1,8 @@ +import Collections +import DiscordBM import Logging import ServiceLifecycle -import DiscordBM -import Collections + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -9,7 +10,7 @@ import Foundation #endif actor SwiftReleasesChecker: Service { - + struct Storage: Sendable, Codable { var currentReleases: [SwiftOrgRelease] = [] } @@ -32,7 +33,8 @@ actor SwiftReleasesChecker: Service { } catch { logger.report("Couldn't check Swift releases", error: error) } - try await Task.sleep(for: .seconds(60 * 15)) /// 15 mins + try await Task.sleep(for: .seconds(60 * 15)) + /// 15 mins try await self.run() } @@ -48,15 +50,18 @@ actor SwiftReleasesChecker: Service { self.storage.currentReleases = releases for release in newReleases { - let image = "https://opengraph.githubassets.com/\(UUID().uuidString)/swiftlang/swift/releases/tag/\(release.tag)" + let image = + "https://opengraph.githubassets.com/\(UUID().uuidString)/swiftlang/swift/releases/tag/\(release.tag)" await discordService.sendMessage( channelId: Constants.Channels.news.id, - payload: .init(embeds: [.init( - title: "Swift \(release.stableName) Release".unicodesPrefix(256), - url: "https://github.com/swiftlang/swift/releases/tag/\(release.tag)", - color: .cyan, - image: .init(url: .exact(image)) - )]) + payload: .init(embeds: [ + .init( + title: "Swift \(release.stableName) Release".unicodesPrefix(256), + url: "https://github.com/swiftlang/swift/releases/tag/\(release.tag)", + color: .cyan, + image: .init(url: .exact(image)) + ) + ]) ) } } @@ -66,7 +71,7 @@ actor SwiftReleasesChecker: Service { } func getCachedDataForCachesStorage() -> Storage { - return self.storage + self.storage } } diff --git a/Sources/Rendering/+LeafRenderer.swift b/Sources/Rendering/+LeafRenderer.swift index 1786256f..62b59ed4 100644 --- a/Sources/Rendering/+LeafRenderer.swift +++ b/Sources/Rendering/+LeafRenderer.swift @@ -1,7 +1,8 @@ -@preconcurrency import LeafKit -import NIO import AsyncHTTPClient +@preconcurrency import LeafKit import Logging +import NIO + #if canImport(FoundationEssentials) import FoundationEssentials #else diff --git a/Sources/Rendering/GHLeafSource.swift b/Sources/Rendering/GHLeafSource.swift index f683a8b7..e5511e5f 100644 --- a/Sources/Rendering/GHLeafSource.swift +++ b/Sources/Rendering/GHLeafSource.swift @@ -1,8 +1,8 @@ import AsyncHTTPClient -import NIO +import LeafKit import Logging +import NIO import Shared -import LeafKit struct GHLeafSource: LeafSource { @@ -50,7 +50,8 @@ struct GHLeafSource: LeafSource { let url = "https://raw.githubusercontent.com/vapor/penny-bot/main/\(path)/\(template)" let request = HTTPClientRequest(url: url) let response = try await httpClient.execute(request, timeout: .seconds(5)) - let body = try await response.body.collect(upTo: 1 << 22) /// 4 MiB + let body = try await response.body.collect(upTo: 1 << 22) + /// 4 MiB guard 200..<300 ~= response.status.code else { throw Errors.httpRequestFailed(response, body: String(buffer: body)) } diff --git a/Sources/Rendering/LeafEncoder.swift b/Sources/Rendering/LeafEncoder.swift index 523c7dd5..f7a2e6ef 100644 --- a/Sources/Rendering/LeafEncoder.swift +++ b/Sources/Rendering/LeafEncoder.swift @@ -1,6 +1,6 @@ +import Algorithms /// Copy-pasted from `Leaf`. import LeafKit -import Algorithms struct LeafEncoder { /// Use `Codable` to convert an (almost) arbitrary encodable type to a dictionary of key/``LeafData`` pairs @@ -16,7 +16,11 @@ struct LeafEncoder { // Unfortunately we have to delay this check until this point thanks to `Encoder` ever so helpfully not // declaring most of its methods as throwing. guard let dictionary = data.dictionary else { - throw LeafError(.illegalAccess("Leaf contexts must be dictionaries or structure types; arrays and scalar values are not permitted.")) + throw LeafError( + .illegalAccess( + "Leaf contexts must be dictionaries or structure types; arrays and scalar values are not permitted." + ) + ) } return dictionary @@ -28,7 +32,7 @@ struct LeafEncoder { /// One of these is always necessary when implementing an unkeyed container, and needed quite often for most /// other things in Codable. Sure would be nice if the stdlib had one instead of there being 1000-odd versions /// floating around various dependencies. -fileprivate struct GenericCodingKey: CodingKey, Hashable { +private struct GenericCodingKey: CodingKey, Hashable { let stringValue: String let intValue: Int? @@ -49,7 +53,7 @@ fileprivate struct GenericCodingKey: CodingKey, Hashable { /// Helper protocol allowing a single existential representation for all of the possible nested storage patterns /// that show up during encoding. -fileprivate protocol LeafEncodingResolvable { +private protocol LeafEncodingResolvable { var resolvedData: LeafData? { get } } @@ -86,7 +90,9 @@ extension LeafEncoder { /// Need to expose the ability to access unwrapped keyed container to enable use of nested /// keyed containers (see the keyed and unkeyed containers). func rawContainer(keyedBy type: Key.Type) -> KeyedContainerImpl { - guard self.storage == nil else { fatalError("Can't encode to multiple containers at the same encoding level") } + guard self.storage == nil else { + fatalError("Can't encode to multiple containers at the same encoding level") + } self.storage = KeyedContainerImpl(encoder: self) return self.storage as! KeyedContainerImpl @@ -99,7 +105,9 @@ extension LeafEncoder { /// See ``Encoder/unkeyedContainer()``. func unkeyedContainer() -> any UnkeyedEncodingContainer { - guard self.storage == nil else { fatalError("Can't encode to multiple containers at the same encoding level") } + guard self.storage == nil else { + fatalError("Can't encode to multiple containers at the same encoding level") + } self.storage = UnkeyedContainerImpl(encoder: self) return self.storage as! UnkeyedContainerImpl @@ -107,7 +115,9 @@ extension LeafEncoder { /// See ``Encoder/singleValueContainer()``. func singleValueContainer() -> any SingleValueEncodingContainer { - guard self.storage == nil else { fatalError("Can't encode to multiple containers at the same encoding level") } + guard self.storage == nil else { + fatalError("Can't encode to multiple containers at the same encoding level") + } return self } @@ -121,7 +131,8 @@ extension LeafEncoder { /// Encode an arbitrary encodable input, optionally deepening the current coding path with a /// given key during encoding, and return it as a resolvable item. - func encode(_ value: T, forKey key: (any CodingKey)?) throws -> (any LeafEncodingResolvable)? where T: Encodable { + func encode(_ value: T, forKey key: (any CodingKey)?) throws -> (any LeafEncodingResolvable)? + where T: Encodable { if let leafRepresentable = value as? (any LeafDataRepresentable) { /// Shortcut through ``LeafDataRepresentable`` if `T` conforms to it. return leafRepresentable.leafData @@ -136,7 +147,8 @@ extension LeafEncoder { } } - private final class KeyedContainerImpl: KeyedEncodingContainerProtocol, LeafEncodingResolvable where Key: CodingKey { + private final class KeyedContainerImpl: KeyedEncodingContainerProtocol, LeafEncodingResolvable + where Key: CodingKey { private weak var encoder: EncoderImpl! private var data: [String: any LeafEncodingResolvable] = [:] private var nestedEncoderCaptures = [AnyObject]() @@ -153,13 +165,16 @@ extension LeafEncoder { func encodeNil(forKey key: Key) throws {} /// See ``KeyedEncodingContainerProtocol/encode(_:forKey:)``. - func encode(_ value: T, forKey key: Key) throws where T : Encodable { + func encode(_ value: T, forKey key: Key) throws where T: Encodable { guard let encodedValue = try self.encoder.encode(value, forKey: key) else { return } self.data[key.stringValue] = encodedValue } /// See ``KeyedEncodingContainerProtocol/nestedContainer(keyedBy:forKey:)``. - func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer { + func nestedContainer( + keyedBy keyType: NestedKey.Type, + forKey key: Key + ) -> KeyedEncodingContainer { guard let encoder = encoder else { fatalError("Encoder deallocated") } @@ -169,11 +184,13 @@ extension LeafEncoder { /// Use a subencoder to create a nested container so the coding paths are correctly maintained. /// Save the subcontainer in our data so it can be resolved later before returning it. - return .init(self.insert( - nestedEncoder.rawContainer(keyedBy: NestedKey.self), - forKey: key, - as: KeyedContainerImpl.self - )) + return .init( + self.insert( + nestedEncoder.rawContainer(keyedBy: NestedKey.self), + forKey: key, + as: KeyedContainerImpl.self + ) + ) } /// See ``KeyedEncodingContainerProtocol/nestedUnkeyedContainer(forKey:)``. @@ -214,7 +231,8 @@ extension LeafEncoder { } /// Helper for the encoding methods. - private func insert(_ value: any LeafEncodingResolvable, forKey key: any CodingKey, as: T.Type = T.self) -> T { + private func insert(_ value: any LeafEncodingResolvable, forKey key: any CodingKey, as: T.Type = T.self) -> T + { self.data[key.stringValue] = value return value as! T } @@ -249,7 +267,8 @@ extension LeafEncoder { } /// See ``UnkeyedEncodingContainer/nestedContainer(keyedBy:)``. - func nestedContainer(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer { + func nestedContainer(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer + { guard let encoder = encoder else { fatalError("Encoder deallocated") } @@ -257,10 +276,12 @@ extension LeafEncoder { let nestedEncoder = EncoderImpl(from: encoder, withKey: self.nextCodingKey) nestedEncoderCaptures.append(nestedEncoder) - return .init(self.add( - nestedEncoder.rawContainer(keyedBy: NestedKey.self), - as: KeyedContainerImpl.self - )) + return .init( + self.add( + nestedEncoder.rawContainer(keyedBy: NestedKey.self), + as: KeyedContainerImpl.self + ) + ) } /// See ``UnkeyedEncodingContainer/nestedUnkeyedContainer()``. diff --git a/Sources/Rendering/RawTag.swift b/Sources/Rendering/RawTag.swift index 95cb4de5..c9168516 100644 --- a/Sources/Rendering/RawTag.swift +++ b/Sources/Rendering/RawTag.swift @@ -1,4 +1,3 @@ - struct RawTag: UnsafeUnescapedLeafTag { func render(_ context: LeafContext) throws -> LeafData { guard context.parameters.count == 1 else { diff --git a/Sources/Shared/+String.swift b/Sources/Shared/+String.swift index 3412883e..71f191d1 100644 --- a/Sources/Shared/+String.swift +++ b/Sources/Shared/+String.swift @@ -1,4 +1,3 @@ - extension String { package func urlPathEncoded() -> String { self.addingPercentEncoding( @@ -29,7 +28,7 @@ extension String { while trimmed.unicodeScalars.count >= maxUnicodeScalars { trimmed.removeLast() } - + /// Append `U+2026 HORIZONTAL ELLIPSIS` trimmed.append("\u{2026}") diff --git a/Sources/Shared/Discord+ui.swift b/Sources/Shared/Discord+ui.swift index 5166525d..b6738334 100644 --- a/Sources/Shared/Discord+ui.swift +++ b/Sources/Shared/Discord+ui.swift @@ -2,9 +2,7 @@ import DiscordBM extension Guild.Member { package var uiName: String? { - self.nick ?? - self.user?.global_name ?? - self.user?.username + self.nick ?? self.user?.global_name ?? self.user?.username } package var uiAvatarURL: String? { @@ -30,9 +28,7 @@ extension Guild.Member { extension Guild.PartialMember { package var uiName: String? { - self.nick ?? - self.user?.global_name ?? - self.user?.username + self.nick ?? self.user?.global_name ?? self.user?.username } package var uiAvatarURL: String? { @@ -58,8 +54,7 @@ extension Guild.PartialMember { extension DiscordUser { package var uiName: String { - self.global_name ?? - self.username + self.global_name ?? self.username } package var uiAvatarURL: String? { diff --git a/Sources/Shared/ServiceError.swift b/Sources/Shared/ServiceError.swift index 22459b4d..205e8972 100644 --- a/Sources/Shared/ServiceError.swift +++ b/Sources/Shared/ServiceError.swift @@ -1,5 +1,5 @@ -import NIOHTTP1 import AsyncHTTPClient +import NIOHTTP1 enum ServiceError: Error { case badStatus(HTTPClientResponse) diff --git a/Sources/Shared/UsersService/DefaultUsersService.swift b/Sources/Shared/UsersService/DefaultUsersService.swift index e1e629ad..596ed554 100644 --- a/Sources/Shared/UsersService/DefaultUsersService.swift +++ b/Sources/Shared/UsersService/DefaultUsersService.swift @@ -1,12 +1,13 @@ +import AsyncHTTPClient +import DiscordModels +import Logging +import Models + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import AsyncHTTPClient -import Logging -import DiscordModels -import Models struct DefaultUsersService: UsersService { let httpClient: HTTPClient @@ -15,7 +16,7 @@ struct DefaultUsersService: UsersService { let decoder = JSONDecoder() let encoder = JSONEncoder() - + init(httpClient: HTTPClient, apiBaseURL: String) { self.httpClient = httpClient self.apiBaseURL = apiBaseURL @@ -37,14 +38,18 @@ struct DefaultUsersService: UsersService { ) logger.trace("Received HTTP response", metadata: ["response": "\(response)"]) - let body = try await response.body.collect(upTo: 1 << 24) /// 16 MiB + let body = try await response.body.collect(upTo: 1 << 24) + /// 16 MiB guard 200..<300 ~= response.status.code else { - logger.error("Get-coin-count failed", metadata: [ - "status": "\(response.status)", - "headers": "\(response.headers)", - "body": "\(String(buffer: body))", - ]) + logger.error( + "Get-coin-count failed", + metadata: [ + "status": "\(response.status)", + "headers": "\(response.headers)", + "body": "\(String(buffer: body))", + ] + ) throw ServiceError.badStatus(response) } @@ -67,14 +72,18 @@ struct DefaultUsersService: UsersService { ) logger.trace("Received HTTP response", metadata: ["response": "\(response)"]) - let body = try await response.body.collect(upTo: 1 << 24) /// 16 MiB + let body = try await response.body.collect(upTo: 1 << 24) + /// 16 MiB guard 200..<300 ~= response.status.code else { - logger.error("Get-coin-count failed", metadata: [ - "status": "\(response.status)", - "headers": "\(response.headers)", - "body": "\(String(buffer: body))", - ]) + logger.error( + "Get-coin-count failed", + metadata: [ + "status": "\(response.status)", + "headers": "\(response.headers)", + "body": "\(String(buffer: body))", + ] + ) throw ServiceError.badStatus(response) } @@ -97,17 +106,21 @@ struct DefaultUsersService: UsersService { ) logger.trace("Received HTTP response", metadata: ["response": "\(response)"]) - let body = try await response.body.collect(upTo: 1 << 24) /// 16 MiB + let body = try await response.body.collect(upTo: 1 << 24) + /// 16 MiB guard 200..<300 ~= response.status.code else { - logger.error( "Post-coin failed", metadata: [ - "status": "\(response.status)", - "headers": "\(response.headers)", - "body": "\(String(buffer: body))", - ]) + logger.error( + "Post-coin failed", + metadata: [ + "status": "\(response.status)", + "headers": "\(response.headers)", + "body": "\(String(buffer: body))", + ] + ) throw ServiceError.badStatus(response) } - + return try decoder.decode(CoinResponse.self, from: body) } @@ -132,13 +145,17 @@ struct DefaultUsersService: UsersService { logger.trace("Received HTTP response", metadata: ["response": "\(response)"]) guard 200..<300 ~= response.status.code else { - let collected = try? await response.body.collect(upTo: 1 << 16) /// 64 KiB + let collected = try? await response.body.collect(upTo: 1 << 16) + /// 64 KiB let body = collected.map { String(buffer: $0) } ?? "nil" - logger.error("Link-GitHub-id failed", metadata: [ - "status": "\(response.status)", - "headers": "\(response.headers)", - "body": "\(body)", - ]) + logger.error( + "Link-GitHub-id failed", + metadata: [ + "status": "\(response.status)", + "headers": "\(response.headers)", + "body": "\(body)", + ] + ) throw ServiceError.badStatus(response) } } @@ -160,13 +177,17 @@ struct DefaultUsersService: UsersService { logger.trace("Received HTTP response", metadata: ["response": "\(response)"]) guard 200..<300 ~= response.status.code else { - let collected = try? await response.body.collect(upTo: 1 << 16) /// 64 KiB + let collected = try? await response.body.collect(upTo: 1 << 16) + /// 64 KiB let body = collected.map { String(buffer: $0) } ?? "nil" - logger.error("Unlink-GitHub-id failed", metadata: [ - "status": "\(response.status)", - "headers": "\(response.headers)", - "body": "\(body)", - ]) + logger.error( + "Unlink-GitHub-id failed", + metadata: [ + "status": "\(response.status)", + "headers": "\(response.headers)", + "body": "\(body)", + ] + ) throw ServiceError.badStatus(response) } } @@ -175,32 +196,40 @@ struct DefaultUsersService: UsersService { let user = try await self.getOrCreateUser(discordID: discordID) guard let id = user.githubID, - !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return .notLinked } let encodedID = id.urlPathEncoded() let url = "https://api.github.com/user/\(encodedID)" - logger.debug("Will make a request to get GitHub user name", metadata: [ - "user": "\(user)", - "url": .string(url), - ]) + logger.debug( + "Will make a request to get GitHub user name", + metadata: [ + "user": "\(user)", + "url": .string(url), + ] + ) var request = HTTPClientRequest(url: url) request.headers = [ "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "Penny/1.0.0 (https://github.com/vapor/penny-bot)" + "User-Agent": "Penny/1.0.0 (https://github.com/vapor/penny-bot)", ] let response = try await httpClient.execute(request, timeout: .seconds(5)) - let body = try await response.body.collect(upTo: 1 << 22) /// 4 MiB - - logger.debug("Got user response from id", metadata: [ - "status": .stringConvertible(response.status), - "headers": .stringConvertible(response.headers), - "body": .string(String(buffer: body)), - "id": .string(id) - ]) + let body = try await response.body.collect(upTo: 1 << 22) + /// 4 MiB + + logger.debug( + "Got user response from id", + metadata: [ + "status": .stringConvertible(response.status), + "headers": .stringConvertible(response.headers), + "body": .string(String(buffer: body)), + "id": .string(id), + ] + ) let githubUser = try decoder.decode(User.self, from: body) diff --git a/Tests/PennyTests/Fake/AnyBox.swift b/Tests/PennyTests/Fake/AnyBox.swift index edf60b25..a18b9933 100644 --- a/Tests/PennyTests/Fake/AnyBox.swift +++ b/Tests/PennyTests/Fake/AnyBox.swift @@ -1,8 +1,7 @@ - /// A box to treat `Any` as a Sendable type. class AnyBox: @unchecked Sendable { let value: Any - + init(_ value: Any) { self.value = value } diff --git a/Tests/PennyTests/Fake/EventKey.swift b/Tests/PennyTests/Fake/EventKey.swift index 24f07455..f4973014 100644 --- a/Tests/PennyTests/Fake/EventKey.swift +++ b/Tests/PennyTests/Fake/EventKey.swift @@ -1,4 +1,5 @@ import DiscordBM + @testable import Penny enum EventKey: String, Sendable { @@ -35,54 +36,181 @@ enum EventKey: String, Sendable { case .thanksMessage2: return [.createMessage(channelId: Constants.Channels.thanks.id)] case .linkInteraction: - return [.updateOriginalInteractionResponse(applicationId: "11111111", interactionToken: "aW50ZXJhY3Rpb246MTAzMTExMjExMzk3ODA4OTUwMjpRVGVBVXU3Vk1XZ1R0QXpiYmhXbkpLcnFqN01MOXQ4T2pkcGRXYzRjUFNMZE9TQ3g4R3NyM1d3OGszalZGV2c3a0JJb2ZTZnluS3VlbUNBRDh5N2U3Rm00QzQ2SWRDMGJrelJtTFlveFI3S0RGbHBrZnpoWXJSNU1BV1RqYk5Xaw"), .createInteractionResponse(interactionId: "1031112113978089502", interactionToken: "aW50ZXJhY3Rpb246MTAzMTExMjExMzk3ODA4OTUwMjpRVGVBVXU3Vk1XZ1R0QXpiYmhXbkpLcnFqN01MOXQ4T2pkcGRXYzRjUFNMZE9TQ3g4R3NyM1d3OGszalZGV2c3a0JJb2ZTZnluS3VlbUNBRDh5N2U3Rm00QzQ2SWRDMGJrelJtTFlveFI3S0RGbHBrZnpoWXJSNU1BV1RqYk5Xaw")] + return [ + .updateOriginalInteractionResponse( + applicationId: "11111111", + interactionToken: + "aW50ZXJhY3Rpb246MTAzMTExMjExMzk3ODA4OTUwMjpRVGVBVXU3Vk1XZ1R0QXpiYmhXbkpLcnFqN01MOXQ4T2pkcGRXYzRjUFNMZE9TQ3g4R3NyM1d3OGszalZGV2c3a0JJb2ZTZnluS3VlbUNBRDh5N2U3Rm00QzQ2SWRDMGJrelJtTFlveFI3S0RGbHBrZnpoWXJSNU1BV1RqYk5Xaw" + ), + .createInteractionResponse( + interactionId: "1031112113978089502", + interactionToken: + "aW50ZXJhY3Rpb246MTAzMTExMjExMzk3ODA4OTUwMjpRVGVBVXU3Vk1XZ1R0QXpiYmhXbkpLcnFqN01MOXQ4T2pkcGRXYzRjUFNMZE9TQ3g4R3NyM1d3OGszalZGV2c3a0JJb2ZTZnluS3VlbUNBRDh5N2U3Rm00QzQ2SWRDMGJrelJtTFlveFI3S0RGbHBrZnpoWXJSNU1BV1RqYk5Xaw" + ), + ] case .thanksReaction: return [.createMessage(channelId: "684159753189982218")] case .thanksReaction2: - return [.updateMessage( - channelId: "684159753189982218", - messageId: "1031112115928449022" - )] + return [ + .updateMessage( + channelId: "684159753189982218", + messageId: "1031112115928449022" + ) + ] case .thanksReaction3: return [.createMessage(channelId: Constants.Channels.thanks.id)] case .thanksReaction4: - return [.updateMessage( - channelId: Constants.Channels.thanks.id, - messageId: "1031112115928111022" - )] + return [ + .updateMessage( + channelId: Constants.Channels.thanks.id, + messageId: "1031112115928111022" + ) + ] case .stopRespondingToMessages: return [.createMessage(channelId: "1067060193982156880")] case .autoPingsTrigger, .autoPingsTrigger2: return [ .createDm, - .createMessage(channelId: "1018169583619821619") + .createMessage(channelId: "1018169583619821619"), ] case .howManyCoins1: - return [.updateOriginalInteractionResponse(applicationId: "11111111", interactionToken: "aW50ZXJhY3Rpb246MTA1OTM0NTUzNjM2NjQyMDExODowbHZldWtVOUVvMVFCMEhnSjR2RmJrMncyOXNuV3J6OVR5Qk9mZ2h6YzhMSDVTdEZ3NWNIMXA1VzJlZ2RteXdHbzFGdGl0dVFMa2dBNVZUUndmVVFqZzJhUDJlTERuNDRjYXBuSWRHZzRwSFZnNjJLR3hZM1hKNjRuaWtCUzZpeg"), .createInteractionResponse(interactionId: "1059345536366420118", interactionToken: "aW50ZXJhY3Rpb246MTA1OTM0NTUzNjM2NjQyMDExODowbHZldWtVOUVvMVFCMEhnSjR2RmJrMncyOXNuV3J6OVR5Qk9mZ2h6YzhMSDVTdEZ3NWNIMXA1VzJlZ2RteXdHbzFGdGl0dVFMa2dBNVZUUndmVVFqZzJhUDJlTERuNDRjYXBuSWRHZzRwSFZnNjJLR3hZM1hKNjRuaWtCUzZpeg")] + return [ + .updateOriginalInteractionResponse( + applicationId: "11111111", + interactionToken: + "aW50ZXJhY3Rpb246MTA1OTM0NTUzNjM2NjQyMDExODowbHZldWtVOUVvMVFCMEhnSjR2RmJrMncyOXNuV3J6OVR5Qk9mZ2h6YzhMSDVTdEZ3NWNIMXA1VzJlZ2RteXdHbzFGdGl0dVFMa2dBNVZUUndmVVFqZzJhUDJlTERuNDRjYXBuSWRHZzRwSFZnNjJLR3hZM1hKNjRuaWtCUzZpeg" + ), + .createInteractionResponse( + interactionId: "1059345536366420118", + interactionToken: + "aW50ZXJhY3Rpb246MTA1OTM0NTUzNjM2NjQyMDExODowbHZldWtVOUVvMVFCMEhnSjR2RmJrMncyOXNuV3J6OVR5Qk9mZ2h6YzhMSDVTdEZ3NWNIMXA1VzJlZ2RteXdHbzFGdGl0dVFMa2dBNVZUUndmVVFqZzJhUDJlTERuNDRjYXBuSWRHZzRwSFZnNjJLR3hZM1hKNjRuaWtCUzZpeg" + ), + ] case .howManyCoins2: - return [.updateOriginalInteractionResponse(applicationId: "11111111", interactionToken: "aW50ZXJhY3Rpb246MTA1OTM0NTY0MTY1MTgzMDg1NTp2NWI1eVFkNEVJdHJaRlc0bUZoRmNjMUFKeHNqS09YcXhHTUxHZGJIMXdzdFhkVkhWSk95YnNUdUV4U29UdUl3ejJsN2k0RTlDNVA3Nmhza2xIdkdrR2ZQRnduOEFBNUFlM28zN1NzSlJta0tVSkt1M1FxQ1lvb3FZU1lnMWg1ag"), .createInteractionResponse(interactionId: "1059345641651830855", interactionToken: "aW50ZXJhY3Rpb246MTA1OTM0NTY0MTY1MTgzMDg1NTp2NWI1eVFkNEVJdHJaRlc0bUZoRmNjMUFKeHNqS09YcXhHTUxHZGJIMXdzdFhkVkhWSk95YnNUdUV4U29UdUl3ejJsN2k0RTlDNVA3Nmhza2xIdkdrR2ZQRnduOEFBNUFlM28zN1NzSlJta0tVSkt1M1FxQ1lvb3FZU1lnMWg1ag")] + return [ + .updateOriginalInteractionResponse( + applicationId: "11111111", + interactionToken: + "aW50ZXJhY3Rpb246MTA1OTM0NTY0MTY1MTgzMDg1NTp2NWI1eVFkNEVJdHJaRlc0bUZoRmNjMUFKeHNqS09YcXhHTUxHZGJIMXdzdFhkVkhWSk95YnNUdUV4U29UdUl3ejJsN2k0RTlDNVA3Nmhza2xIdkdrR2ZQRnduOEFBNUFlM28zN1NzSlJta0tVSkt1M1FxQ1lvb3FZU1lnMWg1ag" + ), + .createInteractionResponse( + interactionId: "1059345641651830855", + interactionToken: + "aW50ZXJhY3Rpb246MTA1OTM0NTY0MTY1MTgzMDg1NTp2NWI1eVFkNEVJdHJaRlc0bUZoRmNjMUFKeHNqS09YcXhHTUxHZGJIMXdzdFhkVkhWSk95YnNUdUV4U29UdUl3ejJsN2k0RTlDNVA3Nmhza2xIdkdrR2ZQRnduOEFBNUFlM28zN1NzSlJta0tVSkt1M1FxQ1lvb3FZU1lnMWg1ag" + ), + ] case .serverBoost: return [.createMessage(channelId: "443074453719744522")] case .faqsAdd: - return [.createInteractionResponse(interactionId: "1097057830038667314", interactionToken: "aW50ZXJhY3Rpb246MTA5NzA1NzgzMDAzODY2NzMxNDpISXNabG5KMTlPdUtDOEhSbU93WmlucUd2eGNRYXRuS2lUaVNVZ255RHhLMThLZGM1Q1diU21sY3ByaGJMSkJxYXBZdkdEZXJRbVdNSmZ0WHp0dzNvcVNWWkE5dmllSmxoRUE1UG0xdXVPSUE0cDA3N1AzY2ZlSjluTFFFMzJTRw")] + return [ + .createInteractionResponse( + interactionId: "1097057830038667314", + interactionToken: + "aW50ZXJhY3Rpb246MTA5NzA1NzgzMDAzODY2NzMxNDpISXNabG5KMTlPdUtDOEhSbU93WmlucUd2eGNRYXRuS2lUaVNVZ255RHhLMThLZGM1Q1diU21sY3ByaGJMSkJxYXBZdkdEZXJRbVdNSmZ0WHp0dzNvcVNWWkE5dmllSmxoRUE1UG0xdXVPSUE0cDA3N1AzY2ZlSjluTFFFMzJTRw" + ) + ] case .faqsAddFailure: - return [.updateOriginalInteractionResponse(applicationId: "11111111", interactionToken: "aW50ZXJhY3Rpb246MTA5NzA1NzgzMDAzODY2NzMxNDpISXNabG5KMTlPdUtDOEhSbU93WmlucUd2eGNRYXRuS2lUaVNVZ255RHhLMThLZGM1Q1diU21sY3ByaGJMSkJxYXBZdkdEZXJRbVdNSmZ0WHp0dzNvcVNWWkE5dmllSmxoRUE1UG0xdXVPSUE0cDA3N1AzY2ZlSjluTFFFMzJTRw"),.createInteractionResponse(interactionId: "1097057830038667314", interactionToken: "aW50ZXJhY3Rpb246MTA5NzA1NzgzMDAzODY2NzMxNDpISXNabG5KMTlPdUtDOEhSbU93WmlucUd2eGNRYXRuS2lUaVNVZ255RHhLMThLZGM1Q1diU21sY3ByaGJMSkJxYXBZdkdEZXJRbVdNSmZ0WHp0dzNvcVNWWkE5dmllSmxoRUE1UG0xdXVPSUE0cDA3N1AzY2ZlSjluTFFFMzJTRw")] + return [ + .updateOriginalInteractionResponse( + applicationId: "11111111", + interactionToken: + "aW50ZXJhY3Rpb246MTA5NzA1NzgzMDAzODY2NzMxNDpISXNabG5KMTlPdUtDOEhSbU93WmlucUd2eGNRYXRuS2lUaVNVZ255RHhLMThLZGM1Q1diU21sY3ByaGJMSkJxYXBZdkdEZXJRbVdNSmZ0WHp0dzNvcVNWWkE5dmllSmxoRUE1UG0xdXVPSUE0cDA3N1AzY2ZlSjluTFFFMzJTRw" + ), + .createInteractionResponse( + interactionId: "1097057830038667314", + interactionToken: + "aW50ZXJhY3Rpb246MTA5NzA1NzgzMDAzODY2NzMxNDpISXNabG5KMTlPdUtDOEhSbU93WmlucUd2eGNRYXRuS2lUaVNVZ255RHhLMThLZGM1Q1diU21sY3ByaGJMSkJxYXBZdkdEZXJRbVdNSmZ0WHp0dzNvcVNWWkE5dmllSmxoRUE1UG0xdXVPSUE0cDA3N1AzY2ZlSjluTFFFMzJTRw" + ), + ] case .faqsGet: - return [.updateOriginalInteractionResponse(applicationId: "11111111", interactionToken: "aW50ZXJhY3Rpb246MTA5NzA2MjQ3NDExODg2NDkwNjpJMXhuZEVPeXViZFVteXV0UUpNZjAzZFBNMTNvVndnSkpLZ0xlbFprbnFLbmNLSlpFQmc3bUc3bVF6YzdJVklZemRqNlIzcFhsZlBEM3FoZmtPeXQwZHRkY2psNlExeTRRcDB4dWhmcGwyOW1EWGtuajhVWjdidU1VQ2dUQk5JcA"), .createInteractionResponse(interactionId: "1097062474118864906", interactionToken: "aW50ZXJhY3Rpb246MTA5NzA2MjQ3NDExODg2NDkwNjpJMXhuZEVPeXViZFVteXV0UUpNZjAzZFBNMTNvVndnSkpLZ0xlbFprbnFLbmNLSlpFQmc3bUc3bVF6YzdJVklZemRqNlIzcFhsZlBEM3FoZmtPeXQwZHRkY2psNlExeTRRcDB4dWhmcGwyOW1EWGtuajhVWjdidU1VQ2dUQk5JcA")] + return [ + .updateOriginalInteractionResponse( + applicationId: "11111111", + interactionToken: + "aW50ZXJhY3Rpb246MTA5NzA2MjQ3NDExODg2NDkwNjpJMXhuZEVPeXViZFVteXV0UUpNZjAzZFBNMTNvVndnSkpLZ0xlbFprbnFLbmNLSlpFQmc3bUc3bVF6YzdJVklZemRqNlIzcFhsZlBEM3FoZmtPeXQwZHRkY2psNlExeTRRcDB4dWhmcGwyOW1EWGtuajhVWjdidU1VQ2dUQk5JcA" + ), + .createInteractionResponse( + interactionId: "1097062474118864906", + interactionToken: + "aW50ZXJhY3Rpb246MTA5NzA2MjQ3NDExODg2NDkwNjpJMXhuZEVPeXViZFVteXV0UUpNZjAzZFBNMTNvVndnSkpLZ0xlbFprbnFLbmNLSlpFQmc3bUc3bVF6YzdJVklZemRqNlIzcFhsZlBEM3FoZmtPeXQwZHRkY2psNlExeTRRcDB4dWhmcGwyOW1EWGtuajhVWjdidU1VQ2dUQk5JcA" + ), + ] case .faqsGetEphemeral: - return [.updateOriginalInteractionResponse(applicationId: "11111111", interactionToken: "aW50ZXJhY3Rpb246MTEyMTc4MjI5Mzk0NjcxNjI0MDpUTVRmTTVJOXNSMVpmVXFPNHc1WG1Pd202Y2ZxOURTTFdKTFRPTWRsdlI5REdDQlJ6cTZDTHhNeE9leVlkYzFlcW9TeTlhdE9FSU4zMmJBV3BUeW9ETnQyTjJub1k1Tk91UjhZQnJoS0I4Q3pPb1NIQWNoTXkxbFY3SHVCbHc0cg"), .createInteractionResponse(interactionId: "1121782293946716240", interactionToken: "aW50ZXJhY3Rpb246MTEyMTc4MjI5Mzk0NjcxNjI0MDpUTVRmTTVJOXNSMVpmVXFPNHc1WG1Pd202Y2ZxOURTTFdKTFRPTWRsdlI5REdDQlJ6cTZDTHhNeE9leVlkYzFlcW9TeTlhdE9FSU4zMmJBV3BUeW9ETnQyTjJub1k1Tk91UjhZQnJoS0I4Q3pPb1NIQWNoTXkxbFY3SHVCbHc0cg")] + return [ + .updateOriginalInteractionResponse( + applicationId: "11111111", + interactionToken: + "aW50ZXJhY3Rpb246MTEyMTc4MjI5Mzk0NjcxNjI0MDpUTVRmTTVJOXNSMVpmVXFPNHc1WG1Pd202Y2ZxOURTTFdKTFRPTWRsdlI5REdDQlJ6cTZDTHhNeE9leVlkYzFlcW9TeTlhdE9FSU4zMmJBV3BUeW9ETnQyTjJub1k1Tk91UjhZQnJoS0I4Q3pPb1NIQWNoTXkxbFY3SHVCbHc0cg" + ), + .createInteractionResponse( + interactionId: "1121782293946716240", + interactionToken: + "aW50ZXJhY3Rpb246MTEyMTc4MjI5Mzk0NjcxNjI0MDpUTVRmTTVJOXNSMVpmVXFPNHc1WG1Pd202Y2ZxOURTTFdKTFRPTWRsdlI5REdDQlJ6cTZDTHhNeE9leVlkYzFlcW9TeTlhdE9FSU4zMmJBV3BUeW9ETnQyTjJub1k1Tk91UjhZQnJoS0I4Q3pPb1NIQWNoTXkxbFY3SHVCbHc0cg" + ), + ] case .faqsGetAutocomplete: - return [.createInteractionResponse(interactionId: "1097060331508994088", interactionToken: "aW50ZXJhY3Rpb246MTA5NzA2MDMzMTUwODk5NDA4ODpyWDROWEtucXBJNm1ZaDRDQ2QzVFVyRDU5Q21pZlhFV3pkUHJaaDZUbHczUlVkc1dGRDdYdHBYdVJFT2VrN2ROUzByTEdUTVJNaXhMRk5uUWk4Mng4MWF5S00yRWdQdzNqbGlkbUR3N3pwTm5HR2JnQVZQUkhtajhJbWltMVBQOQ")] + return [ + .createInteractionResponse( + interactionId: "1097060331508994088", + interactionToken: + "aW50ZXJhY3Rpb246MTA5NzA2MDMzMTUwODk5NDA4ODpyWDROWEtucXBJNm1ZaDRDQ2QzVFVyRDU5Q21pZlhFV3pkUHJaaDZUbHczUlVkc1dGRDdYdHBYdVJFT2VrN2ROUzByTEdUTVJNaXhMRk5uUWk4Mng4MWF5S00yRWdQdzNqbGlkbUR3N3pwTm5HR2JnQVZQUkhtajhJbWltMVBQOQ" + ) + ] case .autoFaqsAdd: - return [.createInteractionResponse(interactionId: "1097057830038667314", interactionToken: "aW50ZXJhY3Rpb246MTA5NzA1NzgzMDAzODY2NzMxNDpISXNabG5KMTlPdUtDOEhSbU93WmlucUd2eGNRYXRuS2lUaVNVZ255RHhLMThLZGM1Q1diU21sY3ByaGJMSkJxYXBZdkdEZXJRbVdNSmZ0WHp0dzNvcVNWWkE5dmllSmxoRUE1UG0xdXVPSUE0cDA3N1AzY2ZlSjluTFFFMzJTRw")] + return [ + .createInteractionResponse( + interactionId: "1097057830038667314", + interactionToken: + "aW50ZXJhY3Rpb246MTA5NzA1NzgzMDAzODY2NzMxNDpISXNabG5KMTlPdUtDOEhSbU93WmlucUd2eGNRYXRuS2lUaVNVZ255RHhLMThLZGM1Q1diU21sY3ByaGJMSkJxYXBZdkdEZXJRbVdNSmZ0WHp0dzNvcVNWWkE5dmllSmxoRUE1UG0xdXVPSUE0cDA3N1AzY2ZlSjluTFFFMzJTRw" + ) + ] case .autoFaqsAddFailure: - return [.updateOriginalInteractionResponse(applicationId: "11111111", interactionToken: "aW50ZXJhY3Rpb246MTA5NzA1NzgzMDAzODY2NzMxNDpISXNabG5KMTlPdUtDOEhSbU93WmlucUd2eGNRYXRuS2lUaVNVZ255RHhLMThLZGM1Q1diU21sY3ByaGJMSkJxYXBZdkdEZXJRbVdNSmZ0WHp0dzNvcVNWWkE5dmllSmxoRUE1UG0xdXVPSUE0cDA3N1AzY2ZlSjluTFFFMzJTRw"),.createInteractionResponse(interactionId: "1097057830038667314", interactionToken: "aW50ZXJhY3Rpb246MTA5NzA1NzgzMDAzODY2NzMxNDpISXNabG5KMTlPdUtDOEhSbU93WmlucUd2eGNRYXRuS2lUaVNVZ255RHhLMThLZGM1Q1diU21sY3ByaGJMSkJxYXBZdkdEZXJRbVdNSmZ0WHp0dzNvcVNWWkE5dmllSmxoRUE1UG0xdXVPSUE0cDA3N1AzY2ZlSjluTFFFMzJTRw")] + return [ + .updateOriginalInteractionResponse( + applicationId: "11111111", + interactionToken: + "aW50ZXJhY3Rpb246MTA5NzA1NzgzMDAzODY2NzMxNDpISXNabG5KMTlPdUtDOEhSbU93WmlucUd2eGNRYXRuS2lUaVNVZ255RHhLMThLZGM1Q1diU21sY3ByaGJMSkJxYXBZdkdEZXJRbVdNSmZ0WHp0dzNvcVNWWkE5dmllSmxoRUE1UG0xdXVPSUE0cDA3N1AzY2ZlSjluTFFFMzJTRw" + ), + .createInteractionResponse( + interactionId: "1097057830038667314", + interactionToken: + "aW50ZXJhY3Rpb246MTA5NzA1NzgzMDAzODY2NzMxNDpISXNabG5KMTlPdUtDOEhSbU93WmlucUd2eGNRYXRuS2lUaVNVZ255RHhLMThLZGM1Q1diU21sY3ByaGJMSkJxYXBZdkdEZXJRbVdNSmZ0WHp0dzNvcVNWWkE5dmllSmxoRUE1UG0xdXVPSUE0cDA3N1AzY2ZlSjluTFFFMzJTRw" + ), + ] case .autoFaqsGet: - return [.updateOriginalInteractionResponse(applicationId: "11111111", interactionToken: "aW50ZXJhY3Rpb246MTA5NzA2MjQ3NDExODg2NDkwNjpJMXhuZEVPeXViZFVteXV0UUpNZjAzZFBNMTNvVndnSkpLZ0xlbFprbnFLbmNLSlpFQmc3bUc3bVF6YzdJVklZemRqNlIzcFhsZlBEM3FoZmtPeXQwZHRkY2psNlExeTRRcDB4dWhmcGwyOW1EWGtuajhVWjdidU1VQ2dUQk5JcA"), .createInteractionResponse(interactionId: "1097062474118864906", interactionToken: "aW50ZXJhY3Rpb246MTA5NzA2MjQ3NDExODg2NDkwNjpJMXhuZEVPeXViZFVteXV0UUpNZjAzZFBNMTNvVndnSkpLZ0xlbFprbnFLbmNLSlpFQmc3bUc3bVF6YzdJVklZemRqNlIzcFhsZlBEM3FoZmtPeXQwZHRkY2psNlExeTRRcDB4dWhmcGwyOW1EWGtuajhVWjdidU1VQ2dUQk5JcA")] + return [ + .updateOriginalInteractionResponse( + applicationId: "11111111", + interactionToken: + "aW50ZXJhY3Rpb246MTA5NzA2MjQ3NDExODg2NDkwNjpJMXhuZEVPeXViZFVteXV0UUpNZjAzZFBNMTNvVndnSkpLZ0xlbFprbnFLbmNLSlpFQmc3bUc3bVF6YzdJVklZemRqNlIzcFhsZlBEM3FoZmtPeXQwZHRkY2psNlExeTRRcDB4dWhmcGwyOW1EWGtuajhVWjdidU1VQ2dUQk5JcA" + ), + .createInteractionResponse( + interactionId: "1097062474118864906", + interactionToken: + "aW50ZXJhY3Rpb246MTA5NzA2MjQ3NDExODg2NDkwNjpJMXhuZEVPeXViZFVteXV0UUpNZjAzZFBNMTNvVndnSkpLZ0xlbFprbnFLbmNLSlpFQmc3bUc3bVF6YzdJVklZemRqNlIzcFhsZlBEM3FoZmtPeXQwZHRkY2psNlExeTRRcDB4dWhmcGwyOW1EWGtuajhVWjdidU1VQ2dUQk5JcA" + ), + ] case .autoFaqsGetEphemeral: - return [.updateOriginalInteractionResponse(applicationId: "11111111", interactionToken: "aW50ZXJhY3Rpb246MTEyMTc4MjI5Mzk0NjcxNjI0MDpUTVRmTTVJOXNSMVpmVXFPNHc1WG1Pd202Y2ZxOURTTFdKTFRPTWRsdlI5REdDQlJ6cTZDTHhNeE9leVlkYzFlcW9TeTlhdE9FSU4zMmJBV3BUeW9ETnQyTjJub1k1Tk91UjhZQnJoS0I4Q3pPb1NIQWNoTXkxbFY3SHVCbHc0cg"), .createInteractionResponse(interactionId: "1121782293946716240", interactionToken: "aW50ZXJhY3Rpb246MTEyMTc4MjI5Mzk0NjcxNjI0MDpUTVRmTTVJOXNSMVpmVXFPNHc1WG1Pd202Y2ZxOURTTFdKTFRPTWRsdlI5REdDQlJ6cTZDTHhNeE9leVlkYzFlcW9TeTlhdE9FSU4zMmJBV3BUeW9ETnQyTjJub1k1Tk91UjhZQnJoS0I4Q3pPb1NIQWNoTXkxbFY3SHVCbHc0cg")] + return [ + .updateOriginalInteractionResponse( + applicationId: "11111111", + interactionToken: + "aW50ZXJhY3Rpb246MTEyMTc4MjI5Mzk0NjcxNjI0MDpUTVRmTTVJOXNSMVpmVXFPNHc1WG1Pd202Y2ZxOURTTFdKTFRPTWRsdlI5REdDQlJ6cTZDTHhNeE9leVlkYzFlcW9TeTlhdE9FSU4zMmJBV3BUeW9ETnQyTjJub1k1Tk91UjhZQnJoS0I4Q3pPb1NIQWNoTXkxbFY3SHVCbHc0cg" + ), + .createInteractionResponse( + interactionId: "1121782293946716240", + interactionToken: + "aW50ZXJhY3Rpb246MTEyMTc4MjI5Mzk0NjcxNjI0MDpUTVRmTTVJOXNSMVpmVXFPNHc1WG1Pd202Y2ZxOURTTFdKTFRPTWRsdlI5REdDQlJ6cTZDTHhNeE9leVlkYzFlcW9TeTlhdE9FSU4zMmJBV3BUeW9ETnQyTjJub1k1Tk91UjhZQnJoS0I4Q3pPb1NIQWNoTXkxbFY3SHVCbHc0cg" + ), + ] case .autoFaqsGetAutocomplete: - return [.createInteractionResponse(interactionId: "1097060331508994088", interactionToken: "aW50ZXJhY3Rpb246MTA5NzA2MDMzMTUwODk5NDA4ODpyWDROWEtucXBJNm1ZaDRDQ2QzVFVyRDU5Q21pZlhFV3pkUHJaaDZUbHczUlVkc1dGRDdYdHBYdVJFT2VrN2ROUzByTEdUTVJNaXhMRk5uUWk4Mng4MWF5S00yRWdQdzNqbGlkbUR3N3pwTm5HR2JnQVZQUkhtajhJbWltMVBQOQ")] + return [ + .createInteractionResponse( + interactionId: "1097060331508994088", + interactionToken: + "aW50ZXJhY3Rpb246MTA5NzA2MDMzMTUwODk5NDA4ODpyWDROWEtucXBJNm1ZaDRDQ2QzVFVyRDU5Q21pZlhFV3pkUHJaaDZUbHczUlVkc1dGRDdYdHBYdVJFT2VrN2ROUzByTEdUTVJNaXhMRk5uUWk4Mng4MWF5S00yRWdQdzNqbGlkbUR3N3pwTm5HR2JnQVZQUkhtajhJbWltMVBQOQ" + ) + ] case .autoFaqsTrigger: return [.createMessage(channelId: "519613337638797315")] } diff --git a/Tests/PennyTests/Fake/FakeAutoFaqsService.swift b/Tests/PennyTests/Fake/FakeAutoFaqsService.swift index 174d4cde..dacda260 100644 --- a/Tests/PennyTests/Fake/FakeAutoFaqsService.swift +++ b/Tests/PennyTests/Fake/FakeAutoFaqsService.swift @@ -1,20 +1,21 @@ -@testable import Penny -import Models import AsyncHTTPClient +import Models + +@testable import Penny actor FakeAutoFaqsService: AutoFaqsService { typealias ResponseRateLimiter = DefaultAutoFaqsService.ResponseRateLimiter - init() { } + init() {} private let all = ["PostgresNIO.PSQLError": "Update your PostgresNIO!"] var responseRateLimiter = ResponseRateLimiter() - func insert(expression: String, value: String) async throws { } + func insert(expression: String, value: String) async throws {} - func remove(expression: String) async throws { } + func remove(expression: String) async throws {} func get(expression: String) async throws -> String? { self.all[expression] @@ -33,10 +34,12 @@ actor FakeAutoFaqsService: AutoFaqsService { } func canRespond(receiverID: UserSnowflake, faqHash: Int) async -> Bool { - responseRateLimiter.canRespond(to: .init( - receiverID: receiverID, - faqHash: faqHash - )) + responseRateLimiter.canRespond( + to: .init( + receiverID: receiverID, + faqHash: faqHash + ) + ) } func consumeCachesStorageData(_ storage: ResponseRateLimiter) async { @@ -44,6 +47,6 @@ actor FakeAutoFaqsService: AutoFaqsService { } func getCachedDataForCachesStorage() async -> ResponseRateLimiter { - return self.responseRateLimiter + self.responseRateLimiter } } diff --git a/Tests/PennyTests/Fake/FakeCacheService.swift b/Tests/PennyTests/Fake/FakeCacheService.swift index 660e6dd8..ccbc2612 100644 --- a/Tests/PennyTests/Fake/FakeCacheService.swift +++ b/Tests/PennyTests/Fake/FakeCacheService.swift @@ -19,5 +19,5 @@ struct FakeCachesService: CachesService { await storage.populateServicesAndReport(context: context) } - func gatherCachedInfoAndSaveToRepository() async { } + func gatherCachedInfoAndSaveToRepository() async {} } diff --git a/Tests/PennyTests/Fake/FakeClientTransport.swift b/Tests/PennyTests/Fake/FakeClientTransport.swift index d8cc7277..8bc989fe 100644 --- a/Tests/PennyTests/Fake/FakeClientTransport.swift +++ b/Tests/PennyTests/Fake/FakeClientTransport.swift @@ -1,5 +1,6 @@ -import OpenAPIRuntime import HTTPTypes +import OpenAPIRuntime + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -8,7 +9,7 @@ import Foundation struct FakeClientTransport: ClientTransport { - init() { } + init() {} func send( _ request: HTTPRequest, @@ -17,8 +18,7 @@ struct FakeClientTransport: ClientTransport { operationID: String ) async throws -> (HTTPResponse, HTTPBody?) { let primaryID = "\(request.method.rawValue)-\(baseURL.absoluteString)\(request.path ?? "")" - guard let data = TestData.for(ghRequestID: primaryID) ?? - TestData.for(ghRequestID: operationID) else { + guard let data = TestData.for(ghRequestID: primaryID) ?? TestData.for(ghRequestID: operationID) else { fatalError("No test GitHub data for primary id: \(primaryID), operation id: \(operationID).") } let body = HTTPBody(data) diff --git a/Tests/PennyTests/Fake/FakeDiscordClient.swift b/Tests/PennyTests/Fake/FakeDiscordClient.swift index ae0c3c45..00aa76a1 100644 --- a/Tests/PennyTests/Fake/FakeDiscordClient.swift +++ b/Tests/PennyTests/Fake/FakeDiscordClient.swift @@ -1,11 +1,12 @@ -@testable import DiscordBM import NIOHTTP1 import Testing +@testable import DiscordBM + struct FakeDiscordClient: DiscordClient { var appId: ApplicationSnowflake? = "11111111" - init() { } + init() {} func send(request: DiscordHTTPRequest) async throws -> DiscordHTTPResponse { await FakeResponseStorage.shared.respond( @@ -23,7 +24,7 @@ struct FakeDiscordClient: DiscordClient { ).map { .init(data: $0) } ) } - + func send( request: DiscordHTTPRequest, payload: E @@ -35,18 +36,19 @@ struct FakeDiscordClient: DiscordClient { } await FakeResponseStorage.shared.respond(to: request.endpoint, with: AnyBox(payload)) - + return DiscordHTTPResponse( host: "discord.com", status: .ok, version: .http2, headers: [:], - body: TestData + body: + TestData .for(gatewayEventKey: request.endpoint.testingKey) .map { .init(data: $0) } ) } - + func sendMultipart( request: DiscordHTTPRequest, payload: E @@ -64,7 +66,8 @@ struct FakeDiscordClient: DiscordClient { status: .ok, version: .http2, headers: [:], - body: TestData + body: + TestData .for(gatewayEventKey: request.endpoint.testingKey) .map { .init(data: $0) } ) diff --git a/Tests/PennyTests/Fake/FakeFaqsService.swift b/Tests/PennyTests/Fake/FakeFaqsService.swift index f8889bcd..6c27b04e 100644 --- a/Tests/PennyTests/Fake/FakeFaqsService.swift +++ b/Tests/PennyTests/Fake/FakeFaqsService.swift @@ -1,16 +1,17 @@ -@testable import Penny -import Models import AsyncHTTPClient +import Models + +@testable import Penny struct FakeFaqsService: FaqsService { - init() { } + init() {} private let all = ["Working Directory": "Test working directory help"] - func insert(name: String, value: String) async throws { } + func insert(name: String, value: String) async throws {} - func remove(name: String) async throws { } + func remove(name: String) async throws {} func get(name: String) async throws -> String? { self.all[name] diff --git a/Tests/PennyTests/Fake/FakeMainService.swift b/Tests/PennyTests/Fake/FakeMainService.swift index 713e1f72..283a31a8 100644 --- a/Tests/PennyTests/Fake/FakeMainService.swift +++ b/Tests/PennyTests/Fake/FakeMainService.swift @@ -1,15 +1,16 @@ -@testable import DiscordBM -@testable import DiscordModels -@testable import Logging -@testable import Penny -import NIO -import DiscordLogger -import SotoCore import AsyncHTTPClient +import DiscordLogger +import NIO import ServiceLifecycle import Shared +import SotoCore import Testing +@testable import DiscordBM +@testable import DiscordModels +@testable import Logging +@testable import Penny + actor FakeMainService: MainService { let manager: FakeManager let cache: DiscordCache @@ -40,14 +41,14 @@ actor FakeMainService: MainService { ) } - func bootstrapLoggingSystem(httpClient: HTTPClient) async throws { } + func bootstrapLoggingSystem(httpClient: HTTPClient) async throws {} func makeBot(httpClient: HTTPClient) async throws -> any GatewayManager { - return manager + manager } func makeCache(bot: any GatewayManager) async throws -> DiscordCache { - return cache + cache } func beforeConnectCall( @@ -56,7 +57,7 @@ actor FakeMainService: MainService { httpClient: HTTPClient, awsClient: AWSClient ) async throws -> HandlerContext { - return context + context } func runServices(context: HandlerContext) async throws { @@ -73,7 +74,7 @@ actor FakeMainService: MainService { services: [ context.backgroundProcessor, context.discordEventListener, - botStateManagerWrappedService + botStateManagerWrappedService, ], logger: Logger(label: "TestServiceGroup") ) @@ -114,13 +115,15 @@ actor FakeMainService: MainService { pingsService: FakePingsService(), faqsService: FakeFaqsService(), autoFaqsService: autoFaqsService, - cachesService: FakeCachesService(context: .init( - autoFaqsService: autoFaqsService, - evolutionChecker: evolutionChecker, - soChecker: soChecker, - swiftReleasesChecker: swiftReleasesChecker, - reactionCache: reactionCache - )), + cachesService: FakeCachesService( + context: .init( + autoFaqsService: autoFaqsService, + evolutionChecker: evolutionChecker, + soChecker: soChecker, + swiftReleasesChecker: swiftReleasesChecker, + reactionCache: reactionCache + ) + ), discordService: discordService, renderClient: .init( renderer: try .forPenny( @@ -150,30 +153,35 @@ actor FakeMainService: MainService { at: .createMessage(channelId: Constants.Channels.botLogs.id) ).value as? Payloads.CreateMessage { if let signal = possibleSignal.content, - StateManagerSignal.shutdown.isInMessage(signal) { + StateManagerSignal.shutdown.isInMessage(signal) + { let content = await botStateManager._tests_didShutdownSignalEventContent() - await manager.send(event: .init( - opcode: .dispatch, - data: .messageCreate(.init( - id: try! .makeFake(), - channel_id: Constants.Channels.botLogs.id, - author: DiscordUser( - id: Snowflake(Constants.botId), - username: "Penny", - discriminator: "#0" - ), - content: content, - timestamp: .fake, - tts: false, - mention_everyone: false, - mention_roles: [], - mentions: [], - attachments: [], - embeds: [], - pinned: false, - type: .default - )) - )) + await manager.send( + event: .init( + opcode: .dispatch, + data: .messageCreate( + .init( + id: try! .makeFake(), + channel_id: Constants.Channels.botLogs.id, + author: DiscordUser( + id: Snowflake(Constants.botId), + username: "Penny", + discriminator: "#0" + ), + content: content, + timestamp: .fake, + tts: false, + mention_everyone: false, + mention_roles: [], + mentions: [], + attachments: [], + embeds: [], + pinned: false, + type: .default + ) + ) + ) + ) break } } @@ -187,7 +195,7 @@ actor FakeMainService: MainService { } } -private extension DiscordTimestamp { - static let fake = DiscordTimestamp(date: .distantPast) - static let inFutureFake = DiscordTimestamp(date: .distantFuture) +extension DiscordTimestamp { + fileprivate static let fake = DiscordTimestamp(date: .distantPast) + fileprivate static let inFutureFake = DiscordTimestamp(date: .distantFuture) } diff --git a/Tests/PennyTests/Fake/FakeManager.swift b/Tests/PennyTests/Fake/FakeManager.swift index 580b863d..33fd6224 100644 --- a/Tests/PennyTests/Fake/FakeManager.swift +++ b/Tests/PennyTests/Fake/FakeManager.swift @@ -1,22 +1,25 @@ -@testable import Penny -import struct NIOCore.ByteBuffer -import DiscordBM import Atomics +import DiscordBM +import Testing + +import struct NIOCore.ByteBuffer + +@testable import Penny + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import Testing actor FakeManager: GatewayManager { nonisolated let client: any DiscordClient = FakeDiscordClient() nonisolated let id: UInt = 0 nonisolated let identifyPayload: Gateway.Identify = .init(token: "", intents: []) var eventContinuations = [AsyncStream.Continuation]() - - init() { } - + + init() {} + func connect() async { for continuation in eventContinuations { continuation.yield( @@ -25,9 +28,9 @@ actor FakeManager: GatewayManager { } } - func requestGuildMembersChunk(payload: Gateway.RequestGuildMembers) async { } - func updatePresence(payload: Gateway.Identify.Presence) async { } - func updateVoiceState(payload: VoiceStateUpdate) async { } + func requestGuildMembersChunk(payload: Gateway.RequestGuildMembers) async {} + func updatePresence(payload: Gateway.Identify.Presence) async {} + func updateVoiceState(payload: VoiceStateUpdate) async {} func makeEventsStream() async -> AsyncStream { AsyncStream { continuation in eventContinuations.append(continuation) @@ -36,7 +39,7 @@ actor FakeManager: GatewayManager { func makeEventsParseFailureStream() async -> AsyncStream<(any Error, ByteBuffer)> { AsyncStream { _ in } } - func disconnect() { } + func disconnect() {} func send(event: Gateway.Event) { for continuation in eventContinuations { @@ -57,7 +60,7 @@ actor FakeManager: GatewayManager { continuation.yield(event) } } - + @_disfavoredOverload func sendAndAwaitResponse( key: EventKey, @@ -72,7 +75,7 @@ actor FakeManager: GatewayManager { sourceLocation: sourceLocation ) } - + func sendAndAwaitResponse( key: EventKey, endpoint: AnyEndpoint? = nil, diff --git a/Tests/PennyTests/Fake/FakeMessageLookupRepo.swift b/Tests/PennyTests/Fake/FakeMessageLookupRepo.swift index 4cd07f04..189724d5 100644 --- a/Tests/PennyTests/Fake/FakeMessageLookupRepo.swift +++ b/Tests/PennyTests/Fake/FakeMessageLookupRepo.swift @@ -1,17 +1,18 @@ -@testable import GHHooksLambda import DiscordModels +@testable import GHHooksLambda + struct FakeMessageLookupRepo: MessageLookupRepo { static let randomMessageID: MessageSnowflake = try! .makeFake() - init() { } + init() {} func getMessageID(repoID: Int, number: Int) async throws -> String { Self.randomMessageID.rawValue } - func markAsUnavailable(repoID: Int, number: Int) async throws { } + func markAsUnavailable(repoID: Int, number: Int) async throws {} - func saveMessageID(messageID: String, repoID: Int, number: Int) async throws { } + func saveMessageID(messageID: String, repoID: Int, number: Int) async throws {} } diff --git a/Tests/PennyTests/Fake/FakePingsService.swift b/Tests/PennyTests/Fake/FakePingsService.swift index 9f0d03b7..aa471e7c 100644 --- a/Tests/PennyTests/Fake/FakePingsService.swift +++ b/Tests/PennyTests/Fake/FakePingsService.swift @@ -1,11 +1,12 @@ -@testable import Penny -import Models -import DiscordModels import AsyncHTTPClient +import DiscordModels +import Models + +@testable import Penny struct FakePingsService: AutoPingsService { - - init() { } + + init() {} private let all = S3AutoPingItems(items: [ .matches("mongodb driver"): ["432065887202181142", "950695294906007573"], @@ -24,16 +25,16 @@ struct FakePingsService: AutoPingsService { ) async throws -> Bool { false } - + func insert( _ expressions: [Expression], forDiscordID id: UserSnowflake - ) async throws { } + ) async throws {} func remove( _ expressions: [Expression], forDiscordID id: UserSnowflake - ) async throws { } + ) async throws {} func get(discordID id: UserSnowflake) async throws -> [Expression] { self.all.items.filter({ $0.value.contains(id) }).map(\.key) diff --git a/Tests/PennyTests/Fake/FakeProposalsService.swift b/Tests/PennyTests/Fake/FakeProposalsService.swift index 8431844a..b6f0b9bc 100644 --- a/Tests/PennyTests/Fake/FakeProposalsService.swift +++ b/Tests/PennyTests/Fake/FakeProposalsService.swift @@ -1,10 +1,11 @@ -@testable import Penny -import Models import EvolutionMetadataModel +import Models + +@testable import Penny struct FakeEvolutionService: EvolutionService { - init() { } + init() {} func list() async throws -> [Proposal] { TestData.proposalsUpdated diff --git a/Tests/PennyTests/Fake/FakeResponseStorage.swift b/Tests/PennyTests/Fake/FakeResponseStorage.swift index ca27eb7a..7d5f5da7 100644 --- a/Tests/PennyTests/Fake/FakeResponseStorage.swift +++ b/Tests/PennyTests/Fake/FakeResponseStorage.swift @@ -1,8 +1,9 @@ -@testable import DiscordBM -@testable import Penny import Atomics -import Testing import Shared +import Testing + +@testable import DiscordBM +@testable import Penny actor FakeResponseStorage { @@ -28,7 +29,7 @@ actor FakeResponseStorage { ) } } - + func awaitResponse( at endpoint: AnyEndpoint, expectFailure: Bool = false, @@ -43,7 +44,7 @@ actor FakeResponseStorage { ) } } - + nonisolated func expect( at endpoint: AnyEndpoint, expectFailure: Bool = false, @@ -59,7 +60,7 @@ actor FakeResponseStorage { ) } } - + private func _expect( at endpoint: any Endpoint, expectFailure: Bool = false, @@ -127,23 +128,21 @@ private struct Continuations: CustomStringConvertible { } typealias Cont = CheckedContinuation - + private var storage: [(endpoint: any Endpoint, id: UInt, continuation: Cont)] = [] /// History for debugging purposes private var history: [(endpoint: any Endpoint, id: UInt, action: Action)] = [] var description: String { - "Continuations(" + - "storage: \(storage.map({ (endpoint: $0.endpoint, id: $0.id) })), " + - "history: \(history)" + - ")" + "Continuations(" + "storage: \(storage.map({ (endpoint: $0.endpoint, id: $0.id) })), " + "history: \(history)" + + ")" } - + mutating func append(endpoint: any Endpoint, id: UInt, continuation: Cont) { storage.append((endpoint, id, continuation)) history.append((endpoint, id, .add)) } - + mutating func retrieve(endpoint: any Endpoint) -> Cont? { if let idx = storage.firstIndex(where: { $0.endpoint.testingKey == endpoint.testingKey }) { let removed = storage.remove(at: idx) @@ -153,7 +152,7 @@ private struct Continuations: CustomStringConvertible { return nil } } - + mutating func retrieve(id: UInt) -> Cont? { if let idx = storage.firstIndex(where: { $0.id == id }) { let removed = storage.remove(at: idx) @@ -167,15 +166,15 @@ private struct Continuations: CustomStringConvertible { private struct UnhandledResponses: CustomStringConvertible { private var storage: [(endpoint: any Endpoint, payload: AnyBox)] = [] - + var description: String { "\(storage.map({ (endpoint: $0, payloadType: type(of: $1.value)) }))" } - + mutating func append(endpoint: any Endpoint, payload: AnyBox) { storage.append((endpoint, payload)) } - + mutating func retrieve(endpoint: any Endpoint) -> AnyBox? { if let idx = storage.firstIndex(where: { $0.endpoint.testingKey == endpoint.testingKey }) { return storage.remove(at: idx).payload diff --git a/Tests/PennyTests/Fake/FakeSOService.swift b/Tests/PennyTests/Fake/FakeSOService.swift index ce7b36c7..ab631d3d 100644 --- a/Tests/PennyTests/Fake/FakeSOService.swift +++ b/Tests/PennyTests/Fake/FakeSOService.swift @@ -1,4 +1,5 @@ @testable import Penny + #if canImport(FoundationEssentials) import FoundationEssentials #else diff --git a/Tests/PennyTests/Fake/FakeUsersService.swift b/Tests/PennyTests/Fake/FakeUsersService.swift index 7641a527..29df3ceb 100644 --- a/Tests/PennyTests/Fake/FakeUsersService.swift +++ b/Tests/PennyTests/Fake/FakeUsersService.swift @@ -1,12 +1,13 @@ -@testable import Penny -@testable import Models import DiscordModels import Shared +@testable import Models +@testable import Penny + struct FakeUsersService: UsersService { - - init() { } - + + init() {} + func postCoin(with coinRequest: UserRequest.CoinEntryRequest) async throws -> CoinResponse { CoinResponse( sender: coinRequest.fromDiscordID, @@ -14,14 +15,14 @@ struct FakeUsersService: UsersService { newCoinCount: coinRequest.amount + .random(in: 0..<10_000) ) } - + func getCoinCount(of discordID: UserSnowflake) async throws -> Int { 2591 } - func linkGitHubID(discordID: UserSnowflake, toGitHubID githubID: String) async throws { } + func linkGitHubID(discordID: UserSnowflake, toGitHubID githubID: String) async throws {} - func unlinkGitHubID(discordID: UserSnowflake) async throws { } + func unlinkGitHubID(discordID: UserSnowflake) async throws {} func getGitHubName(of discordID: UserSnowflake) async throws -> GitHubUserResponse { .userName("fake-username") diff --git a/Tests/PennyTests/Fake/TestData.swift b/Tests/PennyTests/Fake/TestData.swift index fe172a8d..292c719c 100644 --- a/Tests/PennyTests/Fake/TestData.swift +++ b/Tests/PennyTests/Fake/TestData.swift @@ -1,13 +1,15 @@ +import DiscordModels +import EvolutionMetadataModel +import GitHubAPI + +@testable import Penny + #if canImport(FoundationEssentials) import FoundationEssentials import class Foundation.JSONSerialization #else import Foundation #endif -import DiscordModels -import EvolutionMetadataModel -import GitHubAPI -@testable import Penny enum TestData { @@ -18,7 +20,9 @@ enum TestData { let currentDirectory = fileManager.currentDirectoryPath let path = currentDirectory + "/Tests/Resources/" + name guard let data = fileManager.contents(atPath: path) else { - fatalError("Make sure you've set the custom working directory for the current scheme: https://docs.vapor.codes/getting-started/xcode/#custom-working-directory. Current working directory: \(currentDirectory)") + fatalError( + "Make sure you've set the custom working directory for the current scheme: https://docs.vapor.codes/getting-started/xcode/#custom-working-directory. Current working directory: \(currentDirectory)" + ) } return data } @@ -67,7 +71,7 @@ enum TestData { }() static func `for`(gatewayEventKey key: String) -> Data? { - return gatewayEvents[key] + gatewayEvents[key] } static func decodedFor(gatewayEventKey key: String) -> Gateway.Event { @@ -85,7 +89,7 @@ enum TestData { }() static func `for`(ghEventKey key: String) -> Data? { - return ghHooksEvents[key] + ghHooksEvents[key] } private static let ghRestOperations: [String: Data] = { @@ -97,6 +101,6 @@ enum TestData { }() static func `for`(ghRequestID key: String) -> Data? { - return ghRestOperations[key] + ghRestOperations[key] } } diff --git a/Tests/PennyTests/Tests/+XCTest.swift b/Tests/PennyTests/Tests/+XCTest.swift index e0cf9bc4..767f7c60 100644 --- a/Tests/PennyTests/Tests/+XCTest.swift +++ b/Tests/PennyTests/Tests/+XCTest.swift @@ -13,10 +13,12 @@ func expectMultilineStringsEqual( let expression2 = expression2.trimmingSuffix(while: \.isNewline) if expression1 != expression2 { /// Not using `whereSeparator: \.isNewline` so it doesn't match non `\n` characters. - let lines1 = expression1 + let lines1 = + expression1 .split(separator: "\n", omittingEmptySubsequences: false) .map { $0.trimmingSuffix(while: \.isWhitespace) } - let lines2 = expression2 + let lines2 = + expression2 .split(separator: "\n", omittingEmptySubsequences: false) .map { $0.trimmingSuffix(while: \.isWhitespace) } diff --git a/Tests/PennyTests/Tests/CoinHandlerTests.swift b/Tests/PennyTests/Tests/CoinHandlerTests.swift index 8fac337d..90c3f206 100644 --- a/Tests/PennyTests/Tests/CoinHandlerTests.swift +++ b/Tests/PennyTests/Tests/CoinHandlerTests.swift @@ -1,11 +1,13 @@ -@testable import Penny import DiscordBM +import Testing + +@testable import Penny + #if canImport(FoundationEssentials) import FoundationEssentials #else import Foundation #endif -import Testing @Suite struct CoinHandlerTests { @@ -21,8 +23,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - \(user1) thanks! - """, + \(user1) thanks! + """, mentionedUsers: [user1Snowflake] ) let users = coinHandler.findUsers() @@ -32,8 +34,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - \(user1) thank you! - """, + \(user1) thank you! + """, mentionedUsers: [user1Snowflake] ) let users = coinHandler.findUsers() @@ -46,8 +48,8 @@ struct CoinHandlerTests { func userAtTheBeginningAndCoinSignAtTheEnd() throws { let coinHandler = CoinFinder( text: """ - \(user1) xxxx xxxx \(user2) xxxx thank you so MUCH! - """, + \(user1) xxxx xxxx \(user2) xxxx thank you so MUCH! + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -59,8 +61,8 @@ struct CoinHandlerTests { func userAtTheEndAndCoinSignAtTheBeginning() throws { let coinHandler = CoinFinder( text: """ - thaNk you xxxx xxxx \(user2) xxxx xxxx \(user1)! - """, + thaNk you xxxx xxxx \(user2) xxxx xxxx \(user1)! + """, mentionedUsers: [user2Snowflake, user1Snowflake] ) let users = coinHandler.findUsers() @@ -74,8 +76,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx \(user1) 🪙 - """, + xxxx \(user1) 🪙 + """, mentionedUsers: [user1Snowflake] ) let users = coinHandler.findUsers() @@ -85,8 +87,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx \(user1) \(user2) 🚀 - """, + xxxx \(user1) \(user2) 🚀 + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -96,8 +98,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx ++++++++++++++++++++++++++++++ \(user1) - """, + xxxx ++++++++++++++++++++++++++++++ \(user1) + """, mentionedUsers: [user1Snowflake] ) let users = coinHandler.findUsers() @@ -107,8 +109,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx Thanks for your help \(user1) \(user2) - """, + xxxx Thanks for your help \(user1) \(user2) + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -123,8 +125,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx \(user1) thanks a bunch! xxx - """, + xxxx \(user1) thanks a bunch! xxx + """, mentionedUsers: [user1Snowflake] ) let users = coinHandler.findUsers() @@ -134,8 +136,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx \(user1) \(user2) thank you A bunch! xxx - """, + xxxx \(user1) \(user2) thank you A bunch! xxx + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -145,8 +147,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx thank you. \(user1) xxx - """, + xxxx thank you. \(user1) xxx + """, mentionedUsers: [user1Snowflake] ) let users = coinHandler.findUsers() @@ -156,8 +158,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx thank you!\(user1) \(user2) xxx - """, + xxxx thank you!\(user1) \(user2) xxx + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -167,8 +169,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx thanks for the help\(user1) \(user2) xxx - """, + xxxx thanks for the help\(user1) \(user2) xxx + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -182,8 +184,8 @@ struct CoinHandlerTests { /// `+` is not a coin sign, unlike `++`/`+++`/`++++`... . let coinHandler = CoinFinder( text: """ - \(user1) + - """, + \(user1) + + """, mentionedUsers: [user1Snowflake] ) let users = coinHandler.findUsers() @@ -194,8 +196,8 @@ struct CoinHandlerTests { /// `++` is too far. let coinHandler = CoinFinder( text: """ - \(user1) xxxx ++ xxxx - """, + \(user1) xxxx ++ xxxx + """, mentionedUsers: [user1Snowflake] ) let users = coinHandler.findUsers() @@ -216,8 +218,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx \(user1) thanks for the xxxx xxxx xxxx, xxxx xxxx \(user2) \(Constants.ServerEmojis.coin.emoji) xxxx - """, + xxxx \(user1) thanks for the xxxx xxxx xxxx, xxxx xxxx \(user2) \(Constants.ServerEmojis.coin.emoji) xxxx + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -228,8 +230,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - \(user1) thanks xxxx \(user2) & 🙌🏽 xxxx xxxx - """, + \(user1) thanks xxxx \(user2) & 🙌🏽 xxxx xxxx + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -240,8 +242,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - thanks a bunch! \(user1) xxxx thanks a LOT \(user2) xxxx xxxx - """, + thanks a bunch! \(user1) xxxx thanks a LOT \(user2) xxxx xxxx + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -252,8 +254,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx xxxx \(user1) thanks xxxx xxxx \(user2) thanks for the help! - """, + xxxx xxxx \(user1) thanks xxxx xxxx \(user2) thanks for the help! + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -264,8 +266,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx \(user1)thanks xxxx \(user2) += 1 xxxx - """, + xxxx \(user1)thanks xxxx \(user2) += 1 xxxx + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -276,8 +278,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - \(user1)THANK YOU! \(user2) and 👍🏼 - """, + \(user1)THANK YOU! \(user2) and 👍🏼 + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -288,8 +290,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - thanks! \(user1) ++ , \(user2) - """, + thanks! \(user1) ++ , \(user2) + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -303,8 +305,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - thanks! - """, + thanks! + """, replied: user1Snowflake ) let users = coinHandler.findUsers() @@ -315,8 +317,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - Thanks \(user1) xxxx xxxx - """, + Thanks \(user1) xxxx xxxx + """, replied: user1Snowflake, mentionedUsers: [user1Snowflake] ) @@ -328,8 +330,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx xxxx ++ - """, + xxxx xxxx ++ + """, replied: user1Snowflake ) let users = coinHandler.findUsers() @@ -340,9 +342,9 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx xxxx - xxxx xxxx 🪙 - """, + xxxx xxxx + xxxx xxxx 🪙 + """, replied: user1Snowflake ) let users = coinHandler.findUsers() @@ -354,8 +356,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - thanks! - """, + thanks! + """, replied: user1Snowflake, excludedUsers: [user1Snowflake] ) @@ -371,8 +373,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - \(user1) thanks! - """, + \(user1) thanks! + """, mentionedUsers: [] ) let users = coinHandler.findUsers() @@ -382,8 +384,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx xxxx \(user1) thanks xxxx xxxx \(user2) 🙌🏼 - """, + xxxx xxxx \(user1) thanks xxxx xxxx \(user2) 🙌🏼 + """, mentionedUsers: [] ) let users = coinHandler.findUsers() @@ -396,8 +398,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - \(user1) thANKs! - """, + \(user1) thANKs! + """, excludedUsers: [user1Snowflake] ) let users = coinHandler.findUsers() @@ -407,8 +409,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx xxxx \(user1) thanks xxxx xxxx \(user2) 👍🏼 - """, + xxxx xxxx \(user1) thanks xxxx xxxx \(user2) 👍🏼 + """, excludedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -421,8 +423,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - \(user1) thank you! \(user1) +++ - """, + \(user1) thank you! \(user1) +++ + """, mentionedUsers: [user1Snowflake] ) let users = coinHandler.findUsers() @@ -432,8 +434,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - \(user1) \(user1) xxxx +++++ - """, + \(user1) \(user1) xxxx +++++ + """, mentionedUsers: [user1Snowflake] ) let users = coinHandler.findUsers() @@ -443,8 +445,8 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - xxxx xxxx \(user1) thanks xxxx \(user1) 👌🏻 xxxx - """, + xxxx xxxx \(user1) thanks xxxx \(user1) 👌🏻 xxxx + """, mentionedUsers: [user1Snowflake] ) let users = coinHandler.findUsers() @@ -457,9 +459,9 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - \(user1) ThAnK yOu! - \(user2) ++ - """, + \(user1) ThAnK yOu! + \(user2) ++ + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -469,9 +471,9 @@ struct CoinHandlerTests { do { let coinHandler = CoinFinder( text: """ - \(user1) xxxx xxxx thanks! - xxxx \(user2) thanks xxxx - """, + \(user1) xxxx xxxx thanks! + xxxx \(user2) thanks xxxx + """, mentionedUsers: [user1Snowflake, user2Snowflake] ) let users = coinHandler.findUsers() @@ -530,8 +532,8 @@ struct CoinHandlerTests { } } -private extension CoinFinder { - init( +extension CoinFinder { + fileprivate init( text: String, replied repliedUser: UserSnowflake? = nil, mentionedUsers: [UserSnowflake] = [], diff --git a/Tests/PennyTests/Tests/GHHooksTests.swift b/Tests/PennyTests/Tests/GHHooksTests.swift index da29f795..38cace99 100644 --- a/Tests/PennyTests/Tests/GHHooksTests.swift +++ b/Tests/PennyTests/Tests/GHHooksTests.swift @@ -1,19 +1,20 @@ -@testable import GHHooksLambda -@testable import Logging import AsyncHTTPClient -import GitHubAPI -import SotoCore -import DiscordModels import DiscordHTTP -import OpenAPIRuntime -import Rendering -import SwiftSemver -import Shared +import DiscordModels import Foundation +import GitHubAPI import Markdown import NIOPosix +import OpenAPIRuntime +import Rendering +import Shared +import SotoCore +import SwiftSemver import Testing +@testable import GHHooksLambda +@testable import Logging + extension SerializationNamespace { @Suite final class GHHooksTests { @@ -230,7 +231,8 @@ extension SerializationNamespace.GHHooksTests { @Test func markdownFormatting() async throws { do { - let scalars_206 = "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders is not included)" + let scalars_206 = + "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders is not included)" let formatted = scalars_206.formatMarkdown( maxVisualLength: 256, hardLimit: 2_048, @@ -240,7 +242,8 @@ extension SerializationNamespace.GHHooksTests { } do { - let scalars_206 = "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders is not included)" + let scalars_206 = + "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders is not included)" let formatted = scalars_206.formatMarkdown( maxVisualLength: 200, hardLimit: 2_048, @@ -250,51 +253,59 @@ extension SerializationNamespace.GHHooksTests { } do { - let scalars_206 = "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders is not included)" + let scalars_206 = + "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders is not included)" let formatted = scalars_206.formatMarkdown( maxVisualLength: 200, hardLimit: 203, trailingTextMinLength: 64 ) - expectMultilineStringsEqual(formatted, """ - Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders is not inclu\(dots) - """) + expectMultilineStringsEqual( + formatted, + """ + Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders is not inclu\(dots) + """ + ) } do { let text = """ - ``` - Hello, how are you? - ``` - """ + ``` + Hello, how are you? + ``` + """ let formatted = text.formatMarkdown( maxVisualLength: 10, hardLimit: 200, trailingTextMinLength: 64 ) - expectMultilineStringsEqual(formatted, """ - ``` - Hello, ho\(dots) - ``` - """) + expectMultilineStringsEqual( + formatted, + """ + ``` + Hello, ho\(dots) + ``` + """ + ) } /// Remove html and images + length limits. do { - let scalars_206 = "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders is not included)" + let scalars_206 = + "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders is not included)" let text = """ - - - ![test image](https://github.com/vapor/something/9j13e91j3e9j03jr0j230dm02) - - - - \(scalars_206) - - Vapor_docs_dark - - Custom coders specified for a single `JWTSigner` affect token parsing and signing performed only by that signer. Custom coders specified on a `JWTSigners` object will become the default coders for all signers added to that object, unless a given signer already specifies its own custom coders. - """ + + + ![test image](https://github.com/vapor/something/9j13e91j3e9j03jr0j230dm02) + + + + \(scalars_206) + + Vapor_docs_dark + + Custom coders specified for a single `JWTSigner` affect token parsing and signing performed only by that signer. Custom coders specified on a `JWTSigners` object will become the default coders for all signers added to that object, unless a given signer already specifies its own custom coders. + """ let formatted = text.formatMarkdown( maxVisualLength: 256, @@ -306,20 +317,21 @@ extension SerializationNamespace.GHHooksTests { /// Remove html and images + length limits. do { - let scalars_200 = "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders is not int" + let scalars_200 = + "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders is not int" let text = """ - - - ![test image](https://github.com/vapor/something/9j13e91j3e9j03jr0j230dm02) - - - - \(scalars_200) - - Vapor_docs_dark - - Custom coders specified for a single `JWTSigner` affect token parsing and signing performed only by that signer. Custom coders specified on a `JWTSigners` object will become the default coders for all signers added to that object, unless a given signer already specifies its own custom coders. - """ + + + ![test image](https://github.com/vapor/something/9j13e91j3e9j03jr0j230dm02) + + + + \(scalars_200) + + Vapor_docs_dark + + Custom coders specified for a single `JWTSigner` affect token parsing and signing performed only by that signer. Custom coders specified on a `JWTSigners` object will become the default coders for all signers added to that object, unless a given signer already specifies its own custom coders. + """ let formatted = text.formatMarkdown( maxVisualLength: 256, @@ -327,29 +339,33 @@ extension SerializationNamespace.GHHooksTests { trailingTextMinLength: 64 ) let scalars_66 = "Custom coders specified for a single `JWTSigner` affect token par…" - expectMultilineStringsEqual(formatted, scalars_200 + """ - - - \(scalars_66) - """) + expectMultilineStringsEqual( + formatted, + scalars_200 + """ + + + \(scalars_66) + """ + ) } /// Remove html and images + length limits. do { - let scalars_190 = "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders)" + let scalars_190 = + "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders)" let text = """ - - - ![test image](https://github.com/vapor/something/9j13e91j3e9j03jr0j230dm02) - - - - \(scalars_190) - - Vapor_docs_dark - - Custom coders specified for a single `JWTSigner` affect token parsing and signing performed only by that signer. Custom coders specified on a `JWTSigners` object will become the default coders for all signers added to that object, unless a given signer already specifies its own custom coders. - """ + + + ![test image](https://github.com/vapor/something/9j13e91j3e9j03jr0j230dm02) + + + + \(scalars_190) + + Vapor_docs_dark + + Custom coders specified for a single `JWTSigner` affect token parsing and signing performed only by that signer. Custom coders specified on a `JWTSigners` object will become the default coders for all signers added to that object, unless a given signer already specifies its own custom coders. + """ let formatted = text.formatMarkdown( maxVisualLength: 256, @@ -357,85 +373,95 @@ extension SerializationNamespace.GHHooksTests { trailingTextMinLength: 64 ) let scalars_76 = "Custom coders specified for a single `JWTSigner` affect token parsing and s…" - expectMultilineStringsEqual(formatted, scalars_190 + """ - - - \(scalars_76) - """) + expectMultilineStringsEqual( + formatted, + scalars_190 + """ + + + \(scalars_76) + """ + ) } /// Remove html and images + length limits. do { - let scalars_aLot = "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders) Custom coders specified for a single `JWTSigner` affect token parsing and signing performed only by that signer. Custom coders specified" + let scalars_aLot = + "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders) Custom coders specified for a single `JWTSigner` affect token parsing and signing performed only by that signer. Custom coders specified" let text = """ - - - ![test image](https://github.com/vapor/something/9j13e91j3e9j03jr0j230dm02) - - - - \(scalars_aLot) - - Vapor_docs_dark - - on a `JWTSigners` object will become the default coders for all signers added to that object, unless a given signer already specifies its own custom coders. - """ + + + ![test image](https://github.com/vapor/something/9j13e91j3e9j03jr0j230dm02) + + + + \(scalars_aLot) + + Vapor_docs_dark + + on a `JWTSigners` object will become the default coders for all signers added to that object, unless a given signer already specifies its own custom coders. + """ let formatted = text.formatMarkdown( maxVisualLength: 256, hardLimit: 2_048, trailingTextMinLength: 64 ) - expectMultilineStringsEqual(formatted, "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders) Custom coders specified for a single `JWTSigner` affect token parsing and …") + expectMultilineStringsEqual( + formatted, + "Add new, fully source-compatible APIs to `JWTSigners` and `JWTSigner` which allow specifying custom `JSONEncoder` and `JSONDecoder` instances. (The ability to use non-Foundation JSON coders) Custom coders specified for a single `JWTSigner` affect token parsing and …" + ) } /// Remove empty links do { let text = """ - Bumps [sass](https://github.com/sass/dart-sass) from 1.63.6 to 1.64.0. - - [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=sass&package-manager=npm_and_yarn&previous-version=1.63.6&new-version=1.64.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) - - Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. - - [//]: # (dependabot-automerge-start) - [//]: # (dependabot-automerge-end) - - --- - -
- Dependabot commands and options -
- - You can trigger Dependabot actions by commenting on this PR: - """ + Bumps [sass](https://github.com/sass/dart-sass) from 1.63.6 to 1.64.0. + + [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=sass&package-manager=npm_and_yarn&previous-version=1.63.6&new-version=1.64.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) + + Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. + + [//]: # (dependabot-automerge-start) + [//]: # (dependabot-automerge-end) + + --- + +
+ Dependabot commands and options +
+ + You can trigger Dependabot actions by commenting on this PR: + """ let formatted = text.formatMarkdown( maxVisualLength: 256, hardLimit: 2_048, trailingTextMinLength: 96 ) - expectMultilineStringsEqual(formatted, """ - Bumps [sass](https://github.com/sass/dart-sass) from 1.63.6 to 1.64.0. - - Dependabot will resolve any conflicts with this PR as long as you don’t alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. - \(dots) - """) + expectMultilineStringsEqual( + formatted, + """ + Bumps [sass](https://github.com/sass/dart-sass) from 1.63.6 to 1.64.0. + + Dependabot will resolve any conflicts with this PR as long as you don’t alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. + \(dots) + """ + ) } do { let text = """ - ### Describe the bug - - I've got a custom `Codable` type that throws an error when decoding... because it's being asked to decode an empty string, rather than being skipped because I've got `T?` rather than `T` as the type in my `Content`. - - ### To Reproduce - - 1. Declare some custom `Codable` type that throws an error if told to decode from an empty string. - 2. Declare some custom `Content` struct that has an `Optional` of your custom type as a parameter. - 3. Have a browser submit a request that includes `yourThing: ` in the body. (Doable in Safari by creating an HTML form, giving it a date input with the right `name`, and then... not selecting a date before hitting submit) - 4. Observe thrown error. - """ + ### Describe the bug + + I've got a custom `Codable` type that throws an error when decoding... because it's being asked to decode an empty string, rather than being skipped because I've got `T?` rather than `T` as the type in my `Content`. + + ### To Reproduce + + 1. Declare some custom `Codable` type that throws an error if told to decode from an empty string. + 2. Declare some custom `Content` struct that has an `Optional` of your custom type as a parameter. + 3. Have a browser submit a request that includes `yourThing: ` in the body. (Doable in Safari by creating an HTML form, giving it a date input with the right `name`, and then... not selecting a date before hitting submit) + 4. Observe thrown error. + """ let formatted = text.formatMarkdown( maxVisualLength: 256, @@ -443,38 +469,41 @@ extension SerializationNamespace.GHHooksTests { trailingTextMinLength: 96 ) // TODO: Handle this situation better os we don't end up with an empty list item. - expectMultilineStringsEqual(formatted, """ - ### Describe the bug - - I’ve got a custom `Codable` type that throws an error when decoding… because it’s being asked to decode an empty string, rather than being skipped because I’ve got `T?` rather than `T` as the type in my `Content`. - - ### To Reproduce - - 1. - \(dots) - """) + expectMultilineStringsEqual( + formatted, + """ + ### Describe the bug + + I’ve got a custom `Codable` type that throws an error when decoding… because it’s being asked to decode an empty string, rather than being skipped because I’ve got `T?` rather than `T` as the type in my `Content`. + + ### To Reproduce + + 1. + \(dots) + """ + ) } do { let text = """ - ### Describe the bug - - White text on white background is not readable. - - ### To Reproduce - - Go to [https://api.vapor.codes/fluent/documentation/fluent/](https://api.vapor.codes/fluent/documentation/fluent/) - - ### Expected behavior - - Expect some contrast between the text and the background. - - ### Environment - - * Vapor Framework version: current [https://api.vapor.codes/](https://api.vapor.codes/) website - * Vapor Toolbox version: N/A - * OS version: N/A - """ + ### Describe the bug + + White text on white background is not readable. + + ### To Reproduce + + Go to [https://api.vapor.codes/fluent/documentation/fluent/](https://api.vapor.codes/fluent/documentation/fluent/) + + ### Expected behavior + + Expect some contrast between the text and the background. + + ### Environment + + * Vapor Framework version: current [https://api.vapor.codes/](https://api.vapor.codes/) website + * Vapor Toolbox version: N/A + * OS version: N/A + """ let formatted = text.formatMarkdown( maxVisualLength: 256, @@ -482,28 +511,31 @@ extension SerializationNamespace.GHHooksTests { trailingTextMinLength: 96 ) - expectMultilineStringsEqual(formatted, """ - ### Describe the bug - - White text on white background is not readable. - - ### To Reproduce - - Go to - - ### Expected behavior - - Expect some contrast between the text and the background. - \(dots) - """) + expectMultilineStringsEqual( + formatted, + """ + ### Describe the bug + + White text on white background is not readable. + + ### To Reproduce + + Go to + + ### Expected behavior + + Expect some contrast between the text and the background. + \(dots) + """ + ) } do { let text = """ - Final stage of Vapor's `Sendable` journey as `Request` is now `Sendable`. - - There should be no more `Sendable` warnings in Vapor, even with complete concurrency checking turned on. - """ + Final stage of Vapor's `Sendable` journey as `Request` is now `Sendable`. + + There should be no more `Sendable` warnings in Vapor, even with complete concurrency checking turned on. + """ let formatted = text.formatMarkdown( maxVisualLength: 256, @@ -511,20 +543,23 @@ extension SerializationNamespace.GHHooksTests { trailingTextMinLength: 128 ) - expectMultilineStringsEqual(formatted, """ - Final stage of Vapor’s `Sendable` journey as `Request` is now `Sendable`. - - There should be no more `Sendable` warnings in Vapor, even with complete concurrency checking turned on. - """) + expectMultilineStringsEqual( + formatted, + """ + Final stage of Vapor’s `Sendable` journey as `Request` is now `Sendable`. + + There should be no more `Sendable` warnings in Vapor, even with complete concurrency checking turned on. + """ + ) } /// Test modifying GitHub links do { let text = """ - https://github.com/swift-server/swiftly/pull/9Final stage of Vapor’s `Sendable` journey as `Request` is now [#40](https://github.com/swift-server/swiftly/pull/9) `Sendable` at https://github.com/swift-server/swiftly/pull/9. - - There should https://github.com/vapor-bad-link/issues/44 be no more `Sendable` warnings in Vapor https://github.com/vapor/penny-bot/issues/98, even with complete concurrency checking turned on.](https://github.com/vapor/penny-bot/issues/98 - """ + https://github.com/swift-server/swiftly/pull/9Final stage of Vapor’s `Sendable` journey as `Request` is now [#40](https://github.com/swift-server/swiftly/pull/9) `Sendable` at https://github.com/swift-server/swiftly/pull/9. + + There should https://github.com/vapor-bad-link/issues/44 be no more `Sendable` warnings in Vapor https://github.com/vapor/penny-bot/issues/98, even with complete concurrency checking turned on.](https://github.com/vapor/penny-bot/issues/98 + """ let formatted = text.formatMarkdown( maxVisualLength: 512, @@ -532,11 +567,14 @@ extension SerializationNamespace.GHHooksTests { trailingTextMinLength: 128 ) - expectMultilineStringsEqual(formatted, """ - [swift-server/swiftly#9](https://github.com/swift-server/swiftly/pull/9)Final stage of Vapor’s `Sendable` journey as `Request` is now [#40](https://github.com/swift-server/swiftly/pull/9) `Sendable` at [swift-server/swiftly#9](https://github.com/swift-server/swiftly/pull/9). - - There should https://github.com/vapor-bad-link/issues/44 be no more `Sendable` warnings in Vapor [vapor/penny-bot#98](https://github.com/vapor/penny-bot/issues/98), even with complete concurrency checking turned on.]([vapor/penny-bot#98](https://github.com/vapor/penny-bot/issues/98) - """) + expectMultilineStringsEqual( + formatted, + """ + [swift-server/swiftly#9](https://github.com/swift-server/swiftly/pull/9)Final stage of Vapor’s `Sendable` journey as `Request` is now [#40](https://github.com/swift-server/swiftly/pull/9) `Sendable` at [swift-server/swiftly#9](https://github.com/swift-server/swiftly/pull/9). + + There should https://github.com/vapor-bad-link/issues/44 be no more `Sendable` warnings in Vapor [vapor/penny-bot#98](https://github.com/vapor/penny-bot/issues/98), even with complete concurrency checking turned on.]([vapor/penny-bot#98](https://github.com/vapor/penny-bot/issues/98) + """ + ) } } @@ -544,56 +582,59 @@ extension SerializationNamespace.GHHooksTests { func headingFinder() async throws { /// Goes into the `What's Changed` heading. let text = """ - ## What's Changed - * Use HTTP Client from vapor and update APNS library, add multiple configs by @kylebrowning in https://github.com/vapor/apns/pull/46 - * Update package to use Alpha 5 by @kylebrowning in https://github.com/vapor/apns/pull/48 - * Add support for new version of APNSwift by @Gerzer in https://github.com/vapor/apns/pull/51 - * Update to latest APNS by @kylebrowning in https://github.com/vapor/apns/pull/52 - - ## New Contributors - * @Gerzer made their first contribution in https://github.com/vapor/apns/pull/51 - - **Full Changelog**: https://github.com/vapor/apns/compare/3.0.0...4.0.0 - """ + ## What's Changed + * Use HTTP Client from vapor and update APNS library, add multiple configs by @kylebrowning in https://github.com/vapor/apns/pull/46 + * Update package to use Alpha 5 by @kylebrowning in https://github.com/vapor/apns/pull/48 + * Add support for new version of APNSwift by @Gerzer in https://github.com/vapor/apns/pull/51 + * Update to latest APNS by @kylebrowning in https://github.com/vapor/apns/pull/52 + + ## New Contributors + * @Gerzer made their first contribution in https://github.com/vapor/apns/pull/51 + + **Full Changelog**: https://github.com/vapor/apns/compare/3.0.0...4.0.0 + """ let contentsOfHeading = try #require(text.contentsOfHeading(named: "What's Changed")) - expectMultilineStringsEqual(contentsOfHeading, """ - - Use HTTP Client from vapor and update APNS library, add multiple configs by @kylebrowning in https://github.com/vapor/apns/pull/46 - - Update package to use Alpha 5 by @kylebrowning in https://github.com/vapor/apns/pull/48 - - Add support for new version of APNSwift by @Gerzer in https://github.com/vapor/apns/pull/51 - - Update to latest APNS by @kylebrowning in https://github.com/vapor/apns/pull/52 - """) + expectMultilineStringsEqual( + contentsOfHeading, + """ + - Use HTTP Client from vapor and update APNS library, add multiple configs by @kylebrowning in https://github.com/vapor/apns/pull/46 + - Update package to use Alpha 5 by @kylebrowning in https://github.com/vapor/apns/pull/48 + - Add support for new version of APNSwift by @Gerzer in https://github.com/vapor/apns/pull/51 + - Update to latest APNS by @kylebrowning in https://github.com/vapor/apns/pull/52 + """ + ) } @Test func parseCodeOwners() async throws { let text = """ - # This is a comment. - # Each line is a file pattern followed by one or more owners. - - # These owners will be the default owners for everything in - * @global-owner1 @global-owner2 - - *.js @js-owner #This is an inline comment. - - *.go docs@example.com - - *.txt @octo-org/octocats - /build/logs/ @doctocat - - # The `docs/*` pattern will match files like - # `docs/getting-started.md` but not further nested files like - # `docs/build-app/troubleshooting.md`. - docs/* docs@example.com - - apps/ @octocat - /docs/ @doctocat - /scripts/ @doctocat @octocat - **/logs @octocat - - /apps/ @octocat - /apps/github - """ + # This is a comment. + # Each line is a file pattern followed by one or more owners. + + # These owners will be the default owners for everything in + * @global-owner1 @global-owner2 + + *.js @js-owner #This is an inline comment. + + *.go docs@example.com + + *.txt @octo-org/octocats + /build/logs/ @doctocat + + # The `docs/*` pattern will match files like + # `docs/getting-started.md` but not further nested files like + # `docs/build-app/troubleshooting.md`. + docs/* docs@example.com + + apps/ @octocat + /docs/ @doctocat + /scripts/ @doctocat @octocat + **/logs @octocat + + /apps/ @octocat + /apps/github + """ let context = try makeContext( eventName: .pull_request, eventKey: "pr1" @@ -603,7 +644,10 @@ extension SerializationNamespace.GHHooksTests { pr: context.event.pull_request!, number: context.event.number! ) - let expected = ["docs@example.com", "doctocat", "global-owner1", "global-owner2", "js-owner", "octo-org/octocats", "octocat"] + let expected = [ + "docs@example.com", "doctocat", "global-owner1", "global-owner2", "js-owner", "octo-org/octocats", + "octocat", + ] #expect(handler.context.requester.parseCodeOwners(text: text).value.sorted() == expected) } @@ -714,7 +758,10 @@ extension SerializationNamespace.GHHooksTests { try await handleEvent( key: "pr7", eventName: .pull_request, - expect: .error(description: "DiscordHTTPError.emptyBody(DiscordHTTPResponse(host: discord.com, status: 200 OK, version: HTTP/2.0, headers: [], body: nil))") + expect: .error( + description: + "DiscordHTTPError.emptyBody(DiscordHTTPResponse(host: discord.com, status: 200 OK, version: HTTP/2.0, headers: [], body: nil))" + ) ) try await handleEvent( @@ -892,7 +939,8 @@ extension SerializationNamespace.GHHooksTests { } } catch { if case let .error(description) = expect, - description == "\(error)" { + description == "\(error)" + { /// Expected error return } diff --git a/Tests/PennyTests/Tests/GatewayProcessingTests.swift b/Tests/PennyTests/Tests/GatewayProcessingTests.swift index e69335eb..47d09c84 100644 --- a/Tests/PennyTests/Tests/GatewayProcessingTests.swift +++ b/Tests/PennyTests/Tests/GatewayProcessingTests.swift @@ -1,14 +1,15 @@ -@testable import Penny -import Synchronization -@testable import DiscordModels -@testable import Logging -import Foundation import DiscordGateway -import ServiceLifecycle +import Foundation import Models +import ServiceLifecycle import Shared +import Synchronization import Testing +@testable import DiscordModels +@testable import Logging +@testable import Penny + extension SerializationNamespace { @Suite final class GatewayProcessingTests: Sendable { @@ -104,28 +105,28 @@ extension SerializationNamespace.GatewayProcessingTests { @Test func commandsRegisterOnStartup() async throws { await CommandsManager(context: context).registerCommands() - + let response = await responseStorage.awaitResponse( at: .bulkSetApplicationCommands(applicationId: "11111111") ).value - + let commandNames = SlashCommand.allCases.map(\.rawValue) let commands = try #require(response as? [Payloads.ApplicationCommandCreate]) #expect(commands.map(\.name).sorted() == commandNames.sorted()) } - + @Test func messageHandler() async throws { let response = try await manager.sendAndAwaitResponse( key: .thanksMessage, as: Payloads.CreateMessage.self ) - + let description = try #require(response.embeds?.first?.description) #expect(description.hasPrefix("<@950695294906007573> now has "), "\(description)") #expect(description.hasSuffix(" \(Constants.ServerEmojis.coin.emoji)!"), "\(description)") } - + @Test func reactionHandler() async throws { do { @@ -133,17 +134,20 @@ extension SerializationNamespace.GatewayProcessingTests { key: .thanksReaction, as: Payloads.CreateMessage.self ) - + let description = try #require(response.embeds?.first?.description) - #expect(description.hasPrefix( - "Mahdi BM gave a \(Constants.ServerEmojis.coin.emoji) to <@1030118727418646629>, who now has " - ), "\(description)") + #expect( + description.hasPrefix( + "Mahdi BM gave a \(Constants.ServerEmojis.coin.emoji) to <@1030118727418646629>, who now has " + ), + "\(description)" + ) #expect(description.hasSuffix(" \(Constants.ServerEmojis.coin.emoji)!")) } - + // For consistency with `testReactionHandler2()` try await Task.sleep(for: .seconds(1)) - + // The second thanks message should just edit the last one, because the // receiver is the same person and the channel is the same channel. do { @@ -151,11 +155,13 @@ extension SerializationNamespace.GatewayProcessingTests { key: .thanksReaction2, as: Payloads.EditMessage.self ) - + let description = try #require(response.embeds?.first?.description) - #expect(description.hasPrefix( - "Mahdi BM & 0xTim gave 2 \(Constants.ServerEmojis.coin.emoji) to <@1030118727418646629>, who now has " - )) + #expect( + description.hasPrefix( + "Mahdi BM & 0xTim gave 2 \(Constants.ServerEmojis.coin.emoji) to <@1030118727418646629>, who now has " + ) + ) #expect(description.hasSuffix(" \(Constants.ServerEmojis.coin.emoji)!")) } } @@ -167,63 +173,82 @@ extension SerializationNamespace.GatewayProcessingTests { key: .thanksReaction3, as: Payloads.CreateMessage.self ) - + let description = try #require(response.embeds?.first?.description) - #expect(description.hasPrefix(""" - 0xTim gave a \(Constants.ServerEmojis.coin.emoji) to <@1030118727418646629>, who now has - """), "\(description)") - #expect(description.hasSuffix(""" - \(Constants.ServerEmojis.coin.emoji)! (https://discord.com/channels/431917998102675485/431926479752921098/1031112115928442034) - """), "\(description)") - } - + #expect( + description.hasPrefix( + """ + 0xTim gave a \(Constants.ServerEmojis.coin.emoji) to <@1030118727418646629>, who now has + """ + ), + "\(description)" + ) + #expect( + description.hasSuffix( + """ + \(Constants.ServerEmojis.coin.emoji)! (https://discord.com/channels/431917998102675485/431926479752921098/1031112115928442034) + """ + ), + "\(description)" + ) + } + // We need to wait a little bit to make sure Discord's response // is decoded and is used-in/added-to the `ReactionCache`. // This would happen in a real-world situation too. try await Task.sleep(for: .seconds(1)) - + // The second thanks message should edit the last one. do { let response = try await manager.sendAndAwaitResponse( key: .thanksReaction4, as: Payloads.EditMessage.self ) - + let description = try #require(response.embeds?.first?.description) - #expect(description.hasPrefix(""" - 0xTim & Mahdi BM gave 2 \(Constants.ServerEmojis.coin.emoji) to <@1030118727418646629>, who now has - """), "\(description)") - #expect(description.hasSuffix(""" - \(Constants.ServerEmojis.coin.emoji)! (https://discord.com/channels/431917998102675485/431926479752921098/1031112115928442034) - """)) + #expect( + description.hasPrefix( + """ + 0xTim & Mahdi BM gave 2 \(Constants.ServerEmojis.coin.emoji) to <@1030118727418646629>, who now has + """ + ), + "\(description)" + ) + #expect( + description.hasSuffix( + """ + \(Constants.ServerEmojis.coin.emoji)! (https://discord.com/channels/431917998102675485/431926479752921098/1031112115928442034) + """ + ) + ) } } - + @Test func respondsInThanksChannelWhenDoesNotHavePermission() async throws { let response = try await manager.sendAndAwaitResponse( key: .thanksMessage2, as: Payloads.CreateMessage.self ) - + let description = try #require(response.embeds?.first?.description) - + #expect(description.hasPrefix("<@950695294906007573> now has "), "\(description)") let expectedSuffix = """ - \(Constants.ServerEmojis.coin.emoji)! (https://discord.com/channels/431917998102675485/431917998102675487/1029637770005717042) - """ + \(Constants.ServerEmojis.coin.emoji)! (https://discord.com/channels/431917998102675485/431917998102675487/1029637770005717042) + """ #expect(description.hasSuffix(expectedSuffix), "\(description)") } - + @Test func botStateManagerReceivesSignal() async throws { let response = try await manager.sendAndAwaitResponse( key: .stopRespondingToMessages, as: Payloads.CreateMessage.self ) - + #expect(response.content?.count ?? -1 > 20) - + // Wait to make sure BotBotStateManager.shared has had enough time to process try await Task.sleep(for: .milliseconds(800)) let testEvent = Gateway.Event(opcode: .dispatch) @@ -231,7 +256,7 @@ extension SerializationNamespace.GatewayProcessingTests { let canRespond = await context.botStateManager.canRespond(to: testEvent) #expect(canRespond == false) } - + // After 3 seconds, the state manager should allow responses again, because // `BotBotStateManager.shared.disableDuration` has already been passed try await Task.sleep(for: .milliseconds(2600)) @@ -240,7 +265,7 @@ extension SerializationNamespace.GatewayProcessingTests { #expect(canRespond == true) } } - + @Test func autoPings() async throws { let event = EventKey.autoPingsTrigger @@ -253,14 +278,14 @@ extension SerializationNamespace.GatewayProcessingTests { responseStorage.awaitResponse(at: responseEndpoint).value, responseStorage.awaitResponse(at: responseEndpoint).value ) - + let recipients: [UserSnowflake] = ["950695294906007573", "432065887202181142"] - + do { let dmPayload = try #require(createDM1 as? Payloads.CreateDM, "\(createDM1)") #expect(recipients.contains(dmPayload.recipient_id), "\(dmPayload.recipient_id)") } - + let dmMessage1 = try #require(sendDM1 as? Payloads.CreateMessage, "\(sendDM1)") let message1 = try #require(dmMessage1.embeds?.first?.description) #expect(message1.hasPrefix("There is a new message"), "\(message1)") @@ -273,7 +298,7 @@ extension SerializationNamespace.GatewayProcessingTests { let dmPayload = try #require(createDM2 as? Payloads.CreateDM, "\(createDM1)") #expect(recipients.contains(dmPayload.recipient_id), "\(dmPayload.recipient_id)") } - + let dmMessage2 = try #require(sendDM2 as? Payloads.CreateMessage, "\(sendDM1)") let message2 = try #require(dmMessage2.embeds?.first?.description) #expect(message2.hasPrefix("There is a new message"), "\(message2)") @@ -286,7 +311,7 @@ extension SerializationNamespace.GatewayProcessingTests { [message1, message2].contains(where: { $0.contains("- godb dr") }), #"None of the 2 payloads contained "godb dr". Messages: \#([message1, message2]))"# ) - + #expect(message2.hasSuffix(">>> need help with some MongoDB Driver"), "\(message2)") let event2 = EventKey.autoPingsTrigger2 @@ -297,14 +322,14 @@ extension SerializationNamespace.GatewayProcessingTests { responseStorage.awaitResponse(at: createDMEndpoint2, expectFailure: true).value, responseStorage.awaitResponse(at: responseEndpoint2).value ) - + /// The DM channel has already been created for the last tests, /// so should not be created again since it should have been cached. do { - let payload: Never? = try #require(createDM as? Optional) + let payload: Never? = try #require(createDM as? Never?) #expect(payload == .none) } - + do { let dmMessage = try #require(sendDM as? Payloads.CreateMessage, "\(sendDM)") let message = try #require(dmMessage.embeds?.first?.description) @@ -315,10 +340,15 @@ extension SerializationNamespace.GatewayProcessingTests { #expect(message.contains("- discord-kit"), "\(message)") #expect(message.contains("- cord"), "\(message)") - #expect(message.hasSuffix(">>> I want to use the discord-kit library\nhttps://www.swift.org/blog/swift-certificates-and-asn1/"), "\(message)") + #expect( + message.hasSuffix( + ">>> I want to use the discord-kit library\nhttps://www.swift.org/blog/swift-certificates-and-asn1/" + ), + "\(message)" + ) } } - + @Test func howManyCoins() async throws { do { @@ -329,7 +359,7 @@ extension SerializationNamespace.GatewayProcessingTests { let message = try #require(response.embeds?.first?.description) #expect(message == "<@290483761559240704> has 2591 \(Constants.ServerEmojis.coin.emoji)!") } - + do { let response = try await manager.sendAndAwaitResponse( key: .howManyCoins2, @@ -339,7 +369,7 @@ extension SerializationNamespace.GatewayProcessingTests { #expect(message == "<@961607141037326386> has 2591 \(Constants.ServerEmojis.coin.emoji)!") } } - + @Test func serverBoostCoins() async throws { let response = try await manager.sendAndAwaitResponse( @@ -358,7 +388,7 @@ extension SerializationNamespace.GatewayProcessingTests { ) #expect(description.hasSuffix(" \(Constants.ServerEmojis.coin.emoji)!")) } - + @Test func evolutionChecker() async throws { /// This tests expects the `CachesStorage` population to have worked correctly @@ -369,11 +399,10 @@ extension SerializationNamespace.GatewayProcessingTests { try await self.context.evolutionChecker.run() } - let endpoint = APIEndpoint.createMessage(channelId: Constants.Channels.evolution.id) let _messages = await [ self.responseStorage.awaitResponse(at: endpoint).value, - self.responseStorage.awaitResponse(at: endpoint).value + self.responseStorage.awaitResponse(at: endpoint).value, ] let messages = try _messages.map { try #require($0 as? Payloads.CreateMessage, "\($0), messages: \(_messages)") @@ -381,17 +410,23 @@ extension SerializationNamespace.GatewayProcessingTests { /// New proposal message do { - let message = try #require(messages.first(where: { - $0.embeds?.first?.title?.contains("stride") == true - }), "\(messages)") + let message = try #require( + messages.first(where: { + $0.embeds?.first?.title?.contains("stride") == true + }), + "\(messages)" + ) - #expect(message.embeds?.first?.url == "https://github.com/apple/swift-evolution/blob/main/proposals/0051-stride-semantics.md") + #expect( + message.embeds?.first?.url + == "https://github.com/apple/swift-evolution/blob/main/proposals/0051-stride-semantics.md" + ) let buttons = try #require(message.components?.first?.components, "\(message)") #expect(buttons.count == 2, "\(buttons)") let expectedLinks = [ "https://forums.swift.org/t/accepted-se-0400-init-accessors/66212", - "https://forums.swift.org/search?q=Conventionalizing%20stride%20semantics%20%23evolution" + "https://forums.swift.org/search?q=Conventionalizing%20stride%20semantics%20%23evolution", ] for (idx, buttonComponent) in buttons.enumerated() { if let url = try buttonComponent.requireButton().url { @@ -403,23 +438,32 @@ extension SerializationNamespace.GatewayProcessingTests { let embed = try #require(message.embeds?.first) #expect(embed.title == "[SE-0051] Withdrawn: Conventionalizing stride semantics") - #expect(embed.description == "> \n\n**Status: Withdrawn**\n\n**Author(s):** [Erica Sadun](http://github.com/erica)\n") + #expect( + embed.description + == "> \n\n**Status: Withdrawn**\n\n**Author(s):** [Erica Sadun](http://github.com/erica)\n" + ) #expect(embed.color == .brown) } /// Updated proposal message do { - let message = try #require(messages.first(where: { - $0.embeds?.first?.title?.contains("(most)") == true - }), "\(messages)") + let message = try #require( + messages.first(where: { + $0.embeds?.first?.title?.contains("(most)") == true + }), + "\(messages)" + ) - #expect(message.embeds?.first?.url == "https://github.com/apple/swift-evolution/blob/main/proposals/0001-keywords-as-argument-labels.md") + #expect( + message.embeds?.first?.url + == "https://github.com/apple/swift-evolution/blob/main/proposals/0001-keywords-as-argument-labels.md" + ) let buttons = try #require(message.components?.first?.components) #expect(buttons.count == 2, "\(buttons)") let expectedLinks = [ "https://forums.swift.org/t/accepted-se-0400-init-accessors/66212", - "https://forums.swift.org/search?q=Allow%20(most)%20keywords%20as%20argument%20labels%20%23evolution" + "https://forums.swift.org/search?q=Allow%20(most)%20keywords%20as%20argument%20labels%20%23evolution", ] for (idx, buttonComponent) in buttons.enumerated() { if let url = try buttonComponent.requireButton().url { @@ -431,13 +475,16 @@ extension SerializationNamespace.GatewayProcessingTests { let embed = try #require(message.embeds?.first) #expect(embed.title == "[SE-0001] In Active Review: Allow (most) keywords as argument labels") - #expect(embed.description == "> Argument labels are an important part of the interface of a Swift function, describing what particular arguments to the function do and improving readability. Sometimes, the most natural label for an argument coincides with a language keyword, such as `in`, `repeat`, or `defer`. Such keywords should be allowed as argument labels, allowing better expression of these interfaces.\n\n**Status:** Implemented -> **Active Review**\n\n**Author(s):** [Doug Gregor](https://github.com/DougGregor)\n") + #expect( + embed.description + == "> Argument labels are an important part of the interface of a Swift function, describing what particular arguments to the function do and improving readability. Sometimes, the most natural label for an argument coincides with a language keyword, such as `in`, `repeat`, or `defer`. Such keywords should be allowed as argument labels, allowing better expression of these interfaces.\n\n**Status:** Implemented -> **Active Review**\n\n**Author(s):** [Doug Gregor](https://github.com/DougGregor)\n" + ) #expect(embed.color == .orange) } serviceTask.cancel() } - + @Test func soChecker() async throws { let serviceTask = Task { [self] in @@ -457,7 +504,9 @@ extension SerializationNamespace.GatewayProcessingTests { #expect(messages[0].embeds?.first?.title == "Vapor Logger doesn't log any messages into System Log") #expect(messages[1].embeds?.first?.title == "Postgre-Kit: Unable to complete code access to PostgreSQL DB") - #expect(messages[2].embeds?.first?.title == "How to decide to use siblings or parent/children relations in vapor?") + #expect( + messages[2].embeds?.first?.title == "How to decide to use siblings or parent/children relations in vapor?" + ) #expect(messages[3].embeds?.first?.title == "How to make a optional query filter in Vapor") let lastCheckDate = await self.context.soChecker.storage.lastCheckDate @@ -483,7 +532,7 @@ extension SerializationNamespace.GatewayProcessingTests { at: endpoint, expectFailure: true ).value - let newMessage: Never? = try #require(_newMessage as? Optional) + let newMessage: Never? = try #require(_newMessage as? Never?) #expect(newMessage == .none) serviceTask.cancel() @@ -502,7 +551,7 @@ extension SerializationNamespace.GatewayProcessingTests { Issue.record("Wrong response data type for `/faqs add`: \(response.data as Any)") } } - + do { let response = try await manager.sendAndAwaitResponse( key: .faqsAddFailure, @@ -511,7 +560,7 @@ extension SerializationNamespace.GatewayProcessingTests { let message = try #require(response.embeds?.first?.description) #expect(message.hasPrefix("You don't have access to this command; it is only available to"), "\(message)") } - + do { let response = try await manager.sendAndAwaitResponse( key: .faqsGet, @@ -520,7 +569,7 @@ extension SerializationNamespace.GatewayProcessingTests { let message = try #require(response.embeds?.first?.description) #expect(message == "Test working directory help") } - + do { let key = EventKey.faqsGetEphemeral let response = try await manager.sendAndAwaitResponse( @@ -537,7 +586,7 @@ extension SerializationNamespace.GatewayProcessingTests { Issue.record("Unexpected response: \(response)") } } - + do { let response = try await manager.sendAndAwaitResponse( key: .faqsGetAutocomplete, @@ -550,7 +599,7 @@ extension SerializationNamespace.GatewayProcessingTests { } } } - + @Test func autoFaqsCommand() async throws { do { @@ -564,7 +613,7 @@ extension SerializationNamespace.GatewayProcessingTests { Issue.record("Wrong response data type for `/auto-faqs add`: \(response.data as Any)") } } - + do { let response = try await manager.sendAndAwaitResponse( key: .autoFaqsAddFailure, @@ -573,7 +622,7 @@ extension SerializationNamespace.GatewayProcessingTests { let message = try #require(response.embeds?.first?.description) #expect(message.hasPrefix("You don't have access to this command; it is only available to"), "\(message)") } - + do { let response = try await manager.sendAndAwaitResponse( key: .autoFaqsGet, @@ -582,7 +631,7 @@ extension SerializationNamespace.GatewayProcessingTests { let message = try #require(response.embeds?.first?.description) #expect(message == "Update your PostgresNIO!") } - + do { let key = EventKey.autoFaqsGetEphemeral let response = try await manager.sendAndAwaitResponse( @@ -599,7 +648,7 @@ extension SerializationNamespace.GatewayProcessingTests { Issue.record("Unexpected response: \(response)") } } - + do { let response = try await manager.sendAndAwaitResponse( key: .autoFaqsGetAutocomplete, @@ -611,19 +660,19 @@ extension SerializationNamespace.GatewayProcessingTests { Issue.record("Wrong response data type for `/auto-faqs get`: \(response.data as Any)") } } - + do { let response = try await manager.sendAndAwaitResponse( key: .autoFaqsTrigger, as: Payloads.CreateMessage.self ) - + let embed = try #require(response.embeds?.first) - + #expect(embed.title == "🤖 Automated Answer") #expect(embed.description == "Update your PostgresNIO!") } - + /// This one should fail since there is a rate-limiter do { let key: EventKey = .autoFaqsTrigger @@ -632,7 +681,7 @@ extension SerializationNamespace.GatewayProcessingTests { at: key.responseEndpoints[0], expectFailure: true ).value - let payload: Never? = try #require(response as? Optional, "\(response)") + let payload: Never? = try #require(response as? Never?, "\(response)") #expect(payload == .none) } } diff --git a/Tests/PennyTests/Tests/LeafRenderTests.swift b/Tests/PennyTests/Tests/LeafRenderTests.swift index 5086d5a1..433d86d7 100644 --- a/Tests/PennyTests/Tests/LeafRenderTests.swift +++ b/Tests/PennyTests/Tests/LeafRenderTests.swift @@ -1,14 +1,15 @@ -@testable import GHHooksLambda -@testable import Penny -import Models -import DiscordUtilities -import Rendering import AsyncHTTPClient +import DiscordUtilities import GitHubAPI import Logging +import Models import NIOPosix +import Rendering import Testing +@testable import GHHooksLambda +@testable import Penny + @Suite struct LeafRenderTests { let ghHooksRenderClient = RenderClient( @@ -17,7 +18,7 @@ struct LeafRenderTests { logger: Logger(label: "RenderClientGHHooksTests") ) ) - + let pennyRenderClient = RenderClient( renderer: try! .forPenny( httpClient: .shared, @@ -27,13 +28,13 @@ struct LeafRenderTests { ) init() {} - + @Test func translationNeededDescription() async throws { let rendered = try await ghHooksRenderClient.translationNeededDescription(number: 1) #expect(rendered.count > 20) } - + @Test func newReleaseDescription() async throws { do { @@ -42,9 +43,9 @@ struct LeafRenderTests { pr: .init( title: "PR title right here", body: """ - > This PR is really good! - > pls accept! - """, + > This PR is really good! + > pls accept! + """, author: "0xTim", number: 833 ), @@ -58,37 +59,40 @@ struct LeafRenderTests { ) ) ) - - expectMultilineStringsEqual(rendered, """ - ## What's Changed - PR title right here by @0xTim in #833 - - > This PR is really good! - > pls accept! - - ## New Contributor - - @0xTim made their first contribution in #833 🎉 - - ## Reviewers - Thanks to the reviewers for their help: - - @joannis - - @vzsg - - ###### _This patch was released by @MahdiBM_ - - **Full Changelog**: https://github.com/vapor/async-kit/compare/1.0.0...4.8.9 - """) + + expectMultilineStringsEqual( + rendered, + """ + ## What's Changed + PR title right here by @0xTim in #833 + + > This PR is really good! + > pls accept! + + ## New Contributor + - @0xTim made their first contribution in #833 🎉 + + ## Reviewers + Thanks to the reviewers for their help: + - @joannis + - @vzsg + + ###### _This patch was released by @MahdiBM_ + + **Full Changelog**: https://github.com/vapor/async-kit/compare/1.0.0...4.8.9 + """ + ) } - + do { let rendered = try await ghHooksRenderClient.newReleaseDescription( context: .init( pr: .init( title: "PR title right here", body: """ - > This PR is really good! - > pls accept! - """, + > This PR is really good! + > pls accept! + """, author: "0xTim", number: 833 ), @@ -102,52 +106,61 @@ struct LeafRenderTests { ) ) ) - - expectMultilineStringsEqual(rendered, """ - ## What's Changed - PR title right here by @0xTim in #833 - - > This PR is really good! - > pls accept! - - - - ###### _This patch was released by @MahdiBM_ - - **Full Changelog**: https://github.com/vapor/async-kit/compare/1.0.0...4.8.9 - """) + + expectMultilineStringsEqual( + rendered, + """ + ## What's Changed + PR title right here by @0xTim in #833 + + > This PR is really good! + > pls accept! + + + + ###### _This patch was released by @MahdiBM_ + + **Full Changelog**: https://github.com/vapor/async-kit/compare/1.0.0...4.8.9 + """ + ) } } - + @Test func ticketReport() async throws { do { let rendered = try await ghHooksRenderClient.ticketReport( title: "Some more improvements", body: """ - - Use newer Swift and AWSCLI v2, unpin from very old CloudFormation action, and ditch old deploy actions in global deploy-api-docs workflow. + - Use newer Swift and AWSCLI v2, unpin from very old CloudFormation action, and ditch old deploy actions in global deploy-api-docs workflow. + """ + ) + + expectMultilineStringsEqual( + rendered, + """ + ### Some more improvements + + >>> - Use newer Swift and AWSCLI v2, unpin from very old CloudFormation action, and ditch old deploy actions in global deploy-api-docs workflow. """ ) - - expectMultilineStringsEqual(rendered, """ - ### Some more improvements - - >>> - Use newer Swift and AWSCLI v2, unpin from very old CloudFormation action, and ditch old deploy actions in global deploy-api-docs workflow. - """) } - + do { let rendered = try await ghHooksRenderClient.ticketReport( title: "Some more improvements", body: "" ) - - expectMultilineStringsEqual(rendered, """ - ### Some more improvements - """) + + expectMultilineStringsEqual( + rendered, + """ + ### Some more improvements + """ + ) } } - + @Test func autoPingsHelp() async throws { let rendered = try await pennyRenderClient.autoPingsHelp( @@ -165,40 +178,43 @@ struct LeafRenderTests { defaultExpression: S3AutoPingItems.Expression.Kind.default.UIDescription ) ) - - expectMultilineStringsEqual(rendered, #""" - ## Auto-Pings Help - - You can add texts to be pinged for. - When someone uses those texts, Penny will DM you about the message. - - - Penny can't DM you about messages in channels which Penny doesn't have access to (such as the role-related channels) - - > All auto-pings commands are ||private||, meaning they are visible to you and you only, and won't even trigger the indicator. - - ### Adding Expressions - - You can add multiple texts using , separating the texts using commas (`,`). This command is Slack-compatible so you can copy-paste your Slack keywords to it. - - - Using 'mode' argument You can configure penny to look for exact matches or plain containment. Defaults to 'Containment'. - - - All texts are **case-insensitive** (e.g. `a` == `A`), **diacritic-insensitive** (e.g. `a` == `á` == `ã`) and also **punctuation-insensitive**. Some examples of punctuations are: `“!?-_/\(){}`. - - - All texts are **space-sensitive**. - - > Make sure Penny is able to DM you. You can enable direct messages for Vapor server members under your Server Settings. - - ### Removing Expressions - - You can remove multiple texts using , separating the texts using commas (`,`). - - ### Your Pings List - - You can use to see your current expressions. - - ### Testing Expressions - - You can use to test if a message triggers some expressions. - """#) + + expectMultilineStringsEqual( + rendered, + #""" + ## Auto-Pings Help + + You can add texts to be pinged for. + When someone uses those texts, Penny will DM you about the message. + + - Penny can't DM you about messages in channels which Penny doesn't have access to (such as the role-related channels) + + > All auto-pings commands are ||private||, meaning they are visible to you and you only, and won't even trigger the indicator. + + ### Adding Expressions + + You can add multiple texts using , separating the texts using commas (`,`). This command is Slack-compatible so you can copy-paste your Slack keywords to it. + + - Using 'mode' argument You can configure penny to look for exact matches or plain containment. Defaults to 'Containment'. + + - All texts are **case-insensitive** (e.g. `a` == `A`), **diacritic-insensitive** (e.g. `a` == `á` == `ã`) and also **punctuation-insensitive**. Some examples of punctuations are: `“!?-_/\(){}`. + + - All texts are **space-sensitive**. + + > Make sure Penny is able to DM you. You can enable direct messages for Vapor server members under your Server Settings. + + ### Removing Expressions + + You can remove multiple texts using , separating the texts using commas (`,`). + + ### Your Pings List + + You can use to see your current expressions. + + ### Testing Expressions + + You can use to test if a message triggers some expressions. + """# + ) } } diff --git a/Tests/PennyTests/Tests/OtherTests.swift b/Tests/PennyTests/Tests/OtherTests.swift index 8ad3f84b..dee99d77 100644 --- a/Tests/PennyTests/Tests/OtherTests.swift +++ b/Tests/PennyTests/Tests/OtherTests.swift @@ -1,14 +1,16 @@ -@testable import Penny +import EvolutionMetadataModel +import Markdown +import Testing + @testable import Models +@testable import Penny + #if canImport(FoundationEssentials) import FoundationEssentials import struct Foundation.CharacterSet #else import Foundation #endif -import EvolutionMetadataModel -import Markdown -import Testing @Suite struct OtherTests { @@ -52,7 +54,8 @@ struct OtherTests { func autoPingItemExpressionCodable() throws { typealias Expression = S3AutoPingItems.Expression - do { /// Expression.matches + do { + /// Expression.matches let exp = Expression.matches("Hello-world") let encoder = JSONEncoder() let encoded = try encoder.encode(exp) @@ -70,7 +73,8 @@ struct OtherTests { } } - do { /// Expression.contains + do { + /// Expression.contains let exp = Expression.contains("Hello-world") let encoder = JSONEncoder() let encoded = try encoder.encode(exp) @@ -103,7 +107,10 @@ struct OtherTests { let newMarkup = repairer.visit(document) let editedLink = try #require(newMarkup?.child(through: 1, 0, 0, 1) as? Link) - #expect(editedLink.destination == "https://github.com/apple/swift-evolution/blob/main/proposals/0400-init-accessors.md") + #expect( + editedLink.destination + == "https://github.com/apple/swift-evolution/blob/main/proposals/0400-init-accessors.md" + ) } @Test diff --git a/scripts/format.bash b/scripts/format.bash new file mode 100755 index 00000000..822d830a --- /dev/null +++ b/scripts/format.bash @@ -0,0 +1,31 @@ +#!/bin/bash + +set -eu + +# Formats all Swift files + +# Update PATH incase it's run from Xcode scripts. +PATH="$PATH:/usr/local/bin/:/opt/homebrew/bin/" + +# This script is in `./scripts` directory so `./scripts/..` would be the same as `./`. +BASE_DIR=$(dirname "$0")/.. + +note() { + printf -- "** %s\n" "$*" >&2 +} + +note "Starting the script at $(date)" + +# Take an argument for BASE_DIR, incase this is run from Xcode scripts. +if [ "${1:-}" != "" ]; then + note "Got BASE_DIR argument: $1" + BASE_DIR=$1 +fi + +cd "$BASE_DIR" + +note "Will run swift-format while BASE_DIR is $BASE_DIR" + +# Format all Swift files, excluding the ones in .swiftformatignore +# Code grabbed from https://raw.githubusercontent.com/swiftlang/github-workflows/refs/heads/main/.github/workflows/scripts/check-swift-format.sh +tr '\n' '\0' <.swiftformatignore | xargs -0 -I% printf '":(exclude)%" ' | xargs git ls-files -z '*.swift' | xargs -0 swift format format --parallel --in-place