diff --git a/.buildpacks b/.buildpacks index c8e6d53..b6bb1a0 100644 --- a/.buildpacks +++ b/.buildpacks @@ -1,2 +1,2 @@ https://github.com/HashNuke/heroku-buildpack-elixir.git -https://github.com/gjaldon/heroku-buildpack-phoenix-static +https://github.com/gjaldon/heroku-buildpack-phoenix-static.git diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..50bc86b --- /dev/null +++ b/.credo.exs @@ -0,0 +1,189 @@ +# This file contains the configuration for Credo and you are probably reading +# this after creating it with `mix credo.gen.config`. +# +# If you find anything wrong or unclear in this file, please report an +# issue on GitHub: https://github.com/rrrene/credo/issues +# +%{ + # + # You can have as many configs as you like in the `configs:` field. + configs: [ + %{ + # + # Run any config using `mix credo -C `. If no config name is given + # "default" is used. + # + name: "default", + # + # These are the files included in the analysis: + files: %{ + # + # You can give explicit globs or simply directories. + # In the latter case `**/*.{ex,exs}` will be used. + # + included: [ + "lib/", + "src/", + "test/", + "web/", + "apps/*/lib/", + "apps/*/src/", + "apps/*/test/", + "apps/*/web/" + ], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + # + # Load and configure plugins here: + # + plugins: [], + # + # If you create your own checks, you must specify the source files for + # them here, so they can be loaded by Credo before running the analysis. + # + requires: [], + # + # If you want to enforce a style guide and need a more traditional linting + # experience, you can change `strict` to `true` below: + # + strict: true, + # + # To modify the timeout for parsing files, change this value: + # + parse_timeout: 5000, + # + # If you want to use uncolored output by default, you can change `color` + # to `false` below: + # + color: true, + # + # You can customize the parameters of any check by adding a second element + # to the tuple. + # + # To disable a check put `false` as second element: + # + # {Credo.Check.Design.DuplicatedCode, false} + # + checks: [ + # + ## Consistency Checks + # + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + # + ## Design Checks + # + # You can customize the priority of any check + # Priority values are: `low, normal, high, higher` + # + {Credo.Check.Design.AliasUsage, + [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + # You can also customize the exit_status of each check. + # If you don't want TODO comments to cause `mix credo` to fail, just + # set this value to 0 (zero). + # + {Credo.Check.Design.TagTODO, [exit_status: 2]}, + {Credo.Check.Design.TagFIXME, []}, + + # + ## Readability Checks + # + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + + # + ## Refactoring Opportunities + # + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + # {Credo.Check.Refactor.MapInto, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + + # + ## Warnings + # + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + # {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.MixEnv, false}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.UnsafeExec, []}, + + # + # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) + + # + # Controversial and experimental checks (opt-in, just replace `false` with `[]`) + # + {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, + {Credo.Check.Consistency.UnusedVariableNames, false}, + {Credo.Check.Design.DuplicatedCode, false}, + {Credo.Check.Readability.AliasAs, false}, + {Credo.Check.Readability.BlockPipe, false}, + {Credo.Check.Readability.ImplTrue, false}, + {Credo.Check.Readability.MultiAlias, false}, + {Credo.Check.Readability.SeparateAliasRequire, false}, + {Credo.Check.Readability.SinglePipe, false}, + {Credo.Check.Readability.Specs, false}, + {Credo.Check.Readability.StrictModuleLayout, false}, + {Credo.Check.Readability.WithCustomTaggedTuple, false}, + {Credo.Check.Refactor.ABCSize, false}, + {Credo.Check.Refactor.AppendSingleItem, false}, + {Credo.Check.Refactor.DoubleBooleanNegation, false}, + {Credo.Check.Refactor.ModuleDependencies, false}, + {Credo.Check.Refactor.NegatedIsNil, false}, + {Credo.Check.Refactor.PipeChainStart, false}, + {Credo.Check.Refactor.VariableRebinding, false}, + {Credo.Check.Warning.LeakyEnvironment, false}, + {Credo.Check.Warning.MapGetUnsafePass, false}, + {Credo.Check.Warning.UnsafeToAtom, false} + + # + # Custom checks can be created using `mix credo.gen.check`. + # + ] + } + ] +} diff --git a/.doctor.exs b/.doctor.exs new file mode 100644 index 0000000..83306bf --- /dev/null +++ b/.doctor.exs @@ -0,0 +1,26 @@ +%Doctor.Config{ + exception_moduledoc_required: true, + failed: false, + ignore_modules: [ + DeepThought.Application, + DeepThought.Repo, + DeepThoughtWeb, + DeepThoughtWeb.Endpoint, + DeepThoughtWeb.ErrorHelpers, + DeepThoughtWeb.ErrorView, + DeepThoughtWeb.LayoutView, + DeepThoughtWeb.Router, + DeepThoughtWeb.Telemetry, + DeepThoughtWeb.UserSocket + ], + ignore_paths: [], + min_module_doc_coverage: 100, + min_module_spec_coverage: 100, + min_overall_doc_coverage: 100, + min_overall_spec_coverage: 100, + moduledoc_required: true, + raise: true, + reporter: Doctor.Reporters.Full, + struct_type_spec_required: true, + umbrella: false +} diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml new file mode 100644 index 0000000..10fe028 --- /dev/null +++ b/.github/workflows/default.yml @@ -0,0 +1,98 @@ +name: default + +on: [push] + +env: + APPSIGNAL_OTP_APP: deep_thought + APPSIGNAL_APP_NAME: Deep Thought + APPSIGNAL_APP_ENV: ci + APPSIGNAL_PUSH_API_KEY: ${{ secrets.APPSIGNAL_PUSH_API_KEY }} + DEEPL_AUTH_KEY: auth_key + ELIXIR_VERSION: 1.12.1 + MIX_ENV: dev + OTP_VERSION: 24.0.3 + SLACK_BOT_TOKEN: bot_token + SLACK_SIGNING_SECRET: signing_secret + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + services: + db: + image: postgres:latest + ports: + - 5432:5432 + env: + POSTGRES_PASSWORD: postgres + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Erlang/OTP and Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: ${{ env.OTP_VERSION }} + elixir-version: ${{ env.ELIXIR_VERSION }} + + - name: Restore dependencies cache + uses: actions/cache@v2 + with: + path: | + deps + _build + key: ${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-mix- + + - name: Fetch dependencies + run: mix deps.get + + - name: Check code formatting + run: mix format --check-formatted + + - name: Compile the code + run: mix compile --warnings-as-errors + + - name: Run a test suite + run: MIX_ENV=test mix test + + - name: Run the Credo static code analysis tool + run: mix credo --strict + + - name: Run Doctor + run: mix doctor + + - name: Restore PLTs cache + uses: actions/cache@v2 + with: + path: priv/plts + key: ${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-plts-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-plts- + + - name: Run Dialyzer + run: mix dialyzer + + deploy: + if: github.ref == 'refs/heads/master' + name: Deploy + needs: [test] + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Deploy to Lightsail + uses: dokku/github-action@v1.0.2 + with: + git_push_flags: --force + git_remote_url: ${{ secrets.GIT_REMOTE_URL }} + ssh_host_key: ${{ secrets.SSH_HOST_KEY }} + ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} diff --git a/.gitignore b/.gitignore index e22054e..1325e2a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ npm-debug.log /priv/static/ /.env +/.vscode +/priv/plts/*.plt +/priv/plts/*.plt.hash diff --git a/README.md b/README.md index 37ebbc6..0627b95 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ To deploy _Deep Thought_, you first need to create a Slack application, then con always_online: true slash_commands: - command: /translate - url: https://YOUR.DOMAIN.HERE/slack/commands/translate + url: https://YOUR.DOMAIN.HERE/slack/commands description: Translate your message, sending both the translation and original text to the channel diff --git a/assets/package-lock.json b/assets/package-lock.json index 5e05ff4..c3b53d9 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -3,35 +3,35 @@ "lockfileVersion": 1, "dependencies": { "@babel/code-frame": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.13.tgz", - "integrity": "sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.14.5.tgz", + "integrity": "sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==", "dev": true, "requires": { - "@babel/highlight": "^7.12.13" + "@babel/highlight": "^7.14.5" } }, "@babel/compat-data": { - "version": "7.14.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.14.4.tgz", - "integrity": "sha512-i2wXrWQNkH6JplJQGn3Rd2I4Pij8GdHkXwHMxm+zV5YG/Jci+bCNrWZEWC4o+umiDkRrRs4dVzH3X4GP7vyjQQ==", + "version": "7.14.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.14.7.tgz", + "integrity": "sha512-nS6dZaISCXJ3+518CWiBfEr//gHyMO02uDxBkXTKZDN5POruCnOZ1N4YBRZDCabwF8nZMWBpRxIicmXtBs+fvw==", "dev": true }, "@babel/core": { - "version": "7.14.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.14.3.tgz", - "integrity": "sha512-jB5AmTKOCSJIZ72sd78ECEhuPiDMKlQdDI/4QRI6lzYATx5SSogS1oQA2AoPecRCknm30gHi2l+QVvNUu3wZAg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.12.13", - "@babel/generator": "^7.14.3", - "@babel/helper-compilation-targets": "^7.13.16", - "@babel/helper-module-transforms": "^7.14.2", - "@babel/helpers": "^7.14.0", - "@babel/parser": "^7.14.3", - "@babel/template": "^7.12.13", - "@babel/traverse": "^7.14.2", - "@babel/types": "^7.14.2", + "version": "7.14.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.14.6.tgz", + "integrity": "sha512-gJnOEWSqTk96qG5BoIrl5bVtc23DCycmIePPYnamY9RboYdI4nFy5vAQMSl81O5K/W0sLDWfGysnOECC+KUUCA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.14.5", + "@babel/generator": "^7.14.5", + "@babel/helper-compilation-targets": "^7.14.5", + "@babel/helper-module-transforms": "^7.14.5", + "@babel/helpers": "^7.14.6", + "@babel/parser": "^7.14.6", + "@babel/template": "^7.14.5", + "@babel/traverse": "^7.14.5", + "@babel/types": "^7.14.5", "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -41,68 +41,68 @@ } }, "@babel/generator": { - "version": "7.14.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.3.tgz", - "integrity": "sha512-bn0S6flG/j0xtQdz3hsjJ624h3W0r3llttBMfyHX3YrZ/KtLYr15bjA0FXkgW7FpvrDuTuElXeVjiKlYRpnOFA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.14.5.tgz", + "integrity": "sha512-y3rlP+/G25OIX3mYKKIOlQRcqj7YgrvHxOLbVmyLJ9bPmi5ttvUmpydVjcFjZphOktWuA7ovbx91ECloWTfjIA==", "dev": true, "requires": { - "@babel/types": "^7.14.2", + "@babel/types": "^7.14.5", "jsesc": "^2.5.1", "source-map": "^0.5.0" } }, "@babel/helper-annotate-as-pure": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.13.tgz", - "integrity": "sha512-7YXfX5wQ5aYM/BOlbSccHDbuXXFPxeoUmfWtz8le2yTkTZc+BxsiEnENFoi2SlmA8ewDkG2LgIMIVzzn2h8kfw==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.14.5.tgz", + "integrity": "sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA==", "dev": true, "requires": { - "@babel/types": "^7.12.13" + "@babel/types": "^7.14.5" } }, "@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.12.13.tgz", - "integrity": "sha512-CZOv9tGphhDRlVjVkAgm8Nhklm9RzSmWpX2my+t7Ua/KT616pEzXsQCjinzvkRvHWJ9itO4f296efroX23XCMA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.14.5.tgz", + "integrity": "sha512-YTA/Twn0vBXDVGJuAX6PwW7x5zQei1luDDo2Pl6q1qZ7hVNl0RZrhHCQG/ArGpR29Vl7ETiB8eJyrvpuRp300w==", "dev": true, "requires": { - "@babel/helper-explode-assignable-expression": "^7.12.13", - "@babel/types": "^7.12.13" + "@babel/helper-explode-assignable-expression": "^7.14.5", + "@babel/types": "^7.14.5" } }, "@babel/helper-compilation-targets": { - "version": "7.14.4", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.14.4.tgz", - "integrity": "sha512-JgdzOYZ/qGaKTVkn5qEDV/SXAh8KcyUVkCoSWGN8T3bwrgd6m+/dJa2kVGi6RJYJgEYPBdZ84BZp9dUjNWkBaA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.14.5.tgz", + "integrity": "sha512-v+QtZqXEiOnpO6EYvlImB6zCD2Lel06RzOPzmkz/D/XgQiUu3C/Jb1LOqSt/AIA34TYi/Q+KlT8vTQrgdxkbLw==", "dev": true, "requires": { - "@babel/compat-data": "^7.14.4", - "@babel/helper-validator-option": "^7.12.17", + "@babel/compat-data": "^7.14.5", + "@babel/helper-validator-option": "^7.14.5", "browserslist": "^4.16.6", "semver": "^6.3.0" } }, "@babel/helper-create-class-features-plugin": { - "version": "7.14.4", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.14.4.tgz", - "integrity": "sha512-idr3pthFlDCpV+p/rMgGLGYIVtazeatrSOQk8YzO2pAepIjQhCN3myeihVg58ax2bbbGK9PUE1reFi7axOYIOw==", + "version": "7.14.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.14.6.tgz", + "integrity": "sha512-Z6gsfGofTxH/+LQXqYEK45kxmcensbzmk/oi8DmaQytlQCgqNZt9XQF8iqlI/SeXWVjaMNxvYvzaYw+kh42mDg==", "dev": true, "requires": { - "@babel/helper-annotate-as-pure": "^7.12.13", - "@babel/helper-function-name": "^7.14.2", - "@babel/helper-member-expression-to-functions": "^7.13.12", - "@babel/helper-optimise-call-expression": "^7.12.13", - "@babel/helper-replace-supers": "^7.14.4", - "@babel/helper-split-export-declaration": "^7.12.13" + "@babel/helper-annotate-as-pure": "^7.14.5", + "@babel/helper-function-name": "^7.14.5", + "@babel/helper-member-expression-to-functions": "^7.14.5", + "@babel/helper-optimise-call-expression": "^7.14.5", + "@babel/helper-replace-supers": "^7.14.5", + "@babel/helper-split-export-declaration": "^7.14.5" } }, "@babel/helper-create-regexp-features-plugin": { - "version": "7.14.3", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.14.3.tgz", - "integrity": "sha512-JIB2+XJrb7v3zceV2XzDhGIB902CmKGSpSl4q2C6agU9SNLG/2V1RtFRGPG1Ajh9STj3+q6zJMOC+N/pp2P9DA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.14.5.tgz", + "integrity": "sha512-TLawwqpOErY2HhWbGJ2nZT5wSkR192QpN+nBg1THfBfftrlvOh+WbhrxXCH4q4xJ9Gl16BGPR/48JA+Ryiho/A==", "dev": true, "requires": { - "@babel/helper-annotate-as-pure": "^7.12.13", + "@babel/helper-annotate-as-pure": "^7.14.5", "regexpu-core": "^4.7.1" } }, @@ -123,362 +123,361 @@ } }, "@babel/helper-explode-assignable-expression": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.13.0.tgz", - "integrity": "sha512-qS0peLTDP8kOisG1blKbaoBg/o9OSa1qoumMjTK5pM+KDTtpxpsiubnCGP34vK8BXGcb2M9eigwgvoJryrzwWA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.14.5.tgz", + "integrity": "sha512-Htb24gnGJdIGT4vnRKMdoXiOIlqOLmdiUYpAQ0mYfgVT/GDm8GOYhgi4GL+hMKrkiPRohO4ts34ELFsGAPQLDQ==", "dev": true, "requires": { - "@babel/types": "^7.13.0" + "@babel/types": "^7.14.5" } }, "@babel/helper-function-name": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.14.2.tgz", - "integrity": "sha512-NYZlkZRydxw+YT56IlhIcS8PAhb+FEUiOzuhFTfqDyPmzAhRge6ua0dQYT/Uh0t/EDHq05/i+e5M2d4XvjgarQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz", + "integrity": "sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==", "dev": true, "requires": { - "@babel/helper-get-function-arity": "^7.12.13", - "@babel/template": "^7.12.13", - "@babel/types": "^7.14.2" + "@babel/helper-get-function-arity": "^7.14.5", + "@babel/template": "^7.14.5", + "@babel/types": "^7.14.5" } }, "@babel/helper-get-function-arity": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.13.tgz", - "integrity": "sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz", + "integrity": "sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==", "dev": true, "requires": { - "@babel/types": "^7.12.13" + "@babel/types": "^7.14.5" } }, "@babel/helper-hoist-variables": { - "version": "7.13.16", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.13.16.tgz", - "integrity": "sha512-1eMtTrXtrwscjcAeO4BVK+vvkxaLJSPFz1w1KLawz6HLNi9bPFGBNwwDyVfiu1Tv/vRRFYfoGaKhmAQPGPn5Wg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz", + "integrity": "sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==", "dev": true, "requires": { - "@babel/traverse": "^7.13.15", - "@babel/types": "^7.13.16" + "@babel/types": "^7.14.5" } }, "@babel/helper-member-expression-to-functions": { - "version": "7.13.12", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.13.12.tgz", - "integrity": "sha512-48ql1CLL59aKbU94Y88Xgb2VFy7a95ykGRbJJaaVv+LX5U8wFpLfiGXJJGUozsmA1oEh/o5Bp60Voq7ACyA/Sw==", + "version": "7.14.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.14.7.tgz", + "integrity": "sha512-TMUt4xKxJn6ccjcOW7c4hlwyJArizskAhoSTOCkA0uZ+KghIaci0Qg9R043kUMWI9mtQfgny+NQ5QATnZ+paaA==", "dev": true, "requires": { - "@babel/types": "^7.13.12" + "@babel/types": "^7.14.5" } }, "@babel/helper-module-imports": { - "version": "7.13.12", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.13.12.tgz", - "integrity": "sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz", + "integrity": "sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==", "dev": true, "requires": { - "@babel/types": "^7.13.12" + "@babel/types": "^7.14.5" } }, "@babel/helper-module-transforms": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.14.2.tgz", - "integrity": "sha512-OznJUda/soKXv0XhpvzGWDnml4Qnwp16GN+D/kZIdLsWoHj05kyu8Rm5kXmMef+rVJZ0+4pSGLkeixdqNUATDA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.14.5.tgz", + "integrity": "sha512-iXpX4KW8LVODuAieD7MzhNjmM6dzYY5tfRqT+R9HDXWl0jPn/djKmA+G9s/2C2T9zggw5tK1QNqZ70USfedOwA==", "dev": true, "requires": { - "@babel/helper-module-imports": "^7.13.12", - "@babel/helper-replace-supers": "^7.13.12", - "@babel/helper-simple-access": "^7.13.12", - "@babel/helper-split-export-declaration": "^7.12.13", - "@babel/helper-validator-identifier": "^7.14.0", - "@babel/template": "^7.12.13", - "@babel/traverse": "^7.14.2", - "@babel/types": "^7.14.2" + "@babel/helper-module-imports": "^7.14.5", + "@babel/helper-replace-supers": "^7.14.5", + "@babel/helper-simple-access": "^7.14.5", + "@babel/helper-split-export-declaration": "^7.14.5", + "@babel/helper-validator-identifier": "^7.14.5", + "@babel/template": "^7.14.5", + "@babel/traverse": "^7.14.5", + "@babel/types": "^7.14.5" } }, "@babel/helper-optimise-call-expression": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.13.tgz", - "integrity": "sha512-BdWQhoVJkp6nVjB7nkFWcn43dkprYauqtk++Py2eaf/GRDFm5BxRqEIZCiHlZUGAVmtwKcsVL1dC68WmzeFmiA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz", + "integrity": "sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==", "dev": true, "requires": { - "@babel/types": "^7.12.13" + "@babel/types": "^7.14.5" } }, "@babel/helper-plugin-utils": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.13.0.tgz", - "integrity": "sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz", + "integrity": "sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==", "dev": true }, "@babel/helper-remap-async-to-generator": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.13.0.tgz", - "integrity": "sha512-pUQpFBE9JvC9lrQbpX0TmeNIy5s7GnZjna2lhhcHC7DzgBs6fWn722Y5cfwgrtrqc7NAJwMvOa0mKhq6XaE4jg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.14.5.tgz", + "integrity": "sha512-rLQKdQU+HYlxBwQIj8dk4/0ENOUEhA/Z0l4hN8BexpvmSMN9oA9EagjnhnDpNsRdWCfjwa4mn/HyBXO9yhQP6A==", "dev": true, "requires": { - "@babel/helper-annotate-as-pure": "^7.12.13", - "@babel/helper-wrap-function": "^7.13.0", - "@babel/types": "^7.13.0" + "@babel/helper-annotate-as-pure": "^7.14.5", + "@babel/helper-wrap-function": "^7.14.5", + "@babel/types": "^7.14.5" } }, "@babel/helper-replace-supers": { - "version": "7.14.4", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.14.4.tgz", - "integrity": "sha512-zZ7uHCWlxfEAAOVDYQpEf/uyi1dmeC7fX4nCf2iz9drnCwi1zvwXL3HwWWNXUQEJ1k23yVn3VbddiI9iJEXaTQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.14.5.tgz", + "integrity": "sha512-3i1Qe9/8x/hCHINujn+iuHy+mMRLoc77b2nI9TB0zjH1hvn9qGlXjWlggdwUcju36PkPCy/lpM7LLUdcTyH4Ow==", "dev": true, "requires": { - "@babel/helper-member-expression-to-functions": "^7.13.12", - "@babel/helper-optimise-call-expression": "^7.12.13", - "@babel/traverse": "^7.14.2", - "@babel/types": "^7.14.4" + "@babel/helper-member-expression-to-functions": "^7.14.5", + "@babel/helper-optimise-call-expression": "^7.14.5", + "@babel/traverse": "^7.14.5", + "@babel/types": "^7.14.5" } }, "@babel/helper-simple-access": { - "version": "7.13.12", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.13.12.tgz", - "integrity": "sha512-7FEjbrx5SL9cWvXioDbnlYTppcZGuCY6ow3/D5vMggb2Ywgu4dMrpTJX0JdQAIcRRUElOIxF3yEooa9gUb9ZbA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.14.5.tgz", + "integrity": "sha512-nfBN9xvmCt6nrMZjfhkl7i0oTV3yxR4/FztsbOASyTvVcoYd0TRHh7eMLdlEcCqobydC0LAF3LtC92Iwxo0wyw==", "dev": true, "requires": { - "@babel/types": "^7.13.12" + "@babel/types": "^7.14.5" } }, "@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.12.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz", - "integrity": "sha512-Mf5AUuhG1/OCChOJ/HcADmvcHM42WJockombn8ATJG3OnyiSxBK/Mm5x78BQWvmtXZKHgbjdGL2kin/HOLlZGA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.14.5.tgz", + "integrity": "sha512-dmqZB7mrb94PZSAOYtr+ZN5qt5owZIAgqtoTuqiFbHFtxgEcmQlRJVI+bO++fciBunXtB6MK7HrzrfcAzIz2NQ==", "dev": true, "requires": { - "@babel/types": "^7.12.1" + "@babel/types": "^7.14.5" } }, "@babel/helper-split-export-declaration": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz", - "integrity": "sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz", + "integrity": "sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==", "dev": true, "requires": { - "@babel/types": "^7.12.13" + "@babel/types": "^7.14.5" } }, "@babel/helper-validator-identifier": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.0.tgz", - "integrity": "sha512-V3ts7zMSu5lfiwWDVWzRDGIN+lnCEUdaXgtVHJgLb1rGaA6jMrtB9EmE7L18foXJIE8Un/A/h6NJfGQp/e1J4A==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz", + "integrity": "sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==", "dev": true }, "@babel/helper-validator-option": { - "version": "7.12.17", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.12.17.tgz", - "integrity": "sha512-TopkMDmLzq8ngChwRlyjR6raKD6gMSae4JdYDB8bByKreQgG0RBTuKe9LRxW3wFtUnjxOPRKBDwEH6Mg5KeDfw==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz", + "integrity": "sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==", "dev": true }, "@babel/helper-wrap-function": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.13.0.tgz", - "integrity": "sha512-1UX9F7K3BS42fI6qd2A4BjKzgGjToscyZTdp1DjknHLCIvpgne6918io+aL5LXFcER/8QWiwpoY902pVEqgTXA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.14.5.tgz", + "integrity": "sha512-YEdjTCq+LNuNS1WfxsDCNpgXkJaIyqco6DAelTUjT4f2KIWC1nBcaCaSdHTBqQVLnTBexBcVcFhLSU1KnYuePQ==", "dev": true, "requires": { - "@babel/helper-function-name": "^7.12.13", - "@babel/template": "^7.12.13", - "@babel/traverse": "^7.13.0", - "@babel/types": "^7.13.0" + "@babel/helper-function-name": "^7.14.5", + "@babel/template": "^7.14.5", + "@babel/traverse": "^7.14.5", + "@babel/types": "^7.14.5" } }, "@babel/helpers": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.14.0.tgz", - "integrity": "sha512-+ufuXprtQ1D1iZTO/K9+EBRn+qPWMJjZSw/S0KlFrxCw4tkrzv9grgpDHkY9MeQTjTY8i2sp7Jep8DfU6tN9Mg==", + "version": "7.14.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.14.6.tgz", + "integrity": "sha512-yesp1ENQBiLI+iYHSJdoZKUtRpfTlL1grDIX9NRlAVppljLw/4tTyYupIB7uIYmC3stW/imAv8EqaKaS/ibmeA==", "dev": true, "requires": { - "@babel/template": "^7.12.13", - "@babel/traverse": "^7.14.0", - "@babel/types": "^7.14.0" + "@babel/template": "^7.14.5", + "@babel/traverse": "^7.14.5", + "@babel/types": "^7.14.5" } }, "@babel/highlight": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.0.tgz", - "integrity": "sha512-YSCOwxvTYEIMSGaBQb5kDDsCopDdiUGsqpatp3fOlI4+2HQSkTmEVWnVuySdAC5EWCqSWWTv0ib63RjR7dTBdg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.14.5.tgz", + "integrity": "sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==", "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.14.0", + "@babel/helper-validator-identifier": "^7.14.5", "chalk": "^2.0.0", "js-tokens": "^4.0.0" } }, "@babel/parser": { - "version": "7.14.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.4.tgz", - "integrity": "sha512-ArliyUsWDUqEGfWcmzpGUzNfLxTdTp6WU4IuP6QFSp9gGfWS6boxFCkJSJ/L4+RG8z/FnIU3WxCk6hPL9SSWeA==", + "version": "7.14.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.14.7.tgz", + "integrity": "sha512-X67Z5y+VBJuHB/RjwECp8kSl5uYi0BvRbNeWqkaJCVh+LiTPl19WBUfG627psSgp9rSf6ojuXghQM3ha6qHHdA==", "dev": true }, "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.13.12", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.13.12.tgz", - "integrity": "sha512-d0u3zWKcoZf379fOeJdr1a5WPDny4aOFZ6hlfKivgK0LY7ZxNfoaHL2fWwdGtHyVvra38FC+HVYkO+byfSA8AQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.14.5.tgz", + "integrity": "sha512-ZoJS2XCKPBfTmL122iP6NM9dOg+d4lc9fFk3zxc8iDjvt8Pk4+TlsHSKhIPf6X+L5ORCdBzqMZDjL/WHj7WknQ==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.12.1", - "@babel/plugin-proposal-optional-chaining": "^7.13.12" + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.14.5", + "@babel/plugin-proposal-optional-chaining": "^7.14.5" } }, "@babel/plugin-proposal-async-generator-functions": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.14.2.tgz", - "integrity": "sha512-b1AM4F6fwck4N8ItZ/AtC4FP/cqZqmKRQ4FaTDutwSYyjuhtvsGEMLK4N/ztV/ImP40BjIDyMgBQAeAMsQYVFQ==", + "version": "7.14.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.14.7.tgz", + "integrity": "sha512-RK8Wj7lXLY3bqei69/cc25gwS5puEc3dknoFPFbqfy3XxYQBQFvu4ioWpafMBAB+L9NyptQK4nMOa5Xz16og8Q==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0", - "@babel/helper-remap-async-to-generator": "^7.13.0", + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/helper-remap-async-to-generator": "^7.14.5", "@babel/plugin-syntax-async-generators": "^7.8.4" } }, "@babel/plugin-proposal-class-properties": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.13.0.tgz", - "integrity": "sha512-KnTDjFNC1g+45ka0myZNvSBFLhNCLN+GeGYLDEA8Oq7MZ6yMgfLoIRh86GRT0FjtJhZw8JyUskP9uvj5pHM9Zg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.14.5.tgz", + "integrity": "sha512-q/PLpv5Ko4dVc1LYMpCY7RVAAO4uk55qPwrIuJ5QJ8c6cVuAmhu7I/49JOppXL6gXf7ZHzpRVEUZdYoPLM04Gg==", "dev": true, "requires": { - "@babel/helper-create-class-features-plugin": "^7.13.0", - "@babel/helper-plugin-utils": "^7.13.0" + "@babel/helper-create-class-features-plugin": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-proposal-class-static-block": { - "version": "7.14.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.14.3.tgz", - "integrity": "sha512-HEjzp5q+lWSjAgJtSluFDrGGosmwTgKwCXdDQZvhKsRlwv3YdkUEqxNrrjesJd+B9E9zvr1PVPVBvhYZ9msjvQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.14.5.tgz", + "integrity": "sha512-KBAH5ksEnYHCegqseI5N9skTdxgJdmDoAOc0uXa+4QMYKeZD0w5IARh4FMlTNtaHhbB8v+KzMdTgxMMzsIy6Yg==", "dev": true, "requires": { - "@babel/helper-create-class-features-plugin": "^7.14.3", - "@babel/helper-plugin-utils": "^7.13.0", - "@babel/plugin-syntax-class-static-block": "^7.12.13" + "@babel/helper-create-class-features-plugin": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/plugin-syntax-class-static-block": "^7.14.5" } }, "@babel/plugin-proposal-dynamic-import": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.14.2.tgz", - "integrity": "sha512-oxVQZIWFh91vuNEMKltqNsKLFWkOIyJc95k2Gv9lWVyDfPUQGSSlbDEgWuJUU1afGE9WwlzpucMZ3yDRHIItkA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.14.5.tgz", + "integrity": "sha512-ExjiNYc3HDN5PXJx+bwC50GIx/KKanX2HiggnIUAYedbARdImiCU4RhhHfdf0Kd7JNXGpsBBBCOm+bBVy3Gb0g==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-plugin-utils": "^7.14.5", "@babel/plugin-syntax-dynamic-import": "^7.8.3" } }, "@babel/plugin-proposal-export-namespace-from": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.14.2.tgz", - "integrity": "sha512-sRxW3z3Zp3pFfLAgVEvzTFutTXax837oOatUIvSG9o5gRj9mKwm3br1Se5f4QalTQs9x4AzlA/HrCWbQIHASUQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.14.5.tgz", + "integrity": "sha512-g5POA32bXPMmSBu5Dx/iZGLGnKmKPc5AiY7qfZgurzrCYgIztDlHFbznSNCoQuv57YQLnQfaDi7dxCtLDIdXdA==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-plugin-utils": "^7.14.5", "@babel/plugin-syntax-export-namespace-from": "^7.8.3" } }, "@babel/plugin-proposal-json-strings": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.14.2.tgz", - "integrity": "sha512-w2DtsfXBBJddJacXMBhElGEYqCZQqN99Se1qeYn8DVLB33owlrlLftIbMzn5nz1OITfDVknXF433tBrLEAOEjA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.14.5.tgz", + "integrity": "sha512-NSq2fczJYKVRIsUJyNxrVUMhB27zb7N7pOFGQOhBKJrChbGcgEAqyZrmZswkPk18VMurEeJAaICbfm57vUeTbQ==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-plugin-utils": "^7.14.5", "@babel/plugin-syntax-json-strings": "^7.8.3" } }, "@babel/plugin-proposal-logical-assignment-operators": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.14.2.tgz", - "integrity": "sha512-1JAZtUrqYyGsS7IDmFeaem+/LJqujfLZ2weLR9ugB0ufUPjzf8cguyVT1g5im7f7RXxuLq1xUxEzvm68uYRtGg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.14.5.tgz", + "integrity": "sha512-YGn2AvZAo9TwyhlLvCCWxD90Xq8xJ4aSgaX3G5D/8DW94L8aaT+dS5cSP+Z06+rCJERGSr9GxMBZ601xoc2taw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-plugin-utils": "^7.14.5", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" } }, "@babel/plugin-proposal-nullish-coalescing-operator": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.14.2.tgz", - "integrity": "sha512-ebR0zU9OvI2N4qiAC38KIAK75KItpIPTpAtd2r4OZmMFeKbKJpUFLYP2EuDut82+BmYi8sz42B+TfTptJ9iG5Q==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.14.5.tgz", + "integrity": "sha512-gun/SOnMqjSb98Nkaq2rTKMwervfdAoz6NphdY0vTfuzMfryj+tDGb2n6UkDKwez+Y8PZDhE3D143v6Gepp4Hg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-plugin-utils": "^7.14.5", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" } }, "@babel/plugin-proposal-numeric-separator": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.14.2.tgz", - "integrity": "sha512-DcTQY9syxu9BpU3Uo94fjCB3LN9/hgPS8oUL7KrSW3bA2ePrKZZPJcc5y0hoJAM9dft3pGfErtEUvxXQcfLxUg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.14.5.tgz", + "integrity": "sha512-yiclALKe0vyZRZE0pS6RXgjUOt87GWv6FYa5zqj15PvhOGFO69R5DusPlgK/1K5dVnCtegTiWu9UaBSrLLJJBg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-plugin-utils": "^7.14.5", "@babel/plugin-syntax-numeric-separator": "^7.10.4" } }, "@babel/plugin-proposal-object-rest-spread": { - "version": "7.14.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.14.4.tgz", - "integrity": "sha512-AYosOWBlyyXEagrPRfLJ1enStufsr7D1+ddpj8OLi9k7B6+NdZ0t/9V7Fh+wJ4g2Jol8z2JkgczYqtWrZd4vbA==", + "version": "7.14.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.14.7.tgz", + "integrity": "sha512-082hsZz+sVabfmDWo1Oct1u1AgbKbUAyVgmX4otIc7bdsRgHBXwTwb3DpDmD4Eyyx6DNiuz5UAATT655k+kL5g==", "dev": true, "requires": { - "@babel/compat-data": "^7.14.4", - "@babel/helper-compilation-targets": "^7.14.4", - "@babel/helper-plugin-utils": "^7.13.0", + "@babel/compat-data": "^7.14.7", + "@babel/helper-compilation-targets": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.14.2" + "@babel/plugin-transform-parameters": "^7.14.5" } }, "@babel/plugin-proposal-optional-catch-binding": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.14.2.tgz", - "integrity": "sha512-XtkJsmJtBaUbOxZsNk0Fvrv8eiqgneug0A6aqLFZ4TSkar2L5dSXWcnUKHgmjJt49pyB/6ZHvkr3dPgl9MOWRQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.14.5.tgz", + "integrity": "sha512-3Oyiixm0ur7bzO5ybNcZFlmVsygSIQgdOa7cTfOYCMY+wEPAYhZAJxi3mixKFCTCKUhQXuCTtQ1MzrpL3WT8ZQ==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-plugin-utils": "^7.14.5", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" } }, "@babel/plugin-proposal-optional-chaining": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.14.2.tgz", - "integrity": "sha512-qQByMRPwMZJainfig10BoaDldx/+VDtNcrA7qdNaEOAj6VXud+gfrkA8j4CRAU5HjnWREXqIpSpH30qZX1xivA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.14.5.tgz", + "integrity": "sha512-ycz+VOzo2UbWNI1rQXxIuMOzrDdHGrI23fRiz/Si2R4kv2XZQ1BK8ccdHwehMKBlcH/joGW/tzrUmo67gbJHlQ==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.12.1", + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.14.5", "@babel/plugin-syntax-optional-chaining": "^7.8.3" } }, "@babel/plugin-proposal-private-methods": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.13.0.tgz", - "integrity": "sha512-MXyyKQd9inhx1kDYPkFRVOBXQ20ES8Pto3T7UZ92xj2mY0EVD8oAVzeyYuVfy/mxAdTSIayOvg+aVzcHV2bn6Q==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.14.5.tgz", + "integrity": "sha512-838DkdUA1u+QTCplatfq4B7+1lnDa/+QMI89x5WZHBcnNv+47N8QEj2k9I2MUU9xIv8XJ4XvPCviM/Dj7Uwt9g==", "dev": true, "requires": { - "@babel/helper-create-class-features-plugin": "^7.13.0", - "@babel/helper-plugin-utils": "^7.13.0" + "@babel/helper-create-class-features-plugin": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-proposal-private-property-in-object": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.14.0.tgz", - "integrity": "sha512-59ANdmEwwRUkLjB7CRtwJxxwtjESw+X2IePItA+RGQh+oy5RmpCh/EvVVvh5XQc3yxsm5gtv0+i9oBZhaDNVTg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-62EyfyA3WA0mZiF2e2IV9mc9Ghwxcg8YTu8BS4Wss4Y3PY725OmS9M0qLORbJwLqFtGh+jiE4wAmocK2CTUK2Q==", "dev": true, "requires": { - "@babel/helper-annotate-as-pure": "^7.12.13", - "@babel/helper-create-class-features-plugin": "^7.14.0", - "@babel/helper-plugin-utils": "^7.13.0", - "@babel/plugin-syntax-private-property-in-object": "^7.14.0" + "@babel/helper-annotate-as-pure": "^7.14.5", + "@babel/helper-create-class-features-plugin": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" } }, "@babel/plugin-proposal-unicode-property-regex": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.13.tgz", - "integrity": "sha512-XyJmZidNfofEkqFV5VC/bLabGmO5QzenPO/YOfGuEbgU+2sSwMmio3YLb4WtBgcmmdwZHyVyv8on77IUjQ5Gvg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.14.5.tgz", + "integrity": "sha512-6axIeOU5LnY471KenAB9vI8I5j7NQ2d652hIYwVyRfgaZT5UpiqFKCuVXCDMSrU+3VFafnu2c5m3lrWIlr6A5Q==", "dev": true, "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.12.13", - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-create-regexp-features-plugin": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-syntax-async-generators": { @@ -500,12 +499,12 @@ } }, "@babel/plugin-syntax-class-static-block": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.12.13.tgz", - "integrity": "sha512-ZmKQ0ZXR0nYpHZIIuj9zE7oIqCx2hw9TKi+lIo73NNrMPAZGHfS92/VRV0ZmPj6H2ffBgyFHXvJ5NYsNeEaP2A==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-syntax-dynamic-import": { @@ -590,364 +589,364 @@ } }, "@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.0.tgz", - "integrity": "sha512-bda3xF8wGl5/5btF794utNOL0Jw+9jE5C1sLZcoK7c4uonE/y3iQiyG+KbkF3WBV/paX58VCpjhxLPkdj5Fe4w==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-syntax-top-level-await": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.13.tgz", - "integrity": "sha512-A81F9pDwyS7yM//KwbCSDqy3Uj4NMIurtplxphWxoYtNPov7cJsDkAFNNyVlIZ3jwGycVsurZ+LtOA8gZ376iQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-arrow-functions": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.13.0.tgz", - "integrity": "sha512-96lgJagobeVmazXFaDrbmCLQxBysKu7U6Do3mLsx27gf5Dk85ezysrs2BZUpXD703U/Su1xTBDxxar2oa4jAGg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.14.5.tgz", + "integrity": "sha512-KOnO0l4+tD5IfOdi4x8C1XmEIRWUjNRV8wc6K2vz/3e8yAOoZZvsRXRRIF/yo/MAOFb4QjtAw9xSxMXbSMRy8A==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-async-to-generator": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.13.0.tgz", - "integrity": "sha512-3j6E004Dx0K3eGmhxVJxwwI89CTJrce7lg3UrtFuDAVQ/2+SJ/h/aSFOeE6/n0WB1GsOffsJp6MnPQNQ8nmwhg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.14.5.tgz", + "integrity": "sha512-szkbzQ0mNk0rpu76fzDdqSyPu0MuvpXgC+6rz5rpMb5OIRxdmHfQxrktL8CYolL2d8luMCZTR0DpIMIdL27IjA==", "dev": true, "requires": { - "@babel/helper-module-imports": "^7.12.13", - "@babel/helper-plugin-utils": "^7.13.0", - "@babel/helper-remap-async-to-generator": "^7.13.0" + "@babel/helper-module-imports": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/helper-remap-async-to-generator": "^7.14.5" } }, "@babel/plugin-transform-block-scoped-functions": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.13.tgz", - "integrity": "sha512-zNyFqbc3kI/fVpqwfqkg6RvBgFpC4J18aKKMmv7KdQ/1GgREapSJAykLMVNwfRGO3BtHj3YQZl8kxCXPcVMVeg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.14.5.tgz", + "integrity": "sha512-dtqWqdWZ5NqBX3KzsVCWfQI3A53Ft5pWFCT2eCVUftWZgjc5DpDponbIF1+c+7cSGk2wN0YK7HGL/ezfRbpKBQ==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-block-scoping": { - "version": "7.14.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.14.4.tgz", - "integrity": "sha512-5KdpkGxsZlTk+fPleDtGKsA+pon28+ptYmMO8GBSa5fHERCJWAzj50uAfCKBqq42HO+Zot6JF1x37CRprwmN4g==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.14.5.tgz", + "integrity": "sha512-LBYm4ZocNgoCqyxMLoOnwpsmQ18HWTQvql64t3GvMUzLQrNoV1BDG0lNftC8QKYERkZgCCT/7J5xWGObGAyHDw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-classes": { - "version": "7.14.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.14.4.tgz", - "integrity": "sha512-p73t31SIj6y94RDVX57rafVjttNr8MvKEgs5YFatNB/xC68zM3pyosuOEcQmYsYlyQaGY9R7rAULVRcat5FKJQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.14.5.tgz", + "integrity": "sha512-J4VxKAMykM06K/64z9rwiL6xnBHgB1+FVspqvlgCdwD1KUbQNfszeKVVOMh59w3sztHYIZDgnhOC4WbdEfHFDA==", "dev": true, "requires": { - "@babel/helper-annotate-as-pure": "^7.12.13", - "@babel/helper-function-name": "^7.14.2", - "@babel/helper-optimise-call-expression": "^7.12.13", - "@babel/helper-plugin-utils": "^7.13.0", - "@babel/helper-replace-supers": "^7.14.4", - "@babel/helper-split-export-declaration": "^7.12.13", + "@babel/helper-annotate-as-pure": "^7.14.5", + "@babel/helper-function-name": "^7.14.5", + "@babel/helper-optimise-call-expression": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/helper-replace-supers": "^7.14.5", + "@babel/helper-split-export-declaration": "^7.14.5", "globals": "^11.1.0" } }, "@babel/plugin-transform-computed-properties": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.13.0.tgz", - "integrity": "sha512-RRqTYTeZkZAz8WbieLTvKUEUxZlUTdmL5KGMyZj7FnMfLNKV4+r5549aORG/mgojRmFlQMJDUupwAMiF2Q7OUg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.14.5.tgz", + "integrity": "sha512-pWM+E4283UxaVzLb8UBXv4EIxMovU4zxT1OPnpHJcmnvyY9QbPPTKZfEj31EUvG3/EQRbYAGaYEUZ4yWOBC2xg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-destructuring": { - "version": "7.14.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.14.4.tgz", - "integrity": "sha512-JyywKreTCGTUsL1OKu1A3ms/R1sTP0WxbpXlALeGzF53eB3bxtNkYdMj9SDgK7g6ImPy76J5oYYKoTtQImlhQA==", + "version": "7.14.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.14.7.tgz", + "integrity": "sha512-0mDE99nK+kVh3xlc5vKwB6wnP9ecuSj+zQCa/n0voENtP/zymdT4HH6QEb65wjjcbqr1Jb/7z9Qp7TF5FtwYGw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-dotall-regex": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.13.tgz", - "integrity": "sha512-foDrozE65ZFdUC2OfgeOCrEPTxdB3yjqxpXh8CH+ipd9CHd4s/iq81kcUpyH8ACGNEPdFqbtzfgzbT/ZGlbDeQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.14.5.tgz", + "integrity": "sha512-loGlnBdj02MDsFaHhAIJzh7euK89lBrGIdM9EAtHFo6xKygCUGuuWe07o1oZVk287amtW1n0808sQM99aZt3gw==", "dev": true, "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.12.13", - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-create-regexp-features-plugin": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-duplicate-keys": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.13.tgz", - "integrity": "sha512-NfADJiiHdhLBW3pulJlJI2NB0t4cci4WTZ8FtdIuNc2+8pslXdPtRRAEWqUY+m9kNOk2eRYbTAOipAxlrOcwwQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.14.5.tgz", + "integrity": "sha512-iJjbI53huKbPDAsJ8EmVmvCKeeq21bAze4fu9GBQtSLqfvzj2oRuHVx4ZkDwEhg1htQ+5OBZh/Ab0XDf5iBZ7A==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-exponentiation-operator": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.13.tgz", - "integrity": "sha512-fbUelkM1apvqez/yYx1/oICVnGo2KM5s63mhGylrmXUxK/IAXSIf87QIxVfZldWf4QsOafY6vV3bX8aMHSvNrA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.14.5.tgz", + "integrity": "sha512-jFazJhMBc9D27o9jDnIE5ZErI0R0m7PbKXVq77FFvqFbzvTMuv8jaAwLZ5PviOLSFttqKIW0/wxNSDbjLk0tYA==", "dev": true, "requires": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.12.13", - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-for-of": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.13.0.tgz", - "integrity": "sha512-IHKT00mwUVYE0zzbkDgNRP6SRzvfGCYsOxIRz8KsiaaHCcT9BWIkO+H9QRJseHBLOGBZkHUdHiqj6r0POsdytg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.14.5.tgz", + "integrity": "sha512-CfmqxSUZzBl0rSjpoQSFoR9UEj3HzbGuGNL21/iFTmjb5gFggJp3ph0xR1YBhexmLoKRHzgxuFvty2xdSt6gTA==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-function-name": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.13.tgz", - "integrity": "sha512-6K7gZycG0cmIwwF7uMK/ZqeCikCGVBdyP2J5SKNCXO5EOHcqi+z7Jwf8AmyDNcBgxET8DrEtCt/mPKPyAzXyqQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.14.5.tgz", + "integrity": "sha512-vbO6kv0fIzZ1GpmGQuvbwwm+O4Cbm2NrPzwlup9+/3fdkuzo1YqOZcXw26+YUJB84Ja7j9yURWposEHLYwxUfQ==", "dev": true, "requires": { - "@babel/helper-function-name": "^7.12.13", - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-function-name": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-literals": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.13.tgz", - "integrity": "sha512-FW+WPjSR7hiUxMcKqyNjP05tQ2kmBCdpEpZHY1ARm96tGQCCBvXKnpjILtDplUnJ/eHZ0lALLM+d2lMFSpYJrQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.14.5.tgz", + "integrity": "sha512-ql33+epql2F49bi8aHXxvLURHkxJbSmMKl9J5yHqg4PLtdE6Uc48CH1GS6TQvZ86eoB/ApZXwm7jlA+B3kra7A==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-member-expression-literals": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.13.tgz", - "integrity": "sha512-kxLkOsg8yir4YeEPHLuO2tXP9R/gTjpuTOjshqSpELUN3ZAg2jfDnKUvzzJxObun38sw3wm4Uu69sX/zA7iRvg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.14.5.tgz", + "integrity": "sha512-WkNXxH1VXVTKarWFqmso83xl+2V3Eo28YY5utIkbsmXoItO8Q3aZxN4BTS2k0hz9dGUloHK26mJMyQEYfkn/+Q==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-modules-amd": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.14.2.tgz", - "integrity": "sha512-hPC6XBswt8P3G2D1tSV2HzdKvkqOpmbyoy+g73JG0qlF/qx2y3KaMmXb1fLrpmWGLZYA0ojCvaHdzFWjlmV+Pw==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.14.5.tgz", + "integrity": "sha512-3lpOU8Vxmp3roC4vzFpSdEpGUWSMsHFreTWOMMLzel2gNGfHE5UWIh/LN6ghHs2xurUp4jRFYMUIZhuFbody1g==", "dev": true, "requires": { - "@babel/helper-module-transforms": "^7.14.2", - "@babel/helper-plugin-utils": "^7.13.0", + "@babel/helper-module-transforms": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", "babel-plugin-dynamic-import-node": "^2.3.3" } }, "@babel/plugin-transform-modules-commonjs": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.14.0.tgz", - "integrity": "sha512-EX4QePlsTaRZQmw9BsoPeyh5OCtRGIhwfLquhxGp5e32w+dyL8htOcDwamlitmNFK6xBZYlygjdye9dbd9rUlQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.14.5.tgz", + "integrity": "sha512-en8GfBtgnydoao2PS+87mKyw62k02k7kJ9ltbKe0fXTHrQmG6QZZflYuGI1VVG7sVpx4E1n7KBpNlPb8m78J+A==", "dev": true, "requires": { - "@babel/helper-module-transforms": "^7.14.0", - "@babel/helper-plugin-utils": "^7.13.0", - "@babel/helper-simple-access": "^7.13.12", + "@babel/helper-module-transforms": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/helper-simple-access": "^7.14.5", "babel-plugin-dynamic-import-node": "^2.3.3" } }, "@babel/plugin-transform-modules-systemjs": { - "version": "7.13.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.13.8.tgz", - "integrity": "sha512-hwqctPYjhM6cWvVIlOIe27jCIBgHCsdH2xCJVAYQm7V5yTMoilbVMi9f6wKg0rpQAOn6ZG4AOyvCqFF/hUh6+A==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.14.5.tgz", + "integrity": "sha512-mNMQdvBEE5DcMQaL5LbzXFMANrQjd2W7FPzg34Y4yEz7dBgdaC+9B84dSO+/1Wba98zoDbInctCDo4JGxz1VYA==", "dev": true, "requires": { - "@babel/helper-hoist-variables": "^7.13.0", - "@babel/helper-module-transforms": "^7.13.0", - "@babel/helper-plugin-utils": "^7.13.0", - "@babel/helper-validator-identifier": "^7.12.11", + "@babel/helper-hoist-variables": "^7.14.5", + "@babel/helper-module-transforms": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/helper-validator-identifier": "^7.14.5", "babel-plugin-dynamic-import-node": "^2.3.3" } }, "@babel/plugin-transform-modules-umd": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.14.0.tgz", - "integrity": "sha512-nPZdnWtXXeY7I87UZr9VlsWme3Y0cfFFE41Wbxz4bbaexAjNMInXPFUpRRUJ8NoMm0Cw+zxbqjdPmLhcjfazMw==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.14.5.tgz", + "integrity": "sha512-RfPGoagSngC06LsGUYyM9QWSXZ8MysEjDJTAea1lqRjNECE3y0qIJF/qbvJxc4oA4s99HumIMdXOrd+TdKaAAA==", "dev": true, "requires": { - "@babel/helper-module-transforms": "^7.14.0", - "@babel/helper-plugin-utils": "^7.13.0" + "@babel/helper-module-transforms": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.13.tgz", - "integrity": "sha512-Xsm8P2hr5hAxyYblrfACXpQKdQbx4m2df9/ZZSQ8MAhsadw06+jW7s9zsSw6he+mJZXRlVMyEnVktJo4zjk1WA==", + "version": "7.14.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.14.7.tgz", + "integrity": "sha512-DTNOTaS7TkW97xsDMrp7nycUVh6sn/eq22VaxWfEdzuEbRsiaOU0pqU7DlyUGHVsbQbSghvjKRpEl+nUCKGQSg==", "dev": true, "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.12.13" + "@babel/helper-create-regexp-features-plugin": "^7.14.5" } }, "@babel/plugin-transform-new-target": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.13.tgz", - "integrity": "sha512-/KY2hbLxrG5GTQ9zzZSc3xWiOy379pIETEhbtzwZcw9rvuaVV4Fqy7BYGYOWZnaoXIQYbbJ0ziXLa/sKcGCYEQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.14.5.tgz", + "integrity": "sha512-Nx054zovz6IIRWEB49RDRuXGI4Gy0GMgqG0cII9L3MxqgXz/+rgII+RU58qpo4g7tNEx1jG7rRVH4ihZoP4esQ==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-object-super": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.13.tgz", - "integrity": "sha512-JzYIcj3XtYspZDV8j9ulnoMPZZnF/Cj0LUxPOjR89BdBVx+zYJI9MdMIlUZjbXDX+6YVeS6I3e8op+qQ3BYBoQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.14.5.tgz", + "integrity": "sha512-MKfOBWzK0pZIrav9z/hkRqIk/2bTv9qvxHzPQc12RcVkMOzpIKnFCNYJip00ssKWYkd8Sf5g0Wr7pqJ+cmtuFg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13", - "@babel/helper-replace-supers": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/helper-replace-supers": "^7.14.5" } }, "@babel/plugin-transform-parameters": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.14.2.tgz", - "integrity": "sha512-NxoVmA3APNCC1JdMXkdYXuQS+EMdqy0vIwyDHeKHiJKRxmp1qGSdb0JLEIoPRhkx6H/8Qi3RJ3uqOCYw8giy9A==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.14.5.tgz", + "integrity": "sha512-Tl7LWdr6HUxTmzQtzuU14SqbgrSKmaR77M0OKyq4njZLQTPfOvzblNKyNkGwOfEFCEx7KeYHQHDI0P3F02IVkA==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-property-literals": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.13.tgz", - "integrity": "sha512-nqVigwVan+lR+g8Fj8Exl0UQX2kymtjcWfMOYM1vTYEKujeyv2SkMgazf2qNcK7l4SDiKyTA/nHCPqL4e2zo1A==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.14.5.tgz", + "integrity": "sha512-r1uilDthkgXW8Z1vJz2dKYLV1tuw2xsbrp3MrZmD99Wh9vsfKoob+JTgri5VUb/JqyKRXotlOtwgu4stIYCmnw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-regenerator": { - "version": "7.13.15", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.13.15.tgz", - "integrity": "sha512-Bk9cOLSz8DiurcMETZ8E2YtIVJbFCPGW28DJWUakmyVWtQSm6Wsf0p4B4BfEr/eL2Nkhe/CICiUiMOCi1TPhuQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.14.5.tgz", + "integrity": "sha512-NVIY1W3ITDP5xQl50NgTKlZ0GrotKtLna08/uGY6ErQt6VEQZXla86x/CTddm5gZdcr+5GSsvMeTmWA5Ii6pkg==", "dev": true, "requires": { "regenerator-transform": "^0.14.2" } }, "@babel/plugin-transform-reserved-words": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.13.tgz", - "integrity": "sha512-xhUPzDXxZN1QfiOy/I5tyye+TRz6lA7z6xaT4CLOjPRMVg1ldRf0LHw0TDBpYL4vG78556WuHdyO9oi5UmzZBg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.14.5.tgz", + "integrity": "sha512-cv4F2rv1nD4qdexOGsRQXJrOcyb5CrgjUH9PKrrtyhSDBNWGxd0UIitjyJiWagS+EbUGjG++22mGH1Pub8D6Vg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-shorthand-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.13.tgz", - "integrity": "sha512-xpL49pqPnLtf0tVluuqvzWIgLEhuPpZzvs2yabUHSKRNlN7ScYU7aMlmavOeyXJZKgZKQRBlh8rHbKiJDraTSw==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.14.5.tgz", + "integrity": "sha512-xLucks6T1VmGsTB+GWK5Pl9Jl5+nRXD1uoFdA5TSO6xtiNjtXTjKkmPdFXVLGlK5A2/or/wQMKfmQ2Y0XJfn5g==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-spread": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.13.0.tgz", - "integrity": "sha512-V6vkiXijjzYeFmQTr3dBxPtZYLPcUfY34DebOU27jIl2M/Y8Egm52Hw82CSjjPqd54GTlJs5x+CR7HeNr24ckg==", + "version": "7.14.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.14.6.tgz", + "integrity": "sha512-Zr0x0YroFJku7n7+/HH3A2eIrGMjbmAIbJSVv0IZ+t3U2WUQUA64S/oeied2e+MaGSjmt4alzBCsK9E8gh+fag==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.12.1" + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.14.5" } }, "@babel/plugin-transform-sticky-regex": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.13.tgz", - "integrity": "sha512-Jc3JSaaWT8+fr7GRvQP02fKDsYk4K/lYwWq38r/UGfaxo89ajud321NH28KRQ7xy1Ybc0VUE5Pz8psjNNDUglg==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.14.5.tgz", + "integrity": "sha512-Z7F7GyvEMzIIbwnziAZmnSNpdijdr4dWt+FJNBnBLz5mwDFkqIXU9wmBcWWad3QeJF5hMTkRe4dAq2sUZiG+8A==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-template-literals": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.13.0.tgz", - "integrity": "sha512-d67umW6nlfmr1iehCcBv69eSUSySk1EsIS8aTDX4Xo9qajAh6mYtcl4kJrBkGXuxZPEgVr7RVfAvNW6YQkd4Mw==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.14.5.tgz", + "integrity": "sha512-22btZeURqiepOfuy/VkFr+zStqlujWaarpMErvay7goJS6BWwdd6BY9zQyDLDa4x2S3VugxFb162IZ4m/S/+Gg==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.13.0" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-typeof-symbol": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.13.tgz", - "integrity": "sha512-eKv/LmUJpMnu4npgfvs3LiHhJua5fo/CysENxa45YCQXZwKnGCQKAg87bvoqSW1fFT+HA32l03Qxsm8ouTY3ZQ==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.14.5.tgz", + "integrity": "sha512-lXzLD30ffCWseTbMQzrvDWqljvZlHkXU+CnseMhkMNqU1sASnCsz3tSzAaH3vCUXb9PHeUb90ZT1BdFTm1xxJw==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-unicode-escapes": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.13.tgz", - "integrity": "sha512-0bHEkdwJ/sN/ikBHfSmOXPypN/beiGqjo+o4/5K+vxEFNPRPdImhviPakMKG4x96l85emoa0Z6cDflsdBusZbw==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.14.5.tgz", + "integrity": "sha512-crTo4jATEOjxj7bt9lbYXcBAM3LZaUrbP2uUdxb6WIorLmjNKSpHfIybgY4B8SRpbf8tEVIWH3Vtm7ayCrKocA==", "dev": true, "requires": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/plugin-transform-unicode-regex": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.13.tgz", - "integrity": "sha512-mDRzSNY7/zopwisPZ5kM9XKCfhchqIYwAKRERtEnhYscZB79VRekuRSoYbN0+KVe3y8+q1h6A4svXtP7N+UoCA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.14.5.tgz", + "integrity": "sha512-UygduJpC5kHeCiRw/xDVzC+wj8VaYSoKl5JNVmbP7MadpNinAm3SvZCxZ42H37KZBKztz46YC73i9yV34d0Tzw==", "dev": true, "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.12.13", - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-create-regexp-features-plugin": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5" } }, "@babel/preset-env": { - "version": "7.14.4", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.14.4.tgz", - "integrity": "sha512-GwMMsuAnDtULyOtuxHhzzuSRxFeP0aR/LNzrHRzP8y6AgDNgqnrfCCBm/1cRdTU75tRs28Eh76poHLcg9VF0LA==", - "dev": true, - "requires": { - "@babel/compat-data": "^7.14.4", - "@babel/helper-compilation-targets": "^7.14.4", - "@babel/helper-plugin-utils": "^7.13.0", - "@babel/helper-validator-option": "^7.12.17", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.13.12", - "@babel/plugin-proposal-async-generator-functions": "^7.14.2", - "@babel/plugin-proposal-class-properties": "^7.13.0", - "@babel/plugin-proposal-class-static-block": "^7.14.3", - "@babel/plugin-proposal-dynamic-import": "^7.14.2", - "@babel/plugin-proposal-export-namespace-from": "^7.14.2", - "@babel/plugin-proposal-json-strings": "^7.14.2", - "@babel/plugin-proposal-logical-assignment-operators": "^7.14.2", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.2", - "@babel/plugin-proposal-numeric-separator": "^7.14.2", - "@babel/plugin-proposal-object-rest-spread": "^7.14.4", - "@babel/plugin-proposal-optional-catch-binding": "^7.14.2", - "@babel/plugin-proposal-optional-chaining": "^7.14.2", - "@babel/plugin-proposal-private-methods": "^7.13.0", - "@babel/plugin-proposal-private-property-in-object": "^7.14.0", - "@babel/plugin-proposal-unicode-property-regex": "^7.12.13", + "version": "7.14.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.14.7.tgz", + "integrity": "sha512-itOGqCKLsSUl0Y+1nSfhbuuOlTs0MJk2Iv7iSH+XT/mR8U1zRLO7NjWlYXB47yhK4J/7j+HYty/EhFZDYKa/VA==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.14.7", + "@babel/helper-compilation-targets": "^7.14.5", + "@babel/helper-plugin-utils": "^7.14.5", + "@babel/helper-validator-option": "^7.14.5", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.14.5", + "@babel/plugin-proposal-async-generator-functions": "^7.14.7", + "@babel/plugin-proposal-class-properties": "^7.14.5", + "@babel/plugin-proposal-class-static-block": "^7.14.5", + "@babel/plugin-proposal-dynamic-import": "^7.14.5", + "@babel/plugin-proposal-export-namespace-from": "^7.14.5", + "@babel/plugin-proposal-json-strings": "^7.14.5", + "@babel/plugin-proposal-logical-assignment-operators": "^7.14.5", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5", + "@babel/plugin-proposal-numeric-separator": "^7.14.5", + "@babel/plugin-proposal-object-rest-spread": "^7.14.7", + "@babel/plugin-proposal-optional-catch-binding": "^7.14.5", + "@babel/plugin-proposal-optional-chaining": "^7.14.5", + "@babel/plugin-proposal-private-methods": "^7.14.5", + "@babel/plugin-proposal-private-property-in-object": "^7.14.5", + "@babel/plugin-proposal-unicode-property-regex": "^7.14.5", "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-namespace-from": "^7.8.3", "@babel/plugin-syntax-json-strings": "^7.8.3", @@ -957,46 +956,46 @@ "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.0", - "@babel/plugin-syntax-top-level-await": "^7.12.13", - "@babel/plugin-transform-arrow-functions": "^7.13.0", - "@babel/plugin-transform-async-to-generator": "^7.13.0", - "@babel/plugin-transform-block-scoped-functions": "^7.12.13", - "@babel/plugin-transform-block-scoping": "^7.14.4", - "@babel/plugin-transform-classes": "^7.14.4", - "@babel/plugin-transform-computed-properties": "^7.13.0", - "@babel/plugin-transform-destructuring": "^7.14.4", - "@babel/plugin-transform-dotall-regex": "^7.12.13", - "@babel/plugin-transform-duplicate-keys": "^7.12.13", - "@babel/plugin-transform-exponentiation-operator": "^7.12.13", - "@babel/plugin-transform-for-of": "^7.13.0", - "@babel/plugin-transform-function-name": "^7.12.13", - "@babel/plugin-transform-literals": "^7.12.13", - "@babel/plugin-transform-member-expression-literals": "^7.12.13", - "@babel/plugin-transform-modules-amd": "^7.14.2", - "@babel/plugin-transform-modules-commonjs": "^7.14.0", - "@babel/plugin-transform-modules-systemjs": "^7.13.8", - "@babel/plugin-transform-modules-umd": "^7.14.0", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.12.13", - "@babel/plugin-transform-new-target": "^7.12.13", - "@babel/plugin-transform-object-super": "^7.12.13", - "@babel/plugin-transform-parameters": "^7.14.2", - "@babel/plugin-transform-property-literals": "^7.12.13", - "@babel/plugin-transform-regenerator": "^7.13.15", - "@babel/plugin-transform-reserved-words": "^7.12.13", - "@babel/plugin-transform-shorthand-properties": "^7.12.13", - "@babel/plugin-transform-spread": "^7.13.0", - "@babel/plugin-transform-sticky-regex": "^7.12.13", - "@babel/plugin-transform-template-literals": "^7.13.0", - "@babel/plugin-transform-typeof-symbol": "^7.12.13", - "@babel/plugin-transform-unicode-escapes": "^7.12.13", - "@babel/plugin-transform-unicode-regex": "^7.12.13", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-transform-arrow-functions": "^7.14.5", + "@babel/plugin-transform-async-to-generator": "^7.14.5", + "@babel/plugin-transform-block-scoped-functions": "^7.14.5", + "@babel/plugin-transform-block-scoping": "^7.14.5", + "@babel/plugin-transform-classes": "^7.14.5", + "@babel/plugin-transform-computed-properties": "^7.14.5", + "@babel/plugin-transform-destructuring": "^7.14.7", + "@babel/plugin-transform-dotall-regex": "^7.14.5", + "@babel/plugin-transform-duplicate-keys": "^7.14.5", + "@babel/plugin-transform-exponentiation-operator": "^7.14.5", + "@babel/plugin-transform-for-of": "^7.14.5", + "@babel/plugin-transform-function-name": "^7.14.5", + "@babel/plugin-transform-literals": "^7.14.5", + "@babel/plugin-transform-member-expression-literals": "^7.14.5", + "@babel/plugin-transform-modules-amd": "^7.14.5", + "@babel/plugin-transform-modules-commonjs": "^7.14.5", + "@babel/plugin-transform-modules-systemjs": "^7.14.5", + "@babel/plugin-transform-modules-umd": "^7.14.5", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.14.7", + "@babel/plugin-transform-new-target": "^7.14.5", + "@babel/plugin-transform-object-super": "^7.14.5", + "@babel/plugin-transform-parameters": "^7.14.5", + "@babel/plugin-transform-property-literals": "^7.14.5", + "@babel/plugin-transform-regenerator": "^7.14.5", + "@babel/plugin-transform-reserved-words": "^7.14.5", + "@babel/plugin-transform-shorthand-properties": "^7.14.5", + "@babel/plugin-transform-spread": "^7.14.6", + "@babel/plugin-transform-sticky-regex": "^7.14.5", + "@babel/plugin-transform-template-literals": "^7.14.5", + "@babel/plugin-transform-typeof-symbol": "^7.14.5", + "@babel/plugin-transform-unicode-escapes": "^7.14.5", + "@babel/plugin-transform-unicode-regex": "^7.14.5", "@babel/preset-modules": "^0.1.4", - "@babel/types": "^7.14.4", - "babel-plugin-polyfill-corejs2": "^0.2.0", - "babel-plugin-polyfill-corejs3": "^0.2.0", - "babel-plugin-polyfill-regenerator": "^0.2.0", - "core-js-compat": "^3.9.0", + "@babel/types": "^7.14.5", + "babel-plugin-polyfill-corejs2": "^0.2.2", + "babel-plugin-polyfill-corejs3": "^0.2.2", + "babel-plugin-polyfill-regenerator": "^0.2.2", + "core-js-compat": "^3.15.0", "semver": "^6.3.0" } }, @@ -1014,48 +1013,49 @@ } }, "@babel/runtime": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.0.tgz", - "integrity": "sha512-JELkvo/DlpNdJ7dlyw/eY7E0suy5i5GQH+Vlxaq1nsNJ+H7f4Vtv3jMeCEgRhZZQFXTjldYfQgv2qmM6M1v5wA==", + "version": "7.14.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.6.tgz", + "integrity": "sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg==", "dev": true, "requires": { "regenerator-runtime": "^0.13.4" } }, "@babel/template": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.12.13.tgz", - "integrity": "sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.14.5.tgz", + "integrity": "sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g==", "dev": true, "requires": { - "@babel/code-frame": "^7.12.13", - "@babel/parser": "^7.12.13", - "@babel/types": "^7.12.13" + "@babel/code-frame": "^7.14.5", + "@babel/parser": "^7.14.5", + "@babel/types": "^7.14.5" } }, "@babel/traverse": { - "version": "7.14.2", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.2.tgz", - "integrity": "sha512-TsdRgvBFHMyHOOzcP9S6QU0QQtjxlRpEYOy3mcCO5RgmC305ki42aSAmfZEMSSYBla2oZ9BMqYlncBaKmD/7iA==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.12.13", - "@babel/generator": "^7.14.2", - "@babel/helper-function-name": "^7.14.2", - "@babel/helper-split-export-declaration": "^7.12.13", - "@babel/parser": "^7.14.2", - "@babel/types": "^7.14.2", + "version": "7.14.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.14.7.tgz", + "integrity": "sha512-9vDr5NzHu27wgwejuKL7kIOm4bwEtaPQ4Z6cpCmjSuaRqpH/7xc4qcGEscwMqlkwgcXl6MvqoAjZkQ24uSdIZQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.14.5", + "@babel/generator": "^7.14.5", + "@babel/helper-function-name": "^7.14.5", + "@babel/helper-hoist-variables": "^7.14.5", + "@babel/helper-split-export-declaration": "^7.14.5", + "@babel/parser": "^7.14.7", + "@babel/types": "^7.14.5", "debug": "^4.1.0", "globals": "^11.1.0" } }, "@babel/types": { - "version": "7.14.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.4.tgz", - "integrity": "sha512-lCj4aIs0xUefJFQnwwQv2Bxg7Omd6bgquZ6LGC+gGMh6/s5qDVfjuCMlDmYQ15SLsWHd9n+X3E75lKIhl5Lkiw==", + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.14.5.tgz", + "integrity": "sha512-M/NzBpEL95I5Hh4dwhin5JlE7EzO5PHMAuzjxss3tiOBD46KfQvVedN/3jEPZvdRvtsK2222XfdHogNIttFgcg==", "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.14.0", + "@babel/helper-validator-identifier": "^7.14.5", "to-fast-properties": "^2.0.0" } }, @@ -1564,13 +1564,13 @@ } }, "babel-plugin-polyfill-corejs3": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.2.2.tgz", - "integrity": "sha512-l1Cf8PKk12eEk5QP/NQ6TH8A1pee6wWDJ96WjxrMXFLHLOBFzYM4moG80HFgduVhTqAFez4alnZKEhP/bYHg0A==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.2.3.tgz", + "integrity": "sha512-rCOFzEIJpJEAU14XCcV/erIf/wZQMmMT5l5vXOpL5uoznyOGfDIjPj6FVytMvtzaKSTSVKouOCTPJ5OMUZH30g==", "dev": true, "requires": { "@babel/helper-define-polyfill-provider": "^0.2.2", - "core-js-compat": "^3.9.1" + "core-js-compat": "^3.14.0" } }, "babel-plugin-polyfill-regenerator": { @@ -1998,9 +1998,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001230", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001230.tgz", - "integrity": "sha512-5yBd5nWCBS+jWKTcHOzXwo5xzcj4ePE/yjtkZyUV1BTUmrBaA9MRGC+e7mxnqXSA90CmCA8L3eKLaSUkt099IQ==", + "version": "1.0.30001241", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001241.tgz", + "integrity": "sha512-1uoSZ1Pq1VpH0WerIMqwptXHNNGfdl7d1cJUFs80CwQ/lVzdhTvsFZCeNFslze7AjsQnb4C85tzclPa1VShbeQ==", "dev": true }, "caseless": { @@ -2021,20 +2021,20 @@ } }, "chokidar": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", - "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", + "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==", "dev": true, "optional": true, "requires": { - "anymatch": "~3.1.1", + "anymatch": "~3.1.2", "braces": "~3.0.2", - "fsevents": "~2.3.1", - "glob-parent": "~5.1.0", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", - "readdirp": "~3.5.0" + "readdirp": "~3.6.0" }, "dependencies": { "braces": { @@ -2325,9 +2325,9 @@ "dev": true }, "convert-source-map": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", - "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", + "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", "dev": true, "requires": { "safe-buffer": "~5.1.1" @@ -2457,9 +2457,9 @@ } }, "core-js-compat": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.13.1.tgz", - "integrity": "sha512-mdrcxc0WznfRd8ZicEZh1qVeJ2mu6bwQFh8YVUK48friy/FOwFV5EJj9/dlh+nMQ74YusdVfBFDuomKgUspxWQ==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.15.2.tgz", + "integrity": "sha512-Wp+BJVvwopjI+A1EFqm2dwUmWYXrvucmtIB2LgXn/Rb+gWPKYxtmb4GKHGKG/KGF1eK9jfjzT38DITbTOCX/SQ==", "dev": true, "requires": { "browserslist": "^4.16.6", @@ -3008,9 +3008,9 @@ } }, "electron-to-chromium": { - "version": "1.3.742", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.742.tgz", - "integrity": "sha512-ihL14knI9FikJmH2XUIDdZFWJxvr14rPSdOhJ7PpS27xbz8qmaRwCwyg/bmFwjWKmWK9QyamiCZVCvXm5CH//Q==", + "version": "1.3.764", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.764.tgz", + "integrity": "sha512-nI8fb0ePu2LjzGQMoJ2j4wCnpbSMtuXmOZz/dFAduroICL/B9rU6Iwck/oTvXdzZCfN3ZdU5mpY4XCizU2saow==", "dev": true }, "elliptic": { @@ -4839,18 +4839,18 @@ } }, "mime-db": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.47.0.tgz", - "integrity": "sha512-QBmA/G2y+IfeS4oktet3qRZ+P5kPhCKRXxXnQEudYqUaEioAU1/Lq2us3D/t1Jfo4hE9REQPrbB7K5sOczJVIw==", + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.48.0.tgz", + "integrity": "sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ==", "dev": true }, "mime-types": { - "version": "2.1.30", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.30.tgz", - "integrity": "sha512-crmjA4bLtR8m9qLpHvgxSChT+XoSlZi8J4n/aIdn3z92e/U47Z0V/yl+Wh9W046GgFVAmoNR/fmdbZYcSSIUeg==", + "version": "2.1.31", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.31.tgz", + "integrity": "sha512-XGZnNzm3QvgKxa8dpzyhFTHmpP3l5YNusmne07VUOXxou9CqUqYa/HBy124RqtVh/O2pECas/MOcsDgpilPOPg==", "dev": true, "requires": { - "mime-db": "1.47.0" + "mime-db": "1.48.0" } }, "mini-css-extract-plugin": { @@ -5128,9 +5128,9 @@ "dev": true }, "node-releases": { - "version": "1.1.72", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.72.tgz", - "integrity": "sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw==", + "version": "1.1.73", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.73.tgz", + "integrity": "sha512-uW7fodD6pyW2FZNZnp/Z3hvWKeEW1Y8R1+1CnErE8cXFXzl5blBOoVB41CvMer6P6Q0S5FXDwcHgFd1Wj0U9zg==", "dev": true }, "node-sass": { @@ -5387,9 +5387,9 @@ } }, "optimize-css-assets-webpack-plugin": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/optimize-css-assets-webpack-plugin/-/optimize-css-assets-webpack-plugin-5.0.6.tgz", - "integrity": "sha512-JAYw7WrIAIuHWoKeSBB3lJ6ZG9PSDK3JJduv/FMpIY060wvbA8Lqn/TCtxNGICNlg0X5AGshLzIhpYrkltdq+A==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/optimize-css-assets-webpack-plugin/-/optimize-css-assets-webpack-plugin-5.0.8.tgz", + "integrity": "sha512-mgFS1JdOtEGzD8l+EuISqL57cKO+We9GcoiQEmdCWRqqck+FGNmYJtx9qfAPzEz+lRrlThWMuGDaRkI/yWNx/Q==", "dev": true, "requires": { "cssnano": "^4.1.10", @@ -5634,9 +5634,9 @@ "dev": true }, "postcss": { - "version": "7.0.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.35.tgz", - "integrity": "sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg==", + "version": "7.0.36", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.36.tgz", + "integrity": "sha512-BebJSIUMwJHRH0HAQoxN4u1CN86glsrwsW0q7T+/m44eXOUAxSNdHRkNZPYz5vVUbg17hFgOQDE7fZk7li3pZw==", "dev": true, "requires": { "chalk": "^2.4.2", @@ -6419,9 +6419,9 @@ } }, "readdirp": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", - "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "optional": true, "requires": { diff --git a/config/config.exs b/config/config.exs index ffd231f..8d017e1 100644 --- a/config/config.exs +++ b/config/config.exs @@ -13,10 +13,10 @@ config :deep_thought, # Configures the endpoint config :deep_thought, DeepThoughtWeb.Endpoint, url: [host: "localhost"], - secret_key_base: "02c5zaZSAEWFRKzGQI3woV+givqta4RT6oif5uWeX00saQNkdUcj/vLJJDdcwjQr", + secret_key_base: "6a4n+/AnbDyHlOT1rYbUNdVa85/S9Egb4k55q/a54JKBihccTJDb9DHHxGUGv2LC", render_errors: [view: DeepThoughtWeb.ErrorView, accepts: ~w(html json), layout: false], pubsub_server: DeepThought.PubSub, - live_view: [signing_salt: "lVgWDZGI"] + live_view: [signing_salt: "yBzcq+Em"] # Configures Elixir's Logger config :logger, :console, @@ -31,4 +31,3 @@ config :tesla, adapter: Tesla.Adapter.Hackney # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env()}.exs" -import_config "config.secret.exs" diff --git a/config/config.secret.exs b/config/config.secret.exs index 6d0f83c..78a17b6 100644 --- a/config/config.secret.exs +++ b/config/config.secret.exs @@ -14,6 +14,9 @@ slack_signing_secret = System.get_env("SLACK_SIGNING_SECRET") || raise "environment variable SLACK_SIGNING_SECRET is missing." +slack_feedback_channel = System.get_env("SLACK_FEEDBACK_CHANNEL") + config :deep_thought, :slack, bot_token: slack_bot_token, + feedback_channel: slack_feedback_channel, signing_secret: slack_signing_secret diff --git a/config/dev.exs b/config/dev.exs index 3574917..2990c83 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -74,3 +74,5 @@ config :phoenix, :stacktrace_depth, 20 # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime + +import_config "config.secret.exs" diff --git a/config/prod.exs b/config/prod.exs index 013b298..6d7de7e 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -56,4 +56,5 @@ config :logger, level: :info # Finally import the config/prod.secret.exs which loads secrets # and configuration from environment variables. +import_config "config.secret.exs" import_config "prod.secret.exs" diff --git a/config/test.exs b/config/test.exs index dab3f50..13af537 100644 --- a/config/test.exs +++ b/config/test.exs @@ -20,3 +20,11 @@ config :deep_thought, DeepThoughtWeb.Endpoint, # Print only warnings and errors during test config :logger, level: :warn + +config :tesla, adapter: Tesla.Mock + +config :deep_thought, :deepl, auth_key: "auth_key" + +config :deep_thought, :slack, + bot_token: "bot_token", + signing_secret: "signing_secret" diff --git a/elixir_buildpack.config b/elixir_buildpack.config index 7ffcdc8..d405454 100644 --- a/elixir_buildpack.config +++ b/elixir_buildpack.config @@ -1,2 +1,2 @@ -elixir_version=1.11.4 -erlang_version=23.2.7.1 +elixir_version=1.12.1 +erlang_version=24.0.3 diff --git a/lib/deep_thought/action_supervisor.ex b/lib/deep_thought/action_supervisor.ex new file mode 100644 index 0000000..12a68ee --- /dev/null +++ b/lib/deep_thought/action_supervisor.ex @@ -0,0 +1,27 @@ +defmodule DeepThought.ActionSupervisor do + @moduledoc """ + Module invoked from `DeepThoughtWeb.ActionController` responsible for handling user interactions through Slack + actions, such as clicking on button in an overflow menu or issuing a Slack command. + """ + + @opts [restart: :transient] + + @doc """ + Determines the appropriate handler for an action + """ + @spec process(map(), map()) :: DynamicSupervisor.on_start_child() + def process( + %{"action_id" => "delete_overflow", "selected_option" => %{"value" => "delete"}} = action, + context + ), + do: + Task.Supervisor.start_child( + __MODULE__, + DeepThought.Slack.Handler.Delete, + :delete_message, + [action, context], + @opts + ) + + def process(_action, _context), do: nil +end diff --git a/lib/deep_thought/application.ex b/lib/deep_thought/application.ex index 4ce920d..22e142b 100644 --- a/lib/deep_thought/application.ex +++ b/lib/deep_thought/application.ex @@ -17,11 +17,9 @@ defmodule DeepThought.Application do DeepThoughtWeb.Endpoint, # Start a worker by calling: DeepThought.Worker.start_link(arg) # {DeepThought.Worker, arg} - {Task.Supervisor, - name: DeepThought.TranslatorSupervisor, - restart: :transient, - max_restarts: 3, - max_seconds: 15} + {Task.Supervisor, name: DeepThought.ActionSupervisor, max_restarts: 3, max_seconds: 60}, + {Task.Supervisor, name: DeepThought.CommandSupervisor, max_restarts: 3, max_seconds: 60}, + {Task.Supervisor, name: DeepThought.EventSupervisor, max_restarts: 3, max_seconds: 60} ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/deep_thought/command_supervisor.ex b/lib/deep_thought/command_supervisor.ex new file mode 100644 index 0000000..72f4bef --- /dev/null +++ b/lib/deep_thought/command_supervisor.ex @@ -0,0 +1,24 @@ +defmodule DeepThought.CommandSupervisor do + @moduledoc """ + Module invoked from `DeepThoughtWeb.CommandController` responsible for dispatching the received event into an + appropriate event handler function. + """ + + @opts [restart: :transient] + + @doc """ + Determines the appropriate handler for the command and dispatches the command details to a command handler. + """ + @spec process(String.t(), map()) :: DynamicSupervisor.on_start_child() | nonempty_list() + def process("/translate", command), + do: + Task.Supervisor.start_child( + __MODULE__, + DeepThought.Slack.Handler.Translate, + :translate, + [command], + @opts + ) + + def process(_type, _command), do: nil +end diff --git a/lib/deep_thought/deepl/api.ex b/lib/deep_thought/deepl/api.ex index f2f7289..ca1750b 100644 --- a/lib/deep_thought/deepl/api.ex +++ b/lib/deep_thought/deepl/api.ex @@ -1,35 +1,41 @@ defmodule DeepThought.DeepL.API do - use Tesla + @moduledoc """ + Module used to interact with the DeepL translation API. In order to use functions defined in this module, + an environmental variable `DEEPL_AUTH_KEY` (which gets loaded into application config) is required, containing the + value of the DeepL auth key generated in your DeepL Pro account. Currently, the paid version of the DeepL API is + always called, which doesn’t work with free auth key. + """ - plug(Tesla.Middleware.BaseUrl, "https://api.deepl.com/v2") - plug(Tesla.Middleware.EncodeFormUrlencoded) - plug(Tesla.Middleware.DecodeJson) + use Tesla - @auth_key Application.get_env(:deep_thought, :deepl)[:auth_key] - @headers [{"content-type", "application/x-www-form-urlencoded"}] + plug Tesla.Middleware.BaseUrl, "https://api.deepl.com/v2" + plug Tesla.Middleware.Query, auth_key: Application.get_env(:deep_thought, :deepl)[:auth_key] + plug Tesla.Middleware.EncodeFormUrlencoded + plug Tesla.Middleware.DecodeJson + plug Tesla.Middleware.Logger + @doc """ + Invoke DeepL’s translation API, converting `text` into a translation in `target_language`. + """ + @spec translate(String.t(), String.t()) :: {:ok, String.t()} | {:error, String.t()} def translate(text, target_language) do - with {:ok, response} <- - post("/translate", translate_request_body(text, target_language), headers: @headers), - body when is_map(body) <- response.body(), - [translation | _] <- Map.get(body, "translations"), - text <- Map.get(translation, "text") do - {:ok, text} - else - {:error, error} -> - {:error, error} + {:ok, response} = post("/translate", translate_request_body(text, target_language)) + + case response.status() do + 200 -> + {:ok, Enum.at(response.body()["translations"], 0)["text"]} _ -> - {:ok, ":x: Failed to translate due to an unexpected response from DeepL API"} + {:error, "Failed to translate due to an unexpected response from translation server"} end end + @spec translate_request_body(String.t(), String.t()) :: %{String.t() => String.t()} defp translate_request_body(text, target_language), do: %{ - "auth_key" => @auth_key, "text" => text, "target_lang" => target_language, "tag_handling" => "xml", - "ignore_tags" => "emoji,link,username" + "ignore_tags" => "c,d,e,l,u" } end diff --git a/lib/deep_thought/deepl/simple_translator.ex b/lib/deep_thought/deepl/simple_translator.ex deleted file mode 100644 index 62fdc41..0000000 --- a/lib/deep_thought/deepl/simple_translator.ex +++ /dev/null @@ -1,42 +0,0 @@ -defmodule DeepThought.DeepL.SimpleTranslator do - alias DeepThought.DeepL - alias DeepThought.Slack - alias DeepThought.Slack.LanguageConverter - - def simple_translate(channel_id, text, username, user_id) do - [target_language, message_text] = String.split(text, " ", parts: 2) - - case String.trim(target_language, ":") - |> LanguageConverter.reaction_to_lang() do - {:ok, target_language} -> - {:ok, translation} = DeepL.API.translate(message_text, target_language) - - generate_message(translation, message_text, username) - |> say_in_channel(channel_id) - - :error -> - unsupported_language() - |> say_privately(channel_id, user_id) - end - end - - defp say_in_channel(text, channel_id) do - Slack.API.chat_post_message(channel_id, text) - end - - defp say_privately(text, channel_id, user_id) do - Slack.API.chat_post_ephemeral(channel_id, user_id, text) - end - - defp generate_message(translation, original_message, username) do - "_@" <> - username <> - " asked me to translate this:_ " <> - translation <> "\n_The original message was:_ " <> original_message - end - - defp unsupported_language, - do: - "Sorry, that doesn’t look like a language I can translate to just yet… " <> - "Can you please make sure that the first word after the `/translate` command is a language shorthand or flag emoji?" -end diff --git a/lib/deep_thought/deepl/translator.ex b/lib/deep_thought/deepl/translator.ex deleted file mode 100644 index 0959fa2..0000000 --- a/lib/deep_thought/deepl/translator.ex +++ /dev/null @@ -1,182 +0,0 @@ -defmodule DeepThought.DeepL.Translator do - alias DeepThought.DeepL - alias DeepThought.Slack - alias DeepThought.Slack.LanguageConverter - - def translate(event_details, reaction, channel_id, message_ts) do - case LanguageConverter.reaction_to_lang(reaction) do - {:ok, language} -> - unless Slack.recently_translated?(channel_id, message_ts, language) do - {:ok, [message | _]} = Slack.API.conversations_replies(channel_id, message_ts) - message_text = escape_message_text(message) - {:ok, translation} = DeepL.API.translate(message_text, language) - translatedText = handle_usernames(translation) - - :ok = - say_in_thread(channel_id, translatedText, message, message_text |> handle_usernames) - - params = create_translation_event_params(event_details, language) - - Slack.create_event(params) - end - - :error -> - nil - end - end - - def delete(payload) do - %{ - "actions" => [%{"selected_option" => %{"value" => "delete"}}], - "container" => %{ - "channel_id" => channel_id, - "message_ts" => message_ts, - "thread_ts" => thread_ts - }, - "user" => %{"id" => user_id} - } = Jason.decode!(payload) - - :ok = Slack.API.chat_delete(channel_id, message_ts) - message = "I deleted the translation." - - Slack.API.chat_post_ephemeral(channel_id, user_id, message, thread_ts) - end - - defp handle_usernames(message_text) do - message_text - |> unescape_message_text() - |> collect_user_ids() - |> load_cached_user_ids() - |> replace_all_user_ids() - end - - defp load_cached_user_ids({message_text, user_ids}) do - resolved_usernames = Slack.find_users(user_ids) - - resolved_user_ids = - resolved_usernames - |> Enum.map(& &1.user_id) - - {message_text, resolved_usernames, user_ids -- resolved_user_ids} - end - - defp replace_all_user_ids({message_text, resolved, unresolved_user_ids}) do - stream = - Task.async_stream(unresolved_user_ids, fn user_id -> - case Slack.API.users_profile_get(user_id) do - {:ok, %{"real_name" => real_name}} -> - {user_id, real_name} - - {:ok, nil} -> - {user_id, ""} - - {:error, _error} -> - {user_id, ""} - end - end) - - usernames = - Enum.reduce(stream, [], fn {:ok, {user_id, real_name}}, acc -> - [%{user_id: user_id, real_name: real_name} | acc] - end) - - Slack.upsert_users(usernames) - - Enum.reduce( - usernames ++ resolved, - message_text, - fn %{real_name: real_name, user_id: user_id}, acc -> - String.replace(acc, "<@#{user_id}>", " `@#{real_name}`") - end - ) - end - - defp collect_user_ids(message_text) do - {message_text, - Regex.scan(~r/<@(\S+)>/i, message_text, capture: :all_but_first) - |> List.flatten() - |> Enum.uniq()} - end - - defp escape_message_text(%{"text" => message_text}) do - message_text - |> escape_usernames() - |> escape_emojis() - |> escape_links() - end - - defp escape_usernames(text) do - Regex.replace(~r/<([!@#]\S+)>/i, text, fn _, username -> - "<" <> username <> ">" - end) - end - - defp escape_emojis(text) do - Regex.replace(~r/(:\S+:)/i, text, fn _, emoji -> - "" <> emoji <> "" - end) - end - - defp escape_links(text) do - Regex.replace(~r/<(http\S+)>/i, text, fn _, link -> - "" <> link <> "" - end) - end - - defp unescape_message_text(message_text) do - message_text - |> unescape_usernames() - |> unescape_emojis() - |> unescape_links() - end - - defp unescape_usernames(text) do - Regex.replace(~r/<([!@#]\S+)><\/username>/i, text, fn - _, "!" <> global -> - "`!" <> global <> "`" - - _, "@" <> username -> - "<@" <> username <> ">" - - _, "#" <> channel_name -> - "<#" <> channel_name <> ">" - end) - end - - defp unescape_emojis(text) do - Regex.replace(~r/<\/?emoji>/i, text, "") - end - - defp unescape_links(text) do - Regex.replace(~r/<\/?link>/i, text, "") - end - - defp create_translation_event_params( - %{"item" => %{"channel" => channel_id, "ts" => message_ts}, "type" => type}, - language - ) do - %{ - "type" => type, - "target_language" => language, - "channel_id" => channel_id, - "message_ts" => message_ts - } - end - - defp say_in_thread(channel_id, text, message, original_text) do - blocks = - [ - Slack.TranslationBlock.generate(text), - Slack.FooterBlock.generate(message, channel_id, original_text) - ] - |> Jason.encode!() - - Slack.API.chat_post_message(channel_id, text, - blocks: blocks, - thread_ts: get_thread_ts(message) - ) - end - - defp get_thread_ts(%{"thread_ts" => thread_ts}), do: thread_ts - defp get_thread_ts(%{"ts" => ts}), do: ts -end diff --git a/lib/deep_thought/event_supervisor.ex b/lib/deep_thought/event_supervisor.ex new file mode 100644 index 0000000..28d8d1e --- /dev/null +++ b/lib/deep_thought/event_supervisor.ex @@ -0,0 +1,25 @@ +defmodule DeepThought.EventSupervisor do + @moduledoc """ + Module invoked from `DeepThoughtWeb.EventController` responsible for dispatching the received event into an + appropriate event handler function. In case the event handler dies, a certain number of restarts is attempted before + giving up. + """ + + @opts [restart: :transient] + + @doc """ + Determines the appropriate handler for an event and dispatches the event details to that particular event handler. + """ + @spec process(String.t(), map()) :: DynamicSupervisor.on_start_child() | nil + def process("reaction_added", event), + do: + Task.Supervisor.start_child( + __MODULE__, + DeepThought.Slack.Handler.ReactionAdded, + :reaction_added, + [event], + @opts + ) + + def process(_type, _event), do: nil +end diff --git a/lib/deep_thought/slack.ex b/lib/deep_thought/slack.ex index 160875d..f538840 100644 --- a/lib/deep_thought/slack.ex +++ b/lib/deep_thought/slack.ex @@ -5,90 +5,65 @@ defmodule DeepThought.Slack do import Ecto.Query, warn: false alias DeepThought.Repo - - alias DeepThought.Slack.{Event, User} + alias DeepThought.Slack.{Translation, User} @doc """ - Returns the list of events. - - ## Examples - - iex> list_events() - [%Event{}, ...] - + Find users by user_ids. """ - def list_events do - Repo.all(Event) + @spec find_users_by_user_ids([String.t()]) :: [User.t()] + def find_users_by_user_ids(user_ids) do + User.find_by_user_ids(user_ids) + |> Repo.all() end @doc """ - Gets a single event. - - Raises `Ecto.NoResultsError` if the Event does not exist. - - ## Examples - - iex> get_event!(123) - %Event{} - - iex> get_event!(456) - ** (Ecto.NoResultsError) - + Inserts or updates user information in database. """ - def get_event!(id), do: Repo.get!(Event, id) - - def recently_translated?(channel_id, message_ts, target_language) do - count = - Event.recently_translated(channel_id, message_ts, target_language) - |> Ecto.Query.select([q], count(q.id)) - |> Repo.one() - - count > 0 + @spec update_users!([map()]) :: [User.t()] + def update_users!(users) do + users + |> Stream.map(fn user -> User.changeset(%User{}, user) end) + |> Enum.reduce([], fn changeset, acc -> + [ + Repo.insert!(changeset, + returning: true, + conflict_target: :user_id, + on_conflict: {:replace_all_except, [:id, :user_id]} + ) + | acc + ] + end) + |> Enum.reverse() end @doc """ - Creates a event. - - ## Examples - - iex> create_event(%{field: value}) - {:ok, %Event{}} - - iex> create_event(%{field: bad_value}) - {:error, %Ecto.Changeset{}} + Determines whether message was recently translated into a given language. + """ + @spec recently_translated?(String.t(), String.t(), String.t()) :: boolean() + def recently_translated?(channel_id, message_ts, target_language), + do: + Translation.recently_translated?(channel_id, message_ts, target_language) + |> Repo.all() + |> Enum.count() > 0 + @doc """ + Marks a translation as deleted from the Slack thread. """ - def create_event(%{"type" => "url_verification"} = attrs) do - %Event{} - |> Event.url_verification_changeset(attrs) - |> Repo.insert() + @spec mark_as_deleted(String.t(), String.t()) :: Translation.r() + def mark_as_deleted(channel_id, message_ts) do + Translation.find_by_translation(channel_id, message_ts) + |> Repo.one!() + |> Translation.deletion_changeset(%{status: "deleted"}) + |> Repo.update() end - def create_event(%{"type" => "reaction_added"} = attrs) do - %Event{} - |> Event.reaction_added_changeset(attrs) + @doc """ + Creates a translation request record in database. + """ + @spec create_translation(map()) :: Translation.r() + def create_translation(attrs \\ %{}) do + %Translation{} + |> Translation.changeset(attrs) |> Repo.insert() end - - def find_users(user_ids) do - User.with_user_ids(user_ids) - |> Repo.all() - end - - def upsert_users(data) do - now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) - - data = - data - |> Enum.map(fn row -> - row - |> Map.put(:inserted_at, now) - |> Map.put(:updated_at, now) - end) - - Repo.insert_all(User, data, - conflict_target: [:user_id], - on_conflict: {:replace, [:real_name, :updated_at]} - ) - end end diff --git a/lib/deep_thought/slack/api.ex b/lib/deep_thought/slack/api.ex index c2e14a4..d391b28 100644 --- a/lib/deep_thought/slack/api.ex +++ b/lib/deep_thought/slack/api.ex @@ -1,65 +1,129 @@ defmodule DeepThought.Slack.API do + @moduledoc """ + Module used to interact with the Slack API. + """ + use Tesla + alias DeepThought.Slack.API.Message - plug(Tesla.Middleware.BaseUrl, "https://slack.com/api") + plug Tesla.Middleware.BaseUrl, "https://slack.com/api" + plug Tesla.Middleware.Headers, [{"Authorization", bearer_token()}] + plug Tesla.Middleware.JSON + plug Tesla.Middleware.Logger - plug(Tesla.Middleware.Headers, [ - {"Authorization", "Bearer " <> Application.get_env(:deep_thought, :slack)[:bot_token]} - ]) + @doc """ + Query Slack API for permalink to a given message in a channel. + """ + @type api_error :: {:error, atom() | non_neg_integer() | String.t()} + @spec chat_get_permalink(String.t(), String.t()) :: {:ok, String.t()} | api_error() + def chat_get_permalink(channel_id, message_ts) do + case get("/chat.getPermalink", query: [channel: channel_id, message_ts: message_ts]) do + {:ok, response} -> + case {response.status(), response.body()["ok"]} do + {200, true} -> {:ok, response.body()["permalink"]} + {_, false} -> {:error, response.body()["error"]} + {code, _} -> {:error, code} + end - plug(Tesla.Middleware.JSON) + {:error, error} -> + {:error, error} + end + end + @doc """ + Ask Slack API to delete a message. + """ + @spec chat_delete(String.t(), String.t()) :: :ok | api_error() def chat_delete(channel_id, message_ts) do case post("/chat.delete", %{channel: channel_id, ts: message_ts}) do - {:ok, _} -> :ok - {:error, error} -> {:error, error} - end - end + {:ok, response} -> + case {response.status(), response.body()["ok"]} do + {200, true} -> :ok + {_, false} -> {:error, response.body()["error"]} + {code, _} -> {:error, code} + end - def chat_get_permalink(channel_id, message_ts) do - case get("/chat.getPermalink", query: [channel: channel_id, message_ts: message_ts]) do - {:ok, response} -> {:ok, response.body() |> Map.get("permalink")} - {:error, error} -> {:error, error} + error -> + error end end - def chat_post_ephemeral(channel_id, user_id, text, thread_ts \\ nil) do - params = - %{ - channel: channel_id, - user: user_id, - text: text, - thread_ts: thread_ts - } - |> Enum.reject(fn {_k, v} -> v == nil end) - |> Enum.into(%{}) + @doc """ + Post a Slack ephemeral message, which is visible only to a specific user. + """ + @spec chat_post_ephemeral(Message.t()) :: :ok | api_error() + def chat_post_ephemeral(message) do + case post("/chat.postEphemeral", message) do + {:ok, response} -> + case {response.status(), response.body()["ok"]} do + {200, true} -> :ok + {_, false} -> {:error, response.body()["error"]} + {code, _} -> {:error, code} + end - case post("/chat.postEphemeral", params) do - {:ok, _response} -> :ok - {:error, error} -> {:error, error} + error -> + error end end - def chat_post_message(channel, text, opts \\ %{}) do - case post("/chat.postMessage", Enum.into(opts, %{channel: channel, text: text})) do - {:ok, _response} -> :ok - {:error, error} -> {:error, error} + @doc """ + Post a message in a Slack channel or, when supplied a valid `thread_ts`, in a discussion thread. In case of success, + returns channel ID and message TS of the posted message. + """ + @spec chat_post_message(Message.t()) :: {:ok, String.t(), String.t()} | api_error() + def chat_post_message(message) do + case post("/chat.postMessage", message) do + {:ok, response} -> + case {response.status(), response.body()["ok"]} do + {200, true} -> {:ok, response.body()["channel"], response.body()["ts"]} + {_, false} -> {:error, response.body()["error"]} + {code, _} -> {:error, code} + end + + error -> + error end end - def conversations_replies(channel_id, message_ts) do + @doc """ + Query Slack API to return a conversation history given a specified channel ID and timestamp, which are both obtained + typically from an Events API event. + """ + @spec conversations_replies(String.t(), String.t(), boolean()) :: {:ok, [map()]} | api_error() + def conversations_replies(channel_id, message_ts, inclusive \\ true) do case get("/conversations.replies", - query: [channel: channel_id, ts: message_ts, inclusive: true] + query: [channel: channel_id, ts: message_ts, inclusive: inclusive] ) do - {:ok, response} -> {:ok, response.body() |> Map.get("messages")} - {:error, error} -> {:error, error} + {:ok, response} -> + case {response.status(), response.body()["ok"]} do + {200, true} -> {:ok, response.body()["messages"]} + {_, false} -> {:error, response.body()["error"]} + {code, _} -> {:error, code} + end + + error -> + error end end + @doc """ + Query Slack API to return user profile for a given user ID. + """ + @spec users_profile_get(String.t()) :: {:ok, map()} | api_error() def users_profile_get(user_id) do case get("/users.profile.get", query: [user: user_id]) do - {:ok, response} -> {:ok, response.body() |> Map.get("profile")} - {:error, error} -> {:error, error} + {:ok, response} -> + case {response.status(), response.body()["ok"]} do + {200, true} -> {:ok, response.body()["profile"]} + {_, false} -> {:error, response.body()["error"]} + {code, _} -> {:error, code} + end + + error -> + error end end + + @spec bearer_token() :: String.t() + defp bearer_token, do: "Bearer " <> Application.get_env(:deep_thought, :slack)[:bot_token] end diff --git a/lib/deep_thought/slack/api/confirm.ex b/lib/deep_thought/slack/api/confirm.ex new file mode 100644 index 0000000..f3f94e9 --- /dev/null +++ b/lib/deep_thought/slack/api/confirm.ex @@ -0,0 +1,36 @@ +defmodule DeepThought.Slack.API.Confirm do + @moduledoc """ + Struct module for representing a Confirm object. + """ + + alias DeepThought.Slack.API.{Confirm, Text} + + @derive {Jason.Encoder, only: [:title, :text, :confirm, :deny]} + @type t :: %__MODULE__{ + title: Text.t() | nil, + text: Text.t() | nil, + confirm: Text.t() | nil, + deny: Text.t() | nil + } + defstruct title: nil, text: nil, confirm: nil, deny: nil + + @doc """ + Creates a new empty Confirm object. + """ + @spec new(Text.t(), Text.t(), Text.t(), Text.t()) :: Confirm.t() + def new(title, text, confirm, deny), + do: %Confirm{title: title, text: text, confirm: confirm, deny: deny} + + @doc """ + Created a new Confirm object with default values. + """ + @spec default() :: Confirm.t() + def default, + do: + Confirm.new( + Text.new("Are you sure?", "plain_text"), + Text.new("Are you sure you want to proceed?"), + Text.new("Yes, proceed", "plain_text"), + Text.new("No, I changed my mind", "plain_text") + ) +end diff --git a/lib/deep_thought/slack/api/context_block.ex b/lib/deep_thought/slack/api/context_block.ex new file mode 100644 index 0000000..7541041 --- /dev/null +++ b/lib/deep_thought/slack/api/context_block.ex @@ -0,0 +1,26 @@ +defmodule DeepThought.Slack.API.ContextBlock do + @moduledoc """ + Struct used to represent a Slack Block of a Context type. Can be attached to a message. + """ + + alias DeepThought.Slack.API.{ContextBlock, Text} + + @derive {Jason.Encoder, only: [:type, :elements]} + @type t :: %__MODULE__{ + type: String.t(), + elements: [Text.t()] + } + defstruct type: "context", elements: [] + + @doc """ + Create a new empty Context Block. + """ + @spec new() :: ContextBlock.t() + def new, do: %ContextBlock{} + + @doc """ + Take an existing Context Block and append a new Text element to it. + """ + @spec with_text(ContextBlock.t(), Text.t()) :: ContextBlock.t() + def with_text(block, text), do: %ContextBlock{block | elements: [text | block.elements]} +end diff --git a/lib/deep_thought/slack/api/message.ex b/lib/deep_thought/slack/api/message.ex new file mode 100644 index 0000000..98fd6bb --- /dev/null +++ b/lib/deep_thought/slack/api/message.ex @@ -0,0 +1,164 @@ +defmodule DeepThought.Slack.API.Message do + @moduledoc """ + Struct used to represent a message to be sent through Slack API. + """ + + alias DeepThought.Slack + alias DeepThought.Slack.{API, User} + alias DeepThought.Slack.API.Message + + @derive {Jason.Encoder, only: [:channel, :text, :blocks, :thread_ts, :user]} + @type t :: %__MODULE__{ + channel: String.t(), + text: String.t(), + blocks: [any()], + thread_ts: String.t() | nil, + user: String.t() | nil, + usernames: map() + } + defstruct channel: nil, text: nil, blocks: [], thread_ts: nil, user: nil, usernames: %{} + + @doc """ + Create a message struct, initializing the required fields. + """ + @spec new(String.t(), String.t()) :: Message.t() + def new(text, channel_id), do: %Message{channel: channel_id, text: text} + + @doc """ + Take an existing message struct and make it a reply in a thread by supplying a `thread_ts` value. + """ + @spec in_thread(Message.t(), String.t()) :: Message.t() + def in_thread(message, thread_ts), do: %Message{message | thread_ts: thread_ts} + + @doc """ + Take an existing message and make it targetted to a specific user by supplying a `user` value. Only for ephemeral + messages. + """ + @spec for_user(Message.t(), String.t()) :: Message.t() + def for_user(message, user_id), do: %Message{message | user: user_id} + + @doc """ + Add a Slack Block to the message. + """ + @spec add_block(Message.t(), any()) :: Message.t() + def add_block(message, block), do: %Message{message | blocks: message.blocks ++ [block]} + + @doc """ + Take a message ready for sending to Slack API and unescape all text that might’ve been previously escaped in order to + survive the translation process. + """ + @spec unescape(Message.t()) :: Message.t() + def unescape(message), + do: + message + |> unescape_emojis + |> unescape_channels + |> unescape_links + |> unescape_usernames + |> unescape_code + + @spec unescape_emojis(Message.t()) :: Message.t() + defp unescape_emojis(%{text: text} = message), + do: %Message{message | text: Regex.replace(~r/<\/?e>/ui, text, "")} + + @spec unescape_channels(Message.t()) :: Message.t() + defp unescape_channels(%{text: text} = message), + do: %Message{ + message + | text: + Regex.replace(~r/(#C\w+)<\/c>/ui, text, fn _, channel_id -> + "<" <> channel_id <> ">" + end) + } + + @spec unescape_links(Message.t()) :: Message.t() + defp unescape_links(%{text: text} = message), + do: %Message{ + message + | text: + Regex.replace(~r/(.+?)<\/l>/ui, text, fn _, link -> + "<" <> link <> ">" + end) + } + + @spec unescape_usernames(Message.t()) :: Message.t() + def unescape_usernames(message), + do: + message + |> collect_user_ids + |> fetch_cached_usernames + |> fetch_remaining_usernames + |> replace_usernames + + @spec unescape_code(Message.t()) :: Message.t() + def unescape_code(%{text: text} = message), + do: %Message{ + message + | text: + Regex.replace(~r/(?<=(\s))?(.+?)<\/d>(?=([\s,$]?))/mui, text, fn + _, prev_char, code, next_char -> + surround(code, prev_char, next_char) + end) + } + + @spec collect_user_ids(Message.t()) :: Message.t() + defp collect_user_ids(%{text: text} = message), + do: %Message{ + message + | usernames: + Regex.scan(~r/@([UW]\w+?)<\/u>/ui, text, capture: :all_but_first) + |> List.flatten() + |> Enum.into(%{}, fn user_id -> {user_id, nil} end) + } + + @spec fetch_cached_usernames(Message.t()) :: Message.t() + defp fetch_cached_usernames(%{usernames: usernames} = message), + do: %Message{ + message + | usernames: + Map.keys(usernames) + |> Slack.find_users_by_user_ids() + |> Enum.into(%{}, fn user -> {user.user_id, User.display_name(user)} end) + |> Map.merge(usernames, fn _k, cached, _unresolved -> cached end) + } + + @spec fetch_remaining_usernames(Message.t()) :: Message.t() + defp fetch_remaining_usernames(%{usernames: usernames} = message), + do: %Message{ + message + | usernames: + usernames + |> Enum.filter(fn {_user_id, username} -> username == nil end) + |> Task.async_stream( + fn {user_id, _username} -> + {:ok, profile} = API.users_profile_get(user_id) + {user_id, profile} + end, + max_concurrency: 5 + ) + |> Enum.reduce([], fn {:ok, {user_id, profile}}, acc -> + [Map.put(profile, "user_id", user_id) | acc] + end) + |> Slack.update_users!() + |> Enum.into(%{}, fn user -> {user.user_id, User.display_name(user)} end) + |> Map.merge(usernames, fn _k, resolved, cached -> cached || resolved end) + } + + @spec replace_usernames(Message.t()) :: Message.t() + defp replace_usernames(%{text: text, usernames: usernames} = message), + do: %Message{ + message + | text: + Regex.replace(~r/(?<=(\s))?@([UW]\w+?)<\/u>(?=([\s,$]?))/ui, text, fn + _, prev_char, user_id, next_char -> + "_@#{usernames[user_id]}_" + |> surround(prev_char, next_char) + end) + } + + @spec surround(String.t(), String.t(), String.t()) :: String.t() + defp surround(text, "", ""), do: " " <> text <> " " + defp surround(text, "", _next), do: " " <> text + defp surround(text, _prev, ""), do: text <> " " + defp surround(text, _prev, _next), do: text +end diff --git a/lib/deep_thought/slack/api/option.ex b/lib/deep_thought/slack/api/option.ex new file mode 100644 index 0000000..35829a9 --- /dev/null +++ b/lib/deep_thought/slack/api/option.ex @@ -0,0 +1,21 @@ +defmodule DeepThought.Slack.API.Option do + @moduledoc """ + Module struct for representing an option, which can be used for example in an overflow menu accessory. + """ + + alias DeepThought.Slack.API.{Option, Text} + + @derive {Jason.Encoder, only: [:text, :value]} + @type t :: %__MODULE__{ + text: Text.t() | nil, + value: String.t() | nil + } + defstruct text: nil, value: nil + + @doc """ + Create a new option, initializing it with text object for visual representation, and a value for identification + purposes. + """ + @spec new(Text.t(), String.t()) :: Option.t() + def new(text, value), do: %Option{text: text, value: value} +end diff --git a/lib/deep_thought/slack/api/overflow_accessory.ex b/lib/deep_thought/slack/api/overflow_accessory.ex new file mode 100644 index 0000000..c5527f0 --- /dev/null +++ b/lib/deep_thought/slack/api/overflow_accessory.ex @@ -0,0 +1,26 @@ +defmodule DeepThought.Slack.API.OverflowAccessory do + @moduledoc """ + Struct module for representing the Overflow Accessory that can be appended to a block. + """ + + alias DeepThought.Slack.API.{Confirm, Option, OverflowAccessory} + + @derive {Jason.Encoder, only: [:type, :action_id, :options, :confirm]} + @type t :: %__MODULE__{ + type: String.t(), + action_id: String.t() | nil, + options: [Option.t()], + confirm: [Confirm.t()] + } + defstruct type: "overflow", action_id: nil, options: [], confirm: [] + + @doc """ + Create a new accessory of Overflow type. + """ + @spec new(Option.t() | [Option.t()], Confirm.t(), String.t()) :: OverflowAccessory.t() + def new(options, confirm, action_id) when is_list(options), + do: %OverflowAccessory{action_id: action_id, options: options, confirm: confirm} + + def new(option, confirm, action_id), + do: OverflowAccessory.new([option], confirm, action_id) +end diff --git a/lib/deep_thought/slack/api/section_block.ex b/lib/deep_thought/slack/api/section_block.ex new file mode 100644 index 0000000..9bb391e --- /dev/null +++ b/lib/deep_thought/slack/api/section_block.ex @@ -0,0 +1,33 @@ +defmodule DeepThought.Slack.API.SectionBlock do + @moduledoc """ + Struct used to represent a Slack Block of a Section type. Can be attached to a message. + """ + + alias DeepThought.Slack.API.{SectionBlock, Text} + + @derive {Jason.Encoder, only: [:type, :text, :accessory]} + @type t :: %__MODULE__{ + type: String.t(), + text: Text.t() | nil, + accessory: any() | nil + } + defstruct type: "section", text: nil, accessory: nil + + @doc """ + Create a new empty Section Block. + """ + @spec new() :: SectionBlock.t() + def new, do: %SectionBlock{} + + @doc """ + Take an existing Section Block and return a new one with modified text. + """ + @spec with_text(SectionBlock.t(), Text.t()) :: SectionBlock.t() + def with_text(block, text), do: %SectionBlock{block | text: text} + + @doc """ + Add a Accessory element to a block. + """ + @spec add_accessory(SectionBlock.t(), any()) :: SectionBlock.t() + def add_accessory(block, accessory), do: %SectionBlock{block | accessory: accessory} +end diff --git a/lib/deep_thought/slack/api/text.ex b/lib/deep_thought/slack/api/text.ex new file mode 100644 index 0000000..5cf3d55 --- /dev/null +++ b/lib/deep_thought/slack/api/text.ex @@ -0,0 +1,20 @@ +defmodule DeepThought.Slack.API.Text do + @moduledoc """ + Struct used to represent Slack API’s Text object. + """ + + alias DeepThought.Slack.API.Text + + @derive {Jason.Encoder, only: [:type, :text]} + @type t :: %__MODULE__{ + type: String.t(), + text: String.t() + } + defstruct type: "", text: "" + + @doc """ + Create a new Text object with given text and optionally a type. + """ + @spec new(String.t(), String.t()) :: Text.t() + def new(text, type \\ "mrkdwn"), do: %Text{type: type, text: text} +end diff --git a/lib/deep_thought/slack/event.ex b/lib/deep_thought/slack/event.ex deleted file mode 100644 index 960b4b1..0000000 --- a/lib/deep_thought/slack/event.ex +++ /dev/null @@ -1,41 +0,0 @@ -defmodule DeepThought.Slack.Event do - use Ecto.Schema - - alias DeepThought.Slack.Event - import Ecto.Changeset - import Ecto.Query - - schema "events" do - field(:type, :string, null: false) - field(:challenge, :string) - field(:target_language, :string) - field(:channel_id, :string) - field(:message_ts, :string) - - timestamps() - end - - @doc false - def url_verification_changeset(event, attrs) do - event - |> cast(attrs, [:type, :challenge]) - |> validate_required([:type, :challenge]) - end - - def reaction_added_changeset(event, attrs) do - event - |> cast(attrs, [:type, :target_language, :channel_id, :message_ts]) - |> validate_required([:type, :target_language, :channel_id, :message_ts]) - end - - def recently_translated(channel_id, message_ts, target_language) do - one_day_ago = NaiveDateTime.utc_now() |> NaiveDateTime.add(-60 * 60 * 24) - - from(e in Event, - where: - e.type == ^"reaction_added" and e.channel_id == ^channel_id and - e.message_ts == ^message_ts and e.target_language == ^target_language and - e.inserted_at >= ^one_day_ago - ) - end -end diff --git a/lib/deep_thought/slack/footer_block.ex b/lib/deep_thought/slack/footer_block.ex deleted file mode 100644 index 5df4bfa..0000000 --- a/lib/deep_thought/slack/footer_block.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule DeepThought.Slack.FooterBlock do - alias DeepThought.Slack - - def generate(%{"ts" => message_ts, "user" => user_id}, channel_id, original_text) do - abbreviated_text(original_text) - |> append_username(user_id) - |> append_permalink(channel_id, message_ts) - |> block_content() - end - - defp abbreviated_text(original_text) do - case String.length(original_text) do - x when x in 0..49 -> original_text - _ -> String.slice(original_text, 0..48) <> "…" - end - end - - defp append_username(footer_text, user_id) do - case Slack.API.users_profile_get(user_id) do - {:ok, %{"real_name" => real_name}} -> - footer_text <> "\nOriginally sent by: " <> real_name - - _ -> - footer_text - end - end - - defp append_permalink(footer_text, channel_id, message_ts) do - case Slack.API.chat_get_permalink(channel_id, message_ts) do - {:ok, permalink} -> footer_text <> " (<" <> permalink <> "|permalink>)" - _ -> footer_text - end - end - - defp block_content(footer_text) do - %{ - "type" => "context", - "elements" => [ - %{ - "type" => "mrkdwn", - "text" => footer_text - } - ] - } - end -end diff --git a/lib/deep_thought/slack/handlers/delete.ex b/lib/deep_thought/slack/handlers/delete.ex new file mode 100644 index 0000000..8463764 --- /dev/null +++ b/lib/deep_thought/slack/handlers/delete.ex @@ -0,0 +1,37 @@ +defmodule DeepThought.Slack.Handler.Delete do + @moduledoc """ + Module responsible for handling the `delete` option from the `overflow_delete` Slack Action, which is triggered + whenever a user opens the overflow menu and confirms the _Delete Translation_ option. We need to delete the + translation from the thread and respond to the user via an ephemeral message. + """ + + alias DeepThought.Slack + alias DeepThought.Slack.API.Message + + @doc """ + Mark message as deleted in DB so that it can be re-translated into the same language in the future, delete it from the + thread and notify the owner of the deletion request of its sucess. + """ + @spec delete_message(map(), map()) :: + :ok | {:error, atom() | Ecto.Changeset.t() | non_neg_integer() | String.t()} + def delete_message(_action, %{ + "container" => %{ + "channel_id" => channel_id, + "message_ts" => message_ts, + "thread_ts" => thread_ts + }, + "user" => %{"id" => user_id} + }) do + with :ok <- Slack.API.chat_delete(channel_id, message_ts), + {:ok, _} <- Slack.mark_as_deleted(channel_id, message_ts) do + Message.new("I deleted the translation!", channel_id) + |> Message.in_thread(thread_ts) + |> Message.for_user(user_id) + |> Slack.API.chat_post_ephemeral() + + :ok + else + error -> error + end + end +end diff --git a/lib/deep_thought/slack/handlers/reaction_added.ex b/lib/deep_thought/slack/handlers/reaction_added.ex new file mode 100644 index 0000000..c61f343 --- /dev/null +++ b/lib/deep_thought/slack/handlers/reaction_added.ex @@ -0,0 +1,133 @@ +defmodule DeepThought.Slack.Handler.ReactionAdded do + @moduledoc """ + Module responsible for handling the `reaction_added` Slack event, which is received when user adds a reaction emoji to + a message. This might be a normal emoji, or a flag emoji, which would indicate the desire to translate the message to + another language. + """ + + alias DeepThought.DeepL + alias DeepThought.Slack + + alias DeepThought.Slack.API.{ + Confirm, + ContextBlock, + Message, + Option, + OverflowAccessory, + SectionBlock, + Text + } + + alias DeepThought.Slack.{Language, MessageEscape} + + @doc """ + Take event details, interpret the details, if the event points to a translation request, fetch the message details, + translate the message text and post the translation in thread. + """ + @typep reason() :: :unknown_language | non_neg_integer() | atom() + @spec reaction_added(map()) :: {:ok, String.t()} | {:error, reason()} + def reaction_added(%{ + "item" => %{"channel" => channel_id, "ts" => message_ts, "type" => "message"}, + "user" => user_id, + "reaction" => reaction + }) do + with {:ok, %{deepl_code: language_code}} <- Language.new(reaction), + false <- Slack.recently_translated?(channel_id, message_ts, language_code) do + record = %{ + channel_id: channel_id, + message_ts: message_ts, + user_id: user_id, + target_language: language_code + } + + with {:ok, [%{"text" => original} = message | _]} <- + Slack.API.conversations_replies(channel_id, message_ts), + escaped_original <- MessageEscape.escape(original), + {_, translation} <- DeepL.API.translate(escaped_original, language_code), + {:ok, translation_channel_id, translation_message_ts} <- + say_in_thread(channel_id, translation, message) do + record + |> Map.merge(%{ + status: "success", + translation_channel_id: translation_channel_id, + translation_message_ts: translation_message_ts + }) + |> Slack.create_translation() + + {:ok, translation} + else + {:error, error} -> + record + |> Map.put(:status, Kernel.inspect(error)) + |> Slack.create_translation() + end + else + true -> + {:error, :recently_translated} + + error -> + error + end + end + + @spec say_in_thread(String.t(), String.t(), map()) :: + {:ok, String.t(), String.t()} | {:error, atom()} + defp say_in_thread(channel_id, translation, message) do + reply = + Message.new(translation, channel_id) + |> Message.in_thread(extract_thread_ts(message)) + |> Message.unescape() + + Message.add_block( + reply, + translation_block(reply.text) + |> SectionBlock.add_accessory(delete_button()) + ) + |> Message.add_block(footer_block(channel_id, message)) + |> Slack.API.chat_post_message() + end + + @spec delete_button() :: OverflowAccessory.t() + defp delete_button, + do: + Text.new("Delete translation", "plain_text") + |> Option.new("delete") + |> OverflowAccessory.new(Confirm.default(), "delete_overflow") + + @spec extract_thread_ts(map()) :: String.t() + defp extract_thread_ts(%{"thread_ts" => thread_ts}), do: thread_ts + defp extract_thread_ts(%{"ts" => ts}), do: ts + + @spec translation_block(String.t()) :: SectionBlock.t() + defp translation_block(translation), + do: + SectionBlock.new() + |> SectionBlock.with_text(Text.new(translation)) + + @spec footer_block(String.t(), map()) :: ContextBlock.t() + defp footer_block(channel_id, message), + do: + ContextBlock.new() + |> ContextBlock.with_text( + Text.new( + generate_permalink(channel_id, message) + |> append_feedback_channel( + Application.get_env(:deep_thought, :slack)[:feedback_channel] + ) + ) + ) + + @spec generate_permalink(String.t(), map()) :: String.t() + defp generate_permalink(channel_id, %{"ts" => message_ts}) do + case Slack.API.chat_get_permalink(channel_id, message_ts) do + {:ok, permalink} -> "<" <> permalink <> "|View original message>" + _ -> "⚠️ Could not find original message" + end + end + + @spec append_feedback_channel(String.t(), String.t() | nil) :: String.t() + defp append_feedback_channel(text, feedback_channel) when feedback_channel == nil, do: text + + defp append_feedback_channel(text, feedback_channel), + do: text <> " | Share your feedback in <#" <> feedback_channel <> ">!" +end diff --git a/lib/deep_thought/slack/handlers/translate.ex b/lib/deep_thought/slack/handlers/translate.ex new file mode 100644 index 0000000..cb33455 --- /dev/null +++ b/lib/deep_thought/slack/handlers/translate.ex @@ -0,0 +1,80 @@ +defmodule DeepThought.Slack.Handler.Translate do + @moduledoc """ + Module responsible for handling the `/translate` Slack command, determining the target language and securing the + translation, incl. all required escaping and unescaping. + """ + + alias DeepThought.DeepL + alias DeepThought.Slack + alias DeepThought.Slack.API.Message + alias DeepThought.Slack.{Language, MessageEscape} + + @doc """ + Translates the given text into the target language. + """ + @typep reason() :: :missing_text | :unknown_language + @spec translate(map()) :: {:ok, String.t()} | {:error, reason()} | Slack.API.api_error() + def translate(%{ + "channel_id" => channel_id, + "text" => params, + "user_id" => user_id, + "user_name" => username + }) do + with [language, original_text] <- String.split(params, " ", parts: 2), + {:ok, %{deepl_code: language_code}} <- Language.new(String.trim(language, ":")), + escaped_text <- MessageEscape.escape(original_text), + {_, translation} <- DeepL.API.translate(escaped_text, language_code), + {:ok, message} <- say_in_channel(channel_id, username, original_text, translation) do + {:ok, message} + else + [_language] -> + handle_missing_text(channel_id, user_id) + + {:error, :missing_text} + + {:error, :unknown_language} = error -> + handle_unknown_language(channel_id, user_id) + error + + error -> + error + end + end + + @spec say_in_channel(String.t(), String.t(), String.t(), String.t()) :: + {:ok, String.t()} | Slack.API.api_error() + def say_in_channel(channel_id, username, original_text, translation) do + message_text = """ + _@#{username}_ asked me to translate this: #{original_text} + Translation: #{translation}\ + """ + + case Message.new(message_text, channel_id) + |> Message.unescape() + |> Slack.API.chat_post_message() do + {:ok, _channel_id, _message_ts} -> {:ok, message_text} + error -> error + end + end + + @spec handle_missing_text(String.t(), String.t()) :: :ok | Slack.API.api_error() + defp handle_missing_text(channel_id, user_id), + do: + Message.new( + """ + Did you forget to submit the text to translate? + + If unsure, type `/translate` to see usage instructions!\ + """, + channel_id + ) + |> Message.for_user(user_id) + |> Slack.API.chat_post_ephemeral() + + @spec handle_unknown_language(String.t(), String.t()) :: :ok | Slack.API.api_error() + defp handle_unknown_language(channel_id, user_id), + do: + Message.new("Sorry, I don’t know that language yet!", channel_id) + |> Message.for_user(user_id) + |> Slack.API.chat_post_ephemeral() +end diff --git a/lib/deep_thought/slack/language_converter.ex b/lib/deep_thought/slack/language.ex similarity index 77% rename from lib/deep_thought/slack/language_converter.ex rename to lib/deep_thought/slack/language.ex index 5bee69d..f0c0efa 100644 --- a/lib/deep_thought/slack/language_converter.ex +++ b/lib/deep_thought/slack/language.ex @@ -1,16 +1,32 @@ -defmodule DeepThought.Slack.LanguageConverter do - def reaction_to_lang("flag-" <> reaction), do: get_reaction_to_lang(reaction) - def reaction_to_lang(reaction), do: get_reaction_to_lang(reaction) +defmodule DeepThought.Slack.Language do + @moduledoc """ + Module that allows for converstion between country codes (used by Slack flag emojis) and language codes (used by DeepL + translation engine). + """ - defp get_reaction_to_lang(reaction) do - case Map.has_key?(mapping(), reaction) do - true -> {:ok, Map.get(mapping(), reaction) |> String.upcase()} - _ -> :error + @type t() :: %__MODULE__{slack_code: String.t(), deepl_code: String.t()} + defstruct slack_code: "", deepl_code: "" + + alias DeepThought.Slack.Language + + @doc """ + Given a country code as a `String`, attempt conversion to a language code. + """ + @spec new(String.t()) :: {:ok, Language.t()} | {:error, :unknown_language} + def new("flag-" <> reaction), do: do_new(reaction) + def new(reaction), do: do_new(reaction) + + @spec do_new(String.t()) :: {:ok, Language.t()} | {:error, :unknown_language} + defp do_new(reaction) do + case Map.get(mapping(), reaction) do + nil -> {:error, :unknown_language} + value -> {:ok, %Language{slack_code: reaction, deepl_code: String.upcase(value)}} end end - defp mapping do - %{ + @spec mapping() :: %{String.t() => String.t()} + defp mapping, + do: %{ "bg" => "bg", "cz" => "cs", "dk" => "da", @@ -166,5 +182,4 @@ defmodule DeepThought.Slack.LanguageConverter do "se" => "sv", "cn" => "zh" } - end end diff --git a/lib/deep_thought/slack/message_escape.ex b/lib/deep_thought/slack/message_escape.ex new file mode 100644 index 0000000..1092d86 --- /dev/null +++ b/lib/deep_thought/slack/message_escape.ex @@ -0,0 +1,79 @@ +defmodule DeepThought.Slack.MessageEscape do + @moduledoc """ + Helper module which takes a Slack message in the form of a string (as returned from the `conversations.replies` API + method) and escapes its content in such a way that the likelihood of all important pieces of information to survive + the translation process is increased to maximum. + """ + + @doc """ + Execute the escaping pipeline on a given string. + """ + @spec escape(String.t()) :: String.t() + def escape(text), + do: + text + |> remove_global_mentions + |> escape_emojis + |> escape_usernames + |> escape_channels + |> escape_links + |> escape_code + + @spec remove_global_mentions(String.t()) :: String.t() + defp remove_global_mentions(text), + do: Regex.replace(~r/ ?/ui, text, "") + + @spec escape_emojis(String.t()) :: String.t() + defp escape_emojis(text), + do: + Regex.replace(~r/(:(?![\n])[()#$@\-\w]+:)/ui, text, fn _, emoji -> + "" <> emoji <> "" + end) + + @spec escape_usernames(String.t()) :: String.t() + defp escape_usernames(text), + do: + Regex.replace(~r/<(@[UW]\w+?)(?:\|.+?)?>/ui, text, fn _, username -> + "" <> username <> "" + end) + + @spec escape_channels(String.t()) :: String.t() + defp escape_channels(text), + do: + Regex.replace(~r/<(#C\w+)(?:\|.+?)?>/ui, text, fn _, channel_id -> + "" <> channel_id <> "" + end) + + @spec escape_links(String.t()) :: String.t() + defp escape_links(text), + # credo:disable-for-lines:3 + do: + Regex.replace( + ~r/<((?:mailto:\S+?@\S+?(?:\|.+?)?)|(?:https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})(?:\|.+?)?)>/ui, + text, + fn _, link -> + "" <> link <> "" + end + ) + + @spec escape_code(String.t()) :: String.t() + defp escape_code(text), + do: + text + |> escape_codeblocks() + |> escape_inline_code() + + @spec escape_codeblocks(String.t()) :: String.t() + defp escape_codeblocks(text), + do: + Regex.replace(~r/(^```.+?```$)/mui, text, fn _, code -> + "" <> code <> "" + end) + + @spec escape_inline_code(String.t()) :: String.t() + defp escape_inline_code(text), + do: + Regex.replace(~r/(?])(`.+?`)(?![`<])/ui, text, fn _, code -> + "" <> code <> "" + end) +end diff --git a/lib/deep_thought/slack/translation.ex b/lib/deep_thought/slack/translation.ex new file mode 100644 index 0000000..dbe20d4 --- /dev/null +++ b/lib/deep_thought/slack/translation.ex @@ -0,0 +1,93 @@ +defmodule DeepThought.Slack.Translation do + @moduledoc """ + Module struct for representing translation requests, whether they were successful or not. + """ + use Ecto.Schema + alias DeepThought.Slack.Translation + import Ecto.Changeset + import Ecto.Query + + @type t :: %__MODULE__{ + __meta__: Ecto.Schema.Metadata.t(), + id: integer() | nil, + channel_id: String.t() | nil, + message_ts: String.t() | nil, + status: String.t() | nil, + target_language: String.t() | nil, + translation_channel_id: String.t() | nil, + translation_message_ts: String.t() | nil, + user_id: String.t() | nil, + inserted_at: NaiveDateTime.t() | nil, + updated_at: NaiveDateTime.t() | nil + } + @type r :: {:ok, Translation.t()} | {:error, Ecto.Changeset.t()} + schema "translations" do + field :channel_id, :string + field :message_ts, :string + field :status, :string + field :target_language, :string + field :translation_channel_id, :string + field :translation_message_ts, :string + field :user_id, :string + + timestamps() + end + + @doc """ + Given information about message about to be translated, returns information whether this message was recently + translated into the same language. + """ + @spec recently_translated?(String.t(), String.t(), String.t()) :: Ecto.Query.t() + def recently_translated?(channel_id, message_ts, target_language), + do: + from(t in Translation, + where: + t.channel_id == ^channel_id and + t.message_ts == ^message_ts and + t.target_language == ^target_language and + t.status == "success" and + t.inserted_at >= ^one_day_ago() + ) + + @doc """ + Finds a translation record by the details of the translation message, such as the channel it was posted in and the + time it was posted. + """ + @spec find_by_translation(String.t(), String.t()) :: Ecto.Query.t() + def find_by_translation(channel_id, message_ts), + do: + from(t in Translation, + where: t.translation_channel_id == ^channel_id and t.translation_message_ts == ^message_ts + ) + + @doc """ + Changeset for creating a new translation request record. + """ + @spec changeset(Translation.t(), map()) :: Ecto.Changeset.t() + def changeset(translation, attrs), + do: + translation + |> cast(attrs, [ + :user_id, + :channel_id, + :message_ts, + :target_language, + :status, + :translation_channel_id, + :translation_message_ts + ]) + |> validate_required([:user_id, :channel_id, :message_ts, :target_language, :status]) + + @doc """ + Changeset for marking a translation request as deleted. + """ + @spec deletion_changeset(Translation.t(), map()) :: Ecto.Changeset.t() + def deletion_changeset(translation, attrs), + do: + translation + |> cast(attrs, [:status]) + |> validate_required([:status]) + + @spec one_day_ago() :: NaiveDateTime.t() + defp one_day_ago, do: NaiveDateTime.utc_now() |> NaiveDateTime.add(-24 * 60 * 60) +end diff --git a/lib/deep_thought/slack/translation_block.ex b/lib/deep_thought/slack/translation_block.ex deleted file mode 100644 index 35fe361..0000000 --- a/lib/deep_thought/slack/translation_block.ex +++ /dev/null @@ -1,49 +0,0 @@ -defmodule DeepThought.Slack.TranslationBlock do - def generate(translated_text) do - block_content(translated_text) - end - - defp block_content(translated_text) do - %{ - "type" => "section", - "text" => %{ - "type" => "mrkdwn", - "text" => translated_text - }, - "accessory" => accessory_content() - } - end - - defp accessory_content do - %{ - "type" => "overflow", - "confirm" => %{ - "title" => confirm_title(), - "text" => confirm_text(), - "confirm" => confirm_button(), - "deny" => deny_button() - }, - "options" => accessory_options(), - "action_id" => "overflow" - } - end - - defp confirm_title, do: %{"type" => "plain_text", "text" => "Are you sure?"} - - defp confirm_text, - do: %{"type" => "mrkdwn", "text" => "Are you sure you want to delete this translation? 🗑️"} - - defp confirm_button, do: %{"type" => "plain_text", "text" => "Do it!"} - defp deny_button, do: %{"type" => "plain_text", "text" => "Stop, I changed my mind!"} - - defp accessory_options, - do: [ - %{ - "text" => %{ - "type" => "plain_text", - "text" => "Delete this translation" - }, - "value" => "delete" - } - ] -end diff --git a/lib/deep_thought/slack/user.ex b/lib/deep_thought/slack/user.ex index ecd872a..b1817bc 100644 --- a/lib/deep_thought/slack/user.ex +++ b/lib/deep_thought/slack/user.ex @@ -1,30 +1,76 @@ defmodule DeepThought.Slack.User do + @moduledoc """ + Module struct for representing the database-cached data of a Slack user. Not all fields are used for translation + purposes, but they are collected for possible future analytics use. + """ use Ecto.Schema - alias DeepThought.Slack.User import Ecto.Changeset import Ecto.Query + @type t :: %__MODULE__{ + __meta__: Ecto.Schema.Metadata.t(), + id: integer() | nil, + display_name: String.t() | nil, + display_name_normalized: String.t() | nil, + email: String.t() | nil, + first_name: String.t() | nil, + last_name: String.t() | nil, + real_name: String.t() | nil, + real_name_normalized: String.t() | nil, + user_id: String.t() | nil, + inserted_at: NaiveDateTime.t() | nil, + updated_at: NaiveDateTime.t() | nil + } schema "slack_users" do - field(:user_id, :string) - field(:real_name, :string) + field :display_name, :string + field :display_name_normalized, :string + field :email, :string + field :first_name, :string + field :last_name, :string + field :real_name, :string + field :real_name_normalized, :string + field :user_id, :string timestamps() end - @doc false - def changeset(user, attrs) do - user - |> cast(attrs, [:user_id, :real_name]) - |> validate_required([:user_id, :real_name]) - end + @doc """ + Given a Slack user, return a user-friendly username of that user. Prefers real name over display name. + """ + @spec display_name(User.t()) :: String.t() + def display_name(%{real_name: real_name}) when real_name != nil, do: real_name + def display_name(%{display_name: display_name}) when display_name != nil, do: display_name + def display_name(_), do: "unknown user" - def with_user_ids(user_ids) do - half_day_ago = NaiveDateTime.utc_now() |> NaiveDateTime.add(-60 * 60 * 12) + @doc """ + Given a list of Slack user IDs, find all matching cached users and return their profile information. + """ + @spec find_by_user_ids([String.t()]) :: Ecto.Query.t() + def find_by_user_ids(user_ids), + do: + from(u in User, where: u.user_id in ^user_ids and u.updated_at >= ^half_day_ago(), select: u) - from(u in User, - where: u.user_id in ^user_ids and u.updated_at >= ^half_day_ago, - select: %{user_id: u.user_id, real_name: u.real_name} - ) - end + @doc """ + Changeset for upserting users based on data obtained from Slack API. + """ + @spec changeset(User.t(), map()) :: Ecto.Changeset.t() + def changeset(user, attrs), + do: + user + |> cast(attrs, [ + :user_id, + :email, + :real_name, + :real_name_normalized, + :display_name, + :display_name_normalized, + :last_name, + :first_name + ]) + |> validate_required([:user_id]) + |> unique_constraint([:user_id]) + + @spec half_day_ago() :: NaiveDateTime.t() + defp half_day_ago, do: NaiveDateTime.utc_now() |> NaiveDateTime.add(-12 * 60 * 60) end diff --git a/lib/deep_thought/translator_supervisor.ex b/lib/deep_thought/translator_supervisor.ex deleted file mode 100644 index e3107f2..0000000 --- a/lib/deep_thought/translator_supervisor.ex +++ /dev/null @@ -1,23 +0,0 @@ -defmodule DeepThought.TranslatorSupervisor do - def translate(event_details, reaction, channel_id, message_ts) do - Task.Supervisor.start_child( - __MODULE__, - DeepThought.DeepL.Translator, - :translate, - [event_details, reaction, channel_id, message_ts] - ) - end - - def delete(payload) do - Task.Supervisor.start_child(__MODULE__, DeepThought.DeepL.Translator, :delete, [payload]) - end - - def simple_translate(channel_id, text, username, user_id) do - Task.Supervisor.start_child( - __MODULE__, - DeepThought.DeepL.SimpleTranslator, - :simple_translate, - [channel_id, text, username, user_id] - ) - end -end diff --git a/lib/deep_thought_web.ex b/lib/deep_thought_web.ex index 663b598..a4f155b 100644 --- a/lib/deep_thought_web.ex +++ b/lib/deep_thought_web.ex @@ -33,6 +33,8 @@ defmodule DeepThoughtWeb do root: "lib/deep_thought_web/templates", namespace: DeepThoughtWeb + use Appsignal.Phoenix.View + # Import convenience functions from controllers import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] diff --git a/lib/deep_thought_web/cache_body_reader.ex b/lib/deep_thought_web/cache_body_reader.ex index d5696f3..cd1636e 100644 --- a/lib/deep_thought_web/cache_body_reader.ex +++ b/lib/deep_thought_web/cache_body_reader.ex @@ -1,4 +1,15 @@ defmodule DeepThoughtWeb.CacheBodyReader do + @moduledoc """ + Helper module that stores the raw, unparsed request body in the `Plug.Conn` for later use. This is required, for + example, if you need to compute a signature from the unparsed request body in order to validate that the request comes + from a trusted source. + """ + + @doc """ + Given a connection, will read the connection’s raw body value and store it under the `:raw_body` key where + `read_cached_body/1` function can fetch it later. + """ + @spec read_body(Plug.Conn.t(), Keyword.t()) :: {:ok, any(), Plug.Conn.t()} def read_body(conn, opts) do {:ok, body, conn} = Plug.Conn.read_body(conn, opts) conn = Plug.Conn.put_private(conn, :raw_body, body) @@ -6,7 +17,9 @@ defmodule DeepThoughtWeb.CacheBodyReader do {:ok, body, conn} end - def read_cached_body(conn) do - conn.private[:raw_body] - end + @doc """ + Given a connection that was previously inspected with `read_body/2`, returns the raw unparsed request body. + """ + @spec read_cached_body(Plug.Conn.t()) :: String.t() + def read_cached_body(conn), do: conn.private[:raw_body] end diff --git a/lib/deep_thought_web/controllers/action_controller.ex b/lib/deep_thought_web/controllers/action_controller.ex index 0a8d6c2..f9ce326 100644 --- a/lib/deep_thought_web/controllers/action_controller.ex +++ b/lib/deep_thought_web/controllers/action_controller.ex @@ -1,11 +1,26 @@ defmodule DeepThoughtWeb.ActionController do + @moduledoc """ + Controller responsible for receiving notifications whenever users perform an action on Slack, such as selecting an + option in a message accessory dropdown. + """ + use DeepThoughtWeb, :controller - action_fallback(DeepThoughtWeb.FallbackController) + @doc """ + Receive a Slack action notification and based on the action type, dispatch to the appropriate handler. + """ + @spec process(Plug.Conn.t(), map()) :: Plug.Conn.t() + def process(conn, %{"payload" => params}) do + case Jason.decode(params) do + {:ok, %{"actions" => actions, "type" => "block_actions"} = payload} -> + Enum.each(actions, fn action -> + DeepThought.ActionSupervisor.process(action, payload) + end) - def create(conn, %{"payload" => payload}) do - DeepThought.TranslatorSupervisor.delete(payload) + _ -> + nil + end - send_resp(conn, :ok, "") + json(conn, %{}) end end diff --git a/lib/deep_thought_web/controllers/command_controller.ex b/lib/deep_thought_web/controllers/command_controller.ex new file mode 100644 index 0000000..7d21dec --- /dev/null +++ b/lib/deep_thought_web/controllers/command_controller.ex @@ -0,0 +1,26 @@ +defmodule DeepThoughtWeb.CommandController do + @moduledoc """ + Controller responsible for receiving Slack commands and passing them to the appropriate background worker. + """ + + use DeepThoughtWeb, :controller + + @doc """ + Receive a Slack command and based on the command name, pass it to an appropriate background worker. + """ + @spec process(Plug.Conn.t(), map()) :: Plug.Conn.t() + def process(conn, %{"command" => "/translate", "text" => ""}), + do: + send_resp(conn, :ok, """ + To use the `/translate` command, please invoke it like so: `/translate [target language] [text to translate]`. + Here’s an example that translates the given text from English to Japanese: `/translate 🇯🇵 Hello, world!` + You can specify the language with an emoji flag, just like you would when using the react-to-translate feature. + """) + + def process(conn, %{"command" => command} = params) when command in ["/translate"] do + DeepThought.CommandSupervisor.process(command, params) + send_resp(conn, :ok, "") + end + + def process(conn, _params), do: send_resp(conn, :bad_request, "") +end diff --git a/lib/deep_thought_web/controllers/event_controller.ex b/lib/deep_thought_web/controllers/event_controller.ex index 538f034..90158e4 100644 --- a/lib/deep_thought_web/controllers/event_controller.ex +++ b/lib/deep_thought_web/controllers/event_controller.ex @@ -1,30 +1,27 @@ defmodule DeepThoughtWeb.EventController do - use DeepThoughtWeb, :controller + @moduledoc """ + Controller responsible for receiving Slack events via webhook and either responding directly (in case of simple + events such as `url_verification`), or dispatching events to appropriate background workers (in case of translation + events). + """ - alias DeepThought.Slack - alias DeepThought.Slack.Event + use DeepThoughtWeb, :controller - action_fallback(DeepThoughtWeb.FallbackController) + @doc """ + Receive a Slack event and based on pattern matching the payload, dispatch an appropriate response or action. + """ + @spec process(Plug.Conn.t(), map()) :: Plug.Conn.t() + def process(conn, %{"challenge" => challenge, "type" => "url_verification"}), + do: json(conn, %{challenge: challenge}) - def create(conn, %{"type" => "url_verification"} = event_params) do - with {:ok, %Event{} = event} <- Slack.create_event(event_params) do - render(conn, "show.json", event: event) - end + def process(conn, %{"event" => %{"type" => type} = event, "type" => "event_callback"}) do + DeepThought.EventSupervisor.process(type, event) + json(conn, %{}) end - def create( - conn, - %{ - "event" => - %{ - "item" => %{"channel" => channel_id, "ts" => message_ts, "type" => "message"}, - "reaction" => reaction, - "type" => "reaction_added" - } = event_details, - "type" => "event_callback" - } - ) do - DeepThought.TranslatorSupervisor.translate(event_details, reaction, channel_id, message_ts) - send_resp(conn, :ok, "") - end + def process(conn, _params), + do: + conn + |> put_status(:bad_request) + |> json(%{}) end diff --git a/lib/deep_thought_web/controllers/fallback_controller.ex b/lib/deep_thought_web/controllers/fallback_controller.ex deleted file mode 100644 index c1b7d7a..0000000 --- a/lib/deep_thought_web/controllers/fallback_controller.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule DeepThoughtWeb.FallbackController do - @moduledoc """ - Translates controller action results into valid `Plug.Conn` responses. - - See `Phoenix.Controller.action_fallback/1` for more details. - """ - use DeepThoughtWeb, :controller - - # This clause handles errors returned by Ecto's insert/update/delete. - def call(conn, {:error, %Ecto.Changeset{} = changeset}) do - conn - |> put_status(:unprocessable_entity) - |> put_view(DeepThoughtWeb.ChangesetView) - |> render("error.json", changeset: changeset) - end - - # This clause is an example of how to handle resources that cannot be found. - def call(conn, {:error, :not_found}) do - conn - |> put_status(:not_found) - |> put_view(DeepThoughtWeb.ErrorView) - |> render(:"404") - end -end diff --git a/lib/deep_thought_web/controllers/translate_controller.ex b/lib/deep_thought_web/controllers/translate_controller.ex deleted file mode 100644 index 8bec557..0000000 --- a/lib/deep_thought_web/controllers/translate_controller.ex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule DeepThoughtWeb.TranslateController do - use DeepThoughtWeb, :controller - - action_fallback(DeepThoughtWeb.FallbackController) - - def create(conn, %{ - "channel_id" => channel_id, - "text" => text, - "user_name" => username, - "user_id" => user_id - }) do - DeepThought.TranslatorSupervisor.simple_translate(channel_id, text, username, user_id) - - send_resp(conn, :ok, "") - end -end diff --git a/lib/deep_thought_web/endpoint.ex b/lib/deep_thought_web/endpoint.ex index dc5f63d..1c00ee2 100644 --- a/lib/deep_thought_web/endpoint.ex +++ b/lib/deep_thought_web/endpoint.ex @@ -8,7 +8,7 @@ defmodule DeepThoughtWeb.Endpoint do @session_options [ store: :cookie, key: "_deep_thought_key", - signing_salt: "7OL933Nk" + signing_salt: "QtGtUIry" ] socket "/socket", DeepThoughtWeb.UserSocket, diff --git a/lib/deep_thought_web/live/page_live.ex b/lib/deep_thought_web/live/page_live.ex index 4576ddf..50534ac 100644 --- a/lib/deep_thought_web/live/page_live.ex +++ b/lib/deep_thought_web/live/page_live.ex @@ -1,28 +1,39 @@ defmodule DeepThoughtWeb.PageLive do + @moduledoc """ + Currently unused landing page module. + """ + use DeepThoughtWeb, :live_view + import Appsignal.Phoenix.LiveView, only: [instrument: 4] @impl true def mount(_params, _session, socket) do - {:ok, assign(socket, query: "", results: %{})} + instrument(__MODULE__, "mount", socket, fn -> + {:ok, assign(socket, query: "", results: %{})} + end) end @impl true def handle_event("suggest", %{"q" => query}, socket) do - {:noreply, assign(socket, results: search(query), query: query)} + instrument(__MODULE__, "suggest", socket, fn -> + {:noreply, assign(socket, results: search(query), query: query)} + end) end @impl true def handle_event("search", %{"q" => query}, socket) do - case search(query) do - %{^query => vsn} -> - {:noreply, redirect(socket, external: "https://hexdocs.pm/#{query}/#{vsn}")} + instrument(__MODULE__, "search", socket, fn -> + case search(query) do + %{^query => vsn} -> + {:noreply, redirect(socket, external: "https://hexdocs.pm/#{query}/#{vsn}")} - _ -> - {:noreply, - socket - |> put_flash(:error, "No dependencies found matching \"#{query}\"") - |> assign(results: %{}, query: query)} - end + _ -> + {:noreply, + socket + |> put_flash(:error, "No dependencies found matching \"#{query}\"") + |> assign(results: %{}, query: query)} + end + end) end defp search(query) do diff --git a/lib/deep_thought_web/plugs/signature_verifier.ex b/lib/deep_thought_web/plugs/signature_verifier.ex deleted file mode 100644 index 67b9f63..0000000 --- a/lib/deep_thought_web/plugs/signature_verifier.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule DeepThoughtWeb.Plugs.SignatureVerifier do - import Plug.Conn - - def init(signing_key), do: signing_key - - def call(conn, signing_key) do - with [timestamp] <- get_req_header(conn, "x-slack-request-timestamp"), - [expected] <- get_req_header(conn, "x-slack-signature"), - body <- get_cached_req_body(conn), - sig_basestring <- get_sig_basestring(timestamp, body), - digest <- calculate_digest(signing_key, sig_basestring), - :ok <- verify_signature(expected, digest) do - conn - else - _ -> - conn - |> halt() - |> send_resp(:unauthorized, "") - end - end - - defp get_cached_req_body(conn), do: DeepThoughtWeb.CacheBodyReader.read_cached_body(conn) - defp get_sig_basestring(timestamp, body), do: "v0:" <> timestamp <> ":" <> body - - defp calculate_digest(signing_key, sig_basestring) do - :crypto.mac(:hmac, :sha256, signing_key, sig_basestring) - |> Base.encode16(case: :lower) - end - - defp verify_signature(expected, digest) when expected == "v0=" <> digest, do: :ok - defp verify_signature(_, _), do: :error -end diff --git a/lib/deep_thought_web/plugs/verify_signature.ex b/lib/deep_thought_web/plugs/verify_signature.ex new file mode 100644 index 0000000..7d7bb08 --- /dev/null +++ b/lib/deep_thought_web/plugs/verify_signature.ex @@ -0,0 +1,45 @@ +defmodule DeepThoughtWeb.Plugs.VerifySignature do + @moduledoc """ + Plug module responsible for terminating execution on requests that fail to provide a valid Slack signature. Requests + like that are either a sign of application misconfiguration (in better case) or malicious attack attempt (in worse case). + """ + + import DeepThoughtWeb.CacheBodyReader + import Plug.Conn + + @doc """ + Initialize the plug with a Slack signing secret that will be used to compute the expected signature. + """ + @spec init(String.t()) :: String.t() + def init(signing_secret), do: signing_secret + + @doc """ + Given an incoming connection and a previously configured Slack signing secret, calculate the request’s signature (from + the request’s raw body) and terminate the request if the expected signature doesn’t match the computed signature. + """ + @spec call(Plug.Conn.t(), String.t()) :: Plug.Conn.t() + def call(conn, signing_secret) do + with [timestamp] <- get_req_header(conn, "x-slack-request-timestamp"), + [signature] <- get_req_header(conn, "x-slack-signature"), + expected when signature == "v0=" <> expected <- + read_cached_body(conn) + |> sig_base_string(timestamp) + |> calculate_digest(signing_secret) do + conn + else + _ -> + conn + |> halt() + |> send_resp(:unauthorized, "") + end + end + + @spec sig_base_string(String.t(), String.t()) :: String.t() + defp sig_base_string(body, timestamp), do: "v0:" <> timestamp <> ":" <> body + + @spec calculate_digest(String.t(), String.t()) :: String.t() + defp calculate_digest(text, signing_secret), + do: + :crypto.mac(:hmac, :sha256, signing_secret, text) + |> Base.encode16(case: :lower) +end diff --git a/lib/deep_thought_web/router.ex b/lib/deep_thought_web/router.ex index 8d05858..ec1b062 100644 --- a/lib/deep_thought_web/router.ex +++ b/lib/deep_thought_web/router.ex @@ -2,37 +2,37 @@ defmodule DeepThoughtWeb.Router do use DeepThoughtWeb, :router pipeline :browser do - plug(:accepts, ["html"]) - plug(:fetch_session) - plug(:fetch_live_flash) - plug(:put_root_layout, {DeepThoughtWeb.LayoutView, :root}) - plug(:protect_from_forgery) - plug(:put_secure_browser_headers) + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, {DeepThoughtWeb.LayoutView, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers end pipeline :api do - plug(:accepts, ["json"]) + plug :accepts, ["json"] end pipeline :slack_api do - plug( - DeepThoughtWeb.Plugs.SignatureVerifier, - Application.get_env(:deep_thought, :slack)[:signing_secret] - ) + unless Mix.env() == :test do + plug DeepThoughtWeb.Plugs.VerifySignature, + Application.get_env(:deep_thought, :slack)[:signing_secret] + end end - scope "/slack", DeepThoughtWeb do - pipe_through([:api, :slack_api]) + scope "/", DeepThoughtWeb do + pipe_through :browser - post("/actions", ActionController, :create) - post("/commands/translate", TranslateController, :create) - post("/events", EventController, :create) + live "/", PageLive, :index end - scope "/", DeepThoughtWeb do - pipe_through(:browser) + scope "/slack", DeepThoughtWeb do + pipe_through [:api, :slack_api] - live("/", PageLive, :index) + post "/actions", ActionController, :process + post "/commands", CommandController, :process + post "/events", EventController, :process end # Other scopes may use custom stacks. @@ -51,8 +51,8 @@ defmodule DeepThoughtWeb.Router do import Phoenix.LiveDashboard.Router scope "/" do - pipe_through(:browser) - live_dashboard("/dashboard", metrics: DeepThoughtWeb.Telemetry) + pipe_through :browser + live_dashboard "/dashboard", metrics: DeepThoughtWeb.Telemetry end end end diff --git a/lib/deep_thought_web/telemetry.ex b/lib/deep_thought_web/telemetry.ex index 7e4dc91..e6bff15 100644 --- a/lib/deep_thought_web/telemetry.ex +++ b/lib/deep_thought_web/telemetry.ex @@ -1,4 +1,6 @@ defmodule DeepThoughtWeb.Telemetry do + @moduledoc false + use Supervisor import Telemetry.Metrics diff --git a/lib/deep_thought_web/views/changeset_view.ex b/lib/deep_thought_web/views/changeset_view.ex deleted file mode 100644 index 828368b..0000000 --- a/lib/deep_thought_web/views/changeset_view.ex +++ /dev/null @@ -1,19 +0,0 @@ -defmodule DeepThoughtWeb.ChangesetView do - use DeepThoughtWeb, :view - - @doc """ - Traverses and translates changeset errors. - - See `Ecto.Changeset.traverse_errors/2` and - `DeepThoughtWeb.ErrorHelpers.translate_error/1` for more details. - """ - def translate_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, &translate_error/1) - end - - def render("error.json", %{changeset: changeset}) do - # When encoded, the changeset returns its errors - # as a JSON object. So we just pass it forward. - %{errors: translate_errors(changeset)} - end -end diff --git a/lib/deep_thought_web/views/event_view.ex b/lib/deep_thought_web/views/event_view.ex deleted file mode 100644 index aa3fbcc..0000000 --- a/lib/deep_thought_web/views/event_view.ex +++ /dev/null @@ -1,7 +0,0 @@ -defmodule DeepThoughtWeb.EventView do - use DeepThoughtWeb, :view - - def render("show.json", %{event: event}) do - %{challenge: event.challenge} - end -end diff --git a/mix.exs b/mix.exs index dc983fe..4be9cb3 100644 --- a/mix.exs +++ b/mix.exs @@ -10,7 +10,15 @@ defmodule DeepThought.MixProject do compilers: [:phoenix, :gettext] ++ Mix.compilers(), start_permanent: Mix.env() == :prod, aliases: aliases(), - deps: deps() + deps: deps(), + dialyzer: dialyzer(), + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test + ] ] end @@ -47,9 +55,14 @@ defmodule DeepThought.MixProject do {:gettext, "~> 0.11"}, {:jason, "~> 1.0"}, {:plug_cowboy, "~> 2.0"}, + {:credo, "~> 1.5", only: :dev, runtime: false}, + {:dialyxir, "~> 1.1", only: :dev, runtime: false}, + {:ex_doc, "~> 0.24", only: :dev, runtime: false}, + {:doctor, "~> 0.18.0", only: :dev, runtime: false}, + {:excoveralls, "~> 0.14", only: :test, runtime: false}, + {:appsignal_phoenix, "~> 2.0"}, {:tesla, "~> 1.4"}, - {:hackney, "~> 1.17"}, - {:appsignal_phoenix, "~> 2.0"} + {:hackney, "~> 1.17"} ] end @@ -67,4 +80,8 @@ defmodule DeepThought.MixProject do test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] ] end + + defp dialyzer do + [plt_file: {:no_warn, "priv/plts/deep_thought.plt"}] + end end diff --git a/mix.lock b/mix.lock index 18eb7a7..dd5d346 100644 --- a/mix.lock +++ b/mix.lock @@ -2,32 +2,44 @@ "appsignal": {:hex, :appsignal, "2.1.8", "7050d664314c476b1c13f88db357236b88cfbb8280bbc37b4148a127985aa458", [:make, :mix], [{:decorator, "~> 1.2.3 or ~> 1.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, ">= 1.3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fa26e7754212db3774447ec15e26f682b7cf1973aefaf7b45b1d94431dee8175"}, "appsignal_phoenix": {:hex, :appsignal_phoenix, "2.0.7", "4806a6ad75619a930bf218789e3f9d6df5c5341b20dee422c015ca3ebb5c33ee", [:mix], [{:appsignal_plug, ">= 2.0.8 and < 3.0.0", [hex: :appsignal_plug, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.11", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.9", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "623ceea265b94bdb958c54b39fcb5d59f2d05eeda30f9a67c9e24d0f4d803520"}, "appsignal_plug": {:hex, :appsignal_plug, "2.0.8", "1021f4f4039a360a2dd01163e035f1cb49ce52fc7d7edf144bfe9743e508f40c", [:mix], [{:appsignal, ">= 2.1.5 and < 3.0.0", [hex: :appsignal, repo: "hexpm", optional: false]}, {:plug, ">= 1.1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "fa7dbf18c5998e60b465fc4aa5de5608b2fee47b1a0ef4fa88c6659f19429074"}, + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"}, "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, + "credo": {:hex, :credo, "1.5.6", "e04cc0fdc236fefbb578e0c04bd01a471081616e741d386909e527ac146016c6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4b52a3e558bd64e30de62a648518a5ea2b6e3e5d2b164ef5296244753fc7eb17"}, "db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"}, + "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, + "doctor": {:hex, :doctor, "0.18.0", "114934c1740239953208a39db617699b7e2660770e81129d7f95cdf7837ab766", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "829c88c365f72c0666e443ea670ffb6f180de7b90c23d536edabdd8c722b88f4"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, "ecto": {:hex, :ecto, "3.6.2", "efdf52acfc4ce29249bab5417415bd50abd62db7b0603b8bab0d7b996548c2bc", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "efad6dfb04e6f986b8a3047822b0f826d9affe8e4ebdd2aeedbfcb14fd48884e"}, "ecto_sql": {:hex, :ecto_sql, "3.6.2", "9526b5f691701a5181427634c30655ac33d11e17e4069eff3ae1176c764e0ba3", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.6.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ec9d7e6f742ea39b63aceaea9ac1d1773d574ea40df5a53ef8afbd9242fdb6b"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"}, + "excoveralls": {:hex, :excoveralls, "0.14.1", "14140e4ef343f2af2de33d35268c77bc7983d7824cb945e6c2af54235bc2e61f", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4a588f9f8cf9dc140cc1f3d0ea4d849b2f76d5d8bee66b73c304bb3d3689c8b0"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "floki": {:hex, :floki, "0.30.1", "75d35526d3a1459920b6e87fdbc2e0b8a3670f965dd0903708d2b267e0904c55", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e9c03524447d1c4cbfccd672d739b8c18453eee377846b119d4fd71b1a176bb8"}, + "floki": {:hex, :floki, "0.31.0", "f05ee8a8e6a3ced4e62beeb2c79a63bc8e12ab98fbaaf6e6a3d9b76b1278e23f", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "b05afa372f5c345a5bf240ac25ea1f0f3d5fcfd7490ac0beeb4a203f9444891e"}, "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, + "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "phoenix": {:hex, :phoenix, "1.5.9", "a6368d36cfd59d917b37c44386e01315bc89f7609a10a45a22f47c007edf2597", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7e4bce20a67c012f1fbb0af90e5da49fa7bf0d34e3a067795703b74aef75427d"}, - "phoenix_ecto": {:hex, :phoenix_ecto, "4.2.1", "13f124cf0a3ce0f1948cf24654c7b9f2347169ff75c1123f44674afee6af3b03", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "478a1bae899cac0a6e02be1deec7e2944b7754c04e7d4107fc5a517f877743c0"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.3.0", "2c69a452c2e0ee8c93345ae1cdc1696ef4877ff9cbb15c305def41960c3c4ebf", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "0ac491924217550c8f42c81c1f390b5d81517d12ceaf9abf3e701156760a848e"}, "phoenix_html": {:hex, :phoenix_html, "2.14.3", "51f720d0d543e4e157ff06b65de38e13303d5778a7919bcc696599e5934271b8", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "efd697a7fff35a13eeeb6b43db884705cba353a1a41d127d118fda5f90c8e80f"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.4.0", "87990e68b60213d7487e65814046f9a2bed4a67886c943270125913499b3e5c3", [:mix], [{:ecto_psql_extras, "~> 0.4.1 or ~> 0.5", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.1 or ~> 2.15", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.15.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.4.0 or ~> 0.5.0 or ~> 0.6.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "8d52149e58188e9e4497cc0d8900ab94d9b66f96998ec38c47c7a4f8f4f50e57"}, - "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.1", "9eba6ad16bd80c45f338b2059c7b255ce30784d76f4181304e7b78640e5a7513", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "f3ae26b5abb85a1cb2bc8bb199e29fbcefb34259e469b31fe0c6323f2175a5ef"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.2", "812bb593fb85ab54876265379f162934c276fca04234c6b3e8bbd32a2bfbfdba", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "6d6f1d3592b677d5f3d0cb743c7c322eb52d975b4ef09cb4a1273bbc9da4249e"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.15.7", "09720b8e5151b3ca8ef739cd7626d4feb987c69ba0b509c9bbdb861d5a365881", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 0.5", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a756cf662420272d0f1b3b908cce5222163b5a95aa9bab404f9d29aff53276e"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, "plug": {:hex, :plug, "1.11.1", "f2992bac66fdae679453c9e86134a4201f6f43a687d8ff1cd1b2862d53c80259", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "23524e4fefbb587c11f0833b3910bfb414bf2e2534d61928e920f54e3a1b881f"}, diff --git a/priv/repo/migrations/20210531122050_create_events.exs b/priv/repo/migrations/20210531122050_create_events.exs deleted file mode 100644 index 5f53920..0000000 --- a/priv/repo/migrations/20210531122050_create_events.exs +++ /dev/null @@ -1,12 +0,0 @@ -defmodule DeepThought.Repo.Migrations.CreateEvents do - use Ecto.Migration - - def change do - create table(:events) do - add :type, :string, null: false - add :challenge, :string - - timestamps() - end - end -end diff --git a/priv/repo/migrations/20210601232943_add_reaction_related_fields_to_events.exs b/priv/repo/migrations/20210601232943_add_reaction_related_fields_to_events.exs deleted file mode 100644 index 97762e9..0000000 --- a/priv/repo/migrations/20210601232943_add_reaction_related_fields_to_events.exs +++ /dev/null @@ -1,11 +0,0 @@ -defmodule DeepThought.Repo.Migrations.AddReactionRelatedFieldsToEvents do - use Ecto.Migration - - def change do - alter table(:events) do - add(:target_language, :string) - add(:channel_id, :string) - add(:message_ts, :string) - end - end -end diff --git a/priv/repo/migrations/20210602063300_create_slack_users.exs b/priv/repo/migrations/20210602063300_create_slack_users.exs deleted file mode 100644 index f7d544b..0000000 --- a/priv/repo/migrations/20210602063300_create_slack_users.exs +++ /dev/null @@ -1,14 +0,0 @@ -defmodule DeepThought.Repo.Migrations.CreateSlackUsers do - use Ecto.Migration - - def change do - create table(:slack_users) do - add(:user_id, :string, null: false) - add(:real_name, :string, null: false) - - timestamps() - end - - create(unique_index(:slack_users, [:user_id])) - end -end diff --git a/priv/repo/migrations/20210713131425_create_users.exs b/priv/repo/migrations/20210713131425_create_users.exs new file mode 100644 index 0000000..73973a8 --- /dev/null +++ b/priv/repo/migrations/20210713131425_create_users.exs @@ -0,0 +1,20 @@ +defmodule DeepThought.Repo.Migrations.CreateUsers do + use Ecto.Migration + + def change do + create table(:slack_users) do + add :user_id, :string + add :email, :string + add :real_name, :string + add :real_name_normalized, :string + add :display_name, :string + add :display_name_normalized, :string + add :last_name, :string + add :first_name, :string + + timestamps() + end + + create unique_index(:slack_users, [:user_id]) + end +end diff --git a/priv/repo/migrations/20210716084730_create_translations.exs b/priv/repo/migrations/20210716084730_create_translations.exs new file mode 100644 index 0000000..6202848 --- /dev/null +++ b/priv/repo/migrations/20210716084730_create_translations.exs @@ -0,0 +1,15 @@ +defmodule DeepThought.Repo.Migrations.CreateTranslations do + use Ecto.Migration + + def change do + create table(:translations) do + add :user_id, :string + add :channel_id, :string + add :message_ts, :string + add :target_language, :string + add :status, :string + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20210717133344_add_translation_ts_to_translations.exs b/priv/repo/migrations/20210717133344_add_translation_ts_to_translations.exs new file mode 100644 index 0000000..71cc2a0 --- /dev/null +++ b/priv/repo/migrations/20210717133344_add_translation_ts_to_translations.exs @@ -0,0 +1,10 @@ +defmodule DeepThought.Repo.Migrations.AddTranslationTsToTranslations do + use Ecto.Migration + + def change do + alter table(:translations) do + add :translation_channel_id, :string + add :translation_message_ts, :string + end + end +end diff --git a/test/deep_thought/deepl/api_test.exs b/test/deep_thought/deepl/api_test.exs new file mode 100644 index 0000000..08f4e2c --- /dev/null +++ b/test/deep_thought/deepl/api_test.exs @@ -0,0 +1,11 @@ +defmodule DeepThought.DeepL.APITest do + @moduledoc """ + Module used to test the interaction with the DeepL translation API. + """ + + use DeepThought.MockCase, async: true + + test "translate/2 can return translated text" do + assert {:ok, "Ahoj, světe!"} == DeepL.API.translate("Hello, world!", "CS") + end +end diff --git a/test/deep_thought/slack/api/message_test.exs b/test/deep_thought/slack/api/message_test.exs new file mode 100644 index 0000000..142ed19 --- /dev/null +++ b/test/deep_thought/slack/api/message_test.exs @@ -0,0 +1,97 @@ +defmodule DeepThought.Slack.API.MessageTest do + @moduledoc """ + Test suite to verify the Message module, especially its unescaping logic. + """ + + use DeepThought.DataCase + alias DeepThought.Slack + alias DeepThought.Slack.API.Message + + setup do + Slack.update_users!([ + %{"user_id" => "U9FE1J23V", "real_name" => "Milan Vít"}, + %{"user_id" => "U0233M3T96K", "real_name" => "Deep Thought"}, + %{"user_id" => "U0171KB36DN", "display_name" => "dokku"} + ]) + + :ok + end + + test "unescape/1 unwraps emoji" do + original = """ + A simple message :email::thinking_face: with an emoji :rolling_on_the_floor_laughing: And some text at the end\ + """ + + expected = """ + A simple message :email::thinking_face: with an emoji :rolling_on_the_floor_laughing: And some text at the end\ + """ + + assert %{text: ^expected} = Message.new(original, "") |> Message.unescape() + end + + test "unescape/1 unwraps usernames" do + original = """ + This message @U9FE1J23V contains some @U0233M3T96K usernames @U0171KB36DN@U0233M3T96KAnd ends with text\ + """ + + expected = """ + This message _@Milan Vít_ contains some _@Deep Thought_ usernames _@dokku_ _@Deep Thought_ And ends with text\ + """ + + assert %{text: ^expected} = Message.new(original, "") |> Message.unescape() + end + + test "unescape/1 unwraps channel names" do + original = """ + Similarly, #C023P3L5WFN this message #C024C2HU4BZreferences a bunch #C023P3L5WFN#C024C2HU4BZof channels\ + """ + + expected = """ + Similarly, <#C023P3L5WFN> this message <#C024C2HU4BZ>references a bunch <#C023P3L5WFN><#C024C2HU4BZ>of channels\ + """ + + assert %{text: ^expected} = Message.new(original, "") |> Message.unescape() + end + + test "unescape/1 unwraps links" do + original = """ + This https://www.milanvit.net message contains https://www.milanvit.net|Czech/in/Japan links. To e-mail mailto:milanvit@milanvit.net as well? mailto:milanvit@milanvit.net|You bet.\ + """ + + expected = """ + This message contains links. To e-mail as well? \ + """ + + assert %{text: ^expected} = Message.new(original, "") |> Message.unescape() + end + + test "unescape/1 unwraps codeblocks" do + original = """ + This message contains a codeblock: + ```awesome |> elixir |> code``` + This is also a valid codeblock: + ```say |> no |> to |> go ```\ + """ + + expected = """ + This message contains a codeblock: + ```awesome |> elixir |> code``` + This is also a valid codeblock: + ```say |> no |> to |> go ``` \ + """ + + assert %{text: ^expected} = Message.new(original, "") |> Message.unescape() + end + + test "unescape/1 unwraps inline code" do + original = """ + This message `contains` in-line `code`, in quite `a bit` of various `forms`,`this one is fine`,`and so is this one`, but what about if we `make things` really difficult`?\ + """ + + expected = """ + This message `contains` in-line `code`, in quite `a bit` of various `forms`, `this one is fine`, `and so is this one`, but what about if we `make things` really difficult`?\ + """ + + assert %{text: ^expected} = Message.new(original, "") |> Message.unescape() + end +end diff --git a/test/deep_thought/slack/api_test.exs b/test/deep_thought/slack/api_test.exs new file mode 100644 index 0000000..ec0a402 --- /dev/null +++ b/test/deep_thought/slack/api_test.exs @@ -0,0 +1,21 @@ +defmodule DeepThought.Slack.APITest do + @moduledoc """ + Module used to test the interaction with the Slack API. + """ + + use DeepThought.MockCase, async: true + alias DeepThought.Slack.API.Message + + test "chat_post_message/3 can post a message" do + assert {:ok, "C1H9RESGA", _ts} = Slack.API.chat_post_message(Message.new("text", "C1H9RESGA")) + end + + test "conversations_replies/3 can fetch message with its details" do + assert {:ok, [%{"text" => "Hello, world!"} | _rest]} = + Slack.API.conversations_replies("channel_id", "ts") + end + + test "users_profile_get/1 can fetch a user profile" do + assert {:ok, %{"real_name" => "Milan Vít"}} = Slack.API.users_profile_get("U9FE1J23V") + end +end diff --git a/test/deep_thought/slack/handlers/delete_test.exs b/test/deep_thought/slack/handlers/delete_test.exs new file mode 100644 index 0000000..7be1e90 --- /dev/null +++ b/test/deep_thought/slack/handlers/delete_test.exs @@ -0,0 +1,45 @@ +defmodule DeepThought.Slack.Handler.DeleteTest do + @moduledoc """ + Test suite for the delete action handler. + """ + + use DeepThought.DataCase + use DeepThought.MockCase + alias DeepThought.Slack + alias DeepThought.Slack.Handler.Delete + + @message %{ + user_id: "U12345", + channel_id: "C12345", + message_ts: "123456789.123456", + target_language: "JA", + translation_channel_id: "C12345", + translation_message_ts: "123456789.654321", + status: "success" + } + @context %{ + "container" => %{ + "channel_id" => @message.translation_channel_id, + "message_ts" => @message.translation_message_ts, + "thread_ts" => "T12345" + }, + "user" => %{"id" => @message.user_id} + } + + setup do + {:ok, translation} = Slack.create_translation(@message) + + {:ok, translation: translation} + end + + test "delete_message/1 returns :ok atom on success" do + assert :ok == Delete.delete_message(nil, @context) + end + + test "delete_message/1 marks translation as deleted in DB", %{translation: translation} do + assert translation.status == "success" + assert :ok == Delete.delete_message(nil, @context) + translation = DeepThought.Repo.reload(translation) + assert translation.status == "deleted" + end +end diff --git a/test/deep_thought/slack/handlers/reaction_added_test.exs b/test/deep_thought/slack/handlers/reaction_added_test.exs new file mode 100644 index 0000000..14d265c --- /dev/null +++ b/test/deep_thought/slack/handlers/reaction_added_test.exs @@ -0,0 +1,36 @@ +defmodule DeepThought.Slack.Handler.ReactionAddedTest do + @moduledoc """ + Test suite for the reaction_added event handler. + """ + + use DeepThought.DataCase + use DeepThought.MockCase + alias DeepThought.Slack.Handler.ReactionAdded + + @event %{ + "type" => "reaction_added", + "user" => "U9FE1J23V", + "item" => %{ + "type" => "message", + "channel" => "C023P3L5WFN", + "ts" => "1622775072.008900" + }, + "reaction" => "flag-cz", + "item_user" => "U9FE1J23V", + "event_ts" => "1625226531.000200" + } + + test "reaction_added/1 returns a translation based on event data" do + assert {:ok, "Ahoj, světe!"} == ReactionAdded.reaction_added(@event) + end + + test "reaction_added/1 ignores emoji reactions that are not flags" do + event = Map.put(@event, "reaction", "rolling_on_the_floor_laughing") + assert {:error, :unknown_language} == ReactionAdded.reaction_added(event) + end + + test "reaction_added/1 ignores emoji flags of unsupported languages" do + event = Map.put(@event, "reaction", "flag-sa") + assert {:error, :unknown_language} == ReactionAdded.reaction_added(event) + end +end diff --git a/test/deep_thought/slack/handlers/translate_test.exs b/test/deep_thought/slack/handlers/translate_test.exs new file mode 100644 index 0000000..10ffec0 --- /dev/null +++ b/test/deep_thought/slack/handlers/translate_test.exs @@ -0,0 +1,37 @@ +defmodule DeepThought.Slack.Handler.TranslateTest do + @moduledoc """ + Test suite for the /translate command handler. + """ + + use DeepThought.DataCase + use DeepThought.MockCase + alias DeepThought.Slack.Handler.Translate + + @command %{ + "channel_id" => "C023P3L5WFN", + "text" => "", + "user_id" => "U9FE1J23V", + "user_name" => "milanvit" + } + + test "translate/1 returns error on missing text" do + command = Map.put(@command, "text", ":jp:") + + assert {:error, :missing_text} == Translate.translate(command) + end + + test "translate/1 returns error on invalid language" do + command = Map.put(@command, "text", ":hello: Hello world!") + + assert {:error, :unknown_language} == Translate.translate(command) + end + + test "translate/1 returns translation on success" do + command = Map.put(@command, "text", ":cz: Hello world!") + + assert {:ok, message} = Translate.translate(command) + assert message =~ "Hello world!" + assert message =~ "Ahoj, světe!" + assert message =~ @command["user_name"] + end +end diff --git a/test/deep_thought/slack/language_test.exs b/test/deep_thought/slack/language_test.exs new file mode 100644 index 0000000..637b692 --- /dev/null +++ b/test/deep_thought/slack/language_test.exs @@ -0,0 +1,29 @@ +defmodule DeepThought.Slack.LanguageTest do + @moduledoc """ + Test suite for a module that converts between country codes and language codes. + """ + + use ExUnit.Case, async: true + + alias DeepThought.Slack.Language + + test "new/1 returns a language struct for valid language code" do + ["cz", "us", "gb", "jp", "pl"] + |> Enum.map(fn reaction -> [reaction, "flag-" <> reaction] end) + |> List.flatten() + |> Enum.each(fn reaction -> + assert {:ok, %Language{slack_code: slack_code, deepl_code: deepl_code}} = + Language.new(reaction) + + assert String.ends_with?(reaction, slack_code) + assert String.upcase(deepl_code) == deepl_code + refute deepl_code == "" + end) + end + + test "new/1 returns error on invalid language code" do + Enum.each(["", "invalid", "🤣"], fn reaction -> + assert {:error, :unknown_language} == Language.new(reaction) + end) + end +end diff --git a/test/deep_thought/slack/message_escape_test.exs b/test/deep_thought/slack/message_escape_test.exs new file mode 100644 index 0000000..2beb1b4 --- /dev/null +++ b/test/deep_thought/slack/message_escape_test.exs @@ -0,0 +1,113 @@ +defmodule DeepThought.Slack.MessageEscapeTest do + @moduledoc """ + Test suite to verify the functionality of message escaping prior to sending the text to the translation service. + """ + + use ExUnit.Case, async: true + alias DeepThought.Slack.MessageEscape + + test "escape/1 removes global mentions" do + original = """ + Thismessage is intentionally annoying\ + """ + + expected = "Thismessage is intentionally annoying" + + assert expected == MessageEscape.escape(original) + end + + test "escape/1 wraps emoji" do + original = """ + A simple message :email::thinking_face: with an emoji :rolling_on_the_floor_laughing: And some text at the end\ + """ + + expected = """ + A simple message :email::thinking_face: with an emoji :rolling_on_the_floor_laughing: And some text at the end\ + """ + + assert expected == MessageEscape.escape(original) + end + + test "escape/1 wraps emoji, nightmare difficulty" do + messages = [ + %{original: ":any-non-whitespace:", expected: ":any-non-whitespace:"}, + %{original: ":text1:sample2:", expected: ":text1:sample2:"}, + %{original: ":@(1@#$@SD: :s:", expected: ":@(1@#$@SD: :s:"}, + %{ + original: ":nospace::inbetween: because there are 2 colons in the middle", + expected: ":nospace::inbetween: because there are 2 colons in the middle" + }, + %{original: ":nospace:middle:nospace:", expected: ":nospace:middle:nospace:"} + ] + + Enum.each(messages, fn message -> + assert message.expected == MessageEscape.escape(message.original) + end) + end + + test "escape/1 wraps usernames" do + original = """ + This message <@U9FE1J23V> contains some <@U0233M3T96K> usernames <@U0171KB36DN><@U0233M3T96K>And ends with text\ + """ + + expected = """ + This message @U9FE1J23V contains some @U0233M3T96K usernames @U0171KB36DN@U0233M3T96KAnd ends with text\ + """ + + assert expected == MessageEscape.escape(original) + end + + test "escape/1 wraps channel names" do + original = """ + Similarly, <#C023P3L5WFN|deep-thought> this message <#C024C2HU4BZ>references a bunch <#C023P3L5WFN|deep-thought><#C024C2HU4BZ|yakun>of channels\ + """ + + expected = """ + Similarly, #C023P3L5WFN this message #C024C2HU4BZreferences a bunch #C023P3L5WFN#C024C2HU4BZof channels\ + """ + + assert expected == MessageEscape.escape(original) + end + + test "escape/1 wraps links" do + original = """ + This message contains links. To e-mail as well? \ + """ + + expected = """ + This https://www.milanvit.net message contains https://www.milanvit.net|Czech/in/Japan links. To e-mail mailto:milanvit@milanvit.net as well? mailto:milanvit@milanvit.net|You bet.\ + """ + + assert expected == MessageEscape.escape(original) + end + + test "escape/1 wraps codeblocks" do + original = """ + This message contains a codeblock: + ```awesome |> elixir |> code``` + This is also a valid codeblock: + ```say |> no |> to |> go ```\ + """ + + expected = """ + This message contains a codeblock: + ```awesome |> elixir |> code``` + This is also a valid codeblock: + ```say |> no |> to |> go ```\ + """ + + assert expected == MessageEscape.escape(original) + end + + test "escape/1 wraps inline code" do + original = """ + This message `contains` in-line `code`, in quite `a bit` of various `forms`,`this one is fine`,`and so is this one`, but what about if we `make things` really difficult`?\ + """ + + expected = """ + This message `contains` in-line `code`, in quite `a bit` of various `forms`,`this one is fine`,`and so is this one`, but what about if we `make things` really difficult`?\ + """ + + assert expected == MessageEscape.escape(original) + end +end diff --git a/test/deep_thought/slack_test.exs b/test/deep_thought/slack_test.exs index dbf40a7..a4dfe17 100644 --- a/test/deep_thought/slack_test.exs +++ b/test/deep_thought/slack_test.exs @@ -1,40 +1,108 @@ defmodule DeepThought.SlackTest do - use DeepThought.DataCase + @moduledoc """ + Test suite for database operations on Slack users. + """ + use DeepThought.DataCase alias DeepThought.Slack - describe "events" do - alias DeepThought.Slack.Event + describe "users" do + alias DeepThought.Slack.User + + @user1 %{ + display_name: "display_name", + real_name: "real_name", + user_id: "U123456" + } + @user2 %{ + display_name: "name_display", + real_name: "name_real", + user_id: "U987654" + } + + test "find_users_by_user_ids/1 finds user accounts" do + assert [%User{}, %User{}] = Slack.update_users!([@user1, @user2]) + assert [user1, user2] = Slack.find_users_by_user_ids(~w[U123456 U987654]) + assert user1.user_id == "U123456" + assert user2.user_id == "U987654" + end + + test "find_users_by_user_ids/1 doesn’t return stale data" do + today = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + yesterday = today |> NaiveDateTime.add(-24 * 60 * 60) - @valid_attrs %{type: "some type"} - @invalid_attrs %{type: nil} + DeepThought.Repo.transaction(fn -> + DeepThought.Repo.insert_all(Slack.User, [ + Map.merge(@user1, %{inserted_at: yesterday, updated_at: yesterday}), + Map.merge(@user2, %{inserted_at: today, updated_at: today}) + ]) - def event_fixture(attrs \\ %{}) do - {:ok, event} = + assert [user] = result = Slack.find_users_by_user_ids(~w[U123456 U987654]) + assert user.user_id == "U987654" + assert Enum.count(result) == 1 + + Repo.rollback(:cleanup) + end) + end + + test "update_users/1 with valid data creates/updates users" do + assert [%User{} = user1, %User{} = user2] = Slack.update_users!([@user1, @user2]) + assert user1.display_name == "display_name" + assert user1.real_name == "real_name" + assert user1.user_id == "U123456" + assert user2.display_name == "name_display" + assert user2.real_name == "name_real" + assert user2.user_id == "U987654" + end + end + + describe "translations" do + alias DeepThought.Slack.Translation + + @valid_attrs %{ + channel_id: "C12345", + message_ts: "12345.000", + status: "success", + target_language: "JA", + user_id: "U12345" + } + @invalid_attrs %{ + channel_id: nil, + message_ts: nil, + status: nil, + target_language: nil, + user_id: nil + } + + def translation_fixture(attrs \\ %{}) do + {:ok, translation} = attrs |> Enum.into(@valid_attrs) - |> Slack.create_event() + |> Slack.create_translation() - event + translation end - test "get_event!/1 returns the event with given id" do - event = event_fixture() - assert Slack.get_event!(event.id) == event - end + test "recently_translated?/3 can find a recently translated message" do + %{channel_id: channel_id, message_ts: message_ts, target_language: target_language} = + @valid_attrs - test "create_event/1 with valid data creates a event" do - assert {:ok, %Event{} = event} = Slack.create_event(@valid_attrs) - assert event.type == "some type" + assert false == Slack.recently_translated?(channel_id, message_ts, target_language) + assert {:ok, %Translation{}} = Slack.create_translation(@valid_attrs) + assert true == Slack.recently_translated?(channel_id, message_ts, target_language) end - test "create_event/1 with invalid data returns error changeset" do - assert {:error, %Ecto.Changeset{}} = Slack.create_event(@invalid_attrs) + test "create_translation/1 with valid data creates a translation" do + assert {:ok, %Translation{} = translation} = Slack.create_translation(@valid_attrs) + assert translation.channel_id == "C12345" + assert translation.message_ts == "12345.000" + assert translation.status == "success" + assert translation.target_language == "JA" + assert translation.user_id == "U12345" end - test "change_event/1 returns a event changeset" do - event = event_fixture() - assert %Ecto.Changeset{} = Slack.change_event(event) + test "create_translation/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Slack.create_translation(@invalid_attrs) end end end diff --git a/test/deep_thought_web/controllers/command_controller_test.exs b/test/deep_thought_web/controllers/command_controller_test.exs new file mode 100644 index 0000000..4f24700 --- /dev/null +++ b/test/deep_thought_web/controllers/command_controller_test.exs @@ -0,0 +1,20 @@ +defmodule DeepThoughtWeb.CommandControllerTest do + @moduledoc """ + Test suite for the CommandController’s ability to handle commands. + """ + + use DeepThoughtWeb.ConnCase + + test "immediately returns usage instructions when no text is provided", %{conn: conn} do + conn = + post(conn, Routes.command_path(conn, :process), %{"command" => "/translate", "text" => ""}) + + assert conn.resp_body =~ "Here’s an example" + end + + test "returns status code 400 on unsupported command", %{conn: conn} do + conn = post(conn, Routes.command_path(conn, :process), %{"command" => "/make-coffee"}) + + assert conn.status == 400 + end +end diff --git a/test/deep_thought_web/controllers/event_controller_test.exs b/test/deep_thought_web/controllers/event_controller_test.exs index d81d46f..9675b14 100644 --- a/test/deep_thought_web/controllers/event_controller_test.exs +++ b/test/deep_thought_web/controllers/event_controller_test.exs @@ -1,44 +1,60 @@ defmodule DeepThoughtWeb.EventControllerTest do + @moduledoc """ + Test suite for the EventController’s ability to receive Slack events. + """ + use DeepThoughtWeb.ConnCase - alias DeepThought.Slack - alias DeepThought.Slack.Event + setup %{conn: conn} do + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end - @create_attrs %{ - type: "some type" + @challenge "igUYoqfhIDfhfkRNJ7aaNm6YuaVjTyzxE2gKhmWW5CKJjo5S7MTw" + @url_verification %{ + "token" => "JaDHzbfk7LgLAoMMPTEVlyWO", + "challenge" => @challenge, + "type" => "url_verification" + } + @reaction_added %{ + "event" => %{ + "type" => "reaction_added", + "user" => "U9FE1J23V", + "item" => %{ + "type" => "message", + "channel" => "C023P3L5WFN", + "ts" => "1622775072.008900" + }, + "reaction" => "flag-cz", + "item_user" => "U9FE1J23V", + "event_ts" => "1625226531.000200" + }, + "type" => "event_callback" } - @invalid_attrs %{type: nil} - def fixture(:event) do - {:ok, event} = Slack.create_event(@create_attrs) - event - end + test "responds to an url_verification payload with expected challenge", %{conn: conn} do + conn = post(conn, Routes.event_path(conn, :process), @url_verification) - setup %{conn: conn} do - {:ok, conn: put_req_header(conn, "accept", "application/json")} + assert %{"challenge" => @challenge} = json_response(conn, 200) end - describe "create event" do - test "renders event when data is valid", %{conn: conn} do - conn = post(conn, Routes.event_path(conn, :create), event: @create_attrs) - assert %{"id" => id} = json_response(conn, 201)["data"] + # test "responds immediately to a reaction_added payload", %{conn: conn} do + # conn = post(conn, Routes.event_path(conn, :process), @reaction_added) + # + # assert %{} = json_response(conn, 200) + # end - conn = get(conn, Routes.event_path(conn, :show, id)) + test "returns status code 400 on unsupported event type", %{conn: conn} do + conn = post(conn, Routes.event_path(conn, :process), Map.delete(@reaction_added, "type")) - assert %{ - "id" => id, - "type" => "some type" - } = json_response(conn, 200)["data"] - end + assert json_response(conn, 400) - test "renders errors when data is invalid", %{conn: conn} do - conn = post(conn, Routes.event_path(conn, :create), event: @invalid_attrs) - assert json_response(conn, 422)["errors"] != %{} - end - end + conn = + post( + conn, + Routes.event_path(conn, :process), + pop_in(@reaction_added["event"]["type"]) |> elem(1) + ) - defp create_event(_) do - event = fixture(:event) - %{event: event} + assert json_response(conn, 400) end end diff --git a/test/deep_thought_web/plugs/verify_signature_test.exs b/test/deep_thought_web/plugs/verify_signature_test.exs new file mode 100644 index 0000000..90dbf62 --- /dev/null +++ b/test/deep_thought_web/plugs/verify_signature_test.exs @@ -0,0 +1,54 @@ +defmodule DeepThoughtWeb.Plugs.VerifySignatureTest do + @moduledoc """ + Test suite for the signature verification module which is used to verify that the incoming requests originated within + the Slack network, as opposed to fake requests by a potential attacker. + """ + + use ExUnit.Case, async: true + use Plug.Test + + alias DeepThoughtWeb.Plugs.VerifySignature + + setup do + body = "{}" + + {:ok, + conn: + conn(:post, "/slack/events", body) + |> Plug.Conn.put_private(:raw_body, body) + |> put_req_header("x-slack-request-timestamp", "1625202589")} + end + + test "init/1 stores the signing secret" do + assert "secret" == VerifySignature.init("secret") + end + + test "request with valid signature is not halted", %{conn: conn} do + conn = + conn + |> put_req_header( + "x-slack-signature", + "v0=27ab08e51bb55b93f4cb2c749193ed0ea90986020a8fe2f0aa3972d480b9a715" + ) + |> VerifySignature.call("secret") + + refute conn.halted + end + + test "request with an invalid signature is halted", %{conn: conn} do + conn = + conn + |> put_req_header("x-slack-signature", "invalid") + |> VerifySignature.call("secret") + + assert conn.status == 401 + assert conn.halted + end + + test "request with a missing signature is halted", %{conn: conn} do + conn = VerifySignature.call(conn, "secret") + + assert conn.status == 401 + assert conn.halted + end +end diff --git a/test/support/channel_case.ex b/test/support/channel_case.ex index c598a8a..20e3981 100644 --- a/test/support/channel_case.ex +++ b/test/support/channel_case.ex @@ -16,6 +16,7 @@ defmodule DeepThoughtWeb.ChannelCase do """ use ExUnit.CaseTemplate + alias Ecto.Adapters.SQL using do quote do @@ -29,10 +30,10 @@ defmodule DeepThoughtWeb.ChannelCase do end setup tags do - :ok = Ecto.Adapters.SQL.Sandbox.checkout(DeepThought.Repo) + :ok = SQL.Sandbox.checkout(DeepThought.Repo) unless tags[:async] do - Ecto.Adapters.SQL.Sandbox.mode(DeepThought.Repo, {:shared, self()}) + SQL.Sandbox.mode(DeepThought.Repo, {:shared, self()}) end :ok diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index 9bb65ab..66dd777 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -16,6 +16,7 @@ defmodule DeepThoughtWeb.ConnCase do """ use ExUnit.CaseTemplate + alias Ecto.Adapters.SQL using do quote do @@ -32,10 +33,10 @@ defmodule DeepThoughtWeb.ConnCase do end setup tags do - :ok = Ecto.Adapters.SQL.Sandbox.checkout(DeepThought.Repo) + :ok = SQL.Sandbox.checkout(DeepThought.Repo) unless tags[:async] do - Ecto.Adapters.SQL.Sandbox.mode(DeepThought.Repo, {:shared, self()}) + SQL.Sandbox.mode(DeepThought.Repo, {:shared, self()}) end {:ok, conn: Phoenix.ConnTest.build_conn()} diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 47ba7c8..cb05b3b 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -15,6 +15,7 @@ defmodule DeepThought.DataCase do """ use ExUnit.CaseTemplate + alias Ecto.Adapters.SQL using do quote do @@ -28,10 +29,10 @@ defmodule DeepThought.DataCase do end setup tags do - :ok = Ecto.Adapters.SQL.Sandbox.checkout(DeepThought.Repo) + :ok = SQL.Sandbox.checkout(DeepThought.Repo) unless tags[:async] do - Ecto.Adapters.SQL.Sandbox.mode(DeepThought.Repo, {:shared, self()}) + SQL.Sandbox.mode(DeepThought.Repo, {:shared, self()}) end :ok diff --git a/test/support/mock_case.ex b/test/support/mock_case.ex new file mode 100644 index 0000000..5120348 --- /dev/null +++ b/test/support/mock_case.ex @@ -0,0 +1,58 @@ +defmodule DeepThought.MockCase do + @moduledoc """ + Module template that sets up DeepL API and Slack API client mocks. + """ + + use ExUnit.CaseTemplate + import Tesla.Mock + + using do + quote do + alias DeepThought.DeepL + alias DeepThought.Slack + end + end + + setup do + Tesla.Mock.mock(fn + %{url: "https://api.deepl.com/" <> _rest} -> setup_deepl() + %{url: "https://slack.com/api" <> method} = env -> setup_slack(env, method) + end) + + :ok + end + + def setup_deepl, do: json(%{"translations" => [%{"text" => "Ahoj, světe!"}]}) + + def setup_slack(env, method) do + case method do + "/chat.getPermalink" -> + json(%{ + "ok" => true, + "permalink" => "https://ghostbusters.slack.com/archives/C1H9RESGA/p135854651500008" + }) + + "/chat.postMessage" -> + json(%{"ok" => true, "channel" => "C1H9RESGA", "ts" => "1459571776.000001"}) + + "/chat." <> _rest -> + json(%{"ok" => true}) + + "/conversations.replies" -> + json(%{ + "ok" => true, + "messages" => [%{"text" => "Hello, world!", "ts" => "1625806692.000500"}] + }) + + "/users.profile.get" -> + real_name = + case env.query[:user] do + "U9FE1J23V" -> "Milan Vít" + "U0233M3T96K" -> "Deep Thought" + "U0171KB36DN" -> "dokku" + end + + json(%{"ok" => true, "profile" => %{"real_name" => real_name}}) + end + end +end