diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..4df2cc6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,120 @@ +name: Deploy +on: + push: + branches: develop + + pull_request: + branches: master + types: [closed] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + env: + APP_ENV: test + BOT_TOKEN: dummy + SUPABASE_URL: ${{secrets.SUPABASE_URL}} + SUPABASE_KEY: ${{secrets.SUPABASE_KEY}} + SUPABASE_SCHEMA: test + + steps: + - name: Setup repo + uses: actions/checkout@v4 + + - uses: denoland/setup-deno@main + with: + deno-version: v1.x + + - name: Cache Dependencies + run: deno cache src/bot.ts + + - name: Run Tests + run: deno task test + + coverage: + name: Coverage + runs-on: ubuntu-latest + + env: + APP_ENV: test + BOT_TOKEN: dummy + SUPABASE_URL: ${{secrets.SUPABASE_URL}} + SUPABASE_KEY: ${{secrets.SUPABASE_KEY}} + SUPABASE_SCHEMA: test + + steps: + - name: Setup repo + uses: actions/checkout@v3 + + - uses: denoland/setup-deno@main + with: + deno-version: v1.x + + - name: Run Coverage + run: deno task coverage + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4.0.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + + + format-and-lint: + name: Format and Lint + runs-on: ubuntu-latest + + env: + APP_ENV: test + BOT_TOKEN: dummy + SUPABASE_URL: ${{secrets.SUPABASE_URL}} + SUPABASE_KEY: ${{secrets.SUPABASE_KEY}} + SUPABASE_SCHEMA: test + + steps: + - name: Setup repo + uses: actions/checkout@v4 + + - uses: denoland/setup-deno@main + with: + deno-version: v1.x + + - name: Run Format + run: deno fmt --check + + - name: Run Lint + run: deno lint + + deploy: + name: Deploy + runs-on: ubuntu-latest + + needs: + - test + - format-and-lint + + environment: + name: ${{github.ref_name}} + + permissions: + id-token: write # Needed for auth with Deno Deploy + contents: read # Needed to clone the repository + + steps: + - name: Clone repository + uses: actions/checkout@v4 + + - name: Install Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v1.x + + - name: Upload to Deno Deploy + uses: denoland/deployctl@v1 + env: + APP_ENV: ${{vars.APP_ENV}} + with: + project: "hn-telegram-bot" + entrypoint: "src/bot.ts" + root: "." diff --git a/.gitignore b/.gitignore index b6778d7..d197312 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ lib-cov # Coverage directory used by tools like istanbul coverage *.lcov +cov_profile # nyc test coverage .nyc_output @@ -74,6 +75,7 @@ web_modules/ # dotenv environment variable files .env +.env.test .env.development.local .env.test.local .env.production.local @@ -129,4 +131,4 @@ dist .yarn/install-state.gz .pnp.* -.prettierrc \ No newline at end of file +.DS_Store \ No newline at end of file diff --git a/.tool-versions b/.tool-versions index 02fda06..3026db5 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -nodejs 20.14.0 +deno 1.45.4 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..e0992ad --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["denoland.vscode-deno", "github.vscode-github-actions"] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..331f12b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "deno.enable": true, + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno", + "editor.formatOnSave": true, + "editor.tabSize": 2 + }, +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7c1946e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,173 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [unreleased] + +### ๐Ÿš€ Features + +- *(analysis)* Send message to user +- Add daily analysis cron job +- *(supabase)* Add test schema support +- Add supabase as storage for preferences +- *(analysis)* Respond with filtered story titles based on user preferences +- *(gemini-adapter)* Add responseSchema for storyIds +- *(gemini-adapter)* Replace with v1beta API and add responseMimeType on config +- *(gemini-adapter)* Implement generateContent call with response parsing +- Add changelog +- Deploy workflow +- Validate Item schema +- [**breaking**] Complete deno migration by fixing last tests +- Add list preferences cmd +- [**breaking**] Replace jest with vitest cause of esm troubles +- Reset preferences and fix convo loop +- Store preferences in session with conversations +- Ask for preference +- Determine session adapter in bot setup +- Get storage adapter for env +- Remove axios to use builtin fetch API +- Get 500 top stories + +### ๐Ÿ› Bug Fixes + +- *(utils)* Type generic supabaseAdapter +- Correctly type users preferences group +- *(config)* Restore test task arg when setting env +- Add missing test variables for coverage +- Remove env leaking log +- Change root to cwd +- Import new types in bot.ts +- Adapt for deno deploy +- Use test flag on test task to retrieve right env file +- Remove overkilling functions and declare adapters inside always condition +- Disable noEmit during build +- Add ESM support for jest +- Remove ts-node and use tsx with watch mode + +### ๐Ÿšœ Refactor + +- *(gemini-adapter)* Simplify body object gen +- Improve env loading with zod +- [**breaking**] Move types in a single declaration file +- Add generic slashCommand generator + +### ๐Ÿ“š Documentation + +- Update changelog +- Add instructions to run tests with test schema +- Force env variable setup +- Removed setMyCommands tip since an issue has been opened for it(#13) +- Fix typo on README +- Sort commits by newest for git-cliff +- Prioritize testing over styling in changelog +- Update changelog +- Add deployment section +- Move codefactor badge on top +- Add codefactor badge +- Add todo for clear preferences and update readme with BDD tips +- Update readme with right usage of jest object +- Add setMyCommands usage tip +- Add testing guideline +- Update README with test instructions +- Add usage instructions + +### ๐Ÿงช Testing + +- *(helpers)* Cleanup database sessions +- *(analysis)* Add promisify factory with item mocks +- Stub global fetch +- [**breaking**] Fix specs adapting to vitest +- It responds to setup command +- Add type checkers +- Add faker and mock array of top stories +- Should return null for not found item +- Mock getItem api +- Add watch flag + +### ๐ŸŽจ Styling + +- Remove unused no-explicit-any in spec +- Set editor.tabSize on 2 +- Underscore file names +- Fix fmt of api.ts +- Add develop codefactor +- Add newline on heading + +### โš™๏ธ Miscellaneous Tasks + +- Update config.ts to load environment variables with export option +- *(analysis)* Parse user preferences and filter their stories +- Correctly type all parts using ramda +- Add deno check task to improve code quality +- *(types)* Type getUserPreferences +- *(ramda)* Add @types/ramda +- Remove any type from custom schemas in utils.ts +- Upgrade @std/testing to major 1 +- Fix supabase deps +- Upgrade deno to 1.45.4 and include unstable cron +- *(analysis)* Get users preferences and seed db +- Add supabase secrets +- *(supabase)* Add db types and client connection +- Update changelog +- *(utils)* Add mapIndexed fn +- *(analysis)* Add error tests todo for __bulkRetrieveItems__ +- *(analysis)* Bulk retrieve top stories as items with parallel requests +- *(gemini-adapter)* Add body builder function +- *(ai-adapter)* Add body call signature +- *(ai-adapter)* Add buildBody method +- *(gemini-adapter)* Init apiKey and baseUrl +- *(ai-adapter)* Rename text to input as generateContent param +- *(ai-adapter)* Init base adapter +- Upgrade deno to 1.45.1 +- *(analysis)* Init folder structure +- *(gemini-response)* Add zod types for request and response schema of `generateContent` api +- Point ramda package to deno instead of nest +- AllowBreaking on updater +- Add format and lint job +- Ignore any lint in specs +- Cache dependencies during test +- Upgrade grammy to 1.27 with latest types +- Reignore .env.test +- Upgrade packages +- Manual update workflow +- Add update workflow +- Add codecov +- Add test job dependency +- Do not ignore .env test +- Add zod package +- Add coverage task +- Add recommended deno ext +- [**breaking**] Add grammy from deno +- [**breaking**] Add faker deno module +- [**breaking**] Add ramda external dep +- [**breaking**] Add deno and remove type definitions and tsconfig +- Add dependabot +- Upgrade dotenvx to major 1 +- [**breaking**] Remove export of bot and use bot.ts as entrypoint +- Upgrade typescript npm +- Upgrade grammy +- Add todo on mocking api calls +- Upgrade grammy +- Init conversation plugin +- Remove some types and cleanup config +- Init setup composer +- [**breaking**] Use bot.ts as entrypoint +- Init grammy bot +- Replace telegraf with grammY +- Add tsconfig.build for excluding specs on compilation +- Remove error test +- Improve item typing for req +- Config jest to match only __tests__ folder +- Config jest for ts and move to src folder +- Move env declaration at root +- Add support of dotenvx +- Add build script for npm to run tsc +- Extend ProcessEnv of node with BOT_TOKEN +- Init project + +### Deno + +- Update imports +- Update imports + + diff --git a/README.md b/README.md index dd12dd4..5543747 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,47 @@ # hn-telegram-bot + +![develop](https://github.com/devsheva/hn-telegram-bot/actions/workflows/deploy.yml/badge.svg?branch=develop) +[![codecov](https://codecov.io/gh/devsheva/hn-telegram-bot/branch/develop/graph/badge.svg?token=KTAGSPACY1)](https://codecov.io/gh/devsheva/hn-telegram-bot) + HackerNews Telegram Bot + +## Usage + +### Development + +To develop in local just run `deno task dev` that will start in watch mode. +To update the changelog just run `git-cliff -o CHANGELOG.md`. + +### Testing + +**Pass the necessary environment variables to run against test mode!** + +- APP_ENV=test +- SUPABASE_KEY=your_key +- SUPABASE_SCHEMA=test +- SUPABASE_URL=your_url + +Put all tests under this [folder](src/__tests__) and run `deno task test`, which will run test environment with Deno in watch mode. + +You must pass at least the following environment variables to make testing work: + +- APP_ENV +- SUPABASE_URL +- SUPABASE_KEY + +> Note: this is a temporary workaround until conversations plugin is fixed + +When running tests on a composer that involves conversation, always add `await new Promise(r => setTimeout(r, 0))`, so it doesn't lead to +deno leaks due to sanitizers. + +### Tips + +This project is made by mainly following BDD principles, so you should stick to it, you won't regret. + +## Deployment + +It's handled with GitHub Actions, with two [workflows](.github/workflows/): + +- **deploy.yml** + - 3 jobs: test, coverage, deploy +- **update.yml**: external GH Action that acts like dependabot but for deno diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..898841e --- /dev/null +++ b/cliff.toml @@ -0,0 +1,89 @@ +# git-cliff ~ default configuration file +# https://git-cliff.org/docs/configuration +# +# Lines starting with "#" are comments. +# Configuration options are organized into tables and keys. +# See documentation for more information on available options. + +[changelog] +# changelog header +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# template for the changelog footer +footer = """ + +""" +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [ + # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL +] + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Replace issue numbers + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit with https://github.com/crate-ci/typos + # If the spelling is incorrect, it will be automatically fixed. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "๐Ÿš€ Features" }, + { message = "^fix", group = "๐Ÿ› Bug Fixes" }, + { message = "^doc", group = "๐Ÿ“š Documentation" }, + { message = "^perf", group = "โšก Performance" }, + { message = "^refactor", group = "๐Ÿšœ Refactor" }, + { message = "^style", group = "๐ŸŽจ Styling" }, + { message = "^test", group = "๐Ÿงช Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore|^ci", group = "โš™๏ธ Miscellaneous Tasks" }, + { body = ".*security", group = "๐Ÿ›ก๏ธ Security" }, + { message = "^revert", group = "โ—€๏ธ Revert" }, +] +# protect breaking changes from being skipped due to matching a skipping commit_parser +protect_breaking_commits = false +# filter out the commits that are not matched by commit parsers +filter_commits = false +# regex for matching git tags +# tag_pattern = "v[0-9].*" +# regex for skipping tags +# skip_tags = "" +# regex for ignoring tags +# ignore_tags = "" +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "newest" +# limit the number of commits included in the changelog. +# limit_commits = 42 diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..4602698 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,31 @@ +{ + "imports": { + "@/": "./src/", + "@adapters/ai": "./src/adapters/mod.ts", + "@bot/": "./src/bot/", + "@std/assert": "jsr:@std/assert@^0.226.0", + "@std/cli": "https://deno.land/std@0.224.0/cli/mod.ts", + "@std/dotenv": "jsr:@std/dotenv@^0.224.2", + "@std/testing": "jsr:@std/testing@^1.0.0" + }, + "fmt": { + "lineWidth": 80, + "include": ["src/**/*.ts"], + "semiColons": false, + "singleQuote": true, + "proseWrap": "preserve" + }, + "tasks": { + "coverage": "rm -rf cov_profile && deno test -A --parallel --coverage=cov_profile -- --test && deno coverage --lcov --output=./coverage.lcov ./cov_profile", + "dev": "deno run --watch --allow-read --allow-net --allow-env src/bot.ts", + "test": "deno test -A --parallel -- --test", + "check": "deno check **/*.ts" + }, + "unstable": ["cron"], + "exclude": ["dist", "node_modules"], + "compilerOptions": { + "lib": ["deno.window", "deno.unstable"], + "strict": true, + "allowJs": true + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..d260810 --- /dev/null +++ b/deno.lock @@ -0,0 +1,528 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "jsr:@std/assert@^0.226.0": "jsr:@std/assert@0.226.0", + "jsr:@std/assert@^1.0.2": "jsr:@std/assert@1.0.2", + "jsr:@std/async@^1.0.2": "jsr:@std/async@1.0.3", + "jsr:@std/data-structures@^1.0.1": "jsr:@std/data-structures@1.0.1", + "jsr:@std/dotenv@^0.224.2": "jsr:@std/dotenv@0.224.2", + "jsr:@std/internal@^1.0.0": "jsr:@std/internal@1.0.1", + "jsr:@std/internal@^1.0.1": "jsr:@std/internal@1.0.1", + "jsr:@std/testing@^1.0.0": "jsr:@std/testing@1.0.0", + "npm:@types/node": "npm:@types/node@18.16.19", + "npm:@types/ramda": "npm:@types/ramda@0.30.1" + }, + "jsr": { + "@std/assert@0.226.0": { + "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3", + "dependencies": [ + "jsr:@std/internal@^1.0.0" + ] + }, + "@std/assert@1.0.2": { + "integrity": "ccacec332958126deaceb5c63ff8b4eaf9f5ed0eac9feccf124110435e59e49c", + "dependencies": [ + "jsr:@std/internal@^1.0.1" + ] + }, + "@std/async@1.0.3": { + "integrity": "6ed64678db43451683c6c176a21426a2ccd21ba0269ebb2c36133ede3f165792" + }, + "@std/data-structures@1.0.1": { + "integrity": "e4fa6bcc33839979ac118e2746f349cd7b57c58bd3036b5b82ac608771ee856e" + }, + "@std/dotenv@0.224.2": { + "integrity": "29081695357e4534696c9e986b2560be29c141ccf52daa32b6c20ff5b5c64ab9" + }, + "@std/internal@1.0.1": { + "integrity": "6f8c7544d06a11dd256c8d6ba54b11ed870aac6c5aeafff499892662c57673e6" + }, + "@std/testing@1.0.0": { + "integrity": "27cfc06392c69c2acffe54e6d0bcb5f961cf193f519255372bd4fff1481bfef8", + "dependencies": [ + "jsr:@std/assert@^1.0.2", + "jsr:@std/async@^1.0.2", + "jsr:@std/data-structures@^1.0.1" + ] + } + }, + "npm": { + "@types/node@18.16.19": { + "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==", + "dependencies": {} + }, + "@types/ramda@0.30.1": { + "integrity": "sha512-aoyF/ADPL6N+/NXXfhPWF+Qj6w1Cql59m9wX0Gi15uyF+bpzXeLd63HPdiTDE2bmLXfNcVufsDPKmbfOrOzTBA==", + "dependencies": { + "types-ramda": "types-ramda@0.30.1" + } + }, + "ts-toolbelt@9.6.0": { + "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==", + "dependencies": {} + }, + "types-ramda@0.30.1": { + "integrity": "sha512-1HTsf5/QVRmLzcGfldPFvkVsAdi1db1BBKzi7iW3KBUlOICg/nKnFS+jGqDJS3YD8VsWbAh7JiHeBvbsw8RPxA==", + "dependencies": { + "ts-toolbelt": "ts-toolbelt@9.6.0" + } + } + } + }, + "redirects": { + "https://lib.deno.dev/x/grammy@v1/mod.ts": "https://deno.land/x/grammy@v1.28.0/mod.ts", + "https://lib.deno.dev/x/grammy@v1/types.ts": "https://deno.land/x/grammy@v1.28.0/types.ts" + }, + "remote": { + "https://cdn.skypack.dev/-/debug@v4.3.4-o4liVvMlOnQWbLSYZMXw/dist=es2019,mode=imports/optimized/debug.js": "671100993996e39b501301a87000607916d4d2d9f8fc8e9c5200ae5ba64a1389", + "https://cdn.skypack.dev/-/ms@v2.1.2-giBDZ1IA5lmQ3ZXaa87V/dist=es2019,mode=imports/optimized/ms.js": "fd88e2d51900437011f1ad232f3393ce97db1b87a7844b3c58dd6d65562c1276", + "https://cdn.skypack.dev/debug@4.3.4": "7b1d010cc930f71b940ba5941da055bc181115229e29de7214bdb4425c68ea76", + "https://deno.land/std@0.150.0/media_types/_util.ts": "ce9b4fc4ba1c447dafab619055e20fd88236ca6bdd7834a21f98bd193c3fbfa1", + "https://deno.land/std@0.150.0/media_types/mod.ts": "2d4b6f32a087029272dc59e0a55ae3cc4d1b27b794ccf528e94b1925795b3118", + "https://deno.land/std@0.150.0/media_types/vendor/mime-db.v1.52.0.ts": "724cee25fa40f1a52d3937d6b4fbbfdd7791ff55e1b7ac08d9319d5632c7f5af", + "https://deno.land/std@0.211.0/path/_common/assert_path.ts": "2ca275f36ac1788b2acb60fb2b79cb06027198bc2ba6fb7e163efaedde98c297", + "https://deno.land/std@0.211.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2", + "https://deno.land/std@0.211.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c", + "https://deno.land/std@0.211.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a", + "https://deno.land/std@0.211.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15", + "https://deno.land/std@0.211.0/path/basename.ts": "5d341aadb7ada266e2280561692c165771d071c98746fcb66da928870cd47668", + "https://deno.land/std@0.211.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d", + "https://deno.land/std@0.211.0/path/posix/basename.ts": "39ee27a29f1f35935d3603ccf01d53f3d6e0c5d4d0f84421e65bd1afeff42843", + "https://deno.land/std@0.211.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808", + "https://deno.land/std@0.211.0/path/windows/basename.ts": "e2dbf31d1d6385bfab1ce38c333aa290b6d7ae9e0ecb8234a654e583cf22f8fe", + "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/cli/_data.json": "cf2cc9d039a192b3adbfe64627167c7e6212704c888c25c769fc8f1709e1e1b8", + "https://deno.land/std@0.224.0/cli/_run_length.ts": "7da8642a0f4f41ac27c0adb1364e18886be856c1d08c5cce6c6b5c00543c8722", + "https://deno.land/std@0.224.0/cli/mod.ts": "9548eaf4fefac2ab9b02e0f8e4de8a08cac5d24b721a6019452efec172b59de3", + "https://deno.land/std@0.224.0/cli/parse_args.ts": "5250832fb7c544d9111e8a41ad272c016f5a53f975ef84d5a9fe5fcb70566ece", + "https://deno.land/std@0.224.0/cli/prompt_secret.ts": "3b2f95214422226482fba4a00cb25441475b6f97069a6f70f442c1c9a16c744c", + "https://deno.land/std@0.224.0/cli/spinner.ts": "cf873605771270b4324cc063b5031ab250d8efee8799e45e1a3bfdd333ff721d", + "https://deno.land/std@0.224.0/cli/unicode_width.ts": "656dd4271ecc90684b6bf23a5fb8c1cf833da625ef2906b61273ad617038072f", + "https://deno.land/x/grammy@v1.28.0/bot.ts": "9576361190e2dfcf7d2a665e6e20b66c15ae7a7cf81c25baea50b6c682b7a439", + "https://deno.land/x/grammy@v1.28.0/composer.ts": "dab5a40d8a6fdc734bfb218f8b2e4ef846c05b833219ddd3fdee3f145bb2660b", + "https://deno.land/x/grammy@v1.28.0/context.ts": "667934ca2f9d7c81fa27067232e3ca5464ba0c8b44f8b2925f9f2bef9b7af6b7", + "https://deno.land/x/grammy@v1.28.0/convenience/constants.ts": "1560129784be52f49aa0bea8716f09ed00dac367fef195be6a2c09bdfc43fb99", + "https://deno.land/x/grammy@v1.28.0/convenience/frameworks.ts": "69f343980c349f9552a022ae732b3d5de0894013df0fd304b4ad29bd62425dc1", + "https://deno.land/x/grammy@v1.28.0/convenience/inline_query.ts": "409d1940c7670708064efa495003bcbfdf6763a756b2e6303c464489fd3394ff", + "https://deno.land/x/grammy@v1.28.0/convenience/input_media.ts": "7af72a5fdb1af0417e31b1327003f536ddfdf64e06ab8bc7f5da6b574de38658", + "https://deno.land/x/grammy@v1.28.0/convenience/keyboard.ts": "de39a47199a9dde779c396c951a0452080df0e964e9ea366ae4237acdc3302f7", + "https://deno.land/x/grammy@v1.28.0/convenience/session.ts": "ae6b973f43552ff165d797c69191bbedc39b81b4de540c58c34cee007138c7f6", + "https://deno.land/x/grammy@v1.28.0/convenience/webhook.ts": "53937566edaa258401a44594a8f750a15983952bd99aa4d21c6722a7aa1d77a9", + "https://deno.land/x/grammy@v1.28.0/core/api.ts": "358b8afc4529fa6f26bf1a04e296a842b56e534069750e904f336da2debb603c", + "https://deno.land/x/grammy@v1.28.0/core/client.ts": "0830ccfa575f926092431896071e1999de8a5bd829ffa6c21c1315d58227b51f", + "https://deno.land/x/grammy@v1.28.0/core/error.ts": "5245f18f273da6be364f525090c24d2e9a282c180904f674f66946f2b2556247", + "https://deno.land/x/grammy@v1.28.0/core/payload.ts": "420e17c3c2830b5576ea187cfce77578fe09f1204b25c25ea2f220ca7c86e73b", + "https://deno.land/x/grammy@v1.28.0/filter.ts": "d5785e971181662d2090b307cb20d80d801f2afdf59ea312c156dca310460f29", + "https://deno.land/x/grammy@v1.28.0/mod.ts": "7723e08709ff7fd01df3e463503e14e4fd1a581669380eed70351e1121e8a833", + "https://deno.land/x/grammy@v1.28.0/platform.deno.ts": "68272a7e1d9a2d74d8a45342526485dbc0531dee812f675d7f8a4e7fc8393028", + "https://deno.land/x/grammy@v1.28.0/types.deno.ts": "8bbd4f78388f2f8bfe2a63c97c6df63ffda294e2b012c29f4f1caa89ee027f42", + "https://deno.land/x/grammy@v1.28.0/types.ts": "729415590dfa188dbe924dea614dff4e976babdbabb28a307b869fc25777cdf0", + "https://deno.land/x/grammy_conversations@v1.2.0/conversation.ts": "4bcad06e2ac562969a7a6661bb1cf1477a3fe9e537c18419de65c456feb69499", + "https://deno.land/x/grammy_conversations@v1.2.0/deps.deno.ts": "c982798d7ca4cd3ebcd5a24319d03c5980dc47827b5a172a77022859abdb404e", + "https://deno.land/x/grammy_conversations@v1.2.0/form.ts": "d2d527fdcb26eb489b4aa1a183ae3aa14bf185a375ebf7d5c6ab8360e8b0e170", + "https://deno.land/x/grammy_conversations@v1.2.0/mod.ts": "4234b7a353ebb6770352c8b1fcafb0de40abd764cc44ae018f10b29c3a065b50", + "https://deno.land/x/grammy_conversations@v1.2.0/utils.ts": "139ebe78dbf078d3bbf8cbc78ad286125a08e7e7895d4985fff7be9cef328e20", + "https://deno.land/x/grammy_storages@v2.4.2/supabase/src/deps.deno.ts": "74b945bf5c1ade714a1e8de482d752bc952db909f8cffdcceb290d2d74aada3a", + "https://deno.land/x/grammy_storages@v2.4.2/supabase/src/mod.ts": "dd28166e9cb8f93dcd67e0732df91753f60ce18da35b13299c3d7467a7d7aad3", + "https://deno.land/x/grammy_types@v3.12.0/api.ts": "ae04d6628e3d25ae805bc07a19475065044fc44cde0a40877405bc3544d03a5f", + "https://deno.land/x/grammy_types@v3.12.0/inline.ts": "9bc165676a2bb984525adf2c457cdf5f432016b25c3d399dceedfeaf638dbf29", + "https://deno.land/x/grammy_types@v3.12.0/langs.ts": "5f5fd09c58ba3ae942dd7cea2696f95587d2032c1829bba4bca81762b7ef73b6", + "https://deno.land/x/grammy_types@v3.12.0/manage.ts": "2774de6691a0dbb2eab69708c9388d2d0415a83fd8a995767d0691a6890693b8", + "https://deno.land/x/grammy_types@v3.12.0/markup.ts": "7430abcea68d294df73a433f621a37cf1281718d3e29e903ed1e474038c7489d", + "https://deno.land/x/grammy_types@v3.12.0/message.ts": "32e60efb2d1984b392b1b8b547864db6f65df6e2ab77742438dcaea3ffcf1aa8", + "https://deno.land/x/grammy_types@v3.12.0/methods.ts": "3bbf57d462eea928af280f9c1267f706783da870e554731b5310d6a7a59ce71a", + "https://deno.land/x/grammy_types@v3.12.0/mod.ts": "7ecea1d3f7085d64419b78183039c78d70d655aeaa8b07f118ffbfb823f5b0b7", + "https://deno.land/x/grammy_types@v3.12.0/passport.ts": "19820e7d6c279521f8bc8912d6a378239f73d4ab525453808994b5f44ef95215", + "https://deno.land/x/grammy_types@v3.12.0/payment.ts": "419c076b4ac2282234cc95b22b2922e8e359d368cc6bc66620f47f1329c3ad77", + "https://deno.land/x/grammy_types@v3.12.0/settings.ts": "f8ff810da6f1007ed24cd504809bf46820229c395ff9bfc3e5c8ceaef5b2aae1", + "https://deno.land/x/grammy_types@v3.12.0/update.ts": "7033ebfbecf6fbbd2d68f7a3550c9b04e553228a2143104fc63c42dd7f8f6881", + "https://deno.land/x/oson@1.0.1/constructors.ts": "2b77dcdc8d8db5ece2860d1657f4dcef37dd761684f1d4b9535c7e56a0fbfcf6", + "https://deno.land/x/oson@1.0.1/mod.ts": "54e494dc517ce0de6c727c25d9731ccc748e8646c883e922dc5d656f523a4e5b", + "https://deno.land/x/oson@1.0.1/oson.ts": "ec3908ae5c9ceff7bfd869d95a2183b929b9d96fbff44b57d28d3b742d06a4a1", + "https://deno.land/x/ramda@v0.27.2/mod.ts": "14262bdcea26c261f2f6490e733b792af9d3ae3582a4d09ca5bab0479b48ca40", + "https://deno.land/x/ramda@v0.27.2/source/F.js": "cf0f5fdb08048909e49bb6f222f6e2f92db8d4dc0dd24900864eaf69c3746e3d", + "https://deno.land/x/ramda@v0.27.2/source/T.js": "f96ac2cdaeedf374fb932a4ec368f0566143f0c2d028919976c41c6ee53c0515", + "https://deno.land/x/ramda@v0.27.2/source/__.js": "0d846e36ac0a9ff96c3e17823a98d1379295bf0a12e02c6b54e252d89228acbb", + "https://deno.land/x/ramda@v0.27.2/source/add.js": "228beb026421789be635c2ef48dbbccfcf030796429b14e08da3092800a54abb", + "https://deno.land/x/ramda@v0.27.2/source/addIndex.js": "eb25b2d1999a36025c7eac9bafde0d4f569b8b9de5f03a2578fc5a82f2f37497", + "https://deno.land/x/ramda@v0.27.2/source/adjust.js": "a018b4127cfa9f2915e2a5fcc767d58c590be7716bcdeba39136f63404de9466", + "https://deno.land/x/ramda@v0.27.2/source/all.js": "ad8982ac59e339bba8eb440eb200b18bec2531906cadab6989a4f9970c56ec4e", + "https://deno.land/x/ramda@v0.27.2/source/allPass.js": "b9b9b39392fae767310690bf8e097150fd9a99f5dc0d64565ae61cc58b6e34ec", + "https://deno.land/x/ramda@v0.27.2/source/always.js": "b38ca58da76a43663c33dfd52a4b09f6f582b8504fd767e204e85291fe9b03ae", + "https://deno.land/x/ramda@v0.27.2/source/and.js": "ba92b5127ff67c6a5bd523ea099961ae8c4d1028aa022a7a2d494bc938d85e54", + "https://deno.land/x/ramda@v0.27.2/source/andThen.js": "4257a859b9273bae3fb3e3a97c7bcf5f862c519eb52de0da7e02adf52f4f262b", + "https://deno.land/x/ramda@v0.27.2/source/any.js": "45983175fa1de66e8082ce7cce43b53b21d3d2223e4a383a11690d4a1c0d84ec", + "https://deno.land/x/ramda@v0.27.2/source/anyPass.js": "6fd0a90a458921d07bc51acac9dba839e5e29f2b9b507524ed95ceb304557a5f", + "https://deno.land/x/ramda@v0.27.2/source/ap.js": "29e48d267711c5ee9dd43a8f3ce766e2198ced3ec359d8d7405a207208ac876b", + "https://deno.land/x/ramda@v0.27.2/source/aperture.js": "b6a5dcde3c5e6f849a3c9680e5faf554bf5dc8a3d6cfb7aadc73cc147d33fe7e", + "https://deno.land/x/ramda@v0.27.2/source/append.js": "bf323062d997e4d995053900134369c1341fbbcd46ef64c2b741f9d1c4ccd55c", + "https://deno.land/x/ramda@v0.27.2/source/apply.js": "b0b90bbd6b73d2cb57f9cf31bb7e45cf398e3b700ef03aefe038b4ec9d71ff10", + "https://deno.land/x/ramda@v0.27.2/source/applySpec.js": "a8e8361ca93c7a5ea938db059d56a2361e80a007229af1d052476d8f5c3731b6", + "https://deno.land/x/ramda@v0.27.2/source/applyTo.js": "8c3029032632e385a877e4587ba8fcb9bc046523dbe4eee6cf41c131ec7e2c18", + "https://deno.land/x/ramda@v0.27.2/source/ascend.js": "84524adf0f4eb7dc4ca815aacd60b99527cb17d1311411e482d8e8e15ea4553d", + "https://deno.land/x/ramda@v0.27.2/source/assoc.js": "f00e345f7c5dcc1bbebc4ae9ef6574b9b7ed2c98576bea5ae4f521dfc96de22e", + "https://deno.land/x/ramda@v0.27.2/source/assocPath.js": "e73615bb6af97ac6264b1497826a72be278f5eb944bb9a09bfd49b5b51a64667", + "https://deno.land/x/ramda@v0.27.2/source/binary.js": "3bcf8608caed02f0005c7efa81ae9303b0a830f035dbb1f749467edd1363c0bb", + "https://deno.land/x/ramda@v0.27.2/source/bind.js": "e54151679a653e4a429f06a4083b951a2956352d43342be2d9362caf15c5284c", + "https://deno.land/x/ramda@v0.27.2/source/both.js": "6ba9a47bc7a1d641d4bbb9a31c1d69ed9db31926c45ec5eafd462a4281a19854", + "https://deno.land/x/ramda@v0.27.2/source/call.js": "30e8a7959e3a627a93b80b353f909f0ccfe633286abe42032efab9110203f252", + "https://deno.land/x/ramda@v0.27.2/source/chain.js": "5aaac847573c89107b229de5070b10137e5085aff44197b362c9f330d97d4cb6", + "https://deno.land/x/ramda@v0.27.2/source/clamp.js": "c5e4794193226010c4311fd4232b54a666ff215f7a2bbb3c0e953e129343f9b9", + "https://deno.land/x/ramda@v0.27.2/source/clone.js": "f87d3eff8eda087ab34ee9d210574f6458558a15503f20863135c652903932d9", + "https://deno.land/x/ramda@v0.27.2/source/collectBy.js": "63a5b7d523d656ed21c8d212ec5b20ae0bede27759620b30a3edad5007e77d1f", + "https://deno.land/x/ramda@v0.27.2/source/comparator.js": "46dcd6df640aa62b62b637a44923b124e08373e016c079d66c8554f5eccac25f", + "https://deno.land/x/ramda@v0.27.2/source/complement.js": "1077ec2fd77148b9b816c9e2d53447dc5f58551dfa4d1fbdc65b4358c348fe7e", + "https://deno.land/x/ramda@v0.27.2/source/compose.js": "88c1ba6338854a69a88c6716d7e1846b41b4c11758e4303e3aff8da2fdb4d886", + "https://deno.land/x/ramda@v0.27.2/source/composeWith.js": "7f1769340bb713ddaddf140b9377c737516b53d3fa8737b4bacdb3d9076e27e7", + "https://deno.land/x/ramda@v0.27.2/source/concat.js": "f3d8d7238dde344d9916f62eb23e652d51b1ee9165112441fcc8772a52882cf1", + "https://deno.land/x/ramda@v0.27.2/source/cond.js": "fc252d1fb0940df257a9cf688f7b18a6b985c2117e0959c433c54116dd1b8f97", + "https://deno.land/x/ramda@v0.27.2/source/construct.js": "d4ab8ddb45b581954bc943cf8d6c75add7b3fedcfeabc20316f17d1b8955e6f6", + "https://deno.land/x/ramda@v0.27.2/source/constructN.js": "985a538f32bec2e893d47d0072277d4e19f0111fdf9b9119e79835804dab1af7", + "https://deno.land/x/ramda@v0.27.2/source/converge.js": "e285777910f7d111772b74dd6c1f4d6c49ff9a603a2ff9a950e830cfda871a9d", + "https://deno.land/x/ramda@v0.27.2/source/countBy.js": "e2fb4127929895d661ca99db0b424bd4e045d366baef8f122a8c8e5e985293a5", + "https://deno.land/x/ramda@v0.27.2/source/curry.js": "69fef7830feee995f75e3aa45ae0fe99f611772462e33dfa3d6044a506d999e3", + "https://deno.land/x/ramda@v0.27.2/source/curryN.js": "523d173826331f03aff210ea1932a445e0d77acdf96f0b337de96fa0cca78903", + "https://deno.land/x/ramda@v0.27.2/source/dec.js": "7c27ae1b7e572910d1bcb63eeac36ebf59d6cce903fa3ba1b170dc7329133843", + "https://deno.land/x/ramda@v0.27.2/source/defaultTo.js": "237e5a9a1f0b1fb190f508a8750a0729814fa1754fbd032f18144d875990ead8", + "https://deno.land/x/ramda@v0.27.2/source/descend.js": "4ca2704b6e4ea7b92015e6620c210c19c7374f29742f7bd5136c8e1aa6c90503", + "https://deno.land/x/ramda@v0.27.2/source/difference.js": "9d388448dfda3232f6006b9f5f56d7cf3982b3bcf47165ff8bf66677e53dff6f", + "https://deno.land/x/ramda@v0.27.2/source/differenceWith.js": "d7c98a3cc916fb0ff050c0c1441f2a1b29cff3d0d301e37878a2adbe8db28de3", + "https://deno.land/x/ramda@v0.27.2/source/dissoc.js": "5cf9c3c42e4f8b568234eee84567b972cc187a0ea3e15c0e674745fdd7187cee", + "https://deno.land/x/ramda@v0.27.2/source/dissocPath.js": "a59d61ccce8df74a37e698d525eee27c26505420710087b58fe4e93b26dbbef8", + "https://deno.land/x/ramda@v0.27.2/source/divide.js": "6e0bfcfd2b18877a0fe1e4ae98ae53349ac1f48c7b5fc35c3ca57364504943ef", + "https://deno.land/x/ramda@v0.27.2/source/drop.js": "4ff5febd91047ca74b4778881097c1051dbe4fabbef0d7117f2e41378dff55e1", + "https://deno.land/x/ramda@v0.27.2/source/dropLast.js": "68d7d9ae7a87f5c344b496cb0b8ca79768d796b11bb079daa951152b63706bdf", + "https://deno.land/x/ramda@v0.27.2/source/dropLastWhile.js": "078af5d7231266e448dfd3002c26aaa146eae18f2ea57123829958e4dcbcb782", + "https://deno.land/x/ramda@v0.27.2/source/dropRepeats.js": "c84877435227b901e8a7c7c42245bbbde0609b3b3b9af97a4377b695f1784136", + "https://deno.land/x/ramda@v0.27.2/source/dropRepeatsWith.js": "9c01e32e3ed2869e68c046dc564e42b59888463234ed637bde31608bc64eba17", + "https://deno.land/x/ramda@v0.27.2/source/dropWhile.js": "28e044253bd3655a759be443dcc233cbda76bd2d80defe51600e10312220b5a0", + "https://deno.land/x/ramda@v0.27.2/source/either.js": "07b4087707e495f59e30ed9e1d579d146a1cfa8fdc758bfd353af9176c6bab9f", + "https://deno.land/x/ramda@v0.27.2/source/empty.js": "df91fc65b55ad83236fbc18e76c599db75c36395ea812a492cb56d95e337fb8f", + "https://deno.land/x/ramda@v0.27.2/source/endsWith.js": "d9bea6890b2474a11d23e14e0d500bc87bafbfbd5975ee4d9eedf99599e9bc3f", + "https://deno.land/x/ramda@v0.27.2/source/eqBy.js": "bb0692cce9d0020fce9a4b3a7c4d8082dd11a4fd03c2610fc38d72987d2e3408", + "https://deno.land/x/ramda@v0.27.2/source/eqProps.js": "b8472fd7aac2e952a78e285bf5908116481e8804839e098e963f6d8db40a6f28", + "https://deno.land/x/ramda@v0.27.2/source/equals.js": "7f54bec675761df5cb71b7cc32a169d2e944c91575d6a9c2304dad7a1ced3373", + "https://deno.land/x/ramda@v0.27.2/source/evolve.js": "14d0d1f87c4891535f88da7209a926a59cfbd486df737a1407157f8847b83630", + "https://deno.land/x/ramda@v0.27.2/source/filter.js": "5c8b8eaba016bf63185df32ce439b4795e1182cfda310fce114a53fb15d2d6de", + "https://deno.land/x/ramda@v0.27.2/source/find.js": "09598aa7e256c37bfd80c1cefd5664e96b22fa0d34cafe637f4c27441002fc1a", + "https://deno.land/x/ramda@v0.27.2/source/findIndex.js": "0cb98806f039b3cfae9d7c491fad7ba48a03e1db9d9e482fa516bb4ed76d3dca", + "https://deno.land/x/ramda@v0.27.2/source/findLast.js": "0b414111d9841354a5b6342a124f2b1026101311df0afd5c3ba4ac59db9cb72c", + "https://deno.land/x/ramda@v0.27.2/source/findLastIndex.js": "d7f2827c928a0481ec3b0cd814ec446a75ed47fb1f3f5b22611efc6d462d7060", + "https://deno.land/x/ramda@v0.27.2/source/flatten.js": "243ddadafe7961aec81baa8572905db0dd493f777dd5056d8cee37563a7cc526", + "https://deno.land/x/ramda@v0.27.2/source/flip.js": "78559e6c275e263c2daae4d16d63639f1168b442b88d738038b6f3147312a695", + "https://deno.land/x/ramda@v0.27.2/source/forEach.js": "39813bce3e81c081a2ff92855f8a55bcda476801f2f44de0e79e210068a027f1", + "https://deno.land/x/ramda@v0.27.2/source/forEachObjIndexed.js": "28fc5331e303c3e68c7c01d77b0d29eae1e0271d8eec68a21b3cee582cab4568", + "https://deno.land/x/ramda@v0.27.2/source/fromPairs.js": "d729f77a9ea6b241368fd95e2157d6bed5f306233f4bd0f3d27e820755268153", + "https://deno.land/x/ramda@v0.27.2/source/groupBy.js": "cdf6123f30919aba99f3cfa86195f724f977804f44681dea298566f1506862f2", + "https://deno.land/x/ramda@v0.27.2/source/groupWith.js": "dc2c4861a44408e25554850dc706175ccb7762a9059b5bec3aeb621ba6590fe7", + "https://deno.land/x/ramda@v0.27.2/source/gt.js": "72ed5925a31ac49a1a89487cc8d2803691384c161ecb93cc07042c5b3404241a", + "https://deno.land/x/ramda@v0.27.2/source/gte.js": "78802ab4ebb7b98c3d947c43c17679d98fca1b363f87fc16513690d3fb5b29a1", + "https://deno.land/x/ramda@v0.27.2/source/has.js": "4476f429c91c6bbb1b1a07c187fa6e0042ac580b736a34aba2843919d1959a59", + "https://deno.land/x/ramda@v0.27.2/source/hasIn.js": "4f3f633d11a76d05448349d60cf91be3f40cd84af67902bfbd3204b4bcb2d732", + "https://deno.land/x/ramda@v0.27.2/source/hasPath.js": "03e7c3cddf8dd04e8eec9765978ee3813c66440e2e0f7153393099250aa40b10", + "https://deno.land/x/ramda@v0.27.2/source/head.js": "4df6d937987d8bc87df68f6e11b1edc4a78f9f0890a3ef8e361f3ae1ffc2c93b", + "https://deno.land/x/ramda@v0.27.2/source/identical.js": "4c3c3f75a7248b6c4450692e31447f933c0484520cbae6f84e3c52c4febeda1d", + "https://deno.land/x/ramda@v0.27.2/source/identity.js": "0897cac8aa156fb05aae9e1fcbec6422265537120c85a59e2d9ed4789400142b", + "https://deno.land/x/ramda@v0.27.2/source/ifElse.js": "586bf559dfe65b7d0baf17bc065f66ed05eab93401d0d534b20bcc2647f79ac3", + "https://deno.land/x/ramda@v0.27.2/source/inc.js": "2ecf4d73a79bde8e5446d1bc67ae47bd918ce915c145cd38307f8e833b417905", + "https://deno.land/x/ramda@v0.27.2/source/includes.js": "d7d55f48f02e6137277eb3f7fe8a37c03a7b20f73461290e5fb954b654e714c3", + "https://deno.land/x/ramda@v0.27.2/source/index.js": "e3f53a897e1f468bbe2c1df7b2c2267fafa27e7daa5058cb62914acf33877d8f", + "https://deno.land/x/ramda@v0.27.2/source/indexBy.js": "8f2623133d78c91bbf5843d1d9e1a633751c224b259b2b6c7be05c864dcca417", + "https://deno.land/x/ramda@v0.27.2/source/indexOf.js": "51c6824676b9ae32384e9fb0b3f2c0c1a639b76c95c366e19d0c66246c531a09", + "https://deno.land/x/ramda@v0.27.2/source/init.js": "64ffbf8f557956006ddd6aa5fb2261029b574808909d576887a804358f49a33e", + "https://deno.land/x/ramda@v0.27.2/source/innerJoin.js": "f6b0aef6118f16e35f35f755118178a259d7ada6d56ffb1a73b6b8e70840470a", + "https://deno.land/x/ramda@v0.27.2/source/insert.js": "ec7eb96300222eb744548031c9f44d0b90eea5142b413ae0134d751e4d5e12b6", + "https://deno.land/x/ramda@v0.27.2/source/insertAll.js": "5abae3eebae86724ea9efdb34686b499ea51ad4e7bcc87266a3798d39678ffd2", + "https://deno.land/x/ramda@v0.27.2/source/internal/_Set.js": "0ceb62124dcad553c7e95a8395d46ef9709efbb5ae819114b1cfc837882d6bef", + "https://deno.land/x/ramda@v0.27.2/source/internal/_aperture.js": "3baea490e23ba04bc1a4c70aae851fff5ee1fd45b27079e7d93bba5a98cf4ad0", + "https://deno.land/x/ramda@v0.27.2/source/internal/_arity.js": "4fa349e2c3d0da5c102353f86025b093844ea13a40b0d70ebb21302b6c9a5bb2", + "https://deno.land/x/ramda@v0.27.2/source/internal/_arrayFromIterator.js": "f46149927bafa8f0fb7f0fdd0eaa19da4b4d7ce147bcecf0fb7406c1a0ca0e91", + "https://deno.land/x/ramda@v0.27.2/source/internal/_assertPromise.js": "11fd2486760cbd60ebbed0ef0b6e2aa2ee4a6e3bd3f257e5024140a21245dea9", + "https://deno.land/x/ramda@v0.27.2/source/internal/_assoc.js": "8200e22a3f4a931e6b416260e516f2c016b1e7f3821e97f39009ff6acabf32f7", + "https://deno.land/x/ramda@v0.27.2/source/internal/_checkForMethod.js": "37ab74c502c1324c1e5266e865d36355301993c381ccd4849898dc8655305d71", + "https://deno.land/x/ramda@v0.27.2/source/internal/_clone.js": "197f999a9f94b426b1e573f4497cb0188444a74aa2aba571a8ce302aa5377b16", + "https://deno.land/x/ramda@v0.27.2/source/internal/_cloneRegExp.js": "9c751f3ea3732e9e2c321726997134419721b77e8ad78b5199d4fb587bce7a89", + "https://deno.land/x/ramda@v0.27.2/source/internal/_complement.js": "4a680d37419308b74099034c1c399264a04632dfc7378f4c4e8b88da18b863e0", + "https://deno.land/x/ramda@v0.27.2/source/internal/_concat.js": "16219d978cd4fc97305a6d56106041d66b56a668b395c0faba555571a766d4a2", + "https://deno.land/x/ramda@v0.27.2/source/internal/_createPartialApplicator.js": "8d5bbdb1629ae482691a309728e832d97d9108d5ec03af1e05e610e8db5dc713", + "https://deno.land/x/ramda@v0.27.2/source/internal/_curry1.js": "471de08b6eba9bdbda8bc3adf75a6c85579abf44b1af9b079b631680d1331bf3", + "https://deno.land/x/ramda@v0.27.2/source/internal/_curry2.js": "2c8ae1b2f6f1f29ae72507eb2f8affa2d29d7e6d593c74c2ab8b11ab7c077ea0", + "https://deno.land/x/ramda@v0.27.2/source/internal/_curry3.js": "4df2775934c2a02472b806c18f4fefcd2f0a330568c7818e3937ed9c05fde9cb", + "https://deno.land/x/ramda@v0.27.2/source/internal/_curryN.js": "e5772e002d36f419f9b29ee72d58f6605d99f05a59fb9f234df6f11bdd0c91ed", + "https://deno.land/x/ramda@v0.27.2/source/internal/_dispatchable.js": "d0caa3b83bcaf612ce5ccc1ef1e3bee8bd0b7c70cd688522d7a1922078d0c8f9", + "https://deno.land/x/ramda@v0.27.2/source/internal/_dissoc.js": "a4f7d454f67c815792a40c49f53d3778a954efdaee98e98eadcb88506508e014", + "https://deno.land/x/ramda@v0.27.2/source/internal/_dropLast.js": "783d1c3682e470b584ee19e69b0a3df190c89a44c50b784051adc08edc9e34b5", + "https://deno.land/x/ramda@v0.27.2/source/internal/_dropLastWhile.js": "45fa88039ae87e0037fce1def28249714b41dd59eeeb92bcbb46e8769e517ee0", + "https://deno.land/x/ramda@v0.27.2/source/internal/_equals.js": "3e10ce96699dcb1d463be64798cf81e0bbb293e1a91e8b3c1fbfb64cda572604", + "https://deno.land/x/ramda@v0.27.2/source/internal/_filter.js": "50a8ecec356ea12974434d74483775a78dd3761e5a24fab814bf761d66f8d129", + "https://deno.land/x/ramda@v0.27.2/source/internal/_flatCat.js": "e87d575a90f01fae5c1fe77e437580081ae00ce056d72759ac6169072449fec5", + "https://deno.land/x/ramda@v0.27.2/source/internal/_forceReduced.js": "4fe3897c0e7327c37352d2a7d63eaa976954b97edff293116de95aeb615725ca", + "https://deno.land/x/ramda@v0.27.2/source/internal/_functionName.js": "15f79e21d2e991ace32b12ae7a0f6ae408a3fec5adb1896d97894eb77c640537", + "https://deno.land/x/ramda@v0.27.2/source/internal/_has.js": "ae63f8e7fffa2034444b2fe2cc71b1ef6d284433470a3b2e4bb73c96f543efcb", + "https://deno.land/x/ramda@v0.27.2/source/internal/_identity.js": "210496061ce9fd6eac4abad8a29368e14dfe6e23562439d25be51aa434e8a1e3", + "https://deno.land/x/ramda@v0.27.2/source/internal/_includes.js": "e39b97bf8ebc835f871abc949696846f94cfd19c33e405a47efd106f89675fa8", + "https://deno.land/x/ramda@v0.27.2/source/internal/_includesWith.js": "0af347d206c2cef356e69de9363d182346662cda8f218fe8214d2b66dd8b47c3", + "https://deno.land/x/ramda@v0.27.2/source/internal/_indexOf.js": "a7d406d57b952f0d7232fd5a52c345ba744fb500caf799cf2b325001d243b2e0", + "https://deno.land/x/ramda@v0.27.2/source/internal/_isArguments.js": "9d10b36c8ec8ebef19308a846cbb4742a72808da7739480b9487ca6374274ac5", + "https://deno.land/x/ramda@v0.27.2/source/internal/_isArray.js": "6389121e11857e7db42afc5e6adb55c1d4ac84ea86abece78e0b73a04d6e5e02", + "https://deno.land/x/ramda@v0.27.2/source/internal/_isArrayLike.js": "2fbbd1bd6dd5c31465ce87ba684b57ddf084506656ebc554d659bdb42799e017", + "https://deno.land/x/ramda@v0.27.2/source/internal/_isFunction.js": "b0f712fbd329875c29d77cfa867f46a2f24c10a0fe6775d6729f71b413e718da", + "https://deno.land/x/ramda@v0.27.2/source/internal/_isInteger.js": "1e0accc105f8ce4d91e9406bcf45369f3d314812dc5bad29362c0c36fae59db8", + "https://deno.land/x/ramda@v0.27.2/source/internal/_isNumber.js": "43d300422b4da7fc0547e1f8190ae42c86dcd31693417126d12bbbaff87b7b35", + "https://deno.land/x/ramda@v0.27.2/source/internal/_isObject.js": "e49a67e910ff51efac8d1267f1e813d0c7575a09578b5408c2ccc352752b701c", + "https://deno.land/x/ramda@v0.27.2/source/internal/_isPlaceholder.js": "f0e59347c4077d5b524cf87afac7f12ff5bb6d05a0664046dcc21057f4451660", + "https://deno.land/x/ramda@v0.27.2/source/internal/_isRegExp.js": "1ff80685a92e7c83f0b9d78c041ba5603de99a7f822b323cf3e6e307f7b8ef45", + "https://deno.land/x/ramda@v0.27.2/source/internal/_isString.js": "defc6d87c06b2dea7f75bfab57ff7591804acda65594130cb8d3af7fe618e42d", + "https://deno.land/x/ramda@v0.27.2/source/internal/_isTransformer.js": "f90ec4a0fb72c8d378f639d02457d71b9fe2260c7d155ce7065260d865b29b89", + "https://deno.land/x/ramda@v0.27.2/source/internal/_isTypedArray.js": "9d365262f7a253a3d31d930fc37e15049fb7f95079f206652ce0bea88294d350", + "https://deno.land/x/ramda@v0.27.2/source/internal/_makeFlat.js": "65f901bfa3f454e40fb312cb862048009f7f4fb152a50f2e58f50d08742bb765", + "https://deno.land/x/ramda@v0.27.2/source/internal/_map.js": "9ed8773d457a11630b1b60a05a5aa88f4eb697eae9b5fea4bd4ab0e9a2dcfa44", + "https://deno.land/x/ramda@v0.27.2/source/internal/_modify.js": "9997861456b9ecf9f6f5ed3a592b38fddc96570cc1817a55d803056ae43c304e", + "https://deno.land/x/ramda@v0.27.2/source/internal/_objectAssign.js": "e556262b1d354d3956d5ac9f723d9e5ae12f3d37799d8de0436b35b2cdbbaa59", + "https://deno.land/x/ramda@v0.27.2/source/internal/_objectIs.js": "96818c22093a4a6465c11760ee82538392bae907daf4c29ab9a0d8c814dfb4d0", + "https://deno.land/x/ramda@v0.27.2/source/internal/_of.js": "592f2a08ad5a73a9b621ef06877b7570c6ab5115d985b1dc9d0ffe7a0a8fcb90", + "https://deno.land/x/ramda@v0.27.2/source/internal/_pipe.js": "284c5c472cd46d456869500d97308db3fbc3d74885b145b5a1a4983255c94634", + "https://deno.land/x/ramda@v0.27.2/source/internal/_promap.js": "bb28d85b6ba984349c5154017802bf8754992f9c249e34d6c7ac1a9f2b93acc2", + "https://deno.land/x/ramda@v0.27.2/source/internal/_quote.js": "5fd76eec69c48e5f8ba4f186b98117ed1e24190e5e7a1c68c82e87caa3b31016", + "https://deno.land/x/ramda@v0.27.2/source/internal/_reduce.js": "dede31c8204bee0fbe39f064ba1c85db32997092c9bfe83c3d6d178673ffc93b", + "https://deno.land/x/ramda@v0.27.2/source/internal/_reduced.js": "3aa5d80b51130286d4d22cbf24a5e60d44d239c3ee7de037061e024069729cc1", + "https://deno.land/x/ramda@v0.27.2/source/internal/_stepCat.js": "65ad7151305023178da9675dead9da892e07b235aec45a36c1d81a8700efd50a", + "https://deno.land/x/ramda@v0.27.2/source/internal/_toISOString.js": "0602756738b1a543cf9383a9269b6b4d4198a64586d5b386258494fac454d7da", + "https://deno.land/x/ramda@v0.27.2/source/internal/_toString.js": "c91ae93e404d8a08b18d470399dc45b45e471572bd4b09e32ff634a40d6a6747", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xall.js": "c8743b2b5ce1dc914aaeed1fe6349c4a8cf558dcbcf76f629c46650a5b4864c2", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xany.js": "debe5d3652a056e84311379946abee746410d9ec6323f5571999cc3e58bec3ea", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xaperture.js": "865d4e92d080a8eb8a05e20ba04053d331ffbbab12e7794f1e550f598d5c3bc5", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xchain.js": "e50a7835149178afef59b354dbe256279fd29a31b0c1382c95013b622a64b42d", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xdrop.js": "d8c35c183ceea82b7743cb432ab1053a4350cd20918653ea46ea365a4aa96cd9", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xdropLast.js": "8ef50fcbd4b0ba885512ce7a6743d4f7fbb21a48a6827309b4a1483ce5188757", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xdropLastWhile.js": "e35a9d0c7eb9f994e947df5134117219e8ae73dda04d5fc498ccb39b01337ab6", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xdropRepeatsWith.js": "695f243d4f57cc93457a85b02fa4d4a868b26c539179c460d2ed482814f619da", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xdropWhile.js": "8c2d857a155cbbb85e1b8061bd51acf551ac79e58be5c6815d98c85c27b1a791", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xfBase.js": "4940a7f86cf2e06b3864588b68b8f8dc3f5626285a71725bc23ca45204b25ba8", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xfilter.js": "5bc0c0ecca3e2216bb8adf788e6b2d156b8c6e917f9210c0bf8cfc7a3173cf64", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xfind.js": "6a796c251023a9adf4da914480096604731565bd84a49034c18e2a720e182ccb", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xfindIndex.js": "d4475489b0589d0d0c0d0e2590306edfbb90c8f8d0cbaf9e0271538478b5e579", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xfindLast.js": "24ea8016cf355af01670392d2e919f6f265ef308ffbf80e8e065398e81b21ffb", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xfindLastIndex.js": "d41691d4b16327c51ed49f09998bec03caca38ba30c69377ff7158f3bb4c40ae", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xmap.js": "fee34673a9500dacda51bc2eef5655bccee9f870f003912d3d21c8ebffef6b40", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xpromap.js": "4f64cad6d84877b0367c9863c78e7165332fc3ec9daa4c1457fd76889ce5784c", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xreduceBy.js": "1b7f4e546d14ab4d9dfd31f25d8ddce5afe5046992e2ab777062ae7f271742fa", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xtake.js": "948587bd66412c9ee7c76b8c9a801b915792d8dbdbc40eb9e41e11e90a21e929", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xtakeWhile.js": "54592c64cb42aa0452d19d954cf257034e0aee8a8bd4c5df1b4338d797b85967", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xtap.js": "8869a093501e3802fc0708893e7e1978e5891c11ef4f6574cc3f2db488966bac", + "https://deno.land/x/ramda@v0.27.2/source/internal/_xwrap.js": "bd5c91087b37e811168370937a3d86e67c76791b0e15be624c992c7020c25e0a", + "https://deno.land/x/ramda@v0.27.2/source/intersection.js": "7b04e4d2cd4da37b5a65cdf740a8103ff4d52ef373fd089fe59165c219c426d6", + "https://deno.land/x/ramda@v0.27.2/source/intersperse.js": "2c903a93ddb28e53a7aace262c6f1c13afad92f668bbdbea520e5b766e851495", + "https://deno.land/x/ramda@v0.27.2/source/into.js": "1a6b854b89b9a6ceb2db23141797ac3e7438dac14aba0d9f7bddaba32f3bff24", + "https://deno.land/x/ramda@v0.27.2/source/invert.js": "b61941582db861f056d4858c2c28706f9d8dab8cfb18487c60790d053a2cfd13", + "https://deno.land/x/ramda@v0.27.2/source/invertObj.js": "17d5e30707e7f965a2240e2465b8c96aea00520a809f88cc827f6771ca4a2086", + "https://deno.land/x/ramda@v0.27.2/source/invoker.js": "d0c4617665108ea72f33b0beda247c4b14a5cd82bc60ba3d29e62cf49d0bb87a", + "https://deno.land/x/ramda@v0.27.2/source/is.js": "f924768de966fba2c4a7cc57de2d132b2ec15dfb2374eb3628ddad80b3be6588", + "https://deno.land/x/ramda@v0.27.2/source/isEmpty.js": "aefbcf318cc3d96fab03c8389130586d171cfcad727f3baccc1db701895eed5f", + "https://deno.land/x/ramda@v0.27.2/source/isNil.js": "8b2f125a75429b4176fc868359a00ed17b1d7fb2b16b4e02fa118bd4e7eb3305", + "https://deno.land/x/ramda@v0.27.2/source/join.js": "0e5cecc18dd748bc3df25e334bf4c71a32f150b8617a1d3a61a8398ae990f6ec", + "https://deno.land/x/ramda@v0.27.2/source/juxt.js": "d4a033b7ceaeefdb40f5ee89ca63ab06d9c920fb20ae41305aaa3fe604083857", + "https://deno.land/x/ramda@v0.27.2/source/keys.js": "86743eb43e3971c1faeb06bd89dcc475033c65f37ee4738737658904781c3393", + "https://deno.land/x/ramda@v0.27.2/source/keysIn.js": "3edb33ccadc4e657facd03e3024c40fe450777c7efbaa169c8a12f1134a40964", + "https://deno.land/x/ramda@v0.27.2/source/last.js": "00bfbc444c72db87adf81c5d32526fc159e0289addf23838bf8acd76a1ae2e54", + "https://deno.land/x/ramda@v0.27.2/source/lastIndexOf.js": "ede20d310e986df5fe7a60cfb26c92920da2e9e8b87f0b83f0f1bf7ea34bc552", + "https://deno.land/x/ramda@v0.27.2/source/length.js": "a5a94623b9aeb2fb5cd2b07fb747160b065d7b5d13579a5e19cb2bf71e91c2cd", + "https://deno.land/x/ramda@v0.27.2/source/lens.js": "000fecf9849fb2fe44e0d53f6658adb64fadfccaa51268341505fb9fdfeb49ef", + "https://deno.land/x/ramda@v0.27.2/source/lensIndex.js": "4d730d854eed30e3f85f1e86bcd2903cd5cc4feb729bfcf20fe3540a9548820b", + "https://deno.land/x/ramda@v0.27.2/source/lensPath.js": "cf78dc1749a2f0e1bf39e3efb204742c0dee9938b8ad6ad11b00fac89408b255", + "https://deno.land/x/ramda@v0.27.2/source/lensProp.js": "91acd07b1fced8fe0d0627a402f17b81d0f5eb9be1528b50e0da89c618853b43", + "https://deno.land/x/ramda@v0.27.2/source/lift.js": "ce2263e5551f42fde8e84996c385b9db275ad0935fc8c907f0fc21c9f3e773eb", + "https://deno.land/x/ramda@v0.27.2/source/liftN.js": "f3bfeda33c2b76683bb80475dc99c33b139487806df613845e53e64355f9c008", + "https://deno.land/x/ramda@v0.27.2/source/lt.js": "6b5600629463c5f2b85a0cc55c4f9f15f223820fbc348aae51ff26042528a9d9", + "https://deno.land/x/ramda@v0.27.2/source/lte.js": "2ce6a61f62ba0987cfea61485de4ba9bb9fbe6f200d221654245980db5c968b0", + "https://deno.land/x/ramda@v0.27.2/source/map.js": "c4a8a02e30f2d24fcca8ee04f1dcea0a2e585d9265817b4c59f14aaa4c9ef48e", + "https://deno.land/x/ramda@v0.27.2/source/mapAccum.js": "4d43db0b02a53e5740642299ac15c0f1ca8b8bacefa94bd5bd7afb2ac175efa6", + "https://deno.land/x/ramda@v0.27.2/source/mapAccumRight.js": "836425269deb9d6e683e0da76d70ee7fe20b87f8ecc6feede8a7bb89bae7e8cf", + "https://deno.land/x/ramda@v0.27.2/source/mapObjIndexed.js": "46e60dc5089394735448ca8233b8237eb2afa46f23d27faee9e724af40230887", + "https://deno.land/x/ramda@v0.27.2/source/match.js": "017770587572908a98d6c89c2587972f043fb8802e443cc5632baf517a530c24", + "https://deno.land/x/ramda@v0.27.2/source/mathMod.js": "2fd281416b9bb73a89bf1f85d9b5310520798c4472a1e5496d1077232e331bfc", + "https://deno.land/x/ramda@v0.27.2/source/max.js": "59795e4fa0944cabcb87a8e22491e0fe8465bb246e1d1a881bc66bf90e92b16d", + "https://deno.land/x/ramda@v0.27.2/source/maxBy.js": "39ff93da0ee1b25c808d147595742f7569f69edfce1bfcd818ef1eb4fd6df141", + "https://deno.land/x/ramda@v0.27.2/source/mean.js": "a62cf4f665061ebcd42c5987ed14bbf2af1debfb1cc460b2023ee19a6684933c", + "https://deno.land/x/ramda@v0.27.2/source/median.js": "8a6f6f22319da6e6d57f09e1b5c2f0744b3087142d7af1b780461f2f2be1ef09", + "https://deno.land/x/ramda@v0.27.2/source/memoizeWith.js": "d2cf79f2549aaf4b716de6499f340dec875de5fdc7eb75a44758461673fb3473", + "https://deno.land/x/ramda@v0.27.2/source/mergeAll.js": "a93dd81e1c33d4f88eee785cdeb200d1979bcc8d19ddd4c040bd4e06ddee06db", + "https://deno.land/x/ramda@v0.27.2/source/mergeDeepLeft.js": "7982504228581281dc896a3f484529bd10441ed9090bbd71d7836d84150d0ef7", + "https://deno.land/x/ramda@v0.27.2/source/mergeDeepRight.js": "d303ee731034f4c7a16fb7da0b9c2ffd27f4cc8a80a57ecdf1e865a2b8b60044", + "https://deno.land/x/ramda@v0.27.2/source/mergeDeepWith.js": "9e215ef9f6256855d6fc17a3926db36af51da8c74891f5574f93b9f03562f063", + "https://deno.land/x/ramda@v0.27.2/source/mergeDeepWithKey.js": "0586221974798b20016471605a90b30bf614d544a2723cc23d0ec7a1165dffc1", + "https://deno.land/x/ramda@v0.27.2/source/mergeLeft.js": "7ebe8becc6ada9b378f7cd450e1fc60025a5714770ec3b3df66e76a5a90435b1", + "https://deno.land/x/ramda@v0.27.2/source/mergeRight.js": "23e7c273b0bd531bb37a9f993e167e097e848046dac2b515ee452993fe3974a9", + "https://deno.land/x/ramda@v0.27.2/source/mergeWith.js": "56bea0749e4901db63bb6e88cc9082eb9d527618f20e0e1323673b02aac7f9e1", + "https://deno.land/x/ramda@v0.27.2/source/mergeWithKey.js": "d8ffbcfa7f443971282f8ca259ee90d447b36ee28c5f16b0a8920c6065f64447", + "https://deno.land/x/ramda@v0.27.2/source/min.js": "69c4d07378fb2323b1a8c90eb2566db9fcd6329efda21febd8f62beca476a8d5", + "https://deno.land/x/ramda@v0.27.2/source/minBy.js": "241cde2c0f99abe6b8fbf1420a0b36496de9be0ddec19502ddd7dffd22743d47", + "https://deno.land/x/ramda@v0.27.2/source/modify.js": "b1ceec42bc8b438d2eebd13d13766dca038b601a72155f1151ea595c47024a91", + "https://deno.land/x/ramda@v0.27.2/source/modifyPath.js": "eed5c0d3d49ef17c801f5bc4e07f3ea380b0cc0561df2111d768cd5838ddcb69", + "https://deno.land/x/ramda@v0.27.2/source/modulo.js": "a3f2befd3799560b33f6f7b3d810245a14fb5915d1a4aeee50ac9edb39f1af85", + "https://deno.land/x/ramda@v0.27.2/source/move.js": "54d9a68bf4465c0dac42f21111b4d927c90ccab86ce02b69607eea8157aab679", + "https://deno.land/x/ramda@v0.27.2/source/multiply.js": "2f0a8a704f06e6db7501f22bb57e02e8fae81fa48a1f7831660f22d1a9099aab", + "https://deno.land/x/ramda@v0.27.2/source/nAry.js": "8010b4e7df19cf6296671038ce675cb6ba1d685972f3fc709e3bb4ac3814acd5", + "https://deno.land/x/ramda@v0.27.2/source/negate.js": "88f6d95122a6fb1c52a8d55100abe8c28d83d5760e5c11988374aa2ba07a7e66", + "https://deno.land/x/ramda@v0.27.2/source/none.js": "6de510fc7f05077009a8b5b429d71d434bd57fd49ea8cfeab71f8c1b134e7d76", + "https://deno.land/x/ramda@v0.27.2/source/not.js": "e585c5327ebd9d1641ade1ee13f4889e97ab1987727a17ec8e9368964c3dfa76", + "https://deno.land/x/ramda@v0.27.2/source/nth.js": "53d6932e224a92a49b4a7d20d310995168961286f466c6821d18962f46122978", + "https://deno.land/x/ramda@v0.27.2/source/nthArg.js": "66fd703e925c7808f3f1a73b46d4dbdf7fc74511a7dfc31aee4b5a82764b6fac", + "https://deno.land/x/ramda@v0.27.2/source/o.js": "57abdf9853832d865288dd8625442a316600f36e2e047f767f5c22a8d434c389", + "https://deno.land/x/ramda@v0.27.2/source/objOf.js": "7f760ec1e38e7012551cbfd9e0f4ca68a19bb491daa8d54be9444a5f0bf68c34", + "https://deno.land/x/ramda@v0.27.2/source/of.js": "ea1421763926f248eee2e50f0cad19c1523dff0b34905243fcc8a8578f6c4582", + "https://deno.land/x/ramda@v0.27.2/source/omit.js": "03e6b388a3191d323ad8af97c1ec66b76335d1fc8b3969e6277d7e1ef48c5424", + "https://deno.land/x/ramda@v0.27.2/source/on.js": "b76d5eef062178a08b58aa78bec4d653b23e861f76acc7ec4a0c66d34dbbc57c", + "https://deno.land/x/ramda@v0.27.2/source/once.js": "39d44ab2951729d186a290aa1cdaedc4e8413c5075cbd7d83a219ee2ceb37e6d", + "https://deno.land/x/ramda@v0.27.2/source/or.js": "44290c4fd7a60d0ab4078ff6ebfb3494a7ec765a7f876d4e0d3ae2d101ba3761", + "https://deno.land/x/ramda@v0.27.2/source/otherwise.js": "1684ddecaf92ccca4e81a1185fb9a03145590bc3b412755daecd584450b8b98d", + "https://deno.land/x/ramda@v0.27.2/source/over.js": "1bafb3f3adb80e842e3f85fbc9cec7d1ff45d74a521155ff874513e5ea656272", + "https://deno.land/x/ramda@v0.27.2/source/pair.js": "a7a08517da7b9dbc795961b5fc61c841f3448357816f92d1c92f4b82b87c3e56", + "https://deno.land/x/ramda@v0.27.2/source/partial.js": "46a8e589c5c13e04548d0d608eae3e98536faa7a07cd21a85ad5bc4422d1ecb6", + "https://deno.land/x/ramda@v0.27.2/source/partialObject.js": "888560badc3265a1f2a565679e384cd328fa67cd1f8593b327ea1e997685d6c2", + "https://deno.land/x/ramda@v0.27.2/source/partialRight.js": "1f648463a8ea8bde88e2ba514db697c89f3d89797476df9d9d33c09416d5ee3e", + "https://deno.land/x/ramda@v0.27.2/source/partition.js": "ce7bf928d78471bb2ab5a3cc4e8bba94fd2dc7eef4190f02ac006b7dd1f94483", + "https://deno.land/x/ramda@v0.27.2/source/path.js": "6e0f6fbfd48310ae97e8b7db1dc10f667792e00b472e918d48299b62c6bdc181", + "https://deno.land/x/ramda@v0.27.2/source/pathEq.js": "210d42b3d630ee51ea8bf05cad1394c7bda25b8d84e3e8f1d745f7bb6580e9dd", + "https://deno.land/x/ramda@v0.27.2/source/pathOr.js": "5393e6f1ce78d2d25fa40ecb31556299b3b204235f922fb10fa5bce3849d7c70", + "https://deno.land/x/ramda@v0.27.2/source/pathSatisfies.js": "31ae1d1bb8d296ccdef738ffcb78931a522aab9d326e7352f74b9c49ce75a162", + "https://deno.land/x/ramda@v0.27.2/source/paths.js": "10e5e2efea2f6846d9599a733765142233c8462dc840f75bdfa49a79850ce93a", + "https://deno.land/x/ramda@v0.27.2/source/pick.js": "ff81a353b32587e28436e620a01babdb98efd8a299b8f6a8df6ac25ce1b94832", + "https://deno.land/x/ramda@v0.27.2/source/pickAll.js": "ca62395b63fd42c0ef68613f49838eaa2bb0ea1ead3cc31e814aab3ee578c8bc", + "https://deno.land/x/ramda@v0.27.2/source/pickBy.js": "276d46c2b30bb150815d8216aa4d4f7b13064c85d2f94f08acaf3d7d819b879a", + "https://deno.land/x/ramda@v0.27.2/source/pipe.js": "ec4af7b8717bab5ffbd64214f307e2e1a5588422c7aa135f07ab889f5b5b3c4c", + "https://deno.land/x/ramda@v0.27.2/source/pipeWith.js": "d29c2efcd05dff0fcd7d063b6765b33c23a00ac111277d57b6c71c7ce9995783", + "https://deno.land/x/ramda@v0.27.2/source/pluck.js": "236c11ce90670c59b40c8282da26488700ce9526752e567019c3f91649efc62d", + "https://deno.land/x/ramda@v0.27.2/source/prepend.js": "8e1817d3905234e1d8beab695d1a887c58d533f9872409022d59e4668c518ca6", + "https://deno.land/x/ramda@v0.27.2/source/product.js": "00a1029c71c3d74687189cb0f230c1b11e6148550cdb7a43f921b70eebefd8e2", + "https://deno.land/x/ramda@v0.27.2/source/project.js": "95e3607631966e481137241ab3d5ce28ecf7c356180f1f0c64e3e17f7e7b5144", + "https://deno.land/x/ramda@v0.27.2/source/promap.js": "5169a3c1c4bc18bfee15843937a2044820f0f5ccb0b756f9dab38b82bdd64fb2", + "https://deno.land/x/ramda@v0.27.2/source/prop.js": "60fadfc312383bf90b89bf030c491c2f9990f7c36195c77ce9d129cd03ad379e", + "https://deno.land/x/ramda@v0.27.2/source/propEq.js": "e2cb2122ea211c4864aa9e636b8b9bdde35c1cc75299609bc5ef55d04d2b139b", + "https://deno.land/x/ramda@v0.27.2/source/propIs.js": "6c352c6598300cdc3cb25d05e3b4811ea5408b83121b7457c846e11c60355da5", + "https://deno.land/x/ramda@v0.27.2/source/propOr.js": "ab65492b7f11b10016375839f0de80817db82f00f88913c4c958d71a67e25c44", + "https://deno.land/x/ramda@v0.27.2/source/propSatisfies.js": "7964b6c9bb6372c6c46c2f7ca7ecb2766aa8f6506a0852e21f0856b057b0792f", + "https://deno.land/x/ramda@v0.27.2/source/props.js": "e1472fc2129790daf2d8926fcf276d06e5d8d399e9ac2ecdb0ab9c0b8626451d", + "https://deno.land/x/ramda@v0.27.2/source/range.js": "a5474da8ed452f871e284152fadab940d7818a0094fab8240c6c6a1ab3aaba11", + "https://deno.land/x/ramda@v0.27.2/source/reduce.js": "d9061c7d234998782c985b95ea8949cdb245b70663d2a934a8a8de0d4de9c05d", + "https://deno.land/x/ramda@v0.27.2/source/reduceBy.js": "2d02bc459e682b552e6b79b00b35e4ddeadb71468e6c8be6dffbd55465e5db9e", + "https://deno.land/x/ramda@v0.27.2/source/reduceRight.js": "a304e786dd69bd64d6100d8b4cb5a9b462b9b6da2e6cdb132fe624bd26985470", + "https://deno.land/x/ramda@v0.27.2/source/reduceWhile.js": "8f1502a371a2329443ab545d302ac125a0bac5154575d84bd87bb783a9a4e916", + "https://deno.land/x/ramda@v0.27.2/source/reduced.js": "dc58f955c3cfa8bb49db99331cca4bbaf593b2ff93e1a65c367b48fea02369b8", + "https://deno.land/x/ramda@v0.27.2/source/reject.js": "9d60a4d7faec58937801a88901aae4ff36e4363066b885c113cfa2872bfc7d83", + "https://deno.land/x/ramda@v0.27.2/source/remove.js": "41fd2e37f5cbd89d5a03ebd74a302f5eed6fe65f7e62e096940c9f312230a205", + "https://deno.land/x/ramda@v0.27.2/source/repeat.js": "ca9ee93b0bf120ddcadc6392658899ea04ae3c53603f5440529f6be23051ecb9", + "https://deno.land/x/ramda@v0.27.2/source/replace.js": "bf2a3c64fb797528ac7038c358eb2a4abd899c9e9fc5ccac5c9974d33f1ed828", + "https://deno.land/x/ramda@v0.27.2/source/reverse.js": "8e7eedc3a061105f9eb3d049e6eb439ddecff3c68c396ec74b95067b57c11f41", + "https://deno.land/x/ramda@v0.27.2/source/scan.js": "efff8bc96cda4d073cd515cf2f6b8997128ee3623dfbf068fe82399335724467", + "https://deno.land/x/ramda@v0.27.2/source/sequence.js": "a4589d32bf479e32f8ae724704cd7f2da30e1cf40761812b74b51be56603755d", + "https://deno.land/x/ramda@v0.27.2/source/set.js": "1529e3b9939e47d4c472d9109919eb69023902c4a7f512a9867dd8ceb63bce5b", + "https://deno.land/x/ramda@v0.27.2/source/slice.js": "6338a85bc03108cefb047b8da4560560d32452c15806cb4b9346e854422c2c5c", + "https://deno.land/x/ramda@v0.27.2/source/sort.js": "5da481cae31593cd22132484dd8abe9cb2c13497cdc165138553b2d05290381f", + "https://deno.land/x/ramda@v0.27.2/source/sortBy.js": "f9b1bc79cf1e58719e958e7e9cabced4b07efc3746dd8dace2bc1b13ac8d844a", + "https://deno.land/x/ramda@v0.27.2/source/sortWith.js": "9b9a2b929c9ee1527ebd396018424029856eb522f6b631a409ca79ad151b1214", + "https://deno.land/x/ramda@v0.27.2/source/split.js": "1c1451902866598c519c83b5d25c7984baa27cc0982221a06bb4eab3699a6c05", + "https://deno.land/x/ramda@v0.27.2/source/splitAt.js": "dee211568c602898afbb49f8fff137b74e552bd46822061fbffcabac703ef93d", + "https://deno.land/x/ramda@v0.27.2/source/splitEvery.js": "8d2df436be887b249ffc2d0f8eaf484a3119a0a3c250470b9fd3228bc549e41f", + "https://deno.land/x/ramda@v0.27.2/source/splitWhen.js": "c8f1f962b74ac56c3613a5b3891a6ca241b16531013540d646315defc68109a7", + "https://deno.land/x/ramda@v0.27.2/source/splitWhenever.js": "914fe5786f583a3bc4ebdd0ab940716fc9c517c6de6597267cf1ddf0f0fcb04a", + "https://deno.land/x/ramda@v0.27.2/source/startsWith.js": "3fcd61621913c4f0ad130d99bda3c114abfa90cf2b14c994997e69ad689a745c", + "https://deno.land/x/ramda@v0.27.2/source/subtract.js": "e4436de0605efcdd800068fb41a52768cd0105455debabb12ac57f369c7edafb", + "https://deno.land/x/ramda@v0.27.2/source/sum.js": "a924a281f0ae40164350080d77218a61d5f2e23a0915717897ea9da548741d5c", + "https://deno.land/x/ramda@v0.27.2/source/symmetricDifference.js": "b38e06671a7c7cbe31f820d6baa81a7e9488feadb76761e9f40a4db5aa898868", + "https://deno.land/x/ramda@v0.27.2/source/symmetricDifferenceWith.js": "24ee2e88d2ae27d32aff7534d69df43e84577aba29423a374d105cb1d915b1a9", + "https://deno.land/x/ramda@v0.27.2/source/tail.js": "8b3056fee001bf8bf025cb9b1f656d7e51b3319616551692826716988d446c62", + "https://deno.land/x/ramda@v0.27.2/source/take.js": "7d63665c2b4ca089898fb32e157380eb7f7fae64292d3e476cdda44017aae0d9", + "https://deno.land/x/ramda@v0.27.2/source/takeLast.js": "07eebe91969563cb4a0ba615fe896b19b5437b0509f292f1eadbae5aa271e902", + "https://deno.land/x/ramda@v0.27.2/source/takeLastWhile.js": "9855485f2e41fad8fcbaee5da05e2861b384e460873854065eab0d4b1af32873", + "https://deno.land/x/ramda@v0.27.2/source/takeWhile.js": "6fbe52b711e4f529f6ef1fed47b0f955b104c5b8574af25388767fde22daded3", + "https://deno.land/x/ramda@v0.27.2/source/tap.js": "711d7e7b0b988c0fb1056bb28546442216a2112c6cead7e72f2e676dc2a02496", + "https://deno.land/x/ramda@v0.27.2/source/test.js": "e275f3b1eab7a0094c61dd7a6d5722d45c868407957a4cf6ff082103672c19ac", + "https://deno.land/x/ramda@v0.27.2/source/thunkify.js": "3d85481b52007df192b2e2add0c24e0ec7a2e0c19fe97d179f9c9d3c1dce6559", + "https://deno.land/x/ramda@v0.27.2/source/times.js": "04698c26c3c5c5ae075001976dc4f9bc79b31f0d9e074c125d1033f79c4e9ed9", + "https://deno.land/x/ramda@v0.27.2/source/toLower.js": "856c9897da372c53a9ea9bb62554a95723c038cb0acb6565ab5e759c64f2afa0", + "https://deno.land/x/ramda@v0.27.2/source/toPairs.js": "bc31cbf69bcdebe53746b870766fd9e44e642f588cb42292647d8ba8f28eac23", + "https://deno.land/x/ramda@v0.27.2/source/toPairsIn.js": "68ea5f619efba23d2ba14618f52a753d8e48b08d7c21a0e14409509a62fab7ac", + "https://deno.land/x/ramda@v0.27.2/source/toString.js": "6e05e4f41a92533031cf3d414f4f548a0ce7a2ca846d4b1588efe0e246fa597c", + "https://deno.land/x/ramda@v0.27.2/source/toUpper.js": "a6144b426d9b3f26d96f8d1e35fa9e69a0811797ee9c321bfc87ebb11dc672a5", + "https://deno.land/x/ramda@v0.27.2/source/transduce.js": "5ac0c4fdc079ccc0aa9322f6d42a6d3e2ea6e68379cf2b6b2921e654b3b957d5", + "https://deno.land/x/ramda@v0.27.2/source/transpose.js": "e5f35926cc5dad4eb16232604586121d5fe5f074695c0348b1caa4eff2a232eb", + "https://deno.land/x/ramda@v0.27.2/source/traverse.js": "130de5570a1d9884e1c9055b0b49c954b0ed50861b984b02147ecc86ee91843d", + "https://deno.land/x/ramda@v0.27.2/source/trim.js": "e3c4f90371a4d306ee851b0b7eeaf60ac3f89473361b26e4185cf182f7b440c2", + "https://deno.land/x/ramda@v0.27.2/source/tryCatch.js": "1e5fa41939c58c4fbaaff873d08dc5ceedb8d3783791b451a498c695131a5723", + "https://deno.land/x/ramda@v0.27.2/source/type.js": "21080ce17378149b100374d4e4066b8ad62795f5cd8cd14f51304b24750ed06c", + "https://deno.land/x/ramda@v0.27.2/source/unapply.js": "7712b047039a8dbb54c8e2d941868a4cad0cd5b8d740f9d0f57a400c4f7f6020", + "https://deno.land/x/ramda@v0.27.2/source/unary.js": "8bdf32559274bccf5b71fa9eda955d584bcf452ef11c22819c724827766f7a4b", + "https://deno.land/x/ramda@v0.27.2/source/uncurryN.js": "2d801d194ce5ee77474e3c1d9bc20015a9b0e2268e340d090c8ae983111d80b9", + "https://deno.land/x/ramda@v0.27.2/source/unfold.js": "8a6aa35bf3634acd7b1483f492e40bcdf4144aee371b04e21988d80c51ea1806", + "https://deno.land/x/ramda@v0.27.2/source/union.js": "6ccc1a0367cd64b6383a1ff70710abc4994a930fb35d79adbba86ba62ab2da2f", + "https://deno.land/x/ramda@v0.27.2/source/unionWith.js": "91ffe1040d3b745e395f67a09d9754512a3b17df60eb0ebcffa440901ef38d2f", + "https://deno.land/x/ramda@v0.27.2/source/uniq.js": "96389b95fd864d7c0a2b6298bce1fc57f1f794a3439f7f288303b582ace5ef20", + "https://deno.land/x/ramda@v0.27.2/source/uniqBy.js": "6059b02c9b4eee0fbdcdb0517ac5e463c51395345ee13a7cfa33f89dab5bdc42", + "https://deno.land/x/ramda@v0.27.2/source/uniqWith.js": "c2990360b02d9ae5dee4d87645444615df1ab08b469c2fa154657406edbe9d58", + "https://deno.land/x/ramda@v0.27.2/source/unless.js": "850b9d5be2bf30beafcb4e527b2b9807782cef2eef603b2de79d78c90f93f004", + "https://deno.land/x/ramda@v0.27.2/source/unnest.js": "3344cca4f2659488bda492a3d4c24a88a213cc26cd0326a57c5f9fd82de87d24", + "https://deno.land/x/ramda@v0.27.2/source/until.js": "75405845374a431dd724ec27fcb83daeaa1eb286e31fd6d452991b398e7ea98c", + "https://deno.land/x/ramda@v0.27.2/source/update.js": "d6103807ab8b29871ede6d8263916a3aa67ed2c77dd3769e8f181b0913c908b6", + "https://deno.land/x/ramda@v0.27.2/source/useWith.js": "79f096300ab6f18859c5cd1d7428f47c012ff878be1eef3dc8b65d2b80d02a35", + "https://deno.land/x/ramda@v0.27.2/source/values.js": "b8b65a949541fd40dab4fd27989e31aa389712f243d5f9e52b9db7c9411e6aac", + "https://deno.land/x/ramda@v0.27.2/source/valuesIn.js": "d89b5ef0a181ec77320e854c9bfb45ab42dd56a8bf38a5353f2e645ed7b8e4d8", + "https://deno.land/x/ramda@v0.27.2/source/view.js": "f9d46719c726def19c0decec8d993544bc238cdc75908a5333502e5af22861cb", + "https://deno.land/x/ramda@v0.27.2/source/when.js": "583b46d6b5cd606caf8b6245423efa451294dff030c104db86c48c2024a2d26f", + "https://deno.land/x/ramda@v0.27.2/source/where.js": "8bb8b7ce295bc07bdd5dab752876d6f00b40c8d62491320563e0561ddfde1ea4", + "https://deno.land/x/ramda@v0.27.2/source/whereAny.js": "f7551df5fab9b5e277251d9cd67655d2de3d58582dc7229f2a9b3102e28c990d", + "https://deno.land/x/ramda@v0.27.2/source/whereEq.js": "3756da145b2b7cc93bbf49f1b6c7c358154ba7565baff9eb4e486e4207f1c31b", + "https://deno.land/x/ramda@v0.27.2/source/without.js": "db7b970efaeec230d056ed0ae25f9eca500fb5858efd4e61c2f695c8fdac4c08", + "https://deno.land/x/ramda@v0.27.2/source/xor.js": "7799612731265355fc3a9b407b12628e4426bc9b70b6b874e6106ba61a66f71d", + "https://deno.land/x/ramda@v0.27.2/source/xprod.js": "ecac5f7263ddad3484c6802097a6d0957b6107ce8e52eea0f27a04be50a16833", + "https://deno.land/x/ramda@v0.27.2/source/zip.js": "29a04e7dd0a012e391fc595900b4cb0124a13c738f4e6f4489c7173b4a6642dc", + "https://deno.land/x/ramda@v0.27.2/source/zipObj.js": "c463fe415ecf647561a12ed9dd3444a0be4dd642e787e156fe8caf7fee0ce4ca", + "https://deno.land/x/ramda@v0.27.2/source/zipWith.js": "2081388ae101a01f60589f0a74b7a8e75fdc7a6416c224673f197633750041ec", + "https://deno.land/x/xhr@0.3.0/mod.ts": "094aacd627fd9635cd942053bf8032b5223b909858fa9dc8ffa583752ff63b20", + "https://deno.land/x/zod@v3.23.8/ZodError.ts": "528da200fbe995157b9ae91498b103c4ef482217a5c086249507ac850bd78f52", + "https://deno.land/x/zod@v3.23.8/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", + "https://deno.land/x/zod@v3.23.8/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", + "https://deno.land/x/zod@v3.23.8/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", + "https://deno.land/x/zod@v3.23.8/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", + "https://deno.land/x/zod@v3.23.8/helpers/parseUtil.ts": "c14814d167cc286972b6e094df88d7d982572a08424b7cd50f862036b6fcaa77", + "https://deno.land/x/zod@v3.23.8/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", + "https://deno.land/x/zod@v3.23.8/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", + "https://deno.land/x/zod@v3.23.8/helpers/util.ts": "30c273131661ca5dc973f2cfb196fa23caf3a43e224cdde7a683b72e101a31fc", + "https://deno.land/x/zod@v3.23.8/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", + "https://deno.land/x/zod@v3.23.8/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", + "https://deno.land/x/zod@v3.23.8/mod.ts": "ec6e2b1255c1a350b80188f97bd0a6bac45801bb46fc48f50b9763aa66046039", + "https://deno.land/x/zod@v3.23.8/types.ts": "1b172c90782b1eaa837100ebb6abd726d79d6c1ec336350c8e851e0fd706bf5c", + "https://esm.sh/@faker-js/faker@8.4.1": "21d589a68f956f90bcf4031bc6f26f5a5c531ca4bd798e16ec33094ea6560bce", + "https://esm.sh/@supabase/supabase-js@2.33.1": "b9e0e18dfb0bfd1078c05be3474a710abf913ddc5b47bcb92317fabc4c5445e0", + "https://esm.sh/v135/@faker-js/faker@8.4.1/denonext/faker.mjs": "bf2af6fc7f2660f80df3c0fda1185082478ef84a73b1aa27ea761977f6fd8426", + "https://esm.sh/v135/@supabase/functions-js@2.1.5/denonext/functions-js.mjs": "739affd2df20c1affd91991c3731c2537d270b82be5cdbd926c43be40eb2fdc5", + "https://esm.sh/v135/@supabase/functions-js@2.3.1/denonext/functions-js.mjs": "c10a0bbd935fc12cae58918b8e23cb339d54c443983caeb597dd8cd76a76f940", + "https://esm.sh/v135/@supabase/gotrue-js@2.57.0/denonext/gotrue-js.mjs": "a4e6768643628fc5445683f4f58090ea5b65c51c3abb165dc0a385cf8eae2121", + "https://esm.sh/v135/@supabase/gotrue-js@2.62.2/denonext/gotrue-js.mjs": "326e26884bb2e359f192078b8c602a2e0acdc754767a274fe1df844319a8d319", + "https://esm.sh/v135/@supabase/node-fetch@2.6.14/denonext/node-fetch.mjs": "23a71af3512da67a8757ab1e2356fa22565758444ea1fc08e0de0e8d4625e8aa", + "https://esm.sh/v135/@supabase/node-fetch@2.6.15/denonext/node-fetch.mjs": "efad00ea3d4cbe1bee688836ef75339d49a1981bc5728a13e9ad5f26791d5efb", + "https://esm.sh/v135/@supabase/postgrest-js@1.15.2/denonext/postgrest-js.mjs": "1d36cf3106c6dd6ce21473b57d88478802ceef6d22e67af1f92c508607302e50", + "https://esm.sh/v135/@supabase/postgrest-js@1.9.0/denonext/postgrest-js.mjs": "e6239e65dca3c73527af617ee406934dc509a33e4082a9da9c3aa6c430519cdf", + "https://esm.sh/v135/@supabase/realtime-js@2.8.4/denonext/realtime-js.mjs": "9f8af6f82b095b1b1d336a92c4ff8519a5b7f783a1e09eab005114b983cafd5d", + "https://esm.sh/v135/@supabase/realtime-js@2.9.4/denonext/realtime-js.mjs": "5a4443e535c1ef8c469c281442a976f47735b3ee378c9e6d8b9a379c56b2f852", + "https://esm.sh/v135/@supabase/storage-js@2.5.4/denonext/storage-js.mjs": "dff68679fb795ec7e157b8f51cb0b8c544d6c3d57ecc093f5b15eb909439efcd", + "https://esm.sh/v135/@supabase/storage-js@2.5.5/denonext/storage-js.mjs": "66e29e4e55c7d396503e2d1d9376cfbc34f046b962bce4524c2d80b209fff413", + "https://esm.sh/v135/@supabase/supabase-js@2.33.1/denonext/supabase-js.mjs": "0f38b8a15b66220153ba6633bd54c26f9416e161fbb9a4fb8841f06946099aa9", + "https://esm.sh/v135/bufferutil@4.0.8/denonext/bufferutil.mjs": "60a4618cbd1a5cb24935c55590b793d4ecb33862357d32e1d4614a0bbb90947f", + "https://esm.sh/v135/cross-fetch@3.1.8/denonext/cross-fetch.mjs": "8fba9e7c3fbaf0d2168beb63ce0cd21b5bfbfbd77e2fcbf8d957d533a71222f6", + "https://esm.sh/v135/es5-ext@0.10.62/denonext/global.js": "2f51d9acef1f761faaa542da919b3c747744ea29fe709d0a904fd531716a93d6", + "https://esm.sh/v135/node-gyp-build@4.6.1/denonext/node-gyp-build.mjs": "5d28b312f145a6cb2ec0dbdd80a7d34c0e0e6b5dcada65411d8bcff6c8991cc6", + "https://esm.sh/v135/utf-8-validate@6.0.3/denonext/utf-8-validate.mjs": "410c48d66840e987e474a4849cd25829817415cedd25466280effb1287d05aa5", + "https://esm.sh/v135/websocket@1.0.34/denonext/websocket.mjs": "dfe5d62007e9f7430d561e2376fe47caa9d04cb016d83d3386fcc2206a90296b", + "https://esm.sh/v135/ws@8.16.0/denonext/ws.mjs": "0fa0c00b69577ba36d0a36001329b2cec91498cb2e33e329fc76aa6d51a0d54d" + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@^0.226.0", + "jsr:@std/dotenv@^0.224.2", + "jsr:@std/testing@^1.0.0" + ] + } +} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 8dfde93..0000000 --- a/package-lock.json +++ /dev/null @@ -1,238 +0,0 @@ -{ - "name": "hn-telegram-bot", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "hn-telegram-bot", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@types/ramda": "^0.30.0", - "ramda": "^0.30.1", - "telegraf": "^4.16.3", - "typescript": "^5.4.5" - } - }, - "node_modules/@telegraf/types": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz", - "integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==", - "license": "MIT" - }, - "node_modules/@types/ramda": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.30.0.tgz", - "integrity": "sha512-DQtfqUbSB18iM9NHbQ++kVUDuBWHMr6T2FpW1XTiksYRGjq4WnNPZLt712OEHEBJs7aMyJ68Mf2kGMOP1srVVw==", - "license": "MIT", - "dependencies": { - "types-ramda": "^0.30.0" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/buffer-alloc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", - "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", - "license": "MIT", - "dependencies": { - "buffer-alloc-unsafe": "^1.1.0", - "buffer-fill": "^1.0.0" - } - }, - "node_modules/buffer-alloc-unsafe": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", - "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "license": "MIT" - }, - "node_modules/buffer-fill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", - "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "license": "MIT", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "license": "MIT" - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/p-timeout": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz", - "integrity": "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ramda": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", - "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/ramda" - } - }, - "node_modules/safe-compare": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz", - "integrity": "sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==", - "license": "MIT", - "dependencies": { - "buffer-alloc": "^1.2.0" - } - }, - "node_modules/sandwich-stream": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz", - "integrity": "sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==", - "license": "Apache-2.0", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/telegraf": { - "version": "4.16.3", - "resolved": "https://registry.npmjs.org/telegraf/-/telegraf-4.16.3.tgz", - "integrity": "sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==", - "license": "MIT", - "dependencies": { - "@telegraf/types": "^7.1.0", - "abort-controller": "^3.0.0", - "debug": "^4.3.4", - "mri": "^1.2.0", - "node-fetch": "^2.7.0", - "p-timeout": "^4.1.0", - "safe-compare": "^1.1.4", - "sandwich-stream": "^2.0.2" - }, - "bin": { - "telegraf": "lib/cli.mjs" - }, - "engines": { - "node": "^12.20.0 || >=14.13.1" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/ts-toolbelt": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", - "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==", - "license": "Apache-2.0" - }, - "node_modules/types-ramda": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/types-ramda/-/types-ramda-0.30.0.tgz", - "integrity": "sha512-oVPw/KHB5M0Du0txTEKKM8xZOG9cZBRdCVXvwHYuNJUVkAiJ9oWyqkA+9Bj2gjMsHgkkhsYevobQBWs8I2/Xvw==", - "license": "MIT", - "dependencies": { - "ts-toolbelt": "^9.6.0" - } - }, - "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 9f0445a..0000000 --- a/package.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "hn-telegram-bot", - "version": "1.0.0", - "description": "HackerNews Telegram Bot", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "author": "Mateo Sheshi", - "license": "ISC", - "dependencies": { - "@types/ramda": "^0.30.0", - "ramda": "^0.30.1", - "telegraf": "^4.16.3", - "typescript": "^5.4.5" - } -} diff --git a/src/__tests__/ai_adapters/base.spec.ts b/src/__tests__/ai_adapters/base.spec.ts new file mode 100644 index 0000000..dab2508 --- /dev/null +++ b/src/__tests__/ai_adapters/base.spec.ts @@ -0,0 +1,33 @@ +import { assertType, describe, IsExact, it } from '@/dev_deps.ts' +import { BaseAdapter, ResponseContent } from '../../ai_adapters/base.ts' + +describe('baseAdapter', () => { + describe('constructor', () => { + it('correctly gets implemented', () => { + class MyAdapter implements BaseAdapter { + generateContent(_input: string): Promise { + return Promise.resolve({ text: 'Hello World!' }) + } + + buildBody(_input: string): object { + return { text: 'Hello World!' } + } + } + + const adapter = new MyAdapter() + assertType>(true) + assertType< + IsExact< + typeof adapter.generateContent, + (text: string) => Promise + > + >( + true, + ) + + assertType object>>( + true, + ) + }) + }) +}) diff --git a/src/__tests__/ai_adapters/gemini.spec.ts b/src/__tests__/ai_adapters/gemini.spec.ts new file mode 100644 index 0000000..3932799 --- /dev/null +++ b/src/__tests__/ai_adapters/gemini.spec.ts @@ -0,0 +1,105 @@ +import { GeminiAdapter } from '@/ai_adapters/gemini.ts' +import { + assertEquals, + assertExists, + assertInstanceOf, + assertObjectMatch, + assertRejects, + assertThrows, + assertType, + describe, + IsExact, + it, + stub, +} from '@/dev_deps.ts' +import { RequestContent, responseSchema } from '@/types.ts' +import { ResponseContent } from '@/ai_adapters/base.ts' + +describe('GeminiAdapter', () => { + describe('constructor', () => { + it('should create an instance of GeminiAdapter', () => { + const adapter = new GeminiAdapter() + + assertInstanceOf(adapter, GeminiAdapter) + assertExists(adapter['_apiKey']) + assertExists(adapter['_baseUrl']) + assertExists(adapter['_model']) + }) + }) + + describe('buildBody', () => { + it('throws an error when input not provided', () => { + const adapter = new GeminiAdapter() + + assertThrows( + () => adapter.buildBody(''), + ) + }) + + it('should return a request content', () => { + const adapter = new GeminiAdapter() + const requestContent = adapter.buildBody('Hello, world!') + + assertObjectMatch(requestContent, { + contents: [ + { + parts: [{ + text: 'Hello, world!', + }], + }, + ], + generationConfig: { + responseMimeType: 'application/json', + responseSchema, + }, + }) + + assertType>(true) + }) + }) + + describe('generateContent', () => { + it('should throw an error when input not provided', () => { + const adapter = new GeminiAdapter() + + assertRejects( + () => adapter.generateContent(''), + Error, + 'Text is required', + ) + }) + + it('should return a response content', async () => { + const adapter = new GeminiAdapter() + + const fakeResponse = Promise.resolve( + new Response( + JSON.stringify({ + candidates: [ + { + content: { + parts: [ + { + text: 'Hello, world!', + }, + ], + }, + safetyRatings: [], + }, + ], + }), + { status: 200 }, + ), + ) + + using _fetchStub = stub(globalThis, 'fetch', () => fakeResponse) + + const response = await adapter.generateContent('Hello, world!') + + assertType>(true) + assertEquals(response.text, 'Hello, world!') + }) + + // TODO: add handle API error test + }) +}) diff --git a/src/__tests__/analysis.spec.ts b/src/__tests__/analysis.spec.ts new file mode 100644 index 0000000..e28e05f --- /dev/null +++ b/src/__tests__/analysis.spec.ts @@ -0,0 +1,139 @@ +// deno-lint-ignore-file no-explicit-any +import { + afterEach, + assertEquals, + assertType, + beforeEach, + describe, + faker, + IsExact, + it, + restore, + returnsNext, + simpleFaker, + stub, +} from '@/dev_deps.ts' +import { bulkRetrieveItems, filterStories } from '@/analysis.ts' +import { Item, TopStories } from '@/types.ts' +import { R } from '@/deps.ts' +import { promisifyFactoryObj } from '@/utils/test_helpers.ts' +import * as F from '@/functions.ts' + +describe('filterStories', () => { + beforeEach(() => { + // Mock the getTopStories function + const fakeTopStories = simpleFaker.helpers.multiple( + simpleFaker.number.int, + { + count: 3, + }, + ) + + const fakeStoryTitles = [ + 'Watch out this new AWS Service: Deno on TypeScript', + 'Deno vs Node: A Comprehensive Comparison', + 'My journey to become a lazy developer', + ] + + // Mock getItem function + const fakeItems: Item[] = F.mapIndexed( + (id, idx: number) => ({ + id, + type: 'story', + by: faker.person.fullName(), + title: fakeStoryTitles[idx], + time: faker.date.recent().getTime(), + }), + fakeTopStories, + ) + + const fakeGeminiResponse = { + candidates: [ + { + content: { + parts: [ + { + text: R.pipe( + R.take(2), + R.map(R.toString), + R.assoc('storyIds', R.__, {}), + (v: any) => JSON.stringify(v), + )(fakeTopStories), + }, + ], + }, + safetyRatings: [], + }, + ], + } + + stub( + globalThis, + 'fetch', + returnsNext([ + promisifyFactoryObj(fakeTopStories), + ...R.map(promisifyFactoryObj, fakeItems), + promisifyFactoryObj(fakeGeminiResponse), + ]), + ) + }) + + afterEach(() => { + restore() + }) + + it('does nothing when no preferences are provided', async () => { + const preferences: string[] = [] + const result = await filterStories(preferences) + assertEquals(result, []) + }) + + it('filters stories by preferences', async () => { + const preferences = ['TypeScript', 'AWS', 'Deno'] + + const filteredStories = await filterStories(preferences) + assertEquals( + filteredStories, + [ + 'Watch out this new AWS Service: Deno on TypeScript', + 'Deno vs Node: A Comprehensive Comparison', + ], + ) + }) +}) + +describe('bulkRetrieveItems', () => { + describe('no items', () => { + it('returns an empty array', async () => { + assertEquals(await bulkRetrieveItems([]), []) + }) + }) + + it('retrieves items in bulk', async () => { + const fakeItems = R.times( + (id: number) => ({ + id, + type: 'story', + by: faker.person.fullName(), + title: faker.lorem.sentence(), + time: faker.date.recent().getTime(), + } as Item), + 3, + ) + + using _fetchStub = stub( + globalThis, + 'fetch', + returnsNext(R.map(promisifyFactoryObj, fakeItems)), + ) + + const ids = [1, 2, 3] + const result = await bulkRetrieveItems(ids) + + assertType>(true) + assertEquals(result.length, ids.length) + assertEquals(result, fakeItems) + }) + + // TODO: add error handling tests +}) diff --git a/src/__tests__/bot/daily_analysis.spec.ts b/src/__tests__/bot/daily_analysis.spec.ts new file mode 100644 index 0000000..d80ffaa --- /dev/null +++ b/src/__tests__/bot/daily_analysis.spec.ts @@ -0,0 +1,124 @@ +// deno-lint-ignore-file no-explicit-any +import { + afterAll, + assertType, + beforeAll, + describe, + faker, + IsExact, + it, + returnsNext, + simpleFaker, + stub, +} from '@/dev_deps.ts' +import { + cleanupDatabase, + promisifyFactoryObj, + seedDatabase, +} from '@/utils/test_helpers.ts' +import dailyAnalysis, { + getUsersPreferences, +} from '@/preference/daily_analysis.ts' +import { R } from '@/deps.ts' +import * as F from '@/functions.ts' +import { Item, TopStories } from '@/types.ts' + +beforeAll(async () => { + await seedDatabase() +}) + +afterAll(async () => { + await cleanupDatabase() +}) + +function mockFullSetup() { + // Mock the getTopStories function + const fakeTopStories = simpleFaker.helpers.multiple( + simpleFaker.number.int, + { + count: 3, + }, + ) + + const fakeStoryTitles = [ + 'Watch out this new AWS Service: Deno on TypeScript', + 'Deno vs Node: A Comprehensive Comparison', + 'My journey to become a lazy developer', + ] + + // Mock getItem function + const fakeItems: Item[] = F.mapIndexed( + (id, idx) => ({ + id, + type: 'story', + by: faker.person.fullName(), + title: fakeStoryTitles[idx], + time: faker.date.recent().getTime(), + }), + fakeTopStories, + ) + + const fakeGeminiResponse = { + candidates: [ + { + content: { + parts: [ + { + text: R.pipe( + R.take(2), + R.map(R.toString), + R.assoc('storyIds', R.__, {}), + (v: any) => JSON.stringify(v), + )(fakeTopStories), + }, + ], + }, + safetyRatings: [], + }, + ], + } + + const fakeResponses = [ + promisifyFactoryObj(fakeTopStories), + ...R.map(promisifyFactoryObj, fakeItems), + promisifyFactoryObj(fakeGeminiResponse), + ] + + stub( + globalThis, + 'fetch', + returnsNext( + fakeResponses, + ), + ) +} + +describe.skip('dailyAnalysis', () => { + it('should reply with filtered articles', async () => { + mockFullSetup() + await dailyAnalysis() + + // TODO: it should send a message to the user + // TODO: find a way to test bot sending message + }) + + it('should skip run if there are no users', async () => { + }) +}) + +describe('getUsersPreferences', () => { + it('should return the user preferences', async () => { + const usersPreferences = await getUsersPreferences() + assertType< + IsExact< + typeof usersPreferences, + { id: string; session: string | null }[] + > + >(true) + }) + + it('should throw an error if the response is not valid', async () => { + // ... + // FIXME: dunno how to test this + }) +}) diff --git a/src/__tests__/bot/setup.spec.ts b/src/__tests__/bot/setup.spec.ts new file mode 100644 index 0000000..e343987 --- /dev/null +++ b/src/__tests__/bot/setup.spec.ts @@ -0,0 +1,191 @@ +import { MemorySessionStorage, R } from '@/deps.ts' +import { afterAll, assertEquals, beforeEach, describe, it } from '@/dev_deps.ts' +import setup from '@/preference/setup.ts' +import { SessionData } from '@/types.ts' +import { + chat, + from, + slashCommand, + testSetupConversation, +} from '@/utils/test_helpers.ts' + +let storageAdapter: MemorySessionStorage | undefined + +const storageAdapterFn = () => { + storageAdapter = new MemorySessionStorage() + return storageAdapter +} + +beforeEach(() => { + storageAdapter = undefined +}) + +// TODO: Remove this once we have a better way to wait for the event loop to clear +afterAll(async () => { + await new Promise((r) => setTimeout(r, 0)) +}) + +describe('setup', () => { + it('saves preferences', async () => { + await testSetupConversation( + storageAdapterFn, + [ + { + update_id: 1, + message: { + message_id: 1, + date: Date.now(), + chat, + from, + text: 'test_preference', + }, + }, + { + update_id: 2, + message: { + message_id: 2, + date: Date.now(), + chat, + from, + text: 'some_other_preference', + }, + }, + ], + [], + setup, + ) + + const preferences = storageAdapter!.read( + R.toString(R.prop('id', chat)), + )?.preferences + + assertEquals(preferences!.length, 2) + assertEquals(preferences, ['test_preference', 'some_other_preference']) + }) + + it('does not save duplicate preferences', async () => { + await testSetupConversation( + storageAdapterFn, + [ + { + update_id: 1, + message: { + message_id: 1, + date: Date.now(), + chat, + from, + text: 'test_preference', + }, + }, + { + update_id: 2, + message: { + message_id: 2, + date: Date.now(), + chat, + from, + text: 'test_preference', + }, + }, + ], + [], + setup, + ) + + const preferences = storageAdapter!.read( + R.toString(R.prop('id', chat)), + )?.preferences + + assertEquals(preferences!.length, 1) + assertEquals(preferences, ['test_preference']) + }) +}) + +describe('reset', () => { + it('clears preferences', async () => { + await testSetupConversation( + storageAdapterFn, + [ + { + update_id: 1, + message: { + message_id: 1, + date: Date.now(), + chat, + from, + text: 'test_preference', + }, + }, + { + update_id: 2, + message: { + message_id: 2, + date: Date.now(), + chat, + from, + text: 'some_other_preference', + }, + }, + ], + slashCommand('reset'), + setup, + ) + + const updatedPreferences = storageAdapter!.read( + R.toString(R.prop('id', chat)), + )?.preferences + + assertEquals(updatedPreferences, []) + }) +}) + +describe('list', () => { + it('lists preferences', async () => { + const outgoingRequests = await testSetupConversation( + storageAdapterFn, + [ + { + update_id: 1, + message: { + message_id: 1, + date: Date.now(), + chat, + from, + text: 'test_preference', + }, + }, + { + update_id: 2, + message: { + message_id: 2, + date: Date.now(), + chat, + from, + text: 'some_other_preference', + }, + }, + ], + slashCommand('list'), + setup, + ) + + const listRequest = R.last(outgoingRequests) + + const payload = R.prop('payload', listRequest!) + assertEquals(payload.text, 'test_preference\nsome_other_preference') + }) + + it('does not list preferences if there are none', async () => { + const outgoingRequests = await testSetupConversation( + storageAdapterFn, + [], + slashCommand('list'), + setup, + ) + + const listRequest = R.last(outgoingRequests) + + const payload = R.prop('payload', listRequest!) + assertEquals(payload.text, 'No preferences set.') + }) +}) diff --git a/src/__tests__/get_item.spec.ts b/src/__tests__/get_item.spec.ts new file mode 100644 index 0000000..5d105c2 --- /dev/null +++ b/src/__tests__/get_item.spec.ts @@ -0,0 +1,56 @@ +// deno-lint-ignore-file no-explicit-any +import { getItem } from '@/api.ts' +import { + assertEquals, + assertExists, + assertInstanceOf, + describe, + it, + stub, +} from '@/dev_deps.ts' + +describe('getItem', () => { + it('should return an item', async () => { + const fakeResponse = Promise.resolve({ + json: () => + Promise.resolve({ + by: 'dhouston', + descendants: 71, + id: 8863, + score: 104, + time: 1175714200, + title: 'My YC app: Dropbox - Throw away your USB drive', + type: 'story', + url: 'http://www.getdropbox.com/u/2/screencast.html', + }), + } as any) + + using _fetchStub = stub( + globalThis, + 'fetch', + () => fakeResponse, + ) + + const item = await getItem(8863) + + assertInstanceOf(item, Object) + assertExists(Object.prototype.hasOwnProperty.call(item, 'by')) + assertExists(Object.prototype.hasOwnProperty.call(item, 'descendants')) + assertExists(Object.prototype.hasOwnProperty.call(item, 'id')) + assertExists(Object.prototype.hasOwnProperty.call(item, 'score')) + assertExists(Object.prototype.hasOwnProperty.call(item, 'time')) + assertExists(Object.prototype.hasOwnProperty.call(item, 'title')) + assertExists(Object.prototype.hasOwnProperty.call(item, 'type')) + assertExists(Object.prototype.hasOwnProperty.call(item, 'url')) + }) + + it('should handle not found item', async () => { + using _fetchStub = stub(globalThis, 'fetch', () => + Promise.resolve({ + json: () => Promise.resolve(null), + } as any)) + + const item = await getItem(-1) + assertEquals(item, null) + }) +}) diff --git a/src/__tests__/get_top_stories.spec.ts b/src/__tests__/get_top_stories.spec.ts new file mode 100644 index 0000000..393bde2 --- /dev/null +++ b/src/__tests__/get_top_stories.spec.ts @@ -0,0 +1,30 @@ +// deno-lint-ignore-file no-explicit-any +import { getTopStories } from '@/api.ts' +import { + assertInstanceOf, + assertObjectMatch, + describe, + faker, + it, + stub, +} from '@/dev_deps.ts' + +describe('getTopStories', () => { + it('should return an array of top stories', async () => { + const fakeResponse = Promise.resolve({ + json: () => + Promise.resolve( + faker.helpers.multiple(faker.number.int, { count: 500 }), + ), + } as any) + + using _fetchStub = stub(globalThis, 'fetch', () => fakeResponse) + + const result = await getTopStories() + assertInstanceOf(result, Array) + + assertObjectMatch(result, { + length: 500, + }) + }) +}) diff --git a/src/__tests__/utils.spec.ts b/src/__tests__/utils.spec.ts new file mode 100644 index 0000000..451c73d --- /dev/null +++ b/src/__tests__/utils.spec.ts @@ -0,0 +1,10 @@ +import { MemorySessionStorage } from '@/deps.ts' +import { assertInstanceOf, describe, it } from '@/dev_deps.ts' +import { getSessionAdapter } from '@/utils.ts' + +const adapter = getSessionAdapter() + +describe('getSessionAdapter', () => + it('returns adapter', () => { + assertInstanceOf(adapter, MemorySessionStorage) + })) diff --git a/src/ai_adapters/base.ts b/src/ai_adapters/base.ts new file mode 100644 index 0000000..d1c41e1 --- /dev/null +++ b/src/ai_adapters/base.ts @@ -0,0 +1,25 @@ +export type ResponseContent = { + text: string +} + +export enum HttpStatus { + OK = 200, + CREATED = 201, + NO_CONTENT = 204, + BAD_REQUEST = 400, + UNAUTHORIZED = 401, + FORBIDDEN = 403, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + CONFLICT = 409, + INTERNAL_SERVER_ERROR = 500, +} + +export interface BaseAdapter { + generateContent(input: string): Promise + + /** + * Build the body for the Gemini API. + */ + buildBody(input: string): object +} diff --git a/src/ai_adapters/gemini.ts b/src/ai_adapters/gemini.ts new file mode 100644 index 0000000..2260590 --- /dev/null +++ b/src/ai_adapters/gemini.ts @@ -0,0 +1,94 @@ +import { config } from '@/config.ts' +import { R } from '@/deps.ts' +import { RequestContent, responseSchema } from '@/types.ts' +import { BaseAdapter, HttpStatus, ResponseContent } from '@/ai_adapters/base.ts' +import { filteredContentSchema } from '@/types.ts' + +export class GeminiAdapter implements BaseAdapter { + private readonly _apiKey: string + private readonly _baseUrl: string + private readonly _model: string + + constructor() { + this._apiKey = config.GEMINI_API_KEY + this._baseUrl = config.BASE_GEMINI_ENDPOINT + this._model = 'gemini-1.5-flash' + } + + /** + * Generate content using the Gemini API. + */ + public generateContent(input: string): Promise { + return new Promise((resolve, reject) => { + if (R.isEmpty(input)) { + reject(new Error('Text is required')) + } + + const urlJoiner = R.join('/') + const request = new Request( + urlJoiner( + [ + this._baseUrl, + 'models', + `${this._model}:generateContent`, + ], + ), + { + method: 'post', + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': this._apiKey, + }, + body: JSON.stringify( + this.buildBody(input), + ), + }, + ) + + fetch(request) + .then((response) => { + if (response.status !== HttpStatus.OK) { + console.error(R.prop('statusText')(response)) + reject(new Error('Failed to generate content')) + } else { + response.json().then((data) => { + const parsedData = filteredContentSchema.parse(data) + + resolve( + R.path([ + 'candidates', + 0, + 'content', + 'parts', + 0, + ])(parsedData) as ResponseContent, + ) + }) + } + }) + }) + } + + public buildBody(input: string): RequestContent { + if (R.isEmpty(input)) { + throw new Error('Text is required') + } + const body: RequestContent = { + contents: [ + { + parts: [ + { + text: input, + }, + ], + }, + ], + generationConfig: { + responseMimeType: 'application/json', + responseSchema: responseSchema, + }, + } + + return body + } +} diff --git a/src/ai_adapters/mod.ts b/src/ai_adapters/mod.ts new file mode 100644 index 0000000..2ea5b53 --- /dev/null +++ b/src/ai_adapters/mod.ts @@ -0,0 +1 @@ +export { GeminiAdapter } from './gemini.ts' diff --git a/src/analysis.ts b/src/analysis.ts new file mode 100644 index 0000000..fed4b58 --- /dev/null +++ b/src/analysis.ts @@ -0,0 +1,65 @@ +import { R } from '@/deps.ts' +import { getItem, getTopStories } from '@/api.ts' +import { Item } from '@/types.ts' +import { GeminiAdapter } from '@/ai_adapters/mod.ts' + +const baseInput: string = ` + Filter the following HackerNews stories by the provided preferences and return only the list of corresponding ids in an array format: +` +function generateInput( + preferences: string[], + titles: [number, string][], +): string { + return ` + ${baseInput} + Preferences: ${preferences.join(', ')} + Titles: ${titles.join(', ')} + ` +} + +export async function filterStories(preferences: string[]): Promise { + if (R.isEmpty(preferences)) { + console.warn('No preferences provided') + return [] + } + + const topStories = await getTopStories() + const items = await bulkRetrieveItems(topStories) + + const adapter = new GeminiAdapter() + const idTitlePairs = R.map( + (item: Item) => R.pair(item!.id, item!.title), + )(items) + const input = generateInput( + preferences, + idTitlePairs, + ) + + const { text } = await adapter.generateContent(input) + + const filteredIds = R.pipe( + (text: string) => JSON.parse(text), + R.prop('storyIds'), + R.map(Number), + )(text) + + const filteredTitles = R.pluck('title')(R.innerJoin( + (item: NonNullable, id: number) => item.id === id, + items as NonNullable[], + filteredIds, + )) + + return filteredTitles +} + +export async function bulkRetrieveItems(ids: number[]): Promise { + if (R.isEmpty(ids)) { + console.warn('No ids provided') + return [] + } + + const promises = R.map(getItem, ids) + const items = R.flatten(await Promise.all(promises)) + + return R.flatten(items) +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..f565ba3 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,25 @@ +import { Item, itemSchema, storiesSchema, TopStories } from '@/types.ts' +import { config } from '@/config.ts' + +export const getTopStories = async (): Promise => { + const data: TopStories = await fetch(`${config.HN_API}/topstories.json`).then( + ( + res, + ) => res.json(), + ) + + storiesSchema.parse(data) + + return data +} + +export const getItem = async (id: number): Promise => { + const data: Item | null = await fetch(`${config.HN_API}/item/${id}.json`) + .then( + (res) => res.json(), + ) + + itemSchema.parse(data) + + return data +} diff --git a/src/bot.ts b/src/bot.ts new file mode 100644 index 0000000..b47d0c0 --- /dev/null +++ b/src/bot.ts @@ -0,0 +1,35 @@ +import { Bot, conversations, session } from '@/deps.ts' +import setup from '@/preference/setup.ts' +import { PreferencesContext, SessionData } from '@/types.ts' +import { getSessionAdapter } from '@/utils.ts' +import { config } from '@/config.ts' +import dailyAnalysis from '@/preference/daily_analysis.ts' + +const bot = new Bot(config.BOT_TOKEN) + +bot.command( + 'help', + (ctx) => + ctx.reply('Bot is under construction. Please wait for the next update.'), +) +bot.use( + session({ + initial: (): SessionData => ({ preferences: [] }), + storage: getSessionAdapter(), + }), + conversations(), +) + +bot.use(setup) + +bot.catch((err) => console.error(err)) + +bot.start() + +Deno.cron('daily analysis', '0 1 * * *', async () => { + console.info('Running daily analysis') + await dailyAnalysis() + console.info('Daily analysis completed') +}) + +export default bot diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..2762e84 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,30 @@ +import { R, z } from '@/deps.ts' +import { load } from '@std/dotenv' +import { parseArgs } from '@std/cli' + +const envSchema = z.object({ + HN_API: z.string().default('https://hacker-news.firebaseio.com/v0'), + BOT_TOKEN: z.string().default('dummy'), + APP_ENV: z.enum(['development', 'test', 'production']).default('development'), + GEMINI_API_KEY: z.string().default('dummy'), + BASE_GEMINI_ENDPOINT: z.string().default( + 'https://generativelanguage.googleapis.com/v1beta', + ), + SUPABASE_URL: z.string().default('dummy'), + SUPABASE_KEY: z.string().default('dummy'), + SUPABASE_SCHEMA: z.string().default('public'), +}) + +try { + const env = await R.ifElse( + R.propEq('test', 'true'), + R.always({}), + () => load({ export: true }), + )(parseArgs(Deno.args)) + + if (R.isEmpty(env)) throw new Error('no env file found') +} catch { + console.warn('no env file found, using environment variables') +} + +export const config = envSchema.parse(Deno.env.toObject()) diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..e346c4f --- /dev/null +++ b/src/database.ts @@ -0,0 +1,126 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + public: { + Tables: { + sessions: { + Row: { + created_at: string + id: string + session: string | null + } + Insert: { + created_at?: string + id: string + session?: string | null + } + Update: { + created_at?: string + id?: string + session?: string | null + } + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type PublicSchema = Database[Extract] + +export type Tables< + PublicTableNameOrOptions extends + | keyof (PublicSchema['Tables'] & PublicSchema['Views']) + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof ( + & Database[PublicTableNameOrOptions['schema']]['Tables'] + & Database[PublicTableNameOrOptions['schema']]['Views'] + ) + : never = never, +> = PublicTableNameOrOptions extends { schema: keyof Database } ? ( + & Database[PublicTableNameOrOptions['schema']]['Tables'] + & Database[PublicTableNameOrOptions['schema']]['Views'] + )[TableName] extends { + Row: infer R + } ? R + : never + : PublicTableNameOrOptions extends keyof ( + & PublicSchema['Tables'] + & PublicSchema['Views'] + ) ? ( + & PublicSchema['Tables'] + & PublicSchema['Views'] + )[PublicTableNameOrOptions] extends { + Row: infer R + } ? R + : never + : never + +export type TablesInsert< + PublicTableNameOrOptions extends + | keyof PublicSchema['Tables'] + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicTableNameOrOptions['schema']]['Tables'] + : never = never, +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I + } ? I + : never + : PublicTableNameOrOptions extends keyof PublicSchema['Tables'] + ? PublicSchema['Tables'][PublicTableNameOrOptions] extends { + Insert: infer I + } ? I + : never + : never + +export type TablesUpdate< + PublicTableNameOrOptions extends + | keyof PublicSchema['Tables'] + | { schema: keyof Database }, + TableName extends PublicTableNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicTableNameOrOptions['schema']]['Tables'] + : never = never, +> = PublicTableNameOrOptions extends { schema: keyof Database } + ? Database[PublicTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U + } ? U + : never + : PublicTableNameOrOptions extends keyof PublicSchema['Tables'] + ? PublicSchema['Tables'][PublicTableNameOrOptions] extends { + Update: infer U + } ? U + : never + : never + +export type Enums< + PublicEnumNameOrOptions extends + | keyof PublicSchema['Enums'] + | { schema: keyof Database }, + EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } + ? keyof Database[PublicEnumNameOrOptions['schema']]['Enums'] + : never = never, +> = PublicEnumNameOrOptions extends { schema: keyof Database } + ? Database[PublicEnumNameOrOptions['schema']]['Enums'][EnumName] + : PublicEnumNameOrOptions extends keyof PublicSchema['Enums'] + ? PublicSchema['Enums'][PublicEnumNameOrOptions] + : never diff --git a/src/deps.ts b/src/deps.ts new file mode 100644 index 0000000..2b6c735 --- /dev/null +++ b/src/deps.ts @@ -0,0 +1,8 @@ +// @deno-types="npm:@types/ramda" +export * as R from 'https://deno.land/x/ramda@v0.27.2/mod.ts' +export * from 'https://deno.land/x/grammy@v1.28.0/mod.ts' +export * from 'https://deno.land/x/grammy_types@v3.12.0/mod.ts' +export * from 'https://deno.land/x/grammy_conversations@v1.2.0/mod.ts' +export * from 'https://deno.land/x/grammy_storages@v2.4.2/supabase/src/mod.ts' +export { z } from 'https://deno.land/x/zod@v3.23.8/mod.ts' +export { createClient } from 'https://esm.sh/@supabase/supabase-js@2.33.1' diff --git a/src/dev_deps.ts b/src/dev_deps.ts new file mode 100644 index 0000000..c69f806 --- /dev/null +++ b/src/dev_deps.ts @@ -0,0 +1,6 @@ +export { faker, simpleFaker } from 'https://esm.sh/@faker-js/faker@8.4.1' +export * from '@std/testing/bdd' +export * from '@std/assert' +export * from '@std/testing/mock' +export * from '@std/testing/types' +export * from '@std/testing/time' diff --git a/src/functions.ts b/src/functions.ts new file mode 100644 index 0000000..5324ac0 --- /dev/null +++ b/src/functions.ts @@ -0,0 +1,3 @@ +import { R } from '@/deps.ts' + +export const mapIndexed = R.addIndex(R.map) diff --git a/src/preference/daily_analysis.ts b/src/preference/daily_analysis.ts new file mode 100644 index 0000000..d349d0c --- /dev/null +++ b/src/preference/daily_analysis.ts @@ -0,0 +1,54 @@ +import { connection } from '@/utils.ts' +import { R } from '@/deps.ts' +import { filterStories } from '@/analysis.ts' +import { SessionData } from '@/types.ts' + +async function getUsersPreferences(): Promise< + { + id: string + session: string | null + }[] +> { + const { data, error } = await connection.from('sessions') + .select('id, session') + + R.unless( + R.isNil, + (err) => { + console.error(JSON.stringify(err)) + throw new Error('Error fetching user preferences') + }, + )(error) + + return data || [] +} + +async function dailyAnalysis() { + console.group('dailyAnalysis') + const usersPreferences = await getUsersPreferences() + console.debug('start', usersPreferences) + + const botModule = await import('@/bot.ts') + const bot = botModule.default + + R.forEach( + async ({ id, session: sessionJson }) => { + const session = JSON.parse(sessionJson!) + console.debug('user, session:', id, session.preferences) + const preferences = R.propOr( + {}, + 'preferences', + )(session) + + console.debug('preferences', preferences) + const filteredStories = await filterStories(preferences) + console.debug('filteredStories', filteredStories) + bot.api.sendMessage(id, filteredStories.join('\n')) + }, + )(usersPreferences) + + console.groupEnd() +} + +export default dailyAnalysis +export { getUsersPreferences } diff --git a/src/preference/setup.ts b/src/preference/setup.ts new file mode 100644 index 0000000..55320b6 --- /dev/null +++ b/src/preference/setup.ts @@ -0,0 +1,53 @@ +import { Composer, createConversation, R } from '../deps.ts' +import { ConversationContext, PreferencesContext } from '@/types.ts' + +const composer = new Composer() + +const preferencesBuilder = async ( + conversation: ConversationContext, + ctx: PreferencesContext, +) => { + const inputPreferences = new Set() + + while (R.T()) { + await ctx.reply('Type your preference. Use /cancel to exit.') + const { + msg: { text }, + } = await conversation.waitFor(':text') + + if (text === '/cancel') { + conversation.session.preferences = Array.from(inputPreferences) + await ctx.reply('Leaving...') + return + } + + inputPreferences.add(text) + } +} + +composer.use( + createConversation(preferencesBuilder, 'preferences'), +) + +composer.command('setup', async (ctx) => { + await ctx.conversation.enter('preferences') +}) + +composer.command('reset', async (ctx) => { + ctx.session.preferences = [] + await ctx.reply('Preferences reset.') +}) + +composer.command('list', async (ctx) => + await ctx.reply( + R.ifElse( + R.isEmpty, + R.always('No preferences set.'), + R.join('\n'), + )(ctx.session.preferences), + )) +composer.on('message', async (ctx) => { + await ctx.reply('Please use the /setup command to setup your preferences.') +}) + +export default composer diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2784f16 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,175 @@ +import { + Context, + Conversation, + ConversationFlavor, + SessionFlavor, + z, +} from '@/deps.ts' + +export const itemSchema = z.object({ + id: z.number(), + deleted: z.optional(z.boolean()), + type: z.optional(z.string()), + by: z.optional(z.string()), + time: z.optional(z.number()), + text: z.optional(z.string()), + dead: z.optional(z.boolean()), + parent: z.optional(z.number()), + poll: z.optional(z.unknown()), + kids: z.optional(z.unknown()), + url: z.optional(z.string()), + score: z.optional(z.number()), + title: z.string(), + parts: z.optional(z.unknown()), + descendants: z.optional(z.number()), +}).nullable() + +export type Item = z.infer + +const storySchema = z.number() + +export const storiesSchema = z.array(storySchema) + +export type TopStories = z.infer + +export interface SessionData { + preferences: string[] +} + +export type Preferences = SessionData['preferences'] + +export type PreferencesContext = + & Context + & SessionFlavor + & ConversationFlavor + +export type ConversationContext = Conversation + +const safetyRatingSchema = z.object({ + category: z.enum([ + 'HARM_CATEGORY_UNSPECIFIED', + 'HARM_CATEGORY_DEROGATORY', + 'HARM_CATEGORY_TOXICITY', + 'HARM_CATEGORY_VIOLENCE', + 'HARM_CATEGORY_SEXUAL', + 'HARM_CATEGORY_MEDICAL', + 'HARM_CATEGORY_DANGEROUS', + 'HARM_CATEGORY_HARASSMENT', + 'HARM_CATEGORY_HATE_SPEECH', + 'HARM_CATEGORY_SEXUALLY_EXPLICIT', + 'HARM_CATEGORY_DANGEROUS_CONTENT', + ]), + probability: z.enum([ + 'HIGH', + 'MEDIUM', + 'LOW', + 'NEGLIGIBLE', + 'HARM_PROBABILITY_UNSPECIFIED', + ]), + blocked: z.boolean().optional(), +}) + +const citationMetadataSchema = z.object({ + citationSources: z.array( + z.object({ + 'startIndex': z.number().int().optional(), + 'endIndex': z.number().int().optional(), + 'uri': z.string().optional(), + 'license': z.string().optional(), + }), + ), +}) + +const contentSchema = z.object({ + parts: z.array( + z.object({ + text: z.string().optional(), + inlineData: z.object({ + mimeType: z.string(), + data: z.string().base64(), + }).optional(), + }), + ), + role: z.enum([ + 'user', + 'model', + ]).optional(), +}) + +const generationConfigSchema = z.object({ + stopSequences: z.array(z.string()).optional(), + candidateCount: z.number().int().optional(), + maxOutputTokens: z.number().int().optional(), + temperature: z.number().int().optional(), + topP: z.number().int().optional(), + responseMimeType: z.enum([ + 'application/json', + 'text/plain', + ]).optional().default('application/json'), + responseSchema: z.object({}).optional(), + topK: z.number().int().optional(), +}) + +export const responseSchema = { + type: 'object', + properties: { + storyIds: { + type: 'array', + items: { + type: 'number', + }, + }, + }, +} +const requestContentSchema = z.object({ + contents: z.array(contentSchema), + generationConfig: generationConfigSchema, +}) + +export type RequestContent = z.infer + +const candidateSchema = z.object({ + content: contentSchema, + finishReason: z.enum( + [ + 'FINISH_REASON_UNSPECIFIED', + 'STOP', + 'MAX_TOKENS', + 'SAFETY', + 'RECITATION', + 'OTHER', + ], + ).default('FINISH_REASON_UNSPECIFIED'), + safetyRatings: z.array(safetyRatingSchema), + citationMetadata: citationMetadataSchema.optional(), + tokenCount: z.number().int().optional(), + index: z.number().int().optional(), +}) +const promptFeedbackSchema = z.object({ + blockReason: z.enum([ + 'BLOCK_REASON_UNSPECIFIED', + 'SAFETY', + 'OTHER', + ]).optional(), + safetyRatings: z.array(safetyRatingSchema), +}) + +const usageMetadataSchema = z.object({ + promptTokenCount: z.number(), + candidatesTokenCount: z.number(), + totalTokenCount: z.number(), +}) + +const generateContentResponseSchema = z.object({ + candidates: z.array( + candidateSchema, + ), + promptFeedback: promptFeedbackSchema.optional(), + usageMetadata: usageMetadataSchema, +}) + +export const filteredContentSchema = generateContentResponseSchema.pick( + { + candidates: true, + }, +) diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..7e14928 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,30 @@ +import { + createClient, + MemorySessionStorage, + R, + supabaseAdapter, +} from '@/deps.ts' +import { SessionData } from '@/types.ts' +import { config } from '@/config.ts' +import { Database } from '@/database.ts' + +export const connection = createClient( + config.SUPABASE_URL, + config.SUPABASE_KEY, + { + db: { + // deno-lint-ignore no-explicit-any + schema: config.SUPABASE_SCHEMA as any, // FIXME: remove any when found a way to type custom schemas (#20) + }, + }, +) + +export const getSessionAdapter = () => + R.ifElse( + R.equals('test'), + R.always(new MemorySessionStorage()), + R.always(supabaseAdapter({ + supabase: connection, + table: 'sessions', + })), + )(config.APP_ENV) diff --git a/src/utils/test_helpers.ts b/src/utils/test_helpers.ts new file mode 100644 index 0000000..329ccc6 --- /dev/null +++ b/src/utils/test_helpers.ts @@ -0,0 +1,143 @@ +// deno-lint-ignore-file no-explicit-any +import { + ApiResponse, + Bot, + Chat, + Composer, + conversations, + MemorySessionStorage, + Middleware, + R, + RawApi, + session, + Update, + User, +} from '@/deps.ts' +import { faker } from '@/dev_deps.ts' +import { PreferencesContext, SessionData } from '@/types.ts' +import { connection } from '@/utils.ts' + +export interface ApiCall { + method: M + result: Awaited> | ApiResponse +} + +export const botInfo = { + id: faker.number.int(), + is_bot: true as const, + first_name: 'Dummy Bot', + username: 'dummy_bot', + can_join_groups: true as const, + can_read_all_group_messages: false as const, + supports_inline_queries: false as const, + can_connect_to_business: false, + has_main_web_app: false, +} + +export const chat: Chat.PrivateChat = { + id: faker.number.int(), + first_name: 'Test', + last_name: 'User', + type: 'private', +} + +export const from: User = { + id: faker.number.int(), + first_name: 'Test', + last_name: 'User', + is_bot: false, +} + +type AvailableCommands = 'setup' | 'reset' | 'list' | 'cancel' + +export const slashCommand = (command: AvailableCommands): Update => ({ + update_id: 1, + message: { + message_id: 1, + date: faker.date.anytime().getTime(), + chat, + from, + text: R.concat('/', command), + entities: [ + { type: 'bot_command', offset: 0, length: R.inc(command.length) }, + ], + }, +}) + +export const testSetupConversation = async ( + storageAdapter: () => MemorySessionStorage, + update: Update | Update[] = [], + afterCancel: Update | Update[] = [], + mw: Middleware = new Composer(), +) => { + const updates = Array.isArray(update) ? update : [update] + const afterCancelUpdates = Array.isArray(afterCancel) + ? afterCancel + : [afterCancel] + + const results: Array<{ + method: string + payload: any + }> = [] + + const bot = new Bot('dummy', { botInfo }) + + bot.api.config.use((_prev, method, payload) => { + results.push({ method, payload }) + return Promise.resolve({ ok: true, result: {} as any }) + }) + + bot.use( + session({ + initial: (): SessionData => ({ preferences: [] }), + storage: storageAdapter(), + }), + conversations(), + ) + + bot.use(mw) + + await bot.handleUpdate(slashCommand('setup')) + for (const update of updates) { + await bot.handleUpdate(update) + } + + await bot.handleUpdate(slashCommand('cancel')) + + for (const update of afterCancelUpdates) { + await bot.handleUpdate(update) + } + + return results +} + +export function promisifyFactoryObj( + obj: TFactory, + status = 200, +): Promise { + return Promise.resolve( + new Response(JSON.stringify(obj), { status }), + ) +} + +export async function seedDatabase() { + await connection.from('sessions') + .upsert( + R.times( + (id: number) => ({ + id: R.join('_', ['test', id]), + session: JSON.stringify({ + preferences: faker.helpers.multiple(faker.commerce.department, { + count: faker.number.int({ min: 1, max: 5 }), + }), + }), + }), + 1, + ), + ) +} + +export async function cleanupDatabase() { + await connection.from('sessions') + .delete().like('id', 'test%') +} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 764cfb1..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "exclude": ["node_modules/**"] -}