diff --git a/.env.example b/.env.example index d214be9..1a535e5 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,10 @@ DATABASE_URL= OPENROUTER_API_KEY= # OpenWeather API key for the weather command OPENWEATHER_API_KEY= +# Massive.com API key for the /stocks command +MASSIVE_API_KEY= +# Optional override for the Massive.com REST base URL +MASSIVE_API_BASE_URL=https://api.massive.com # Log level for the application (debug, info, warn, error) LOG_LEVEL=info # Node environment (development, production) @@ -30,4 +34,4 @@ DEPLOYMENT_WEBHOOK_URL= TOPGG_WEBHOOK_SECRET= TOPGG_WEBHOOK_AUTH= # Top.gg token to check vote status -TOPGG_TOKEN= \ No newline at end of file +TOPGG_TOKEN= diff --git a/bun.lock b/bun.lock index 36a997c..500dc9b 100644 --- a/bun.lock +++ b/bun.lock @@ -7,9 +7,11 @@ "@atproto/identity": "^0.4.9", "@discordjs/rest": "^2.6.0", "@fedify/fedify": "^1.8.12", + "@massive.com/client-js": "^9.0.0", "@types/he": "^1.2.3", "@types/sanitize-html": "^2.16.0", "axios": "^1.12.2", + "canvas": "^3.2.0", "city-timezones": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", @@ -174,6 +176,8 @@ "@logtape/logtape": ["@logtape/logtape@1.0.4", "", {}, "sha512-YvNVrXIxVpnY528zoiEjX8PqTfr0UCtKXyssvaWL8AE+OByFTCooKuKMdPlm6g65YUI9fPXrHn4UnogSskABnA=="], + "@massive.com/client-js": ["@massive.com/client-js@9.0.0", "", { "dependencies": { "axios": "^1.8.4", "cross-fetch": "^3.1.4", "query-string": "^7.0.1", "websocket": "^1.0.34" } }, "sha512-vfOSVMp7uIfFgsyyX58sMZvcwS67psOTbRTox+7ahKMsG7aztFTJfoBwmjj5EjkX4Ise1BB1OEh73fbGzsj+xA=="], + "@multiformats/base-x": ["@multiformats/base-x@4.0.1", "", {}, "sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw=="], "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], @@ -304,8 +308,12 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], @@ -314,8 +322,12 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bufferutil": ["bufferutil@4.0.9", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw=="], + "byte-encodings": ["byte-encodings@1.0.11", "", {}, "sha512-+/xR2+ySc2yKGtud3DGkGSH1DNwHfRVK0KTnMhoeH36/KwG+tHQ4d9B3jxJFq7dW27YcfudkywaYJRPA2dmxzg=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -334,6 +346,8 @@ "canonicalize": ["canonicalize@1.0.8", "", {}, "sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A=="], + "canvas": ["canvas@3.2.0", "", { "dependencies": { "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.3" } }, "sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "change-case": ["change-case@3.1.0", "", { "dependencies": { "camel-case": "^3.0.0", "constant-case": "^2.0.0", "dot-case": "^2.1.0", "header-case": "^1.0.0", "is-lower-case": "^1.1.0", "is-upper-case": "^1.1.0", "lower-case": "^1.1.1", "lower-case-first": "^1.0.0", "no-case": "^2.3.2", "param-case": "^2.1.0", "pascal-case": "^2.0.0", "path-case": "^2.1.0", "sentence-case": "^2.1.0", "snake-case": "^2.1.0", "swap-case": "^1.1.0", "title-case": "^2.1.0", "upper-case": "^1.1.1", "upper-case-first": "^1.1.0" } }, "sha512-2AZp7uJZbYEzRPsFoa+ijKdvp9zsrnnt6+yFokfwEpeJm0xuJDVoxiRCAaTzyJND8GJkofo2IcKWaUZ/OECVzw=="], @@ -346,6 +360,8 @@ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "city-timezones": ["city-timezones@1.3.2", "", { "dependencies": { "lodash": "^4.17.21" } }, "sha512-XztdL/2EWpfmgRIOzrKVOWFp6VdmaD9FNTZPINlez1etIn0mMNn01RMmSfOp6LUP/h1M2ZLX80N1O+WKwhzC+w=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -380,20 +396,30 @@ "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], + "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "d": ["d@1.0.2", "", { "dependencies": { "es5-ext": "^0.10.64", "type": "^2.7.2" } }, "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "dedent-js": ["dedent-js@1.0.1", "", {}, "sha512-OUepMozQULMLUmhxS95Vudo0jb0UchLimi3+pQ2plj61Fcy8axbP9hbiD4Sz6DPqn6XG3kfmziVfQ1rSys5AJQ=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], @@ -404,6 +430,8 @@ "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "discord-api-types": ["discord-api-types@0.38.18", "", {}, "sha512-ygenySjZKUaBf5JT8BNhZSxLzwpwdp41O0wVroOTu/N2DxFH7dxYTZUSnFJ6v+/2F3BMcnD47PC47u4aLOLxrQ=="], @@ -436,6 +464,8 @@ "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -448,6 +478,12 @@ "es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="], + "es5-ext": ["es5-ext@0.10.64", "", { "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", "esniff": "^2.0.1", "next-tick": "^1.1.0" } }, "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg=="], + + "es6-iterator": ["es6-iterator@2.0.3", "", { "dependencies": { "d": "1", "es5-ext": "^0.10.35", "es6-symbol": "^3.1.1" } }, "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g=="], + + "es6-symbol": ["es6-symbol@3.1.4", "", { "dependencies": { "d": "^1.0.2", "ext": "^1.7.0" } }, "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg=="], + "esbuild": ["esbuild@0.25.8", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.8", "@esbuild/android-arm": "0.25.8", "@esbuild/android-arm64": "0.25.8", "@esbuild/android-x64": "0.25.8", "@esbuild/darwin-arm64": "0.25.8", "@esbuild/darwin-x64": "0.25.8", "@esbuild/freebsd-arm64": "0.25.8", "@esbuild/freebsd-x64": "0.25.8", "@esbuild/linux-arm": "0.25.8", "@esbuild/linux-arm64": "0.25.8", "@esbuild/linux-ia32": "0.25.8", "@esbuild/linux-loong64": "0.25.8", "@esbuild/linux-mips64el": "0.25.8", "@esbuild/linux-ppc64": "0.25.8", "@esbuild/linux-riscv64": "0.25.8", "@esbuild/linux-s390x": "0.25.8", "@esbuild/linux-x64": "0.25.8", "@esbuild/netbsd-arm64": "0.25.8", "@esbuild/netbsd-x64": "0.25.8", "@esbuild/openbsd-arm64": "0.25.8", "@esbuild/openbsd-x64": "0.25.8", "@esbuild/openharmony-arm64": "0.25.8", "@esbuild/sunos-x64": "0.25.8", "@esbuild/win32-arm64": "0.25.8", "@esbuild/win32-ia32": "0.25.8", "@esbuild/win32-x64": "0.25.8" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -466,6 +502,8 @@ "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + "esniff": ["esniff@2.0.1", "", { "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", "event-emitter": "^0.3.5", "type": "^2.7.2" } }, "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg=="], + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], @@ -478,14 +516,20 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "event-emitter": ["event-emitter@0.3.5", "", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "0.19.0", "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="], "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], "express-validator": ["express-validator@7.2.1", "", { "dependencies": { "lodash": "^4.17.21", "validator": "~13.12.0" } }, "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ=="], + "ext": ["ext@1.7.0", "", { "dependencies": { "type": "^2.7.2" } }, "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], @@ -506,6 +550,8 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="], + "finalhandler": ["finalhandler@1.3.1", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", "statuses": "2.0.1", "unpipe": "~1.0.0" } }, "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -526,6 +572,8 @@ "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -538,6 +586,8 @@ "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="], @@ -570,6 +620,8 @@ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore-by-default": ["ignore-by-default@1.0.1", "", {}, "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA=="], @@ -580,6 +632,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ip-address": ["ip-address@9.0.5", "", { "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" } }, "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -602,6 +656,8 @@ "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + "is-typedarray": ["is-typedarray@1.0.0", "", {}, "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA=="], + "is-upper-case": ["is-upper-case@1.1.2", "", { "dependencies": { "upper-case": "^1.1.0" } }, "sha512-GQYSJMgfeAmVwh9ixyk888l7OIhNAGKtY6QA+IrWlu9MDTCaXmeozOZ2S9Knj7bQwBO/H6J2kb+pbyTUiMNbsw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -690,10 +746,14 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], "moment-timezone": ["moment-timezone@0.6.0", "", { "dependencies": { "moment": "^2.29.4" } }, "sha512-ldA5lRNm3iJCWZcBCab4pnNL3HSZYXVb/3TYr75/1WCTWYuTqYUb5f/S384pncYjJ88lbO8Z4uPDvmoluHJc8Q=="], @@ -708,16 +768,26 @@ "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + "next-tick": ["next-tick@1.1.0", "", {}, "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ=="], + "no-case": ["no-case@2.3.2", "", { "dependencies": { "lower-case": "^1.1.1" } }, "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ=="], + "node-abi": ["node-abi@3.80.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-LyPuZJcI9HVwzXK1GPxWNzrr+vr8Hp/3UqlmWxxh8p54U1ZbclOqbSog9lWHaCX+dBaiGi6n/hIX+mKu74GmPA=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "nodemon": ["nodemon@3.1.10", "", { "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", "minimatch": "^3.1.2", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", "supports-color": "^5.5.0", "touch": "^3.1.0", "undefsafe": "^2.0.5" }, "bin": { "nodemon": "bin/nodemon.js" } }, "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -730,6 +800,8 @@ "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], "open-graph-scraper": ["open-graph-scraper@6.10.0", "", { "dependencies": { "chardet": "^2.1.0", "cheerio": "^1.0.0-rc.12", "iconv-lite": "^0.6.3", "undici": "^6.21.2" } }, "sha512-JTuaO/mWUPduYCIQvunmsQnfGpSRFUTEh4k5cW2KOafJxTm3Z99z25/c1oO9QnIh2DK7ol5plJAq3EUVy+5xyw=="], @@ -804,6 +876,8 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], @@ -816,6 +890,8 @@ "pstree.remy": ["pstree.remy@1.1.8", "", {}, "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w=="], + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], @@ -824,6 +900,8 @@ "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], + "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], + "queue-lit": ["queue-lit@1.5.2", "", {}, "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -832,6 +910,8 @@ "raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA=="], + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "rdf-canonize": ["rdf-canonize@3.4.0", "", { "dependencies": { "setimmediate": "^1.0.5" } }, "sha512-fUeWjrkOO0t1rg7B2fdyDTvngj+9RlUyL92vOdiB7c0FPguWVsniIMjEtHH+meLBO9rzkUlUzBVXgWrjI8P9LA=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -888,6 +968,10 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], @@ -902,6 +986,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], @@ -910,6 +996,8 @@ "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + "strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], @@ -928,6 +1016,10 @@ "synckit": ["synckit@0.11.11", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw=="], + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], "title-case": ["title-case@2.1.1", "", { "dependencies": { "no-case": "^2.2.0", "upper-case": "^1.0.3" } }, "sha512-EkJoZ2O3zdCz3zJsYCsxyq2OC5hrxR9mfdd5I+w8h/tmFfeOxJ+vvkxsKxdmN0WtS9zLdHEgfgVOiMVgv+Po4Q=="], @@ -938,6 +1030,8 @@ "touch": ["touch@3.1.1", "", { "bin": { "nodetouch": "bin/nodetouch.js" } }, "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], @@ -954,10 +1048,16 @@ "tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + "typedarray-to-buffer": ["typedarray-to-buffer@3.1.5", "", { "dependencies": { "is-typedarray": "^1.0.0" } }, "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q=="], + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], "typescript-eslint": ["typescript-eslint@8.44.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.44.1", "@typescript-eslint/parser": "8.44.1", "@typescript-eslint/typescript-estree": "8.44.1", "@typescript-eslint/utils": "8.44.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-0ws8uWGrUVTjEeN2OM4K1pLKHK/4NiNP/vz6ns+LjT/6sqpaYzIVFajZb1fj/IDwpsrrHb3Jy0Qm5u9CPcKaeg=="], @@ -986,6 +1086,8 @@ "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], + "utf-8-validate": ["utf-8-validate@5.0.10", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], @@ -1000,10 +1102,16 @@ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "websocket": ["websocket@1.0.35", "", { "dependencies": { "bufferutil": "^4.0.1", "debug": "^2.2.0", "es5-ext": "^0.10.63", "typedarray-to-buffer": "^3.1.5", "utf-8-validate": "^5.0.2", "yaeti": "^0.0.6" } }, "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q=="], + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], @@ -1020,12 +1128,16 @@ "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yaeti": ["yaeti@0.0.6", "", {}, "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -1084,6 +1196,8 @@ "concurrently/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "cross-fetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "discord.js/@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="], "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -1098,10 +1212,14 @@ "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], + "websocket/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "whois/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], "@types/body-parser/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], @@ -1140,6 +1258,8 @@ "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "websocket/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "whois/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], "whois/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], diff --git a/environment.d.ts b/environment.d.ts index 73772f1..16660b8 100644 --- a/environment.d.ts +++ b/environment.d.ts @@ -6,6 +6,8 @@ declare global { DATABASE_URL: string; OPENROUTER_API_KEY: string; OPENWEATHER_API_KEY: string; + MASSIVE_API_KEY: string; + MASSIVE_API_BASE_URL: string; SOURCE_COMMIT: string; STATUS_API_KEY: string; TOKEN: string; diff --git a/locales/en-US.json b/locales/en-US.json index e371bd2..3b60830 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -92,6 +92,43 @@ "nolocation": "Location not found. Please check the city name and try again.", "apikeymissing": "OpenWeather API key is missing or invalid." }, + "stocks": { + "name": "stocks", + "description": "Track stock prices and view quick charts", + "option": { + "ticker": { + "name": "ticker", + "description": "The stock ticker symbol (e.g., AAPL, TSLA)" + }, + "range": { + "name": "range", + "description": "Initial timeframe for the chart" + } + }, + "errors": { + "noapikey": "The Massive.com API key is missing. Ask the bot owner to configure MASSIVE_API_KEY.", + "notfound": "I couldn't find any data for {ticker}. Please double-check the symbol.", + "unauthorized": "Only the person who used /stocks can interact with these buttons." + }, + "labels": { + "price": "Price", + "change": "Change", + "dayrange": "Day range", + "volume": "Volume", + "prevclose": "Prev close", + "marketcap": "Market cap", + "nochart": "Chart data unavailable for this timeframe." + }, + "buttons": { + "timeframes": { + "1d": "1D", + "5d": "5D", + "1m": "1M", + "3m": "3M", + "1y": "1Y" + } + } + }, "joke": { "name": "joke", "description": "Get a random joke!", diff --git a/package.json b/package.json index 03bc97b..864b4e4 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,11 @@ "@atproto/identity": "^0.4.9", "@discordjs/rest": "^2.6.0", "@fedify/fedify": "^1.8.12", + "@massive.com/client-js": "^9.0.0", "@types/he": "^1.2.3", "@types/sanitize-html": "^2.16.0", "axios": "^1.12.2", + "canvas": "^3.2.0", "city-timezones": "^1.3.2", "concurrently": "^9.2.1", "cors": "^2.8.5", diff --git a/src/commands/utilities/stocks.ts b/src/commands/utilities/stocks.ts new file mode 100644 index 0000000..ea43495 --- /dev/null +++ b/src/commands/utilities/stocks.ts @@ -0,0 +1,424 @@ +import { + SlashCommandBuilder, + MessageFlags, + InteractionContextType, + ApplicationIntegrationType, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + AttachmentBuilder, + type MessageActionRowComponentBuilder, +} from 'discord.js'; +import { SlashCommandProps } from '@/types/command'; +import logger from '@/utils/logger'; +import { sanitizeInput } from '@/utils/validation'; +import { + getTickerOverview, + getAggregateSeries, + buildBrandingUrl, + sanitizeTickerInput, + StockTimeframe, +} from '@/services/massive'; +import { renderStockCandles } from '@/utils/stockChart'; +import { + createCooldownManager, + checkCooldown, + setCooldown, + createCooldownResponse, +} from '@/utils/cooldown'; +import { createCommandLogger } from '@/utils/commandLogger'; +import { createErrorHandler } from '@/utils/errorHandler'; +import * as config from '@/config'; +import BotClient from '@/services/Client'; + +const cooldownManager = createCooldownManager('stocks', 5000); +const commandLogger = createCommandLogger('stocks'); +const errorHandler = createErrorHandler('stocks'); + +const DEFAULT_TIMEFRAME: StockTimeframe = '1d'; +const SUPPORTED_TIMEFRAMES: StockTimeframe[] = ['1d', '5d', '1m', '3m', '1y']; +const BUTTON_PREFIX = 'stocks_tf'; +const MAX_DESCRIPTION_LENGTH = 350; + +const TIMEFRAME_LABEL_KEYS: Record = { + '1d': 'commands.stocks.buttons.timeframes.1d', + '5d': 'commands.stocks.buttons.timeframes.5d', + '1m': 'commands.stocks.buttons.timeframes.1m', + '3m': 'commands.stocks.buttons.timeframes.3m', + '1y': 'commands.stocks.buttons.timeframes.1y', +}; + +const compactNumber = new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 2, +}); + +function getCurrencyFormatter(code?: string) { + const currency = code && code.length === 3 ? code : 'USD'; + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + maximumFractionDigits: currency === 'JPY' ? 0 : 2, + }); + } catch { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 2, + }); + } +} + +function formatCurrency(value?: number, currency?: string) { + if (typeof value !== 'number' || Number.isNaN(value)) { + return '—'; + } + return getCurrencyFormatter(currency).format(value); +} + +function formatNumber(value?: number) { + if (typeof value !== 'number' || Number.isNaN(value)) { + return '—'; + } + return compactNumber.format(value); +} + +function truncateDescription(description?: string) { + if (!description) return undefined; + const clean = sanitizeInput(description); + if (clean.length <= MAX_DESCRIPTION_LENGTH) { + return clean; + } + return `${clean.slice(0, MAX_DESCRIPTION_LENGTH)}…`; +} + +function resolveCurrencyCode(value?: string) { + if (!value) return 'USD'; + const normalized = value.trim().toUpperCase(); + if (normalized.length === 3) { + return normalized; + } + return 'USD'; +} + +function toValidDate(value?: number | string | null) { + if (value === null || value === undefined) { + return undefined; + } + + if (typeof value === 'number' && Number.isFinite(value)) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; + } + + if (typeof value === 'string' && value.trim().length > 0) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; + } + + return undefined; +} + +interface StocksRenderOptions { + client: BotClient; + locale: string; + ticker: string; + timeframe: StockTimeframe; + userId: string; +} + +async function buildTimeframeButtons( + client: BotClient, + locale: string, + active: StockTimeframe, + userId: string, + ticker: string, +) { + const row = new ActionRowBuilder(); + + for (const timeframe of SUPPORTED_TIMEFRAMES) { + const label = await client.getLocaleText(TIMEFRAME_LABEL_KEYS[timeframe], locale); + row.addComponents( + new ButtonBuilder() + .setCustomId(`${BUTTON_PREFIX}:${userId}:${ticker}:${timeframe}`) + .setLabel(label.toUpperCase()) + .setStyle(timeframe === active ? ButtonStyle.Primary : ButtonStyle.Secondary), + ); + } + + return row; +} + +export async function renderStocksView(options: StocksRenderOptions) { + const normalizedTicker = sanitizeTickerInput(options.ticker); + if (!normalizedTicker) { + const error = new Error('STOCKS_TICKER_NOT_FOUND'); + throw error; + } + + const overview = await getTickerOverview(normalizedTicker); + if (!overview.detail) { + const error = new Error('STOCKS_TICKER_NOT_FOUND'); + throw error; + } + + const aggregates = await getAggregateSeries(normalizedTicker, options.timeframe); + + const detail = overview.detail; + const snapshot = overview.snapshot; + const lastPrice = snapshot?.lastTrade?.p ?? snapshot?.day?.c ?? snapshot?.prevDay?.c; + const prevClose = snapshot?.prevDay?.c; + const changeValue = + snapshot?.todaysChange ?? (lastPrice && prevClose ? lastPrice - prevClose : undefined); + const changePercent = + snapshot?.todaysChangePerc ?? + (changeValue && prevClose ? (changeValue / prevClose) * 100 : undefined); + const trend = + typeof changeValue === 'number' + ? changeValue === 0 + ? 'neutral' + : changeValue > 0 + ? 'up' + : 'down' + : 'neutral'; + const color = trend === 'up' ? 0x1ac486 : trend === 'down' ? 0xff6b6b : 0x5865f2; + const chartBuffer = aggregates.length + ? await renderStockCandles(aggregates, options.timeframe) + : undefined; + + const [ + priceLabel, + changeLabel, + rangeLabel, + volumeLabel, + prevCloseLabel, + marketCapLabel, + providedBy, + ] = await Promise.all([ + options.client.getLocaleText('commands.stocks.labels.price', options.locale), + options.client.getLocaleText('commands.stocks.labels.change', options.locale), + options.client.getLocaleText('commands.stocks.labels.dayrange', options.locale), + options.client.getLocaleText('commands.stocks.labels.volume', options.locale), + options.client.getLocaleText('commands.stocks.labels.prevclose', options.locale), + options.client.getLocaleText('commands.stocks.labels.marketcap', options.locale), + options.client.getLocaleText('providedby', options.locale), + ]); + + const currencySymbol = resolveCurrencyCode(detail.currency_name); + const description = truncateDescription(detail.description); + const dayLow = snapshot?.day?.l ?? snapshot?.prevDay?.l; + const dayHigh = snapshot?.day?.h ?? snapshot?.prevDay?.h; + const thumbnail = buildBrandingUrl(detail.branding?.icon_url ?? detail.branding?.logo_url); + const footerText = `${providedBy} Massive.com`; + + const embed = new EmbedBuilder() + .setColor(color) + .setTitle(`${normalizedTicker} • ${detail.name}`) + .setFooter({ text: footerText }); + + const timestampDate = toValidDate(snapshot?.updated); + embed.setTimestamp(timestampDate ?? new Date()); + + if (description) { + embed.setDescription(description); + } + + if (thumbnail) { + embed.setThumbnail(thumbnail); + } + + let files: AttachmentBuilder[] = []; + if (chartBuffer) { + const attachmentName = `stocks-${normalizedTicker}-${options.timeframe}.png`; + const attachment = new AttachmentBuilder(chartBuffer, { name: attachmentName }); + embed.setImage(`attachment://${attachmentName}`); + files = [attachment]; + } else { + embed.addFields({ + name: '\u200B', + value: await options.client.getLocaleText('commands.stocks.labels.nochart', options.locale), + }); + } + + embed.addFields( + { + name: priceLabel, + value: formatCurrency(lastPrice, currencySymbol), + inline: true, + }, + { + name: changeLabel, + value: + typeof changeValue === 'number' + ? changePercent + ? `${formatCurrency(changeValue, currencySymbol)} (${changePercent.toFixed(2)}%)` + : formatCurrency(changeValue, currencySymbol) + : '—', + inline: true, + }, + { + name: rangeLabel, + value: `${formatCurrency(dayLow, currencySymbol)} - ${formatCurrency(dayHigh, currencySymbol)}`, + inline: true, + }, + { + name: volumeLabel, + value: formatNumber(snapshot?.day?.v ?? snapshot?.prevDay?.v), + inline: true, + }, + { + name: prevCloseLabel, + value: formatCurrency(prevClose, currencySymbol), + inline: true, + }, + { + name: marketCapLabel, + value: formatNumber(detail.market_cap), + inline: true, + }, + ); + + const buttons = await buildTimeframeButtons( + options.client, + options.locale, + options.timeframe, + options.userId, + normalizedTicker, + ); + + return { + embeds: [embed], + components: [buttons], + files, + }; +} + +export default { + data: new SlashCommandBuilder() + .setName('stocks') + .setDescription('Track stock prices and view quick charts') + .addStringOption((option) => + option + .setName('ticker') + .setDescription('The stock ticker symbol (e.g., AAPL, TSLA)') + .setRequired(true) + .setMaxLength(15), + ) + .addStringOption((option) => + option + .setName('range') + .setDescription('Initial timeframe for the chart') + .addChoices( + { name: '1D', value: '1d' }, + { name: '5D', value: '5d' }, + { name: '1M', value: '1m' }, + { name: '3M', value: '3m' }, + { name: '1Y', value: '1y' }, + ), + ) + .setContexts([ + InteractionContextType.BotDM, + InteractionContextType.Guild, + InteractionContextType.PrivateChannel, + ]) + .setIntegrationTypes(ApplicationIntegrationType.UserInstall), + + async execute(client, interaction) { + try { + const cooldownCheck = await checkCooldown( + cooldownManager, + interaction.user.id, + client, + interaction.locale, + ); + if (cooldownCheck.onCooldown) { + return interaction.reply(createCooldownResponse(cooldownCheck.message!)); + } + + if (!config.MASSIVE_API_KEY) { + const msg = await client.getLocaleText( + 'commands.stocks.errors.noapikey', + interaction.locale, + ); + return interaction.reply({ content: msg, flags: MessageFlags.Ephemeral }); + } + + setCooldown(cooldownManager, interaction.user.id); + + const tickerInput = interaction.options.getString('ticker', true); + const timeframeInput = + (interaction.options.getString('range') as StockTimeframe | null) ?? DEFAULT_TIMEFRAME; + const ticker = sanitizeTickerInput(tickerInput); + + if (!ticker) { + const notFound = await client.getLocaleText( + 'commands.stocks.errors.notfound', + interaction.locale, + { + ticker: tickerInput, + }, + ); + return interaction.reply({ content: notFound, flags: MessageFlags.Ephemeral }); + } + + commandLogger.logFromInteraction( + interaction, + `ticker: ${ticker} timeframe: ${timeframeInput}`, + ); + + await interaction.deferReply(); + + try { + const response = await renderStocksView({ + client, + locale: interaction.locale, + ticker, + timeframe: timeframeInput, + userId: interaction.user.id, + }); + + await interaction.editReply(response); + } catch (error) { + if ((error as Error).message === 'STOCKS_TICKER_NOT_FOUND') { + const notFound = await client.getLocaleText( + 'commands.stocks.errors.notfound', + interaction.locale, + { ticker }, + ); + await interaction.editReply({ content: notFound, components: [] }); + return; + } + + await errorHandler({ + interaction, + client, + error: error as Error, + userId: interaction.user.id, + username: interaction.user.tag, + }); + } + } catch (error) { + logger.error('Unexpected error in stocks command:', error); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: await client.getLocaleText('unexpectederror', interaction.locale), + flags: MessageFlags.Ephemeral, + }); + } else if (interaction.deferred) { + const errorMsg = await client.getLocaleText('unexpectederror', interaction.locale); + await interaction.editReply({ content: errorMsg }); + } + } + }, +} as SlashCommandProps; + +export function parseStocksButtonId(customId: string) { + if (!customId.startsWith(`${BUTTON_PREFIX}:`)) return null; + const [, userId, ticker, timeframe] = customId.split(':'); + if (!userId || !ticker || !SUPPORTED_TIMEFRAMES.includes(timeframe as StockTimeframe)) { + return null; + } + return { userId, ticker, timeframe: timeframe as StockTimeframe }; +} diff --git a/src/config/index.ts b/src/config/index.ts index f396ca9..482013c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -25,6 +25,8 @@ export const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS ?? 'http://localhost: export const DATABASE_URL = process.env.DATABASE_URL!; export const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; export const OPENWEATHER_API_KEY = process.env.OPENWEATHER_API_KEY; +export const MASSIVE_API_KEY = process.env.MASSIVE_API_KEY; +export const MASSIVE_API_BASE_URL = process.env.MASSIVE_API_BASE_URL ?? 'https://api.massive.com'; export const SOURCE_COMMIT = process.env.SOURCE_COMMIT; export const TOKEN = process.env.TOKEN!; export const CLIENT_ID = process.env.CLIENT_ID!; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 9b97302..e355308 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,5 +1,7 @@ import { browserHeaders } from '@/constants/index'; import BotClient from '@/services/Client'; +import * as config from '@/config'; +import { renderStocksView, parseStocksButtonId } from '@/commands/utilities/stocks'; import { RandomReddit } from '@/types/base'; import { RemindCommandProps } from '@/types/command'; import logger from '@/utils/logger'; @@ -129,6 +131,51 @@ export default class InteractionCreateEvent { } } + const stocksPayload = parseStocksButtonId(i.customId); + if (stocksPayload) { + if (!config.MASSIVE_API_KEY) { + const message = await this.client.getLocaleText( + 'commands.stocks.errors.noapikey', + i.locale, + ); + return await i.reply({ content: message, flags: MessageFlags.Ephemeral }); + } + + if (stocksPayload.userId !== i.user.id) { + const unauthorized = + (await this.client.getLocaleText('commands.stocks.errors.unauthorized', i.locale)) || + 'Only the person who used /stocks can use these buttons.'; + return await i.reply({ content: unauthorized, flags: MessageFlags.Ephemeral }); + } + + await i.deferUpdate(); + + try { + const response = await renderStocksView({ + client: this.client, + locale: i.locale, + ticker: stocksPayload.ticker, + timeframe: stocksPayload.timeframe, + userId: stocksPayload.userId, + }); + await i.editReply(response); + } catch (error) { + if ((error as Error).message === 'STOCKS_TICKER_NOT_FOUND') { + const notFound = await this.client.getLocaleText( + 'commands.stocks.errors.notfound', + i.locale, + { ticker: stocksPayload.ticker }, + ); + await i.editReply({ content: notFound, components: [] }); + } else { + logger.error('Error updating stocks view:', error); + const failMsg = await this.client.getLocaleText('failedrequest', i.locale); + await i.editReply({ content: failMsg, components: [] }); + } + } + return; + } + const originalUser = i.message.interaction!.user; if (originalUser.id !== i.user.id) { return await i.reply({ diff --git a/src/services/massive.ts b/src/services/massive.ts new file mode 100644 index 0000000..4af5e94 --- /dev/null +++ b/src/services/massive.ts @@ -0,0 +1,280 @@ +import { + DefaultApi, + GetStocksAggregatesSortEnum, + GetStocksAggregatesTimespanEnum, + GetStocksSnapshotTicker200Response, + GetStocksSnapshotTicker200ResponseAllOfTicker, + GetTicker200Response, + GetTicker200ResponseResults, + GetStocksAggregates200Response, + ListTickers200Response, + ListTickers200ResponseResultsInner, + ListTickersMarketEnum, + ListTickersOrderEnum, + ListTickersSortEnum, + restClient, +} from '@massive.com/client-js'; +import * as config from '@/config'; +import logger from '@/utils/logger'; +import { createRateLimiter } from '@/utils/rateLimiter'; +import type { AxiosError } from 'axios'; + +const MASSIVE_RATE_LIMIT = 45; +const rateLimiter = createRateLimiter(MASSIVE_RATE_LIMIT); + +let cachedClient: DefaultApi | null = null; + +export type StockTimeframe = '1d' | '5d' | '1m' | '3m' | '1y'; + +interface TimeframeConfig { + multiplier: number; + timespan: GetStocksAggregatesTimespanEnum; + daysBack: number; + limit: number; + displayWindowMs?: number; +} + +const TIMEFRAME_CONFIG: Record = { + '1d': { + multiplier: 5, + timespan: GetStocksAggregatesTimespanEnum.Minute, + daysBack: 3, + limit: 400, + displayWindowMs: 36 * 60 * 60 * 1000, + }, + '5d': { + multiplier: 15, + timespan: GetStocksAggregatesTimespanEnum.Minute, + daysBack: 7, + limit: 500, + displayWindowMs: 7 * 24 * 60 * 60 * 1000, + }, + '1m': { + multiplier: 1, + timespan: GetStocksAggregatesTimespanEnum.Day, + daysBack: 40, + limit: 120, + }, + '3m': { + multiplier: 1, + timespan: GetStocksAggregatesTimespanEnum.Day, + daysBack: 110, + limit: 200, + }, + '1y': { + multiplier: 1, + timespan: GetStocksAggregatesTimespanEnum.Week, + daysBack: 400, + limit: 400, + }, +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface StockAggregatePoint { + timestamp: number; + open: number; + high: number; + low: number; + close: number; + volume: number; + vwap?: number; +} + +export interface StockOverview { + detail?: GetTicker200ResponseResults; + snapshot?: GetStocksSnapshotTicker200ResponseAllOfTicker; +} + +function ensureClient(): DefaultApi { + if (!config.MASSIVE_API_KEY) { + throw new Error('Massive.com API key is not configured'); + } + + if (!cachedClient) { + cachedClient = restClient(config.MASSIVE_API_KEY, config.MASSIVE_API_BASE_URL, { + pagination: false, + }); + } + + return cachedClient; +} + +async function withClient(callback: (client: DefaultApi) => Promise): Promise { + const client = ensureClient(); + return rateLimiter.schedule(() => callback(client)); +} + +function isNotFoundError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'isAxiosError' in error && + (error as AxiosError).response?.status === 404 + ); +} + +export async function searchTickers( + query: string, + limit = 5, +): Promise { + if (!query.trim()) { + return []; + } + + const response = await withClient((client) => + client.listTickers( + undefined, + undefined, + ListTickersMarketEnum.Stocks, + undefined, + undefined, + undefined, + undefined, + query, + true, + undefined, + undefined, + undefined, + undefined, + ListTickersOrderEnum.Asc, + limit, + ListTickersSortEnum.Ticker, + ), + ); + + return response.results ?? []; +} + +export async function getTickerDetails( + ticker: string, +): Promise { + const normalized = ticker.trim().toUpperCase(); + try { + const response = await withClient((client) => client.getTicker(normalized)); + return response.results ?? null; + } catch (error) { + if (isNotFoundError(error)) { + return null; + } + throw error; + } +} + +export async function getTickerSnapshot( + ticker: string, +): Promise { + const normalized = ticker.trim().toUpperCase(); + try { + const response: GetStocksSnapshotTicker200Response = await withClient((client) => + client.getStocksSnapshotTicker(normalized), + ); + return response.ticker; + } catch (error) { + if (isNotFoundError(error)) { + return undefined; + } + throw error; + } +} + +export async function getTickerOverview(ticker: string): Promise { + const [detail, snapshot] = await Promise.all([ + getTickerDetails(ticker), + getTickerSnapshot(ticker), + ]); + + return { detail: detail ?? undefined, snapshot }; +} + +function formatDate(date: Date): string { + return date.toISOString().split('T')[0]; +} + +export async function getAggregateSeries( + ticker: string, + timeframe: StockTimeframe, +): Promise { + const config = TIMEFRAME_CONFIG[timeframe]; + if (!config) { + throw new Error(`Unsupported timeframe: ${timeframe}`); + } + + const now = new Date(); + const fetchAggregates = async (extraDays: number) => { + const fromDate = new Date(now.getTime() - (config.daysBack + extraDays) * DAY_MS); + return withClient((client) => + client.getStocksAggregates( + ticker.trim().toUpperCase(), + config.multiplier, + config.timespan, + formatDate(fromDate), + formatDate(now), + true, + GetStocksAggregatesSortEnum.Asc, + config.limit, + ), + ); + }; + + try { + let response: GetStocksAggregates200Response = await fetchAggregates(0); + + if ((!response.results || response.results.length === 0) && timeframe === '1d') { + response = await fetchAggregates(5); + } + + const rawResults = response.results ?? []; + if (!rawResults.length) { + return []; + } + + let filteredResults = rawResults.filter( + (result) => typeof result.t === 'number' && typeof result.c === 'number', + ); + + if (config.displayWindowMs && filteredResults.length) { + const latestTimestamp = filteredResults[filteredResults.length - 1].t!; + const cutoff = latestTimestamp - config.displayWindowMs; + filteredResults = filteredResults.filter((result) => result.t! >= cutoff); + } + + return filteredResults.map((result) => ({ + timestamp: result.t!, + open: result.o ?? result.c ?? 0, + high: result.h ?? result.c ?? 0, + low: result.l ?? result.c ?? 0, + close: result.c ?? 0, + volume: result.v ?? 0, + vwap: result.vw, + })); + } catch (error) { + if (isNotFoundError(error)) { + throw new Error('STOCKS_TICKER_NOT_FOUND'); + } + throw error; + } +} + +export function buildBrandingUrl(url?: string): string | undefined { + if (!url) return undefined; + + try { + const parsed = new URL(url); + if (!parsed.searchParams.has('apiKey') && config.MASSIVE_API_KEY) { + parsed.searchParams.set('apiKey', config.MASSIVE_API_KEY); + } + return parsed.toString(); + } catch (error) { + logger.warn('Failed to parse branding URL', { url, error }); + return undefined; + } +} + +export function sanitizeTickerInput(input: string): string { + return input + .trim() + .toUpperCase() + .replace(/[^A-Z0-9.\-]/g, '') + .slice(0, 12); +} diff --git a/src/utils/rateLimiter.ts b/src/utils/rateLimiter.ts new file mode 100644 index 0000000..b17c37f --- /dev/null +++ b/src/utils/rateLimiter.ts @@ -0,0 +1,31 @@ +type RateLimitedTask = () => Promise | T; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function createRateLimiter(limitPerSecond: number) { + const minDelay = Math.ceil(1000 / Math.max(1, limitPerSecond)); + let lastRun = 0; + let chain: Promise = Promise.resolve(); + + const schedule = async (task: RateLimitedTask): Promise => { + const execute = chain.then(async () => { + const elapsed = Date.now() - lastRun; + const waitTime = lastRun === 0 ? 0 : Math.max(0, minDelay - elapsed); + if (waitTime > 0) { + await sleep(waitTime); + } + + const result = await task(); + lastRun = Date.now(); + return result; + }); + + chain = execute.then( + () => undefined, + () => undefined, + ); + return execute; + }; + + return { schedule }; +} diff --git a/src/utils/stockChart.ts b/src/utils/stockChart.ts new file mode 100644 index 0000000..2fca83d --- /dev/null +++ b/src/utils/stockChart.ts @@ -0,0 +1,199 @@ +import { createCanvas } from 'canvas'; +import { StockAggregatePoint, StockTimeframe } from '@/services/massive'; + +const WIDTH = 900; +const HEIGHT = 460; +const PADDING = { + top: 24, + right: 32, + bottom: 48, + left: 64, +}; +const MIN_SPACING = 6; +const UP_COLOR = '#1AC486'; +const DOWN_COLOR = '#FF6B6B'; +const GRID_COLOR = 'rgba(255,255,255,0.08)'; +const AXIS_COLOR = 'rgba(255,255,255,0.4)'; +const TEXT_COLOR = 'rgba(255,255,255,0.85)'; +const BACKGROUND = '#0f1117'; + +const TIME_LABEL_FORMATTER = new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + minute: '2-digit', +}); +const DATE_LABEL_FORMATTER = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', +}); +const WEEKDAY_LABEL_FORMATTER = new Intl.DateTimeFormat('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', +}); + +function formatLabel(timestamp: number, timeframe?: StockTimeframe) { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return ''; + if (timeframe === '1d') return TIME_LABEL_FORMATTER.format(date); + if (timeframe === '5d') return WEEKDAY_LABEL_FORMATTER.format(date); + return DATE_LABEL_FORMATTER.format(date); +} + +export async function renderStockCandles( + points: StockAggregatePoint[], + timeframe?: StockTimeframe, +): Promise { + if (!points.length) { + throw new Error('No aggregate data available for chart'); + } + + const maxCandlesMap: Record = { + '1d': 80, + '5d': 110, + '1m': 140, + '3m': 160, + '1y': 160, + }; + const fallbackLimit = 140; + const limit = maxCandlesMap[timeframe ?? '1m'] ?? fallbackLimit; + + const sorted = points.slice(-limit).sort((a, b) => a.timestamp - b.timestamp); + + const chartWidth = WIDTH - PADDING.left - PADDING.right; + const maxVisible = Math.max(3, Math.floor(chartWidth / MIN_SPACING)); + const candles = sorted.slice(-maxVisible).map((point) => ({ + x: point.timestamp, + open: point.open, + high: point.high, + low: point.low, + close: point.close, + })); + + if (!candles.length) { + throw new Error('No aggregate data available for chart'); + } + + const canvas = createCanvas(WIDTH, HEIGHT); + const ctx = canvas.getContext('2d'); + ctx.antialias = 'subpixel'; + + ctx.fillStyle = BACKGROUND; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + + const values = candles.flatMap((candle) => [candle.high, candle.low]); + const rawMax = Math.max(...values); + const rawMin = Math.min(...values); + + const { niceMin, niceMax, tickSpacing } = computeNiceScale(rawMin, rawMax, 5); + const maxPrice = niceMax; + const minPrice = niceMin; + const priceRange = maxPrice - minPrice || 1; + + const chartHeight = HEIGHT - PADDING.top - PADDING.bottom; + + const stepX = candles.length > 1 ? chartWidth / (candles.length - 1) : 0; + const bodyWidth = + candles.length > 1 ? Math.max(4, Math.min(18, stepX * 0.55)) : Math.min(24, chartWidth * 0.2); + + const mapY = (value: number) => + PADDING.top + chartHeight - ((value - minPrice) / priceRange) * chartHeight; + + ctx.strokeStyle = GRID_COLOR; + ctx.lineWidth = 1; + ctx.font = '12px "SF Pro Display", "Segoe UI", sans-serif'; + ctx.fillStyle = TEXT_COLOR; + + const gridLines = Math.max(2, Math.round(priceRange / tickSpacing)); + for (let i = 0; i <= gridLines; i++) { + const value = maxPrice - tickSpacing * i; + const clampedValue = Math.max(minPrice, Math.min(maxPrice, value)); + const relative = (maxPrice - clampedValue) / priceRange; + const y = PADDING.top + chartHeight * relative; + ctx.beginPath(); + ctx.moveTo(PADDING.left, y); + ctx.lineTo(WIDTH - PADDING.right, y); + ctx.stroke(); + + const priceLabel = clampedValue.toFixed(priceRange >= 10 ? 2 : 3); + ctx.fillText(priceLabel, 16, y + 4); + } + + ctx.strokeStyle = AXIS_COLOR; + ctx.beginPath(); + ctx.moveTo(PADDING.left, PADDING.top); + ctx.lineTo(PADDING.left, HEIGHT - PADDING.bottom); + ctx.lineTo(WIDTH - PADDING.right, HEIGHT - PADDING.bottom); + ctx.stroke(); + + candles.forEach((candle, index) => { + const x = candles.length === 1 ? PADDING.left + chartWidth / 2 : PADDING.left + stepX * index; + const openY = mapY(candle.open); + const closeY = mapY(candle.close); + const highY = mapY(candle.high); + const lowY = mapY(candle.low); + const color = candle.close >= candle.open ? UP_COLOR : DOWN_COLOR; + + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(x, highY); + ctx.lineTo(x, lowY); + ctx.stroke(); + + ctx.beginPath(); + const bodyHeight = Math.max(2, Math.abs(closeY - openY)); + const bodyTop = Math.min(openY, closeY); + ctx.rect(x - bodyWidth / 2, bodyTop, bodyWidth, bodyHeight || 2); + ctx.fillStyle = color; + ctx.fill(); + }); + + const labelCount = Math.min(6, candles.length); + for (let i = 0; i < labelCount; i++) { + const candleIndex = Math.round((i / Math.max(1, labelCount - 1)) * (candles.length - 1)); + const label = formatLabel(candles[candleIndex].x, timeframe); + const x = + candles.length === 1 ? PADDING.left + chartWidth / 2 : PADDING.left + stepX * candleIndex; + ctx.fillStyle = TEXT_COLOR; + ctx.fillText(label, x - ctx.measureText(label).width / 2, HEIGHT - PADDING.bottom + 20); + } + + return canvas.toBuffer('image/png'); +} + +function computeNiceScale(min: number, max: number, maxTicks: number) { + if (!Number.isFinite(min) || !Number.isFinite(max)) { + return { niceMin: 0, niceMax: 1, tickSpacing: 0.2 }; + } + if (min === max) { + const offset = Math.abs(min) * 0.05 || 1; + min -= offset; + max += offset; + } + + const range = niceNum(max - min, false); + const tickSpacing = niceNum(range / (maxTicks - 1), true); + const niceMin = Math.floor(min / tickSpacing) * tickSpacing; + const niceMax = Math.ceil(max / tickSpacing) * tickSpacing; + + return { niceMin, niceMax, tickSpacing }; +} + +function niceNum(range: number, round: boolean) { + const exponent = Math.floor(Math.log10(range)); + const fraction = range / Math.pow(10, exponent); + let niceFraction; + + if (round) { + if (fraction < 1.5) niceFraction = 1; + else if (fraction < 3) niceFraction = 2; + else if (fraction < 7) niceFraction = 5; + else niceFraction = 10; + } else { + if (fraction <= 1) niceFraction = 1; + else if (fraction <= 2) niceFraction = 2; + else if (fraction <= 5) niceFraction = 5; + else niceFraction = 10; + } + return niceFraction * Math.pow(10, exponent); +}