A tool for parsing traffic on the jetstream and applying a moderation workstream based on regexp based rules

Compare changes

Choose any two refs to compare.

+4 -1
.claude/settings.local.json
··· 10 10 "mcp__git-mcp-server__git_status", 11 11 "mcp__git-mcp-server__git_log", 12 12 "mcp__git-mcp-server__git_set_working_dir", 13 - "Bash(npm run test:run:*)" 13 + "Bash(npm run test:run:*)", 14 + "Bash(bunx eslint:*)", 15 + "Bash(bun test:run:*)", 16 + "Bash(bun run type-check:*)" 14 17 ], 15 18 "deny": [], 16 19 "ask": []
+2 -2
.github/workflows/ci.yml
··· 21 21 - name: Install dependencies 22 22 run: bun install 23 23 24 - # - name: Run linter 25 - # run: npm run lint 24 + - name: Run linter 25 + run: bun run lint 26 26 27 27 - name: Type check 28 28 run: bun run type-check
+2 -2
.gitignore
··· 4 4 *.log 5 5 labels.db* 6 6 .DS_Store 7 - src/constants.ts 8 - constants.ts 7 + coverage/ 8 + .session
+250 -1
bun.lock
··· 24 24 "pino": "^9.9.0", 25 25 "pino-pretty": "^13.1.1", 26 26 "prom-client": "^15.1.3", 27 + "redis": "^4.7.0", 27 28 "undici": "^7.15.0", 28 29 }, 29 30 "devDependencies": { ··· 35 36 "@types/express": "^4.17.23", 36 37 "@types/node": "^22.18.0", 37 38 "@types/supertest": "^6.0.3", 39 + "@vitest/coverage-v8": "^1.6.0", 38 40 "@vitest/ui": "^1.6.0", 39 41 "eslint": "^9.34.0", 40 42 "eslint-config-prettier": "^10.1.8", 43 + "eslint-plugin-import": "^2.32.0", 41 44 "prettier": "^3.6.2", 42 45 "supertest": "^7.1.4", 43 46 "tsx": "^4.20.5", ··· 51 54 "protobufjs", 52 55 ], 53 56 "packages": { 57 + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], 58 + 54 59 "@atcute/atproto": ["@atcute/atproto@3.1.7", "", { "dependencies": { "@atcute/lexicons": "^1.2.2" } }, "sha512-3Ym8qaVZg2vf8qw0KO1aue39z/5oik5J+UDoSes1vr8ddw40UVLA5sV4bXSKmLnhzQHiLLgoVZXe4zaKfozPoQ=="], 55 60 56 61 "@atcute/bluesky": ["@atcute/bluesky@1.0.15", "", { "peerDependencies": { "@atcute/client": "^1.0.0 || ^2.0.0" } }, "sha512-+EFiybmKQ97aBAgtaD+cKRJER5AMn3cZMkEwEg/pDdWyzxYJ9m1UgemmLdTgI8VrxPufKqdXS2nl7uO7TY6BPA=="], ··· 124 129 "@babel/traverse": ["@babel/traverse@7.23.2", "", { "dependencies": { "@babel/code-frame": "^7.22.13", "@babel/generator": "^7.23.0", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", "@babel/parser": "^7.23.0", "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" } }, "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw=="], 125 130 126 131 "@babel/types": ["@babel/types@7.17.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.16.7", "to-fast-properties": "^2.0.0" } }, "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw=="], 132 + 133 + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="], 127 134 128 135 "@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="], 129 136 ··· 289 296 290 297 "@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA=="], 291 298 299 + "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="], 300 + 292 301 "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], 293 302 294 303 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], ··· 357 366 358 367 "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], 359 368 369 + "@redis/bloom": ["@redis/bloom@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg=="], 370 + 371 + "@redis/client": ["@redis/client@1.6.1", "", { "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", "yallist": "4.0.0" } }, "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw=="], 372 + 373 + "@redis/graph": ["@redis/graph@1.1.1", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw=="], 374 + 375 + "@redis/json": ["@redis/json@1.0.7", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ=="], 376 + 377 + "@redis/search": ["@redis/search@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw=="], 378 + 379 + "@redis/time-series": ["@redis/time-series@1.1.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g=="], 380 + 360 381 "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="], 361 382 362 383 "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="], ··· 401 422 402 423 "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="], 403 424 425 + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], 426 + 404 427 "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], 405 428 406 429 "@skyware/bot": ["@skyware/bot@0.3.12", "", { "dependencies": { "@atcute/bluesky": "^1.0.7", "@atcute/bluesky-richtext-builder": "^1.0.1", "@atcute/client": "^2.0.3", "@atcute/ozone": "^1.0.5", "quick-lru": "^7.0.0", "rate-limit-threshold": "^0.1.5" }, "optionalDependencies": { "@skyware/firehose": "^0.3.2", "@skyware/jetstream": "^0.2.2" } }, "sha512-5OqTtwItYsBFMh0nwrxfsqgXrvRaJzg1P+ghMV4rlRGwHhdRgBJcnYQYgUqqREFcB247yGo73LNyqq7kHEwV7Q=="], ··· 443 466 444 467 "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], 445 468 469 + "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], 470 + 446 471 "@types/methods": ["@types/methods@1.1.4", "", {}, "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ=="], 447 472 448 473 "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], ··· 481 506 482 507 "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA=="], 483 508 509 + "@vitest/coverage-v8": ["@vitest/coverage-v8@1.6.1", "", { "dependencies": { "@ampproject/remapping": "^2.2.1", "@bcoe/v8-coverage": "^0.2.3", "debug": "^4.3.4", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.4", "istanbul-reports": "^3.1.6", "magic-string": "^0.30.5", "magicast": "^0.3.3", "picocolors": "^1.0.0", "std-env": "^3.5.0", "strip-literal": "^2.0.0", "test-exclude": "^6.0.0" }, "peerDependencies": { "vitest": "1.6.1" } }, "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw=="], 510 + 484 511 "@vitest/expect": ["@vitest/expect@1.6.1", "", { "dependencies": { "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "chai": "^4.3.10" } }, "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog=="], 485 512 486 513 "@vitest/runner": ["@vitest/runner@1.6.1", "", { "dependencies": { "@vitest/utils": "1.6.1", "p-limit": "^5.0.0", "pathe": "^1.1.1" } }, "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA=="], ··· 517 544 518 545 "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 519 546 547 + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], 548 + 520 549 "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], 521 550 551 + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], 552 + 553 + "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], 554 + 555 + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], 556 + 557 + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], 558 + 559 + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], 560 + 522 561 "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], 523 562 524 563 "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], 525 564 526 565 "assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="], 566 + 567 + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], 527 568 528 569 "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], 529 570 530 571 "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], 572 + 573 + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], 531 574 532 575 "avvio": ["avvio@8.4.0", "", { "dependencies": { "@fastify/error": "^3.3.0", "fastq": "^1.17.1" } }, "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA=="], 533 576 ··· 560 603 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 561 604 562 605 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], 606 + 607 + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], 563 608 564 609 "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], 565 610 ··· 627 672 628 673 "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 629 674 675 + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], 676 + 677 + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], 678 + 679 + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], 680 + 630 681 "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], 631 682 632 683 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], ··· 634 685 "deep-eql": ["deep-eql@4.1.4", "", { "dependencies": { "type-detect": "^4.0.0" } }, "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg=="], 635 686 636 687 "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], 688 + 689 + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], 690 + 691 + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], 637 692 638 693 "delay": ["delay@5.0.0", "", {}, "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw=="], 639 694 ··· 651 706 652 707 "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], 653 708 709 + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], 710 + 654 711 "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], 655 712 656 713 "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], ··· 671 728 672 729 "error-causes": ["error-causes@3.0.2", "", {}, "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="], 673 730 731 + "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], 732 + 674 733 "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], 675 734 676 735 "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], ··· 678 737 "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], 679 738 680 739 "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], 740 + 741 + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], 742 + 743 + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], 681 744 682 745 "esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="], 683 746 ··· 691 754 692 755 "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], 693 756 757 + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], 758 + 759 + "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], 760 + 761 + "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], 762 + 694 763 "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], 695 764 696 765 "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], ··· 773 842 774 843 "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], 775 844 845 + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], 846 + 776 847 "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], 777 848 778 849 "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], ··· 782 853 "franc": ["franc@6.2.0", "", { "dependencies": { "trigram-utils": "^2.0.0" } }, "sha512-rcAewP7PSHvjq7Kgd7dhj82zE071kX5B4W1M4ewYMf/P+i6YsDQmj62Xz3VQm9zyUzUXwhIde/wHLGCMrM+yGg=="], 783 854 784 855 "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], 856 + 857 + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], 785 858 786 859 "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 787 860 788 861 "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 789 862 863 + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], 864 + 865 + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], 866 + 867 + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], 868 + 869 + "generic-pool": ["generic-pool@3.9.0", "", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="], 870 + 790 871 "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], 791 872 792 873 "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], ··· 799 880 800 881 "get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], 801 882 883 + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], 884 + 802 885 "get-tsconfig": ["get-tsconfig@4.12.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw=="], 886 + 887 + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], 803 888 804 889 "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], 805 890 806 891 "globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], 807 892 893 + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], 894 + 808 895 "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], 809 896 810 897 "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], 898 + 899 + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], 811 900 812 901 "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], 813 902 903 + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], 904 + 905 + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], 906 + 814 907 "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], 815 908 816 909 "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], ··· 822 915 "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], 823 916 824 917 "hmac-drbg": ["hmac-drbg@1.0.1", "", { "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg=="], 918 + 919 + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], 825 920 826 921 "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], 827 922 ··· 841 936 842 937 "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], 843 938 939 + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], 940 + 844 941 "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], 942 + 943 + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], 845 944 846 945 "ioredis": ["ioredis@5.8.1", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ=="], 847 946 ··· 849 948 850 949 "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], 851 950 951 + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], 952 + 852 953 "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], 853 954 955 + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], 956 + 957 + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], 958 + 959 + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], 960 + 961 + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], 962 + 963 + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], 964 + 965 + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], 966 + 967 + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], 968 + 854 969 "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], 855 970 971 + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], 972 + 856 973 "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], 974 + 975 + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], 857 976 858 977 "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], 859 978 979 + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], 980 + 981 + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], 982 + 860 983 "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], 861 984 985 + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], 986 + 987 + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], 988 + 989 + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], 990 + 991 + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], 992 + 862 993 "is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], 863 994 995 + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], 996 + 997 + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], 998 + 999 + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], 1000 + 1001 + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], 1002 + 1003 + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], 1004 + 1005 + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], 1006 + 1007 + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], 1008 + 864 1009 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 865 1010 866 1011 "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], 1012 + 1013 + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], 1014 + 1015 + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], 1016 + 1017 + "istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="], 1018 + 1019 + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], 867 1020 868 1021 "javascript-natural-sort": ["javascript-natural-sort@0.7.1", "", {}, "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw=="], 869 1022 ··· 884 1037 "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], 885 1038 886 1039 "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], 1040 + 1041 + "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], 887 1042 888 1043 "key-encoder": ["key-encoder@2.0.3", "", { "dependencies": { "@types/elliptic": "^6.4.9", "asn1.js": "^5.0.1", "bn.js": "^4.11.8", "elliptic": "^6.4.1" } }, "sha512-fgBtpAGIr/Fy5/+ZLQZIPPhsZEcbSlYu/Wu96tNDFNSjSACw5lEIOFeaVdQ/iwrb8oxjlWi6wmWdH76hV6GZjg=="], 889 1044 ··· 929 1084 930 1085 "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], 931 1086 1087 + "magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="], 1088 + 1089 + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], 1090 + 932 1091 "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], 933 1092 934 1093 "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], ··· 991 1150 992 1151 "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], 993 1152 1153 + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], 1154 + 1155 + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], 1156 + 1157 + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], 1158 + 1159 + "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], 1160 + 1161 + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], 1162 + 994 1163 "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], 995 1164 996 1165 "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], ··· 1004 1173 "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], 1005 1174 1006 1175 "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], 1176 + 1177 + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], 1007 1178 1008 1179 "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], 1009 1180 ··· 1027 1198 1028 1199 "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], 1029 1200 1201 + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], 1202 + 1030 1203 "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 1031 1204 1205 + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], 1206 + 1032 1207 "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], 1033 1208 1034 1209 "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], ··· 1068 1243 "pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="], 1069 1244 1070 1245 "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], 1246 + 1247 + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], 1071 1248 1072 1249 "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], 1073 1250 ··· 1125 1302 1126 1303 "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], 1127 1304 1305 + "redis": ["redis@4.7.1", "", { "dependencies": { "@redis/bloom": "1.2.0", "@redis/client": "1.6.1", "@redis/graph": "1.1.1", "@redis/json": "1.0.7", "@redis/search": "1.2.0", "@redis/time-series": "1.1.0" } }, "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ=="], 1306 + 1128 1307 "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], 1129 1308 1130 1309 "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], 1131 1310 1311 + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], 1312 + 1313 + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], 1314 + 1132 1315 "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], 1133 1316 1134 1317 "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], 1318 + 1319 + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], 1135 1320 1136 1321 "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], 1137 1322 ··· 1153 1338 1154 1339 "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], 1155 1340 1341 + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], 1342 + 1156 1343 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], 1157 1344 1345 + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], 1346 + 1347 + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], 1348 + 1158 1349 "safe-regex2": ["safe-regex2@3.1.0", "", { "dependencies": { "ret": "~0.4.0" } }, "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug=="], 1159 1350 1160 1351 "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], ··· 1163 1354 1164 1355 "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], 1165 1356 1166 - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 1357 + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], 1167 1358 1168 1359 "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], 1169 1360 ··· 1172 1363 "serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="], 1173 1364 1174 1365 "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], 1366 + 1367 + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], 1368 + 1369 + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], 1370 + 1371 + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], 1175 1372 1176 1373 "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], 1177 1374 ··· 1219 1416 1220 1417 "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], 1221 1418 1419 + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], 1420 + 1222 1421 "stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="], 1223 1422 1224 1423 "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], 1225 1424 1226 1425 "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], 1227 1426 1427 + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], 1428 + 1429 + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], 1430 + 1431 + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], 1432 + 1228 1433 "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], 1229 1434 1230 1435 "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], 1436 + 1437 + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], 1231 1438 1232 1439 "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], 1233 1440 ··· 1243 1450 1244 1451 "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], 1245 1452 1453 + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], 1454 + 1246 1455 "tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="], 1456 + 1457 + "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="], 1247 1458 1248 1459 "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], 1249 1460 ··· 1275 1486 1276 1487 "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], 1277 1488 1489 + "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], 1490 + 1278 1491 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 1279 1492 1280 1493 "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=="], ··· 1287 1500 1288 1501 "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], 1289 1502 1503 + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], 1504 + 1505 + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], 1506 + 1507 + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], 1508 + 1509 + "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], 1510 + 1290 1511 "typed-emitter": ["typed-emitter@2.1.0", "", { "optionalDependencies": { "rxjs": "*" } }, "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA=="], 1291 1512 1292 1513 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], ··· 1298 1519 "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], 1299 1520 1300 1521 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1522 + 1523 + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], 1301 1524 1302 1525 "undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="], 1303 1526 ··· 1329 1552 1330 1553 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 1331 1554 1555 + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], 1556 + 1557 + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], 1558 + 1559 + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], 1560 + 1561 + "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], 1562 + 1332 1563 "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], 1333 1564 1334 1565 "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], ··· 1342 1573 "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], 1343 1574 1344 1575 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], 1576 + 1577 + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], 1345 1578 1346 1579 "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], 1347 1580 ··· 1423 1656 1424 1657 "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 1425 1658 1659 + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 1660 + 1426 1661 "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], 1427 1662 1428 1663 "ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], ··· 1439 1674 1440 1675 "duplexify/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=="], 1441 1676 1677 + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], 1678 + 1679 + "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], 1680 + 1681 + "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], 1682 + 1442 1683 "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1443 1684 1444 1685 "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], ··· 1451 1692 1452 1693 "fastify/secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], 1453 1694 1695 + "fastify/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 1696 + 1454 1697 "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1455 1698 1456 1699 "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], ··· 1462 1705 "listr2/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], 1463 1706 1464 1707 "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], 1708 + 1709 + "magicast/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], 1710 + 1711 + "make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 1465 1712 1466 1713 "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], 1467 1714 ··· 1488 1735 "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], 1489 1736 1490 1737 "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], 1738 + 1739 + "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 1491 1740 1492 1741 "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], 1493 1742
+1
bun.lockb
··· 1 +
+12
compose.dev.yaml
··· 1 + # Development override for docker-compose 2 + # Usage: docker compose -f compose.yaml -f compose.dev.yaml up 3 + # 4 + # This configuration: 5 + # - Runs the app in watch mode (auto-reloads on file changes) 6 + # - Mounts source code so changes are picked up without rebuild 7 + 8 + services: 9 + automod: 10 + command: ["bun", "run", "dev"] 11 + volumes: 12 + - ./src:/app/src
+56 -1
compose.yaml
··· 9 9 version: "3.8" 10 10 11 11 services: 12 + redis: 13 + image: redis:7-alpine 14 + container_name: skywatch-automod-redis 15 + restart: unless-stopped 16 + command: redis-server --appendonly yes --appendfsync everysec 17 + volumes: 18 + - redis-data:/data 19 + networks: 20 + - skywatch-network 21 + healthcheck: 22 + test: ["CMD", "redis-cli", "ping"] 23 + interval: 10s 24 + timeout: 3s 25 + retries: 3 26 + 12 27 automod: 13 28 # Build the Docker image from the Dockerfile in the current directory. 14 29 build: . ··· 19 34 20 35 # Expose the metrics server port to the host machine. 21 36 ports: 22 - - "4100:4101" 37 + - "4101:4101" 23 38 24 39 # Load environment variables from a .env file in the same directory. 25 40 # This is where you should put your BSKY_HANDLE, BSKY_PASSWORD, etc. 26 41 env_file: 27 42 - .env 28 43 44 + # Wait for Redis to be healthy before starting 45 + depends_on: 46 + redis: 47 + condition: service_healthy 48 + 49 + networks: 50 + - skywatch-network 51 + 29 52 # Mount a volume to persist the firehose cursor. 30 53 # This links the `cursor.txt` file from your host into the container at `/app/cursor.txt`. 31 54 # Persisting this file allows the automod to resume from where it left off 32 55 # after a restart, preventing it from reprocessing old events or skipping new ones. 33 56 volumes: 34 57 - ./cursor.txt:/app/cursor.txt 58 + - ./.session:/app/.session 59 + - ./rules:/app/rules 60 + 61 + environment: 62 + - NODE_ENV=production 63 + - REDIS_URL=redis://redis:6379 64 + 65 + prometheus: 66 + image: prom/prometheus:latest 67 + container_name: skywatch-prometheus 68 + restart: unless-stopped 69 + ports: 70 + - "9090:9090" 71 + volumes: 72 + - ./prometheus.yml:/etc/prometheus/prometheus.yml 73 + - prometheus-data:/prometheus 74 + command: 75 + - "--config.file=/etc/prometheus/prometheus.yml" 76 + - "--storage.tsdb.path=/prometheus" 77 + networks: 78 + - skywatch-network 79 + depends_on: 80 + - automod 81 + 82 + volumes: 83 + redis-data: 84 + prometheus-data: 85 + 86 + networks: 87 + skywatch-network: 88 + driver: bridge 89 + name: skywatch-network
+58 -28
eslint.config.mjs
··· 2 2 import stylistic from "@stylistic/eslint-plugin"; 3 3 import prettier from "eslint-config-prettier"; 4 4 import importPlugin from "eslint-plugin-import"; 5 + import { defineConfig } from "eslint/config"; 5 6 import tseslint from "typescript-eslint"; 6 7 7 - export default tseslint.config( 8 + export default defineConfig( 8 9 eslint.configs.recommended, 9 10 ...tseslint.configs.strictTypeChecked, 10 - ...tseslint.configs.stylisticTypeChecked, 11 11 prettier, 12 12 { 13 13 languageOptions: { ··· 25 25 rules: { 26 26 // TypeScript specific rules 27 27 "@typescript-eslint/no-unused-vars": [ 28 - "error", 28 + "warn", 29 29 { argsIgnorePattern: "^_" }, 30 30 ], 31 - "@typescript-eslint/no-explicit-any": "error", 31 + "@typescript-eslint/no-explicit-any": "warn", 32 32 "@typescript-eslint/no-unsafe-assignment": "error", 33 33 "@typescript-eslint/no-unsafe-member-access": "error", 34 34 "@typescript-eslint/no-unsafe-call": "error", 35 35 "@typescript-eslint/no-unsafe-return": "error", 36 36 "@typescript-eslint/no-unsafe-argument": "error", 37 - "@typescript-eslint/prefer-nullish-coalescing": "error", 38 - "@typescript-eslint/prefer-optional-chain": "error", 37 + "@typescript-eslint/prefer-nullish-coalescing": "warn", 38 + "@typescript-eslint/prefer-optional-chain": "warn", 39 39 "@typescript-eslint/no-non-null-assertion": "error", 40 40 "@typescript-eslint/consistent-type-imports": "error", 41 41 "@typescript-eslint/consistent-type-exports": "error", ··· 45 45 "no-console": "warn", 46 46 "no-debugger": "error", 47 47 "no-var": "error", 48 - "prefer-const": "error", 49 - "prefer-template": "error", 50 - "object-shorthand": "error", 51 - "prefer-destructuring": ["error", { object: true, array: false }], 48 + "prefer-const": "warn", 49 + "prefer-template": "warn", 50 + "object-shorthand": "warn", 51 + "prefer-destructuring": ["warn", { object: true, array: false }], 52 52 53 53 // Import rules 54 54 "import/order": [ 55 - "error", 55 + "warn", 56 56 { 57 57 groups: [ 58 58 "builtin", ··· 62 62 "sibling", 63 63 "index", 64 64 ], 65 - "newlines-between": "always", 65 + pathGroups: [ 66 + { 67 + pattern: "@atproto/**", 68 + group: "external", 69 + position: "after", 70 + }, 71 + { 72 + pattern: "@skyware/**", 73 + group: "external", 74 + position: "after", 75 + }, 76 + { 77 + pattern: "@clavata/**", 78 + group: "external", 79 + position: "after", 80 + }, 81 + ], 82 + pathGroupsExcludedImportTypes: ["builtin"], 83 + "newlines-between": "never", 66 84 alphabetize: { order: "asc", caseInsensitive: true }, 67 85 }, 68 86 ], 69 - "import/no-duplicates": "error", 87 + "import/no-duplicates": "warn", 70 88 "import/no-unresolved": "off", // TypeScript handles this 71 89 72 90 // Security-focused rules ··· 81 99 "no-unreachable": "error", 82 100 "no-unreachable-loop": "error", 83 101 84 - // Style preferences 85 - "@stylistic/indent": ["error", 2], 86 - "@stylistic/quotes": ["error", "double"], 87 - "@stylistic/semi": ["error", "always"], 88 - //"@stylistic/comma-dangle": ["error", "es5"], 89 - "@stylistic/object-curly-spacing": ["error", "always"], 90 - "@stylistic/array-bracket-spacing": ["error", "never"], 91 - "@stylistic/space-before-function-paren": [ 92 - "error", 93 - { 94 - anonymous: "always", 95 - named: "never", 96 - asyncArrow: "always", 97 - }, 98 - ], 102 + // Style preferences (prettier handles these) 103 + "@stylistic/indent": "off", 104 + "@stylistic/quotes": "off", 105 + "@stylistic/semi": "off", 106 + "@stylistic/object-curly-spacing": "off", 107 + "@stylistic/array-bracket-spacing": "off", 108 + "@stylistic/space-before-function-paren": "off", 99 109 }, 100 110 }, 101 111 { ··· 110 120 "*.config.js", 111 121 "*.config.mjs", 112 122 "coverage/", 123 + "rules/", 113 124 ], 125 + }, 126 + // Test file overrides 127 + { 128 + files: ["**/*.test.ts", "**/*.test.tsx"], 129 + rules: { 130 + "@typescript-eslint/unbound-method": "off", 131 + "@typescript-eslint/no-unsafe-argument": "off", 132 + "@typescript-eslint/no-unsafe-assignment": "off", 133 + "@typescript-eslint/no-unsafe-call": "off", 134 + "@typescript-eslint/no-unsafe-member-access": "off", 135 + "@typescript-eslint/no-unsafe-return": "off", 136 + "@typescript-eslint/no-explicit-any": "off", 137 + "@typescript-eslint/require-await": "off", 138 + "@typescript-eslint/await-thenable": "off", 139 + "@typescript-eslint/no-confusing-void-expression": "off", 140 + "@typescript-eslint/restrict-template-expressions": "off", 141 + "@typescript-eslint/no-unnecessary-type-conversion": "off", 142 + "@typescript-eslint/no-deprecated": "off", 143 + }, 114 144 }, 115 145 );
+4 -1
package.json
··· 1 1 { 2 2 "name": "skywatch-tools", 3 - "version": "1.3.0", 3 + "version": "2.0.1", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "npx tsx src/main.ts", ··· 27 27 "@types/express": "^4.17.23", 28 28 "@types/node": "^22.18.0", 29 29 "@types/supertest": "^6.0.3", 30 + "@vitest/coverage-v8": "^1.6.0", 30 31 "@vitest/ui": "^1.6.0", 31 32 "eslint": "^9.34.0", 32 33 "eslint-config-prettier": "^10.1.8", 34 + "eslint-plugin-import": "^2.32.0", 33 35 "prettier": "^3.6.2", 34 36 "supertest": "^7.1.4", 35 37 "tsx": "^4.20.5", ··· 58 60 "pino": "^9.9.0", 59 61 "pino-pretty": "^13.1.1", 60 62 "prom-client": "^15.1.3", 63 + "redis": "^4.7.0", 61 64 "undici": "^7.15.0" 62 65 }, 63 66 "trustedDependencies": [
+10
prometheus.yml
··· 1 + global: 2 + scrape_interval: 15s 3 + evaluation_interval: 15s 4 + 5 + scrape_configs: 6 + - job_name: "skywatch-automod" 7 + static_configs: 8 + - targets: ["automod:4101"] 9 + labels: 10 + service: "automod"
+17
rules/accountAge.ts
··· 1 + import type { AccountAgeCheck } from "../src/types.js"; 2 + 3 + /** 4 + * Account age monitoring configurations 5 + * 6 + * This file contains example values. Copy to accountAge.ts and configure with your checks. 7 + */ 8 + export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [ 9 + // Example configuration: 10 + // { 11 + // monitoredDIDs: ["did:plc:example123"], 12 + // anchorDate: "2025-01-15", 13 + // maxAgeDays: 7, 14 + // label: "new-account", 15 + // comment: "Account created within monitored window", 16 + // }, 17 + ];
+20
rules/accountThreshold.ts
··· 1 + import type { AccountThresholdConfig } from "../src/types.js"; 2 + 3 + /** 4 + * Account threshold configurations for automatic labeling 5 + * 6 + * This file contains example values. Copy to accountThreshold.ts and configure with your thresholds. 7 + */ 8 + export const ACCOUNT_THRESHOLD_CONFIGS: AccountThresholdConfig[] = [ 9 + // Example configuration: 10 + // { 11 + // labels: ["example-label"], 12 + // threshold: 3, 13 + // accountLabel: "repeat-offender", 14 + // accountComment: "Account exceeded threshold", 15 + // windowDays: 7, 16 + // reportAcct: false, 17 + // commentAcct: false, 18 + // toLabel: true, 19 + // }, 20 + ];
+10
rules/constants.ts
··· 1 + /** 2 + * Global allowlist for accounts that should bypass all checks 3 + * 4 + * This file contains example values. Copy to constants.ts and configure with your DIDs. 5 + */ 6 + export const GLOBAL_ALLOW: string[] = [ 7 + // Example: "did:plc:example123", 8 + ]; 9 + 10 + export const LINK_SHORTENER = new RegExp("", "i");
+18
rules/handles.ts
··· 1 + import type { Checks } from "../src/types.js"; 2 + 3 + /** 4 + * Handle-based moderation checks 5 + * 6 + * This file contains example values. Copy to handles.ts and configure with your checks. 7 + */ 8 + export const HANDLE_CHECKS: Checks[] = [ 9 + // Example check: 10 + // { 11 + // label: "example-label", 12 + // comment: "Example check found in handle", 13 + // reportAcct: false, 14 + // commentAcct: false, 15 + // toLabel: true, 16 + // check: new RegExp("example-pattern", "i"), 17 + // }, 18 + ];
+18
rules/posts.ts
··· 1 + import type { Checks } from "../src/types.js"; 2 + 3 + /** 4 + * Post content moderation checks 5 + * 6 + * This file contains example values. Copy to posts.ts and configure with your checks. 7 + */ 8 + export const POST_CHECKS: Checks[] = [ 9 + // Example check: 10 + // { 11 + // label: "example-label", 12 + // comment: "Example content found in post", 13 + // reportAcct: false, 14 + // commentAcct: false, 15 + // toLabel: true, 16 + // check: new RegExp("example-pattern", "i"), 17 + // }, 18 + ];
+20
rules/profiles.ts
··· 1 + import type { Checks } from "../src/types.js"; 2 + 3 + /** 4 + * Profile-based moderation checks 5 + * 6 + * This file contains example values. Copy to profiles.ts and configure with your checks. 7 + */ 8 + export const PROFILE_CHECKS: Checks[] = [ 9 + // Example check: 10 + // { 11 + // label: "example-label", 12 + // comment: "Example content found in profile", 13 + // description: true, 14 + // displayName: true, 15 + // reportAcct: false, 16 + // commentAcct: false, 17 + // toLabel: true, 18 + // check: new RegExp("example-pattern", "i"), 19 + // }, 20 + ];
+220
src/accountModeration.ts
··· 1 + import { agent, isLoggedIn } from "./agent.js"; 2 + import { MOD_DID } from "./config.js"; 3 + import { limit } from "./limits.js"; 4 + import { logger } from "./logger.js"; 5 + import { labelsAppliedCounter, labelsCachedCounter } from "./metrics.js"; 6 + import { tryClaimAccountComment, tryClaimAccountLabel } from "./redis.js"; 7 + 8 + const doesLabelExist = ( 9 + labels: { val: string }[] | undefined, 10 + labelVal: string, 11 + ): boolean => { 12 + if (!labels) { 13 + return false; 14 + } 15 + return labels.some((label) => label.val === labelVal); 16 + }; 17 + 18 + export const createAccountLabel = async ( 19 + did: string, 20 + label: string, 21 + comment: string, 22 + ) => { 23 + await isLoggedIn; 24 + 25 + const claimed = await tryClaimAccountLabel(did, label); 26 + if (!claimed) { 27 + logger.debug( 28 + { process: "MODERATION", did, label }, 29 + "Account label already claimed in Redis, skipping", 30 + ); 31 + labelsCachedCounter.inc({ 32 + label_type: label, 33 + target_type: "account", 34 + reason: "redis_cache", 35 + }); 36 + return; 37 + } 38 + 39 + const hasLabel = await checkAccountLabels(did, label); 40 + if (hasLabel) { 41 + logger.debug( 42 + { process: "MODERATION", did, label }, 43 + "Account already has label, skipping", 44 + ); 45 + labelsCachedCounter.inc({ 46 + label_type: label, 47 + target_type: "account", 48 + reason: "existing_label", 49 + }); 50 + return; 51 + } 52 + 53 + logger.info({ process: "MODERATION", did, label }, "Labeling account"); 54 + labelsAppliedCounter.inc({ label_type: label, target_type: "account" }); 55 + 56 + await limit(async () => { 57 + try { 58 + await agent.tools.ozone.moderation.emitEvent( 59 + { 60 + event: { 61 + $type: "tools.ozone.moderation.defs#modEventLabel", 62 + comment, 63 + createLabelVals: [label], 64 + negateLabelVals: [], 65 + }, 66 + // specify the labeled post by strongRef 67 + subject: { 68 + $type: "com.atproto.admin.defs#repoRef", 69 + did, 70 + }, 71 + // put in the rest of the metadata 72 + createdBy: agent.did ?? "", 73 + createdAt: new Date().toISOString(), 74 + modTool: { 75 + name: "skywatch/skywatch-automod", 76 + }, 77 + }, 78 + { 79 + encoding: "application/json", 80 + headers: { 81 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 82 + "atproto-accept-labelers": 83 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 84 + }, 85 + }, 86 + ); 87 + } catch (e) { 88 + logger.error( 89 + { process: "MODERATION", error: e }, 90 + "Failed to create account label", 91 + ); 92 + } 93 + }); 94 + }; 95 + 96 + export const createAccountComment = async ( 97 + did: string, 98 + comment: string, 99 + atURI: string, 100 + ) => { 101 + await isLoggedIn; 102 + 103 + const claimed = await tryClaimAccountComment(did, atURI); 104 + if (!claimed) { 105 + logger.debug( 106 + { process: "MODERATION", did, atURI }, 107 + "Account comment already claimed in Redis, skipping", 108 + ); 109 + return; 110 + } 111 + 112 + logger.info({ process: "MODERATION", did, atURI }, "Commenting on account"); 113 + 114 + await limit(async () => { 115 + try { 116 + await agent.tools.ozone.moderation.emitEvent( 117 + { 118 + event: { 119 + $type: "tools.ozone.moderation.defs#modEventComment", 120 + comment, 121 + }, 122 + // specify the labeled post by strongRef 123 + subject: { 124 + $type: "com.atproto.admin.defs#repoRef", 125 + did, 126 + }, 127 + // put in the rest of the metadata 128 + createdBy: agent.did ?? "", 129 + createdAt: new Date().toISOString(), 130 + modTool: { 131 + name: "skywatch/skywatch-automod", 132 + }, 133 + }, 134 + { 135 + encoding: "application/json", 136 + headers: { 137 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 138 + "atproto-accept-labelers": 139 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 140 + }, 141 + }, 142 + ); 143 + } catch (e) { 144 + logger.error( 145 + { process: "MODERATION", error: e }, 146 + "Failed to create account comment", 147 + ); 148 + } 149 + }); 150 + }; 151 + 152 + export const createAccountReport = async (did: string, comment: string) => { 153 + await isLoggedIn; 154 + await limit(async () => { 155 + try { 156 + await agent.tools.ozone.moderation.emitEvent( 157 + { 158 + event: { 159 + $type: "tools.ozone.moderation.defs#modEventReport", 160 + comment, 161 + reportType: "com.atproto.moderation.defs#reasonOther", 162 + }, 163 + // specify the labeled post by strongRef 164 + subject: { 165 + $type: "com.atproto.admin.defs#repoRef", 166 + did, 167 + }, 168 + // put in the rest of the metadata 169 + createdBy: agent.did ?? "", 170 + createdAt: new Date().toISOString(), 171 + modTool: { 172 + name: "skywatch/skywatch-automod", 173 + }, 174 + }, 175 + { 176 + encoding: "application/json", 177 + headers: { 178 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 179 + "atproto-accept-labelers": 180 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 181 + }, 182 + }, 183 + ); 184 + } catch (e) { 185 + logger.error( 186 + { process: "MODERATION", error: e }, 187 + "Failed to create account report", 188 + ); 189 + } 190 + }); 191 + }; 192 + 193 + export const checkAccountLabels = async ( 194 + did: string, 195 + label: string, 196 + ): Promise<boolean> => { 197 + await isLoggedIn; 198 + return await limit(async () => { 199 + try { 200 + const response = await agent.tools.ozone.moderation.getRepo( 201 + { did }, 202 + { 203 + headers: { 204 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 205 + "atproto-accept-labelers": 206 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 207 + }, 208 + }, 209 + ); 210 + 211 + return doesLabelExist(response.data.labels, label); 212 + } catch (e) { 213 + logger.error( 214 + { process: "MODERATION", did, error: e }, 215 + "Failed to check account labels", 216 + ); 217 + return false; 218 + } 219 + }); 220 + };
+172
src/accountThreshold.ts
··· 1 + import { ACCOUNT_THRESHOLD_CONFIGS } from "../rules/accountThreshold.js"; 2 + import { 3 + createAccountComment, 4 + createAccountLabel, 5 + createAccountReport, 6 + } from "./accountModeration.js"; 7 + import { logger } from "./logger.js"; 8 + import { 9 + accountLabelsThresholdAppliedCounter, 10 + accountThresholdChecksCounter, 11 + accountThresholdMetCounter, 12 + } from "./metrics.js"; 13 + import { 14 + getPostLabelCountInWindow, 15 + trackPostLabelForAccount, 16 + } from "./redis.js"; 17 + import type { AccountThresholdConfig } from "./types.js"; 18 + 19 + function normalizeLabels(labels: string | string[]): string[] { 20 + return Array.isArray(labels) ? labels : [labels]; 21 + } 22 + 23 + function validateAndLoadConfigs(): AccountThresholdConfig[] { 24 + if (ACCOUNT_THRESHOLD_CONFIGS.length === 0) { 25 + logger.warn( 26 + { process: "ACCOUNT_THRESHOLD" }, 27 + "No account threshold configs found", 28 + ); 29 + return []; 30 + } 31 + 32 + for (const config of ACCOUNT_THRESHOLD_CONFIGS) { 33 + const labels = normalizeLabels(config.labels); 34 + if (labels.length === 0) { 35 + throw new Error( 36 + `Invalid account threshold config: labels cannot be empty`, 37 + ); 38 + } 39 + if (config.threshold <= 0) { 40 + throw new Error( 41 + `Invalid account threshold config: threshold must be positive`, 42 + ); 43 + } 44 + if (config.windowDays <= 0) { 45 + throw new Error( 46 + `Invalid account threshold config: windowDays must be positive`, 47 + ); 48 + } 49 + } 50 + 51 + logger.info( 52 + { process: "ACCOUNT_THRESHOLD", count: ACCOUNT_THRESHOLD_CONFIGS.length }, 53 + "Loaded account threshold configs", 54 + ); 55 + 56 + return ACCOUNT_THRESHOLD_CONFIGS; 57 + } 58 + 59 + // Load and cache configs at module initialization 60 + const cachedConfigs = validateAndLoadConfigs(); 61 + 62 + export function loadThresholdConfigs(): AccountThresholdConfig[] { 63 + return cachedConfigs; 64 + } 65 + 66 + export async function checkAccountThreshold( 67 + did: string, 68 + postLabel: string, 69 + timestamp: number, 70 + ): Promise<void> { 71 + try { 72 + const configs = loadThresholdConfigs(); 73 + 74 + const matchingConfigs = configs.filter((config) => { 75 + const labels = normalizeLabels(config.labels); 76 + return labels.includes(postLabel); 77 + }); 78 + 79 + if (matchingConfigs.length === 0) { 80 + logger.debug( 81 + { process: "ACCOUNT_THRESHOLD", did, postLabel }, 82 + "No matching threshold configs for post label", 83 + ); 84 + return; 85 + } 86 + 87 + accountThresholdChecksCounter.inc({ post_label: postLabel }); 88 + 89 + for (const config of matchingConfigs) { 90 + const labels = normalizeLabels(config.labels); 91 + 92 + await trackPostLabelForAccount( 93 + did, 94 + postLabel, 95 + timestamp, 96 + config.windowDays, 97 + ); 98 + 99 + const count = await getPostLabelCountInWindow( 100 + did, 101 + labels, 102 + config.windowDays, 103 + timestamp, 104 + ); 105 + 106 + logger.debug( 107 + { 108 + process: "ACCOUNT_THRESHOLD", 109 + did, 110 + labels, 111 + count, 112 + threshold: config.threshold, 113 + windowDays: config.windowDays, 114 + }, 115 + "Checked account threshold", 116 + ); 117 + 118 + if (count >= config.threshold) { 119 + accountThresholdMetCounter.inc({ account_label: config.accountLabel }); 120 + 121 + logger.info( 122 + { 123 + process: "ACCOUNT_THRESHOLD", 124 + did, 125 + postLabel, 126 + accountLabel: config.accountLabel, 127 + count, 128 + threshold: config.threshold, 129 + }, 130 + "Account threshold met", 131 + ); 132 + 133 + const shouldLabel = config.toLabel !== false; 134 + 135 + if (shouldLabel) { 136 + await createAccountLabel( 137 + did, 138 + config.accountLabel, 139 + config.accountComment, 140 + ); 141 + accountLabelsThresholdAppliedCounter.inc({ 142 + account_label: config.accountLabel, 143 + action: "label", 144 + }); 145 + } 146 + 147 + if (config.reportAcct) { 148 + await createAccountReport(did, config.accountComment); 149 + accountLabelsThresholdAppliedCounter.inc({ 150 + account_label: config.accountLabel, 151 + action: "report", 152 + }); 153 + } 154 + 155 + if (config.commentAcct) { 156 + const atURI = `threshold-comment:${config.accountLabel}:${timestamp.toString()}`; 157 + await createAccountComment(did, config.accountComment, atURI); 158 + accountLabelsThresholdAppliedCounter.inc({ 159 + account_label: config.accountLabel, 160 + action: "comment", 161 + }); 162 + } 163 + } 164 + } 165 + } catch (error) { 166 + logger.error( 167 + { process: "ACCOUNT_THRESHOLD", did, postLabel, error }, 168 + "Error checking account threshold", 169 + ); 170 + throw error; 171 + } 172 + }
+164 -9
src/agent.ts
··· 1 1 import { Agent, setGlobalDispatcher } from "undici"; 2 2 import { AtpAgent } from "@atproto/api"; 3 3 import { BSKY_HANDLE, BSKY_PASSWORD, OZONE_PDS } from "./config.js"; 4 + import { updateRateLimitState } from "./limits.js"; 5 + import { logger } from "./logger.js"; 6 + import { type SessionData, loadSession, saveSession } from "./session.js"; 4 7 5 - setGlobalDispatcher(new Agent({ connect: { timeout: 20_000 } })); 8 + setGlobalDispatcher( 9 + new Agent({ 10 + connect: { timeout: 20_000 }, 11 + keepAliveTimeout: 10_000, 12 + keepAliveMaxTimeout: 20_000, 13 + }), 14 + ); 15 + 16 + const customFetch: typeof fetch = async (input, init) => { 17 + const response = await fetch(input, init); 18 + 19 + // Extract rate limit headers from ATP responses 20 + const limitHeader = response.headers.get("ratelimit-limit"); 21 + const remainingHeader = response.headers.get("ratelimit-remaining"); 22 + const resetHeader = response.headers.get("ratelimit-reset"); 23 + const policyHeader = response.headers.get("ratelimit-policy"); 24 + 25 + if (limitHeader && remainingHeader && resetHeader) { 26 + updateRateLimitState({ 27 + limit: parseInt(limitHeader, 10), 28 + remaining: parseInt(remainingHeader, 10), 29 + reset: parseInt(resetHeader, 10), 30 + policy: policyHeader ?? undefined, 31 + }); 32 + } 33 + 34 + return response; 35 + }; 6 36 7 37 export const agent = new AtpAgent({ 8 38 service: `https://${OZONE_PDS}`, 39 + fetch: customFetch, 9 40 }); 10 - export const login = () => 11 - agent.login({ 12 - identifier: BSKY_HANDLE, 13 - password: BSKY_PASSWORD, 14 - }); 41 + 42 + const JWT_LIFETIME_MS = 2 * 60 * 60 * 1000; // 2 hours (typical ATP JWT lifetime) 43 + const REFRESH_AT_PERCENT = 0.8; // Refresh at 80% of lifetime 44 + let refreshTimer: NodeJS.Timeout | null = null; 45 + 46 + async function refreshSession(): Promise<void> { 47 + try { 48 + logger.info("Refreshing session tokens"); 49 + if (!agent.session) { 50 + throw new Error("No active session to refresh"); 51 + } 52 + await agent.resumeSession(agent.session); 53 + 54 + saveSession(agent.session as SessionData); 55 + scheduleSessionRefresh(); 56 + } catch (error: unknown) { 57 + logger.error({ error }, "Failed to refresh session, will re-authenticate"); 58 + await performLogin(); 59 + } 60 + } 61 + 62 + function scheduleSessionRefresh(): void { 63 + if (refreshTimer) { 64 + clearTimeout(refreshTimer); 65 + } 66 + 67 + const refreshIn = JWT_LIFETIME_MS * REFRESH_AT_PERCENT; 68 + logger.debug( 69 + `Scheduling session refresh in ${(refreshIn / 1000 / 60).toFixed(1)} minutes`, 70 + ); 71 + 72 + refreshTimer = setTimeout(() => { 73 + refreshSession().catch((error: unknown) => { 74 + logger.error({ error }, "Scheduled session refresh failed"); 75 + }); 76 + }, refreshIn); 77 + } 78 + 79 + async function performLogin(): Promise<boolean> { 80 + try { 81 + logger.info("Performing fresh login"); 82 + const response = await agent.login({ 83 + identifier: BSKY_HANDLE, 84 + password: BSKY_PASSWORD, 85 + }); 86 + 87 + if (response.success && agent.session) { 88 + saveSession(agent.session as SessionData); 89 + scheduleSessionRefresh(); 90 + logger.info("Login successful, session saved"); 91 + return true; 92 + } 93 + 94 + logger.error("Login failed: no session returned"); 95 + return false; 96 + } catch (error) { 97 + logger.error({ error }, "Login failed"); 98 + return false; 99 + } 100 + } 101 + 102 + const MAX_LOGIN_RETRIES = 3; 103 + const RETRY_DELAY_MS = 2000; 104 + 105 + let loginPromise: Promise<void> | null = null; 106 + 107 + async function sleep(ms: number): Promise<void> { 108 + return new Promise((resolve) => setTimeout(resolve, ms)); 109 + } 110 + 111 + async function authenticate(): Promise<boolean> { 112 + const savedSession = loadSession(); 113 + 114 + if (savedSession) { 115 + try { 116 + logger.info("Attempting to resume saved session"); 117 + await agent.resumeSession(savedSession); 118 + 119 + // Verify session is still valid with a lightweight call 120 + await agent.getProfile({ actor: savedSession.did }); 121 + 122 + logger.info("Session resumed successfully"); 123 + scheduleSessionRefresh(); 124 + return true; 125 + } catch (error) { 126 + logger.warn({ error }, "Saved session invalid, will re-authenticate"); 127 + } 128 + } 15 129 16 - export const isLoggedIn = login() 17 - .then(() => true) 18 - .catch(() => false); 130 + return performLogin(); 131 + } 132 + 133 + async function authenticateWithRetry(): Promise<void> { 134 + // Reuse existing login attempt if one is in progress 135 + if (loginPromise) { 136 + return loginPromise; 137 + } 138 + 139 + loginPromise = (async () => { 140 + for (let attempt = 1; attempt <= MAX_LOGIN_RETRIES; attempt++) { 141 + logger.info( 142 + { attempt, maxRetries: MAX_LOGIN_RETRIES }, 143 + "Attempting login", 144 + ); 145 + 146 + const success = await authenticate(); 147 + 148 + if (success) { 149 + logger.info("Authentication successful"); 150 + return; 151 + } 152 + 153 + if (attempt < MAX_LOGIN_RETRIES) { 154 + logger.warn( 155 + { attempt, maxRetries: MAX_LOGIN_RETRIES, retryInMs: RETRY_DELAY_MS }, 156 + "Login failed, retrying", 157 + ); 158 + await sleep(RETRY_DELAY_MS); 159 + } 160 + } 161 + 162 + logger.error( 163 + { maxRetries: MAX_LOGIN_RETRIES }, 164 + "All login attempts failed, aborting", 165 + ); 166 + process.exit(1); 167 + })(); 168 + 169 + return loginPromise; 170 + } 171 + 172 + export const login = authenticateWithRetry; 173 + export const isLoggedIn = authenticateWithRetry().then(() => true);
+4 -4
src/config.ts
··· 5 5 export const OZONE_PDS = process.env.OZONE_PDS ?? ""; 6 6 export const BSKY_HANDLE = process.env.BSKY_HANDLE ?? ""; 7 7 export const BSKY_PASSWORD = process.env.BSKY_PASSWORD ?? ""; 8 - export const HOST = process.env.HOST ?? "127.0.0.1"; 9 - export const PORT = process.env.PORT ? Number(process.env.PORT) : 4100; 8 + export const HOST = process.env.HOST ?? "0.0.0.0"; 10 9 export const METRICS_PORT = process.env.METRICS_PORT 11 10 ? Number(process.env.METRICS_PORT) 12 11 : 4101; // Left this intact from the code I adapted this from ··· 21 20 export const CURSOR_UPDATE_INTERVAL = process.env.CURSOR_UPDATE_INTERVAL 22 21 ? Number(process.env.CURSOR_UPDATE_INTERVAL) 23 22 : 60000; 24 - export const LABEL_LIMIT = process.env.LABEL_LIMIT; 25 - export const LABEL_LIMIT_WAIT = process.env.LABEL_LIMIT_WAIT; 23 + export const { LABEL_LIMIT } = process.env; 24 + export const { LABEL_LIMIT_WAIT } = process.env; 25 + export const REDIS_URL = process.env.REDIS_URL ?? "redis://redis:6379";
-25
src/developing_checks.md
··· 1 - # How to build checks for skywatch-automod 2 - 3 - ## Introduction 4 - 5 - Constants.ts defines three types of types of checks: `HANDLE_CHECKS`, `POST_CHECKS`, and `PROFILE_CHECKS`. 6 - 7 - For each check, users need to define a set of regular expressions that will be used to match against the content of the post, handle, or profile. A maximal example of a check is as follows: 8 - 9 - ```typescript 10 - export const HANDLE_CHECKS: Checks[] = [ 11 - { 12 - label: "example", 13 - comment: "Example found in handle", 14 - description: true, // Optional, only used in handle checks 15 - displayName: true, // Optional, only used in handle checks 16 - reportOnly: false, // it true, the check will only report the content against the account, not label. 17 - commentOnly: false, // Poorly named, if true, will generate an account level comment from flagged posts, rather than a report. Intended for use when reportOnly is false, and on posts only where the flag may generate a high volume of reports.. 18 - check: new RegExp("example", "i"), // Regular expression to match against the content 19 - whitelist: new RegExp("example.com", "i"), // Optional, regular expression to whitelist content 20 - ignoredDIDs: ["did:plc:example"], // Optional, array of DIDs to ignore if they match the check. Useful for folks who reclaim words. 21 - }, 22 - ]; 23 - ``` 24 - 25 - In the above example, any handle that contains the word "example" will be labeled with the label "example" unless the handle is `example.com` or the handle belongs to the user with the DID `did:plc:example`.
+115 -8
src/limits.ts
··· 1 1 import { pRateLimit } from "p-ratelimit"; 2 + import { Counter, Gauge, Histogram } from "prom-client"; 3 + import { logger } from "./logger.js"; 2 4 3 - // TypeScript 5 + interface RateLimitState { 6 + limit: number; 7 + remaining: number; 8 + reset: number; // Unix timestamp in seconds 9 + policy?: string; 10 + } 11 + 12 + // Conservative defaults based on previous static configuration 13 + // Will be replaced with dynamic values from ATP response headers 14 + let rateLimitState: RateLimitState = { 15 + limit: 280, 16 + remaining: 280, 17 + reset: Math.floor(Date.now() / 1000) + 30, 18 + }; 4 19 5 - // create a rate limiter that allows up to 30 API calls per second, 6 - // with max concurrency of 10 20 + const SAFETY_BUFFER = 5; // Keep this many requests in reserve (reduced from 20) 21 + const CONCURRENCY = 24; // Reduced from 48 to prevent rapid depletion 7 22 8 - export const limit = pRateLimit({ 9 - interval: 30000, // 1000 ms == 1 second 10 - rate: 280, // 30 API calls per interval 11 - concurrency: 48, // no more than 10 running at once 12 - maxDelay: 0, // an API call delayed > 30 sec is rejected 23 + // Metrics 24 + const rateLimitWaitsTotal = new Counter({ 25 + name: "rate_limit_waits_total", 26 + help: "Total number of times rate limit wait was triggered", 13 27 }); 28 + 29 + const rateLimitWaitDuration = new Histogram({ 30 + name: "rate_limit_wait_duration_seconds", 31 + help: "Duration of rate limit waits in seconds", 32 + buckets: [0.1, 0.5, 1, 5, 10, 30, 60], 33 + }); 34 + 35 + const rateLimitRemaining = new Gauge({ 36 + name: "rate_limit_remaining", 37 + help: "Current remaining rate limit", 38 + }); 39 + 40 + const rateLimitTotal = new Gauge({ 41 + name: "rate_limit_total", 42 + help: "Total rate limit from headers", 43 + }); 44 + 45 + const concurrentRequestsGauge = new Gauge({ 46 + name: "concurrent_requests", 47 + help: "Current number of concurrent requests", 48 + }); 49 + 50 + // Use p-ratelimit purely for concurrency management 51 + const concurrencyLimiter = pRateLimit({ 52 + interval: 1000, 53 + rate: 10000, // Very high rate, we manage rate limiting separately 54 + concurrency: CONCURRENCY, 55 + maxDelay: 0, 56 + }); 57 + 58 + export function getRateLimitState(): RateLimitState { 59 + return { ...rateLimitState }; 60 + } 61 + 62 + export function updateRateLimitState(state: Partial<RateLimitState>): void { 63 + rateLimitState = { ...rateLimitState, ...state }; 64 + 65 + // Update Prometheus metrics 66 + if (state.remaining !== undefined) { 67 + rateLimitRemaining.set(state.remaining); 68 + } 69 + if (state.limit !== undefined) { 70 + rateLimitTotal.set(state.limit); 71 + } 72 + 73 + logger.debug( 74 + { 75 + limit: rateLimitState.limit, 76 + remaining: rateLimitState.remaining, 77 + resetIn: rateLimitState.reset - Math.floor(Date.now() / 1000), 78 + }, 79 + "Rate limit state updated", 80 + ); 81 + } 82 + 83 + async function awaitRateLimit(): Promise<void> { 84 + const state = getRateLimitState(); 85 + const now = Math.floor(Date.now() / 1000); 86 + 87 + // Only wait if we're critically low 88 + if (state.remaining <= SAFETY_BUFFER) { 89 + rateLimitWaitsTotal.inc(); 90 + 91 + const delaySeconds = Math.max(0, state.reset - now); 92 + const delayMs = delaySeconds * 1000; 93 + 94 + if (delayMs > 0) { 95 + logger.warn( 96 + `Rate limit critical (${state.remaining.toString()}/${state.limit.toString()} remaining). Waiting ${delaySeconds.toString()}s until reset...`, 97 + ); 98 + 99 + const waitStart = Date.now(); 100 + await new Promise((resolve) => setTimeout(resolve, delayMs)); 101 + const waitDuration = (Date.now() - waitStart) / 1000; 102 + rateLimitWaitDuration.observe(waitDuration); 103 + 104 + // Don't manually reset state - let the next API response update it 105 + logger.info("Rate limit wait complete, resuming requests"); 106 + } 107 + } 108 + } 109 + 110 + export async function limit<T>(fn: () => Promise<T>): Promise<T> { 111 + return concurrencyLimiter(async () => { 112 + concurrentRequestsGauge.inc(); 113 + try { 114 + await awaitRateLimit(); 115 + return await fn(); 116 + } finally { 117 + concurrentRequestsGauge.dec(); 118 + } 119 + }); 120 + }
+1 -1
src/logger.ts
··· 1 1 import pino from "pino"; 2 2 3 3 export const logger = pino({ 4 - level: process.env.LOG_LEVEL || "info", 4 + level: process.env.LOG_LEVEL ?? "info", 5 5 formatters: { 6 6 level: (label) => { 7 7 return { level: label };
+111 -80
src/main.ts
··· 1 1 import fs from "node:fs"; 2 - import { 2 + import type { 3 3 CommitCreateEvent, 4 4 CommitUpdateEvent, 5 5 IdentityEvent, 6 - Jetstream, 7 6 } from "@skyware/jetstream"; 7 + import { Jetstream } from "@skyware/jetstream"; 8 + import { login } from "./agent.js"; 8 9 import { 9 10 CURSOR_UPDATE_INTERVAL, 10 11 FIREHOSE_URL, ··· 13 14 } from "./config.js"; 14 15 import { logger } from "./logger.js"; 15 16 import { startMetricsServer } from "./metrics.js"; 17 + import { connectRedis, disconnectRedis } from "./redis.js"; 16 18 import { checkAccountAge } from "./rules/account/age.js"; 17 19 import { checkFacetSpam } from "./rules/facets/facets.js"; 18 20 import { checkHandle } from "./rules/handles/checkHandles.js"; ··· 21 23 checkDescription, 22 24 checkDisplayName, 23 25 } from "./rules/profiles/checkProfiles.js"; 24 - import { Handle, LinkFeature, Post } from "./types.js"; 26 + import type { Post } from "./types.js"; 25 27 26 28 let cursor = 0; 27 29 let cursorUpdateInterval: NodeJS.Timeout; ··· 54 56 const jetstream = new Jetstream({ 55 57 wantedCollections: WANTED_COLLECTION, 56 58 endpoint: FIREHOSE_URL, 57 - cursor: cursor, 59 + cursor, 58 60 }); 59 61 60 62 jetstream.on("open", () => { ··· 110 112 "app.bsky.feed.post", 111 113 (event: CommitCreateEvent<"app.bsky.feed.post">) => { 112 114 const atURI = `at://${event.did}/app.bsky.feed.post/${event.commit.rkey}`; 113 - const hasEmbed = event.commit.record.hasOwnProperty("embed"); 114 - const hasFacets = event.commit.record.hasOwnProperty("facets"); 115 - const hasText = event.commit.record.hasOwnProperty("text"); 115 + const hasEmbed = Object.prototype.hasOwnProperty.call( 116 + event.commit.record, 117 + "embed", 118 + ); 119 + const hasFacets = Object.prototype.hasOwnProperty.call( 120 + event.commit.record, 121 + "facets", 122 + ); 123 + const hasText = Object.prototype.hasOwnProperty.call( 124 + event.commit.record, 125 + "text", 126 + ); 116 127 117 128 const tasks: Promise<void>[] = []; 118 129 ··· 134 145 135 146 // Check account age for quote posts 136 147 if (hasEmbed) { 137 - const embed = event.commit.record.embed; 148 + const { embed } = event.commit.record; 138 149 if ( 139 150 embed && 151 + typeof embed === "object" && 152 + "$type" in embed && 140 153 (embed.$type === "app.bsky.embed.record" || 141 154 embed.$type === "app.bsky.embed.recordWithMedia") 142 155 ) { 143 156 const record = 144 157 embed.$type === "app.bsky.embed.record" 145 - ? embed.record 146 - : embed.record.record; 147 - if (record && record.uri) { 158 + ? (embed as { record: { uri?: string } }).record 159 + : (embed as { record: { record: { uri?: string } } }).record.record; 160 + if (record.uri && typeof record.uri === "string") { 148 161 const quotedPostURI = record.uri; 149 162 const quotedDid = quotedPostURI.split("/")[2]; // Extract DID from at://did/... 150 - 151 - tasks.push( 152 - checkAccountAge({ 153 - actorDid: event.did, 154 - quotedDid, 155 - quotedPostURI, 156 - atURI, 157 - time: event.time_us, 158 - }), 159 - ); 163 + if (quotedDid) { 164 + tasks.push( 165 + checkAccountAge({ 166 + actorDid: event.did, 167 + quotedDid, 168 + quotedPostURI, 169 + atURI, 170 + time: event.time_us, 171 + }), 172 + ); 173 + } 160 174 } 161 175 } 162 176 } ··· 164 178 // Check if the record has facets 165 179 if (hasFacets) { 166 180 // Check for facet spam (hidden mentions with duplicate byte positions) 167 - tasks.push( 168 - checkFacetSpam( 169 - event.did, 170 - event.time_us, 171 - atURI, 172 - event.commit.record.facets!, 173 - ), 174 - ); 181 + const facets = event.commit.record.facets ?? null; 182 + tasks.push(checkFacetSpam(event.did, event.time_us, atURI, facets)); 175 183 176 - const hasLinkType = event.commit.record.facets!.some((facet) => 184 + const hasLinkType = facets?.some((facet) => 177 185 facet.features.some( 178 186 (feature) => feature.$type === "app.bsky.richtext.facet#link", 179 187 ), 180 188 ); 181 189 182 - if (hasLinkType) { 183 - const urls = event.commit.record 184 - .facets!.flatMap((facet) => 185 - facet.features.filter( 186 - (feature) => feature.$type === "app.bsky.richtext.facet#link", 187 - ), 188 - ) 189 - .map((feature: LinkFeature) => feature.uri); 190 + if (hasLinkType && facets) { 191 + for (const facet of facets) { 192 + const linkFeatures = facet.features.filter( 193 + (feature) => feature.$type === "app.bsky.richtext.facet#link", 194 + ); 190 195 191 - urls.forEach((url) => { 192 - const posts: Post[] = [ 193 - { 194 - did: event.did, 195 - time: event.time_us, 196 - rkey: event.commit.rkey, 197 - atURI: atURI, 198 - text: url, 199 - cid: event.commit.cid, 200 - }, 201 - ]; 202 - tasks.push(checkPosts(posts)); 203 - }); 196 + for (const feature of linkFeatures) { 197 + if ("uri" in feature && typeof feature.uri === "string") { 198 + const posts: Post[] = [ 199 + { 200 + did: event.did, 201 + time: event.time_us, 202 + rkey: event.commit.rkey, 203 + atURI, 204 + text: feature.uri, 205 + cid: event.commit.cid, 206 + }, 207 + ]; 208 + tasks.push(checkPosts(posts)); 209 + } 210 + } 211 + } 204 212 } 205 213 } 206 214 ··· 210 218 did: event.did, 211 219 time: event.time_us, 212 220 rkey: event.commit.rkey, 213 - atURI: atURI, 221 + atURI, 214 222 text: event.commit.record.text, 215 223 cid: event.commit.cid, 216 224 }, ··· 219 227 } 220 228 221 229 if (hasEmbed) { 222 - const embed = event.commit.record.embed; 223 - if (embed && embed.$type === "app.bsky.embed.external") { 230 + const { embed } = event.commit.record; 231 + if ( 232 + embed && 233 + typeof embed === "object" && 234 + "$type" in embed && 235 + embed.$type === "app.bsky.embed.external" 236 + ) { 237 + const { external } = embed as { external: { uri: string } }; 224 238 const posts: Post[] = [ 225 239 { 226 240 did: event.did, 227 241 time: event.time_us, 228 242 rkey: event.commit.rkey, 229 - atURI: atURI, 230 - text: embed.external.uri, 243 + atURI, 244 + text: external.uri, 231 245 cid: event.commit.cid, 232 246 }, 233 247 ]; 234 248 tasks.push(checkPosts(posts)); 235 249 } 236 250 237 - if (embed && embed.$type === "app.bsky.embed.recordWithMedia") { 238 - if (embed.media.$type === "app.bsky.embed.external") { 251 + if ( 252 + embed && 253 + typeof embed === "object" && 254 + "$type" in embed && 255 + embed.$type === "app.bsky.embed.recordWithMedia" 256 + ) { 257 + const { media } = embed as { 258 + media: { $type: string; external?: { uri: string } }; 259 + }; 260 + if (media.$type === "app.bsky.embed.external" && media.external) { 239 261 const posts: Post[] = [ 240 262 { 241 263 did: event.did, 242 264 time: event.time_us, 243 265 rkey: event.commit.rkey, 244 - atURI: atURI, 245 - text: embed.media.external.uri, 266 + atURI, 267 + text: media.external.uri, 246 268 cid: event.commit.cid, 247 269 }, 248 270 ]; ··· 256 278 // Check for profile updates 257 279 jetstream.onUpdate( 258 280 "app.bsky.actor.profile", 281 + // eslint-disable-next-line @typescript-eslint/no-misused-promises, @typescript-eslint/require-await 259 282 async (event: CommitUpdateEvent<"app.bsky.actor.profile">) => { 260 283 try { 261 284 if (event.commit.record.displayName || event.commit.record.description) { 262 - checkDescription( 285 + void checkDescription( 263 286 event.did, 264 287 event.time_us, 265 288 event.commit.record.displayName as string, 266 289 event.commit.record.description as string, 267 290 ); 268 - checkDisplayName( 291 + void checkDisplayName( 269 292 event.did, 270 293 event.time_us, 271 294 event.commit.record.displayName as string, ··· 282 305 283 306 jetstream.onCreate( 284 307 "app.bsky.actor.profile", 308 + // eslint-disable-next-line @typescript-eslint/no-misused-promises, @typescript-eslint/require-await 285 309 async (event: CommitCreateEvent<"app.bsky.actor.profile">) => { 286 310 try { 287 311 if (event.commit.record.displayName || event.commit.record.description) { 288 - checkDescription( 312 + void checkDescription( 289 313 event.did, 290 314 event.time_us, 291 315 event.commit.record.displayName as string, 292 316 event.commit.record.description as string, 293 317 ); 294 - checkDisplayName( 318 + void checkDisplayName( 295 319 event.did, 296 320 event.time_us, 297 321 event.commit.record.displayName as string, ··· 305 329 ); 306 330 307 331 // Check for handle updates 308 - jetstream.on("identity", async (event: IdentityEvent) => { 309 - if (event.identity.handle) { 310 - checkHandle(event.identity.did, event.identity.handle, event.time_us); 311 - } 312 - }); 332 + jetstream.on( 333 + "identity", 334 + // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-misused-promises 335 + async (event: IdentityEvent) => { 336 + if (event.identity.handle) { 337 + // checkHandle is sync but calls async functions with void 338 + checkHandle(event.identity.did, event.identity.handle, event.time_us); 339 + } 340 + }, 341 + ); 313 342 314 343 const metricsServer = startMetricsServer(METRICS_PORT); 315 344 316 - /* labelerServer.app.listen({ port: PORT, host: HOST }, (error, address) => { 317 - if (error) { 318 - logger.error("Error starting server: %s", error); 319 - } else { 320 - logger.info(`Labeler server listening on ${address}`); 321 - } 322 - });*/ 345 + logger.info({ process: "MAIN" }, "Connecting to Redis"); 346 + await connectRedis(); 347 + 348 + logger.info({ process: "MAIN" }, "Authenticating with Bluesky"); 349 + await login(); 350 + logger.info({ process: "MAIN" }, "Authentication complete, starting Jetstream"); 323 351 324 352 jetstream.start(); 325 353 326 - function shutdown() { 354 + async function shutdown() { 327 355 try { 328 356 logger.info({ process: "MAIN" }, "Shutting down gracefully"); 329 - fs.writeFileSync("cursor.txt", jetstream.cursor!.toString(), "utf8"); 357 + if (jetstream.cursor !== undefined) { 358 + fs.writeFileSync("cursor.txt", jetstream.cursor.toString(), "utf8"); 359 + } 330 360 jetstream.close(); 331 361 metricsServer.close(); 362 + await disconnectRedis(); 332 363 } catch (error) { 333 364 logger.error({ process: "MAIN", error }, "Error shutting down gracefully"); 334 365 process.exit(1); 335 366 } 336 367 } 337 368 338 - process.on("SIGINT", shutdown); 339 - process.on("SIGTERM", shutdown); 369 + process.on("SIGINT", () => void shutdown()); 370 + process.on("SIGTERM", () => void shutdown());
+40 -4
src/metrics.ts
··· 1 1 import express from "express"; 2 - import { Registry, collectDefaultMetrics } from "prom-client"; 2 + import { Counter, Registry, collectDefaultMetrics } from "prom-client"; 3 + import { HOST } from "./config.js"; 3 4 import { logger } from "./logger.js"; 4 5 5 6 const register = new Registry(); 6 7 collectDefaultMetrics({ register }); 7 8 9 + export const labelsAppliedCounter = new Counter({ 10 + name: "skywatch_labels_applied_total", 11 + help: "Total number of labels applied by type", 12 + labelNames: ["label_type", "target_type"], 13 + registers: [register], 14 + }); 15 + 16 + export const labelsCachedCounter = new Counter({ 17 + name: "skywatch_labels_cached_total", 18 + help: "Total number of labels skipped due to cache/existing label", 19 + labelNames: ["label_type", "target_type", "reason"], 20 + registers: [register], 21 + }); 22 + 23 + export const accountLabelsThresholdAppliedCounter = new Counter({ 24 + name: "skywatch_account_labels_threshold_applied_total", 25 + help: "Total number of account actions applied due to threshold", 26 + labelNames: ["account_label", "action"], 27 + registers: [register], 28 + }); 29 + 30 + export const accountThresholdChecksCounter = new Counter({ 31 + name: "skywatch_account_threshold_checks_total", 32 + help: "Total number of account threshold checks performed", 33 + labelNames: ["post_label"], 34 + registers: [register], 35 + }); 36 + 37 + export const accountThresholdMetCounter = new Counter({ 38 + name: "skywatch_account_threshold_met_total", 39 + help: "Total number of times account thresholds were met", 40 + labelNames: ["account_label"], 41 + registers: [register], 42 + }); 43 + 8 44 const app = express(); 9 45 10 46 app.get("/metrics", (req, res) => { ··· 23 59 }); 24 60 }); 25 61 26 - export const startMetricsServer = (port: number, host = "127.0.0.1") => { 27 - return app.listen(port, host, () => { 62 + export const startMetricsServer = (port: number) => { 63 + return app.listen(port, HOST, () => { 28 64 logger.info( 29 - { process: "METRICS", host, port }, 65 + { process: "METRICS", host: HOST, port }, 30 66 "Metrics server is listening", 31 67 ); 32 68 });
+60 -180
src/moderation.ts
··· 2 2 import { MOD_DID } from "./config.js"; 3 3 import { limit } from "./limits.js"; 4 4 import { logger } from "./logger.js"; 5 + import { labelsAppliedCounter, labelsCachedCounter } from "./metrics.js"; 6 + import { tryClaimPostLabel } from "./redis.js"; 5 7 6 8 const doesLabelExist = ( 7 9 labels: { val: string }[] | undefined, ··· 19 21 label: string, 20 22 comment: string, 21 23 duration: number | undefined, 24 + did?: string, 25 + time?: number, 22 26 ) => { 23 27 await isLoggedIn; 24 28 29 + const claimed = await tryClaimPostLabel(uri, label); 30 + if (!claimed) { 31 + logger.debug( 32 + { process: "MODERATION", uri, label }, 33 + "Post label already claimed in Redis, skipping", 34 + ); 35 + labelsCachedCounter.inc({ 36 + label_type: label, 37 + target_type: "post", 38 + reason: "redis_cache", 39 + }); 40 + return; 41 + } 42 + 25 43 const hasLabel = await checkRecordLabels(uri, label); 26 44 if (hasLabel) { 27 45 logger.debug( 28 46 { process: "MODERATION", uri, label }, 29 47 "Post already has label, skipping", 30 48 ); 49 + labelsCachedCounter.inc({ 50 + label_type: label, 51 + target_type: "post", 52 + reason: "existing_label", 53 + }); 31 54 return; 32 55 } 33 56 57 + logger.info( 58 + { process: "MODERATION", label, did, atURI: uri }, 59 + "Labeling post", 60 + ); 61 + labelsAppliedCounter.inc({ label_type: label, target_type: "post" }); 62 + 34 63 await limit(async () => { 35 64 try { 36 65 const event: { ··· 41 70 durationInHours?: number; 42 71 } = { 43 72 $type: "tools.ozone.moderation.defs#modEventLabel", 44 - comment: comment, 73 + comment, 45 74 createLabelVals: [label], 46 75 negateLabelVals: [], 47 76 }; ··· 50 79 event.durationInHours = duration; 51 80 } 52 81 53 - return agent.tools.ozone.moderation.emitEvent( 82 + await agent.tools.ozone.moderation.emitEvent( 54 83 { 55 - event: event, 84 + event, 56 85 // specify the labeled post by strongRef 57 86 subject: { 58 87 $type: "com.atproto.repo.strongRef", 59 - uri: uri, 60 - cid: cid, 88 + uri, 89 + cid, 61 90 }, 62 91 // put in the rest of the metadata 63 - createdBy: `${agent.did}`, 92 + createdBy: agent.did ?? "", 64 93 createdAt: new Date().toISOString(), 65 94 modTool: { 66 95 name: "skywatch/skywatch-automod", ··· 69 98 { 70 99 encoding: "application/json", 71 100 headers: { 72 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 101 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 73 102 "atproto-accept-labelers": 74 103 "did:plc:ar7c4by46qjdydhdevvrndac;redact", 75 104 }, 76 105 }, 77 106 ); 78 - } catch (e) { 79 - logger.error( 80 - { process: "MODERATION", error: e }, 81 - "Failed to create post label", 82 - ); 83 - } 84 - }); 85 - }; 86 107 87 - export const createAccountLabel = async ( 88 - did: string, 89 - label: string, 90 - comment: string, 91 - ) => { 92 - await isLoggedIn; 93 - 94 - const hasLabel = await checkAccountLabels(did, label); 95 - if (hasLabel) { 96 - logger.debug( 97 - { process: "MODERATION", did, label }, 98 - "Account already has label, skipping", 99 - ); 100 - return; 101 - } 102 - 103 - await limit(async () => { 104 - try { 105 - await agent.tools.ozone.moderation.emitEvent( 106 - { 107 - event: { 108 - $type: "tools.ozone.moderation.defs#modEventLabel", 109 - comment: comment, 110 - createLabelVals: [label], 111 - negateLabelVals: [], 112 - }, 113 - // specify the labeled post by strongRef 114 - subject: { 115 - $type: "com.atproto.admin.defs#repoRef", 116 - did: did, 117 - }, 118 - // put in the rest of the metadata 119 - createdBy: `${agent.did}`, 120 - createdAt: new Date().toISOString(), 121 - modTool: { 122 - name: "skywatch/skywatch-automod", 123 - }, 124 - }, 125 - { 126 - encoding: "application/json", 127 - headers: { 128 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 129 - "atproto-accept-labelers": 130 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 131 - }, 132 - }, 133 - ); 108 + if (did && time) { 109 + try { 110 + // Dynamic import to avoid circular dependency: 111 + // accountThreshold imports from moderation (createAccountLabel, etc.) 112 + // moderation imports from accountThreshold (checkAccountThreshold) 113 + const { checkAccountThreshold } = await import( 114 + "./accountThreshold.js" 115 + ); 116 + await checkAccountThreshold(did, label, time); 117 + } catch (error) { 118 + logger.error( 119 + { process: "ACCOUNT_THRESHOLD", did, label, error }, 120 + "Failed to check account threshold", 121 + ); 122 + } 123 + } 134 124 } catch (e) { 135 125 logger.error( 136 126 { process: "MODERATION", error: e }, 137 - "Failed to create account label", 127 + "Failed to create post label", 138 128 ); 139 129 } 140 130 }); ··· 148 138 await isLoggedIn; 149 139 await limit(async () => { 150 140 try { 151 - return agent.tools.ozone.moderation.emitEvent( 141 + return await agent.tools.ozone.moderation.emitEvent( 152 142 { 153 143 event: { 154 144 $type: "tools.ozone.moderation.defs#modEventReport", 155 - comment: comment, 145 + comment, 156 146 reportType: "com.atproto.moderation.defs#reasonOther", 157 147 }, 158 148 // specify the labeled post by strongRef 159 149 subject: { 160 150 $type: "com.atproto.repo.strongRef", 161 - uri: uri, 162 - cid: cid, 151 + uri, 152 + cid, 163 153 }, 164 154 // put in the rest of the metadata 165 - createdBy: `${agent.did}`, 155 + createdBy: agent.did ?? "", 166 156 createdAt: new Date().toISOString(), 167 157 modTool: { 168 158 name: "skywatch/skywatch-automod", ··· 171 161 { 172 162 encoding: "application/json", 173 163 headers: { 174 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 164 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 175 165 "atproto-accept-labelers": 176 166 "did:plc:ar7c4by46qjdydhdevvrndac;redact", 177 167 }, ··· 186 176 }); 187 177 }; 188 178 189 - export const createAccountComment = async (did: string, comment: string) => { 190 - await isLoggedIn; 191 - await limit(async () => { 192 - try { 193 - await agent.tools.ozone.moderation.emitEvent( 194 - { 195 - event: { 196 - $type: "tools.ozone.moderation.defs#modEventComment", 197 - comment: comment, 198 - }, 199 - // specify the labeled post by strongRef 200 - subject: { 201 - $type: "com.atproto.admin.defs#repoRef", 202 - did: did, 203 - }, 204 - // put in the rest of the metadata 205 - createdBy: `${agent.did}`, 206 - createdAt: new Date().toISOString(), 207 - modTool: { 208 - name: "skywatch/skywatch-automod", 209 - }, 210 - }, 211 - { 212 - encoding: "application/json", 213 - headers: { 214 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 215 - "atproto-accept-labelers": 216 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 217 - }, 218 - }, 219 - ); 220 - } catch (e) { 221 - logger.error( 222 - { process: "MODERATION", error: e }, 223 - "Failed to create account comment", 224 - ); 225 - } 226 - }); 227 - }; 228 - 229 - export const createAccountReport = async (did: string, comment: string) => { 230 - await isLoggedIn; 231 - await limit(async () => { 232 - try { 233 - await agent.tools.ozone.moderation.emitEvent( 234 - { 235 - event: { 236 - $type: "tools.ozone.moderation.defs#modEventReport", 237 - comment: comment, 238 - reportType: "com.atproto.moderation.defs#reasonOther", 239 - }, 240 - // specify the labeled post by strongRef 241 - subject: { 242 - $type: "com.atproto.admin.defs#repoRef", 243 - did: did, 244 - }, 245 - // put in the rest of the metadata 246 - createdBy: `${agent.did}`, 247 - createdAt: new Date().toISOString(), 248 - modTool: { 249 - name: "skywatch/skywatch-automod", 250 - }, 251 - }, 252 - { 253 - encoding: "application/json", 254 - headers: { 255 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 256 - "atproto-accept-labelers": 257 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 258 - }, 259 - }, 260 - ); 261 - } catch (e) { 262 - logger.error( 263 - { process: "MODERATION", error: e }, 264 - "Failed to create account report", 265 - ); 266 - } 267 - }); 268 - }; 269 - 270 - export const checkAccountLabels = async ( 271 - did: string, 272 - label: string, 273 - ): Promise<boolean> => { 274 - await isLoggedIn; 275 - return await limit(async () => { 276 - try { 277 - const response = await agent.tools.ozone.moderation.getRepo( 278 - { did }, 279 - { 280 - headers: { 281 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 282 - "atproto-accept-labelers": 283 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 284 - }, 285 - }, 286 - ); 287 - 288 - return doesLabelExist(response.data.labels, label); 289 - } catch (e) { 290 - logger.error( 291 - { process: "MODERATION", did, error: e }, 292 - "Failed to check account labels", 293 - ); 294 - return false; 295 - } 296 - }); 297 - }; 298 - 299 179 export const checkRecordLabels = async ( 300 180 uri: string, 301 181 label: string, ··· 307 187 { uri }, 308 188 { 309 189 headers: { 310 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 190 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 311 191 "atproto-accept-labelers": 312 192 "did:plc:ar7c4by46qjdydhdevvrndac;redact", 313 193 },
+181
src/redis.ts
··· 1 + import { createClient } from "redis"; 2 + import { REDIS_URL } from "./config.js"; 3 + import { logger } from "./logger.js"; 4 + 5 + export const redisClient = createClient({ 6 + url: REDIS_URL, 7 + }); 8 + 9 + redisClient.on("error", (err: Error) => { 10 + logger.error({ err }, "Redis client error"); 11 + }); 12 + 13 + redisClient.on("connect", () => { 14 + logger.info("Redis client connected"); 15 + }); 16 + 17 + redisClient.on("ready", () => { 18 + logger.info("Redis client ready"); 19 + }); 20 + 21 + redisClient.on("reconnecting", () => { 22 + logger.warn("Redis client reconnecting"); 23 + }); 24 + 25 + export async function connectRedis(): Promise<void> { 26 + try { 27 + await redisClient.connect(); 28 + } catch (err) { 29 + logger.error({ err }, "Failed to connect to Redis"); 30 + throw err; 31 + } 32 + } 33 + 34 + export async function disconnectRedis(): Promise<void> { 35 + try { 36 + await redisClient.quit(); 37 + logger.info("Redis client disconnected"); 38 + } catch (err) { 39 + logger.error({ err }, "Error disconnecting Redis"); 40 + } 41 + } 42 + 43 + function getPostLabelCacheKey(atURI: string, label: string): string { 44 + return `post-label:${atURI}:${label}`; 45 + } 46 + 47 + function getAccountLabelCacheKey(did: string, label: string): string { 48 + return `account-label:${did}:${label}`; 49 + } 50 + 51 + export async function tryClaimPostLabel( 52 + atURI: string, 53 + label: string, 54 + ): Promise<boolean> { 55 + try { 56 + const key = getPostLabelCacheKey(atURI, label); 57 + const result = await redisClient.set(key, "1", { 58 + NX: true, 59 + EX: 60 * 60 * 24 * 7, 60 + }); 61 + return result === "OK"; 62 + } catch (err) { 63 + logger.warn( 64 + { err, atURI, label }, 65 + "Error claiming post label in Redis, allowing through", 66 + ); 67 + return true; 68 + } 69 + } 70 + 71 + export async function tryClaimAccountLabel( 72 + did: string, 73 + label: string, 74 + ): Promise<boolean> { 75 + try { 76 + const key = getAccountLabelCacheKey(did, label); 77 + const result = await redisClient.set(key, "1", { 78 + NX: true, 79 + EX: 60 * 60 * 24 * 7, 80 + }); 81 + return result === "OK"; 82 + } catch (err) { 83 + logger.warn( 84 + { err, did, label }, 85 + "Error claiming account label in Redis, allowing through", 86 + ); 87 + return true; 88 + } 89 + } 90 + 91 + export async function tryClaimAccountComment( 92 + did: string, 93 + atURI: string, 94 + ): Promise<boolean> { 95 + try { 96 + const key = `account-comment:${did}:${atURI}`; 97 + const result = await redisClient.set(key, "1", { 98 + NX: true, 99 + EX: 60 * 60 * 24 * 7, 100 + }); 101 + return result === "OK"; 102 + } catch (err) { 103 + logger.warn( 104 + { err, did, atURI }, 105 + "Error claiming account comment in Redis, allowing through", 106 + ); 107 + return true; 108 + } 109 + } 110 + 111 + function getPostLabelTrackingKey( 112 + did: string, 113 + label: string, 114 + windowDays: number, 115 + ): string { 116 + return `account-post-labels:${did}:${label}:${windowDays.toString()}`; 117 + } 118 + 119 + export async function trackPostLabelForAccount( 120 + did: string, 121 + label: string, 122 + timestamp: number, 123 + windowDays: number, 124 + ): Promise<void> { 125 + try { 126 + const key = getPostLabelTrackingKey(did, label, windowDays); 127 + const windowStartTime = timestamp - windowDays * 24 * 60 * 60 * 1000000; 128 + 129 + await redisClient.zRemRangeByScore(key, "-inf", windowStartTime); 130 + 131 + await redisClient.zAdd(key, { 132 + score: timestamp, 133 + value: timestamp.toString(), 134 + }); 135 + 136 + const ttlSeconds = (windowDays + 1) * 24 * 60 * 60; 137 + await redisClient.expire(key, ttlSeconds); 138 + 139 + logger.debug( 140 + { did, label, timestamp, windowDays }, 141 + "Tracked post label for account", 142 + ); 143 + } catch (err) { 144 + logger.error( 145 + { err, did, label, timestamp, windowDays }, 146 + "Error tracking post label in Redis", 147 + ); 148 + throw err; 149 + } 150 + } 151 + 152 + export async function getPostLabelCountInWindow( 153 + did: string, 154 + labels: string[], 155 + windowDays: number, 156 + currentTime: number, 157 + ): Promise<number> { 158 + try { 159 + const windowStartTime = currentTime - windowDays * 24 * 60 * 60 * 1000000; 160 + let totalCount = 0; 161 + 162 + for (const label of labels) { 163 + const key = getPostLabelTrackingKey(did, label, windowDays); 164 + const count = await redisClient.zCount(key, windowStartTime, "+inf"); 165 + totalCount += count; 166 + } 167 + 168 + logger.debug( 169 + { did, labels, windowDays, totalCount }, 170 + "Retrieved post label count in window", 171 + ); 172 + 173 + return totalCount; 174 + } catch (err) { 175 + logger.error( 176 + { err, did, labels, windowDays }, 177 + "Error getting post label count from Redis", 178 + ); 179 + throw err; 180 + } 181 + }
+13 -10
src/rules/account/age.ts
··· 1 + import { ACCOUNT_AGE_CHECKS } from "../../../rules/accountAge.js"; 2 + import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 3 + import { 4 + checkAccountLabels, 5 + createAccountLabel, 6 + } from "../../accountModeration.js"; 1 7 import { agent, isLoggedIn } from "../../agent.js"; 2 8 import { PLC_URL } from "../../config.js"; 3 - import { GLOBAL_ALLOW } from "../../constants.js"; 4 9 import { logger } from "../../logger.js"; 5 - import { checkAccountLabels, createAccountLabel } from "../../moderation.js"; 6 - import { ACCOUNT_AGE_CHECKS } from "./ageConstants.js"; 7 10 8 11 interface InteractionContext { 9 12 // For replies ··· 36 39 try { 37 40 const response = await fetch(`https://${PLC_URL}/${did}/log/audit`); 38 41 if (response.ok) { 39 - const didDoc = await response.json(); 42 + const didDoc = (await response.json()) as unknown; 40 43 41 44 // The plc directory returns an array of operations, first one is creation 42 45 if (Array.isArray(didDoc) && didDoc.length > 0) { 43 - const createdAt = didDoc[0].createdAt; 44 - if (createdAt) { 45 - return new Date(createdAt); 46 + const firstOp = didDoc[0] as { createdAt?: string }; 47 + if (firstOp.createdAt) { 48 + return new Date(firstOp.createdAt); 46 49 } 47 50 } 48 51 } else { ··· 51 54 "Failed to fetch DID document, trying profile fallback", 52 55 ); 53 56 } 54 - } catch (plcError) { 57 + } catch { 55 58 logger.debug( 56 59 { process: "ACCOUNT_AGE", did }, 57 60 "Error fetching from plc directory, trying profile fallback", ··· 65 68 if (profile.data.createdAt) { 66 69 return new Date(profile.data.createdAt); 67 70 } 68 - } catch (profileError) { 71 + } catch { 69 72 logger.debug({ process: "ACCOUNT_AGE", did }, "Failed to get profile"); 70 73 } 71 74 ··· 237 240 await createAccountLabel( 238 241 context.actorDid, 239 242 check.label, 240 - `${context.time}: ${check.comment} - Account created within monitored range - Interaction: ${context.atURI}`, 243 + `${context.time.toString()}: ${check.comment} - Account created within monitored range - Interaction: ${context.atURI}`, 241 244 ); 242 245 243 246 // Only apply one label per interaction
-40
src/rules/account/ageConstants.ts
··· 1 - import { AccountAgeCheck } from "../../types.js"; 2 - 3 - /** 4 - * Account age monitoring configurations 5 - * 6 - * Each configuration monitors replies and/or quote posts to specified DIDs or posts 7 - * and labels accounts that were created within a specific time window. 8 - * 9 - * Example use cases: 10 - * - Monitor replies/quotes to high-profile accounts during harassment campaigns 11 - * - Flag sock puppet accounts created to participate in coordinated harassment 12 - * - Detect brigading on specific controversial posts 13 - */ 14 - export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [ 15 - // Example: Monitor replies to specific accounts 16 - // { 17 - // monitoredDIDs: [ 18 - // "did:plc:example123", // High-profile account 1 19 - // "did:plc:example456", // High-profile account 2 20 - // ], 21 - // anchorDate: "2025-01-15", // Date when harassment campaign started 22 - // maxAgeDays: 7, // Flag accounts less than 7 days old 23 - // label: "new-account-reply", 24 - // comment: "New account replying to monitored user during campaign", 25 - // expires: "2025-02-15", // Optional: automatically stop this check after this date 26 - // }, 27 - // 28 - // Example: Monitor replies to specific posts 29 - // { 30 - // monitoredPostURIs: [ 31 - // "at://did:plc:example123/app.bsky.feed.post/abc123", 32 - // "at://did:plc:example456/app.bsky.feed.post/def456", 33 - // ], 34 - // anchorDate: "2025-01-15", 35 - // maxAgeDays: 7, 36 - // label: "brigading-suspect", 37 - // comment: "New account replying to specific targeted post", 38 - // expires: "2025-02-15", 39 - // }, 40 - ];
+3 -3
src/rules/account/countStarterPacks.ts
··· 1 + import { createAccountLabel } from "../../accountModeration.js"; 1 2 import { agent, isLoggedIn } from "../../agent.js"; 2 3 import { limit } from "../../limits.js"; 3 4 import { logger } from "../../logger.js"; 4 - import { createAccountLabel } from "../../moderation.js"; 5 5 6 6 const ALLOWED_DIDS = ["did:plc:gpunjjgvlyb4racypz3yfiq4"]; 7 7 ··· 32 32 "Labeling account with excessive starter packs", 33 33 ); 34 34 35 - createAccountLabel( 35 + void createAccountLabel( 36 36 did, 37 37 "follow-farming", 38 - `${time}: Account has ${starterPacks} starter packs`, 38 + `${time.toString()}: Account has ${starterPacks.toString()} starter packs`, 39 39 ); 40 40 } 41 41 } catch (error) {
+10 -6
src/rules/account/tests/age.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { ACCOUNT_AGE_CHECKS } from "../../../../rules/accountAge.js"; 3 + import { GLOBAL_ALLOW } from "../../../../rules/constants.js"; 4 + import { 5 + checkAccountLabels, 6 + createAccountLabel, 7 + } from "../../../accountModeration.js"; 2 8 import { agent } from "../../../agent.js"; 3 - import { GLOBAL_ALLOW } from "../../../constants.js"; 9 + import { PLC_URL } from "../../../config.js"; 4 10 import { logger } from "../../../logger.js"; 5 - import { checkAccountLabels, createAccountLabel } from "../../../moderation.js"; 6 11 import { 7 12 calculateAccountAge, 8 13 checkAccountAge, 9 14 getAccountCreationDate, 10 15 } from "../age.js"; 11 - import { ACCOUNT_AGE_CHECKS } from "../ageConstants.js"; 12 16 13 17 // Mock dependencies 14 18 vi.mock("../../../agent.js", () => ({ ··· 27 31 }, 28 32 })); 29 33 30 - vi.mock("../../../moderation.js", () => ({ 34 + vi.mock("../../../accountModeration.js", () => ({ 31 35 createAccountLabel: vi.fn(), 32 36 checkAccountLabels: vi.fn(), 33 37 })); 34 38 35 - vi.mock("../../../constants.js", () => ({ 39 + vi.mock("../../../../rules/constants.js", () => ({ 36 40 GLOBAL_ALLOW: [], 37 41 })); 38 42 ··· 97 101 const result = await getAccountCreationDate("did:plc:test123"); 98 102 99 103 expect(global.fetch).toHaveBeenCalledWith( 100 - "https://plc.directory/did:plc:test123/log/audit", 104 + `https://${PLC_URL}/did:plc:test123/log/audit`, 101 105 ); 102 106 expect(result).toEqual(new Date("2025-01-10T12:00:00.000Z")); 103 107 });
+2 -2
src/rules/account/tests/countStarterPacks.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { createAccountLabel } from "../../../accountModeration.js"; 2 3 import { agent } from "../../../agent.js"; 3 4 import { limit } from "../../../limits.js"; 4 5 import { logger } from "../../../logger.js"; 5 - import { createAccountLabel } from "../../../moderation.js"; 6 6 import { countStarterPacks } from "../countStarterPacks.js"; 7 7 8 8 // Mock dependencies ··· 28 28 }, 29 29 })); 30 30 31 - vi.mock("../../../moderation.js", () => ({ 31 + vi.mock("../../../accountModeration.js", () => ({ 32 32 createAccountLabel: vi.fn(), 33 33 })); 34 34
+13 -6
src/rules/facets/facets.ts
··· 1 + import { createAccountLabel } from "../../accountModeration.js"; 1 2 import { logger } from "../../logger.js"; 2 - import { createAccountLabel } from "../../moderation.js"; 3 - import { Facet } from "../../types.js"; 3 + import type { Facet } from "../../types.js"; 4 4 5 5 // Threshold for duplicate facet positions before flagging as spam 6 6 export const FACET_SPAM_THRESHOLD = 1; ··· 23 23 did: string, 24 24 time: number, 25 25 atURI: string, 26 - facets: Facet[], 26 + facets: Facet[] | null, 27 27 ): Promise<void> => { 28 28 // Check allowlist 29 29 if (FACET_SPAM_ALLOWLIST.includes(did)) { ··· 47 47 ); 48 48 49 49 if (mentionFeature && "did" in mentionFeature) { 50 - const key = `${facet.index.byteStart}:${facet.index.byteEnd}`; 50 + const key = `${facet.index.byteStart.toString()}:${facet.index.byteEnd.toString()}`; 51 51 if (!positionMap.has(key)) { 52 52 positionMap.set(key, new Set()); 53 53 } 54 - positionMap.get(key)!.add(mentionFeature.did as string); 54 + const dids = positionMap.get(key); 55 + if ( 56 + dids && 57 + "did" in mentionFeature && 58 + typeof mentionFeature.did === "string" 59 + ) { 60 + dids.add(mentionFeature.did); 61 + } 55 62 } 56 63 } 57 64 ··· 73 80 await createAccountLabel( 74 81 did, 75 82 FACET_SPAM_LABEL, 76 - `${time}: ${FACET_SPAM_COMMENT} - ${uniqueCount} unique mentions at position ${position} in ${atURI}`, 83 + `${time.toString()}: ${FACET_SPAM_COMMENT} - ${uniqueCount.toString()} unique mentions at position ${position} in ${atURI}`, 77 84 ); 78 85 79 86 // Only label once per post even if multiple positions are suspicious
+3 -3
src/rules/facets/tests/facets.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { createAccountLabel } from "../../../accountModeration.js"; 2 3 import { logger } from "../../../logger.js"; 3 - import { createAccountLabel } from "../../../moderation.js"; 4 - import { Facet } from "../../../types.js"; 4 + import type { Facet } from "../../../types.js"; 5 5 import { 6 6 FACET_SPAM_ALLOWLIST, 7 7 FACET_SPAM_COMMENT, ··· 11 11 } from "../facets.js"; 12 12 13 13 // Mock dependencies 14 - vi.mock("../../../moderation.js", () => ({ 14 + vi.mock("../../../accountModeration.js", () => ({ 15 15 createAccountLabel: vi.fn(), 16 16 })); 17 17
+9 -6
src/rules/handles/checkHandles.test.ts
··· 3 3 createAccountComment, 4 4 createAccountLabel, 5 5 createAccountReport, 6 - } from "../../moderation.js"; 6 + } from "../../accountModeration.js"; 7 7 import { checkHandle } from "./checkHandles.js"; 8 8 9 9 // Mock dependencies 10 - vi.mock("../../moderation.js", () => ({ 10 + vi.mock("../../accountModeration.js", () => ({ 11 11 createAccountReport: vi.fn(), 12 12 createAccountComment: vi.fn(), 13 13 createAccountLabel: vi.fn(), ··· 21 21 }, 22 22 })); 23 23 24 - vi.mock("../../constants.js", () => ({ 24 + vi.mock("../../../rules/constants.js", () => ({ 25 25 GLOBAL_ALLOW: ["did:plc:globalallow"], 26 26 })); 27 27 28 28 // Mock HANDLE_CHECKS with various test scenarios 29 - vi.mock("./constants.js", () => ({ 29 + vi.mock("../../../rules/handles.js", () => ({ 30 30 HANDLE_CHECKS: [ 31 31 { 32 32 label: "spam", ··· 140 140 expect(createAccountComment).toHaveBeenCalledWith( 141 141 "did:plc:user1", 142 142 `${time}: Scam detected - scam-account`, 143 + "handle:did:plc:user1:scam-account", 143 144 ); 144 145 }); 145 146 }); ··· 181 182 expect(createAccountComment).toHaveBeenCalledWith( 182 183 "did:plc:user1", 183 184 `${time}: Scam detected - scam-user`, 185 + "handle:did:plc:user1:scam-user", 184 186 ); 185 187 }); 186 188 ··· 206 208 expect(createAccountComment).toHaveBeenCalledWith( 207 209 "did:plc:user1", 208 210 `${time}: Multi-action triggered - dangerous-account`, 211 + "handle:did:plc:user1:dangerous-account", 209 212 ); 210 213 expect(createAccountLabel).toHaveBeenCalledWith( 211 214 "did:plc:user1", ··· 219 222 it("should process all matching rules", async () => { 220 223 vi.resetModules(); 221 224 // Re-import with a mock that has overlapping patterns 222 - vi.doMock("./constants.js", () => ({ 225 + vi.doMock("../../../rules/handles.js", () => ({ 223 226 HANDLE_CHECKS: [ 224 227 { 225 228 label: "pattern1", ··· 267 270 }); 268 271 269 272 it("should handle very long handles", async () => { 270 - const longHandle = "spam-" + "a".repeat(1000); 273 + const longHandle = `spam-${"a".repeat(1000)}`; 271 274 const time = Date.now(); 272 275 await checkHandle("did:plc:user1", longHandle, time); 273 276
+21 -24
src/rules/handles/checkHandles.ts
··· 1 - import { GLOBAL_ALLOW } from "../../constants.js"; 2 - import { logger } from "../../logger.js"; 1 + import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 2 + import { HANDLE_CHECKS } from "../../../rules/handles.js"; 3 3 import { 4 4 createAccountComment, 5 5 createAccountLabel, 6 6 createAccountReport, 7 - } from "../../moderation.js"; 8 - import { HANDLE_CHECKS } from "./constants.js"; 7 + } from "../../accountModeration.js"; 8 + import { logger } from "../../logger.js"; 9 9 10 - export const checkHandle = async ( 10 + export const checkHandle = ( 11 11 did: string, 12 12 handle: string, 13 13 time: number, 14 - ) => { 14 + ): void => { 15 15 // Check if DID is whitelisted 16 16 if (GLOBAL_ALLOW.includes(did)) { 17 17 logger.warn( ··· 45 45 } 46 46 } 47 47 48 - if (checkList.toLabel === true) { 49 - logger.info( 50 - { process: "CHECKHANDLE", did, handle, time, label: checkList.label }, 51 - "Labeling account", 48 + if (checkList.toLabel) { 49 + void createAccountLabel( 50 + did, 51 + checkList.label, 52 + `${time.toString()}: ${checkList.comment} - ${handle}`, 52 53 ); 53 - { 54 - createAccountLabel( 55 - did, 56 - `${checkList.label}`, 57 - `${time}: ${checkList.comment} - ${handle}`, 58 - ); 59 - } 60 54 } 61 55 62 - if (checkList.reportAcct === true) { 56 + if (checkList.reportAcct) { 63 57 logger.info( 64 58 { process: "CHECKHANDLE", did, handle, time, label: checkList.label }, 65 59 "Reporting account", 66 60 ); 67 - createAccountReport(did, `${time}: ${checkList.comment} - ${handle}`); 61 + void createAccountReport( 62 + did, 63 + `${time.toString()}: ${checkList.comment} - ${handle}`, 64 + ); 68 65 } 69 66 70 - if (checkList.commentAcct === true) { 71 - logger.info( 72 - { process: "CHECKHANDLE", did, handle, time, label: checkList.label }, 73 - "Commenting on account", 67 + if (checkList.commentAcct) { 68 + void createAccountComment( 69 + did, 70 + `${time.toString()}: ${checkList.comment} - ${handle}`, 71 + `handle:${did}:${handle}`, 74 72 ); 75 - createAccountComment(did, `${time}: ${checkList.comment} - ${handle}`); 76 73 } 77 74 } 78 75 });
-89
src/rules/handles/constants.example.ts
··· 1 - import { Checks } from "../../types.js"; 2 - 3 - /** 4 - * Example handle check configurations 5 - * 6 - * This file demonstrates how to configure handle-based moderation rules. 7 - * Copy this file to constants.ts and customize for your labeler's needs. 8 - * 9 - * Each check can match against handles, display names, and/or descriptions 10 - * based on the flags you set (description: true, displayName: true). 11 - */ 12 - 13 - export const HANDLE_CHECKS: Checks[] = [ 14 - // Example 1: Simple pattern matching with whitelist 15 - { 16 - label: "spam-indicator", 17 - comment: "Handle matches common spam patterns", 18 - reportAcct: false, 19 - commentAcct: false, 20 - toLabel: true, 21 - check: new RegExp( 22 - "follow.*?back|gain.*?followers|crypto.*?giveaway|free.*?money", 23 - "i", 24 - ), 25 - whitelist: new RegExp("legitimate.*?business", "i"), 26 - }, 27 - 28 - // Example 2: Check specific domain patterns 29 - { 30 - label: "suspicious-domain", 31 - comment: "Handle uses suspicious domain pattern", 32 - reportAcct: false, 33 - commentAcct: false, 34 - toLabel: true, 35 - check: new RegExp("(?:suspicious-site\\.example)", "i"), 36 - }, 37 - 38 - // Example 3: Check with display name and description matching 39 - { 40 - label: "potential-impersonator", 41 - comment: "Account may be impersonating verified entities", 42 - description: true, 43 - displayName: true, 44 - reportAcct: false, 45 - commentAcct: false, 46 - toLabel: true, 47 - check: new RegExp( 48 - "official.*?support|customer.*?service.*?rep|verified.*?account", 49 - "i", 50 - ), 51 - // Exclude accounts that are actually legitimate 52 - ignoredDIDs: [ 53 - "did:plc:example123", // Real customer support account 54 - "did:plc:example456", // Verified business account 55 - ], 56 - }, 57 - 58 - // Example 4: Pattern with specific character variations 59 - { 60 - label: "suspicious-pattern", 61 - comment: "Handle contains suspicious character patterns", 62 - reportAcct: false, 63 - commentAcct: false, 64 - toLabel: true, 65 - check: new RegExp("[a-z]{2,}[0-9]{6,}|random.*?numbers.*?[0-9]{4,}", "i"), 66 - whitelist: new RegExp("year[0-9]{4}", "i"), 67 - ignoredDIDs: [ 68 - "did:plc:example789", // Legitimate account with number pattern 69 - ], 70 - }, 71 - 72 - // Example 5: Brand protection 73 - { 74 - label: "brand-impersonation", 75 - comment: "Potential brand impersonation detected", 76 - reportAcct: false, 77 - commentAcct: false, 78 - toLabel: true, 79 - check: new RegExp("example-?brand|cool-?company|awesome-?corp", "i"), 80 - whitelist: new RegExp( 81 - "anti-example-brand|not-cool-company|parody.*awesome-corp", 82 - "i", 83 - ), 84 - ignoredDIDs: [ 85 - "did:plc:exampleabc", // Official brand account 86 - "did:plc:exampledef", // Authorized partner 87 - ], 88 - }, 89 - ];
+22 -38
src/rules/posts/checkPosts.ts
··· 1 - import { GLOBAL_ALLOW } from "../../constants.js"; 2 - import { logger } from "../../logger.js"; 1 + import { GLOBAL_ALLOW, LINK_SHORTENER } from "../../../rules/constants.js"; 2 + import { POST_CHECKS } from "../../../rules/posts.js"; 3 3 import { 4 4 createAccountComment, 5 5 createAccountReport, 6 - createPostLabel, 7 - createPostReport, 8 - } from "../../moderation.js"; 9 - import { Post } from "../../types.js"; 6 + } from "../../accountModeration.js"; 7 + import { logger } from "../../logger.js"; 8 + import { createPostLabel, createPostReport } from "../../moderation.js"; 9 + import type { Post } from "../../types.js"; 10 10 import { getFinalUrl } from "../../utils/getFinalUrl.js"; 11 11 import { getLanguage } from "../../utils/getLanguage.js"; 12 12 import { countStarterPacks } from "../account/countStarterPacks.js"; 13 - import { LINK_SHORTENER, POST_CHECKS } from "./constants.js"; 14 13 15 14 export const checkPosts = async (post: Post[]) => { 16 15 if (GLOBAL_ALLOW.includes(post[0].did)) { ··· 103 102 } 104 103 } 105 104 106 - countStarterPacks(post[0].did, post[0].time); 105 + void countStarterPacks(post[0].did, post[0].time); 107 106 108 - if (checkPost.toLabel === true) { 109 - logger.info( 110 - { 111 - process: "CHECKPOSTS", 112 - label: checkPost.label, 113 - did: post[0].did, 114 - atURI: post[0].atURI, 115 - }, 116 - "Labeling post", 117 - ); 118 - createPostLabel( 107 + if (checkPost.toLabel) { 108 + void createPostLabel( 119 109 post[0].atURI, 120 110 post[0].cid, 121 - `${checkPost.label}`, 122 - `${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 111 + checkPost.label, 112 + `${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 123 113 checkPost.duration, 114 + post[0].did, 115 + post[0].time, 124 116 ); 125 117 } 126 118 ··· 134 126 }, 135 127 "Reporting post", 136 128 ); 137 - createPostReport( 129 + void createPostReport( 138 130 post[0].atURI, 139 131 post[0].cid, 140 - `${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 132 + `${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 141 133 ); 142 134 } 143 135 144 - if (checkPost.reportAcct === true) { 136 + if (checkPost.reportAcct) { 145 137 logger.info( 146 138 { 147 139 process: "CHECKPOSTS", ··· 151 143 }, 152 144 "Reporting account", 153 145 ); 154 - createAccountReport( 146 + void createAccountReport( 155 147 post[0].did, 156 - `${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 148 + `${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 157 149 ); 158 150 } 159 151 160 - if (checkPost.commentAcct === true) { 161 - logger.info( 162 - { 163 - process: "CHECKPOSTS", 164 - label: checkPost.label, 165 - did: post[0].did, 166 - atURI: post[0].atURI, 167 - }, 168 - "Commenting on account", 169 - ); 170 - createAccountComment( 152 + if (checkPost.commentAcct) { 153 + void createAccountComment( 171 154 post[0].did, 172 - `${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 155 + `${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 156 + post[0].atURI, 173 157 ); 174 158 } 175 159 }
src/rules/posts/constants.example.ts

This is a binary file and will not be displayed.

+25 -44
src/rules/posts/tests/checkPosts.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { logger } from "../../../logger.js"; 3 2 import { 4 3 createAccountComment, 5 4 createAccountReport, 6 - createPostLabel, 7 - createPostReport, 8 - } from "../../../moderation.js"; 9 - import { Post } from "../../../types.js"; 5 + } from "../../../accountModeration.js"; 6 + import { logger } from "../../../logger.js"; 7 + import { createPostLabel, createPostReport } from "../../../moderation.js"; 8 + import type { Post } from "../../../types.js"; 10 9 import { getFinalUrl } from "../../../utils/getFinalUrl.js"; 11 10 import { getLanguage } from "../../../utils/getLanguage.js"; 12 11 import { countStarterPacks } from "../../account/countStarterPacks.js"; 13 12 import { checkPosts } from "../checkPosts.js"; 14 13 15 14 // Mock dependencies 16 - vi.mock("../constants.js", () => ({ 15 + vi.mock("../../../../rules/constants.js", () => ({ 16 + GLOBAL_ALLOW: ["did:plc:globalallow"], 17 17 LINK_SHORTENER: /tinyurl\.com|bit\.ly/i, 18 + })); 19 + 20 + vi.mock("../../../../rules/posts.js", () => ({ 18 21 POST_CHECKS: [ 19 22 { 20 23 label: "test-label", ··· 80 83 countStarterPacks: vi.fn(), 81 84 })); 82 85 86 + vi.mock("../../../accountModeration.js", () => ({ 87 + createAccountReport: vi.fn(), 88 + createAccountComment: vi.fn(), 89 + })); 90 + 83 91 vi.mock("../../../moderation.js", () => ({ 84 92 createPostLabel: vi.fn(), 85 - createAccountReport: vi.fn(), 86 - createAccountComment: vi.fn(), 87 93 createPostReport: vi.fn(), 88 94 })); 89 95 ··· 93 99 94 100 vi.mock("../../../utils/getFinalUrl.js", () => ({ 95 101 getFinalUrl: vi.fn(), 96 - })); 97 - 98 - vi.mock("../../../constants.js", () => ({ 99 - GLOBAL_ALLOW: ["did:plc:globalallow"], 100 102 })); 101 103 102 104 describe("checkPosts", () => { ··· 244 246 245 247 await checkPosts(post); 246 248 247 - expect(logger.info).toHaveBeenCalledWith( 248 - { 249 - process: "CHECKPOSTS", 250 - label: "test-label", 251 - did: post[0].did, 252 - atURI: post[0].atURI, 253 - }, 254 - "Labeling post", 255 - ); 256 249 expect(createPostLabel).toHaveBeenCalledWith( 257 250 post[0].atURI, 258 251 post[0].cid, 259 252 "test-label", 260 253 expect.stringContaining("Test comment"), 261 254 undefined, 255 + post[0].did, 256 + post[0].time, 262 257 ); 263 258 }); 264 259 ··· 292 287 "language-specific", 293 288 expect.any(String), 294 289 undefined, 290 + post[0].did, 291 + post[0].time, 295 292 ); 296 293 }); 297 294 ··· 345 342 "whitelisted-test", 346 343 expect.any(String), 347 344 undefined, 345 + post[0].did, 346 + post[0].time, 348 347 ); 349 348 }); 350 349 }); ··· 389 388 "ignored-did", 390 389 expect.any(String), 391 390 undefined, 391 + "did:plc:notignored", 392 + post[0].time, 392 393 ); 393 394 }); 394 395 }); ··· 405 406 "all-actions", 406 407 expect.any(String), 407 408 undefined, 409 + post[0].did, 410 + post[0].time, 408 411 ); 409 412 expect(createPostReport).toHaveBeenCalledWith( 410 413 post[0].atURI, ··· 418 421 expect(createAccountComment).toHaveBeenCalledWith( 419 422 post[0].did, 420 423 expect.any(String), 421 - ); 422 - }); 423 - 424 - it("should log all moderation actions", async () => { 425 - const post = createMockPost({ text: "report this" }); 426 - 427 - await checkPosts(post); 428 - 429 - expect(logger.info).toHaveBeenCalledWith( 430 - expect.objectContaining({ label: "all-actions" }), 431 - "Labeling post", 432 - ); 433 - expect(logger.info).toHaveBeenCalledWith( 434 - expect.objectContaining({ label: "all-actions" }), 435 - "Reporting post", 436 - ); 437 - expect(logger.info).toHaveBeenCalledWith( 438 - expect.objectContaining({ label: "all-actions" }), 439 - "Reporting account", 440 - ); 441 - expect(logger.info).toHaveBeenCalledWith( 442 - expect.objectContaining({ label: "all-actions" }), 443 - "Commenting on account", 424 + expect.any(String), 444 425 ); 445 426 }); 446 427 });
+26 -68
src/rules/profiles/checkProfiles.ts
··· 1 - import { GLOBAL_ALLOW } from "../../constants.js"; 2 - import { logger } from "../../logger.js"; 1 + import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 2 + import { PROFILE_CHECKS } from "../../../rules/profiles.js"; 3 3 import { 4 4 createAccountComment, 5 5 createAccountLabel, 6 6 createAccountReport, 7 - } from "../../moderation.js"; 8 - import { PROFILE_CHECKS } from "../../rules/profiles/constants.js"; 7 + } from "../../accountModeration.js"; 8 + import { logger } from "../../logger.js"; 9 9 import { getLanguage } from "../../utils/getLanguage.js"; 10 10 11 11 export const checkDescription = async ( ··· 64 64 } 65 65 } 66 66 67 - if (checkProfiles.toLabel === true) { 68 - createAccountLabel( 67 + if (checkProfiles.toLabel) { 68 + void createAccountLabel( 69 69 did, 70 - `${checkProfiles.label}`, 71 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 72 - ); 73 - logger.info( 74 - { 75 - process: "CHECKDESCRIPTION", 76 - did, 77 - time, 78 - displayName, 79 - description, 80 - label: checkProfiles.label, 81 - }, 82 - "Labeling account", 70 + checkProfiles.label, 71 + `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 83 72 ); 84 73 } 85 74 86 - if (checkProfiles.reportAcct === true) { 87 - createAccountReport( 75 + if (checkProfiles.reportAcct) { 76 + void createAccountReport( 88 77 did, 89 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 78 + `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 90 79 ); 91 80 logger.info( 92 81 { ··· 101 90 ); 102 91 } 103 92 104 - if (checkProfiles.commentAcct === true) { 105 - createAccountComment( 93 + if (checkProfiles.commentAcct) { 94 + void createAccountComment( 106 95 did, 107 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 108 - ); 109 - logger.info( 110 - { 111 - process: "CHECKDESCRIPTION", 112 - did, 113 - time, 114 - displayName, 115 - description, 116 - label: checkProfiles.label, 117 - }, 118 - "Commenting on account", 96 + `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 97 + `profile:${did}:${time.toString()}`, 119 98 ); 120 99 } 121 100 } ··· 180 159 } 181 160 } 182 161 183 - if (checkProfiles.toLabel === true) { 184 - createAccountLabel( 162 + if (checkProfiles.toLabel) { 163 + void createAccountLabel( 185 164 did, 186 - `${checkProfiles.label}`, 187 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 188 - ); 189 - logger.info( 190 - { 191 - process: "CHECKDISPLAYNAME", 192 - did, 193 - time, 194 - displayName, 195 - description, 196 - label: checkProfiles.label, 197 - }, 198 - "Labeling account", 165 + checkProfiles.label, 166 + `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 199 167 ); 200 168 } 201 169 202 - if (checkProfiles.reportAcct === true) { 203 - createAccountReport( 170 + if (checkProfiles.reportAcct) { 171 + void createAccountReport( 204 172 did, 205 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 173 + `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 206 174 ); 207 175 logger.info( 208 176 { ··· 217 185 ); 218 186 } 219 187 220 - if (checkProfiles.commentAcct === true) { 221 - createAccountComment( 188 + if (checkProfiles.commentAcct) { 189 + void createAccountComment( 222 190 did, 223 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 224 - ); 225 - logger.info( 226 - { 227 - process: "CHECKDISPLAYNAME", 228 - did, 229 - time, 230 - displayName, 231 - description, 232 - label: checkProfiles.label, 233 - }, 234 - "Commenting on account", 191 + `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 192 + `profile:${did}:${time.toString()}`, 235 193 ); 236 194 } 237 195 }
src/rules/profiles/constants.example.ts

This is a binary file and will not be displayed.

+6 -49
src/rules/profiles/tests/checkProfiles.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { logger } from "../../../logger.js"; 3 2 import { 4 3 createAccountComment, 5 4 createAccountLabel, 6 5 createAccountReport, 7 - } from "../../../moderation.js"; 6 + } from "../../../accountModeration.js"; 7 + import { logger } from "../../../logger.js"; 8 8 import { getLanguage } from "../../../utils/getLanguage.js"; 9 9 import { checkDescription, checkDisplayName } from "../checkProfiles.js"; 10 10 11 11 // Mock dependencies 12 - vi.mock("../constants.js", () => ({ 12 + vi.mock("../../../../rules/profiles.js", () => ({ 13 13 PROFILE_CHECKS: [ 14 14 { 15 15 label: "test-description", ··· 96 96 }, 97 97 })); 98 98 99 - vi.mock("../../../moderation.js", () => ({ 99 + vi.mock("../../../accountModeration.js", () => ({ 100 100 createAccountLabel: vi.fn(), 101 101 createAccountReport: vi.fn(), 102 102 createAccountComment: vi.fn(), ··· 106 106 getLanguage: vi.fn().mockResolvedValue("eng"), 107 107 })); 108 108 109 - vi.mock("../../../constants.js", () => ({ 109 + vi.mock("../../../../rules/constants.js", () => ({ 110 110 GLOBAL_ALLOW: ["did:plc:globalallow"], 111 111 })); 112 112 ··· 167 167 "This is spam content", 168 168 ); 169 169 170 - expect(logger.info).toHaveBeenCalledWith( 171 - { 172 - process: "CHECKDESCRIPTION", 173 - did: mockDid, 174 - time: mockTime, 175 - displayName: mockDisplayName, 176 - description: "This is spam content", 177 - label: "test-description", 178 - }, 179 - "Labeling account", 180 - ); 181 170 expect(createAccountLabel).toHaveBeenCalledWith( 182 171 mockDid, 183 172 "test-description", ··· 365 354 expect(createAccountComment).toHaveBeenCalledWith( 366 355 mockDid, 367 356 expect.any(String), 368 - ); 369 - }); 370 - 371 - it("should log all moderation actions", async () => { 372 - await checkDescription( 373 - mockDid, 374 - mockTime, 375 - mockDisplayName, 376 - "report this", 377 - ); 378 - 379 - expect(logger.info).toHaveBeenCalledWith( 380 - expect.objectContaining({ label: "all-actions" }), 381 - "Labeling account", 382 - ); 383 - expect(logger.info).toHaveBeenCalledWith( 384 - expect.objectContaining({ label: "all-actions" }), 385 - "Reporting account", 386 - ); 387 - expect(logger.info).toHaveBeenCalledWith( 388 - expect.objectContaining({ label: "all-actions" }), 389 - "Commenting on account", 357 + expect.any(String), 390 358 ); 391 359 }); 392 360 }); ··· 434 402 mockDescription, 435 403 ); 436 404 437 - expect(logger.info).toHaveBeenCalledWith( 438 - { 439 - process: "CHECKDISPLAYNAME", 440 - did: mockDid, 441 - time: mockTime, 442 - displayName: "fake account", 443 - description: mockDescription, 444 - label: "test-displayname", 445 - }, 446 - "Labeling account", 447 - ); 448 405 expect(createAccountLabel).toHaveBeenCalledWith( 449 406 mockDid, 450 407 "test-displayname",
+71
src/session.ts
··· 1 + import { 2 + chmodSync, 3 + existsSync, 4 + readFileSync, 5 + unlinkSync, 6 + writeFileSync, 7 + } from "node:fs"; 8 + import { join } from "node:path"; 9 + import { logger } from "./logger.js"; 10 + 11 + const SESSION_FILE_PATH = join(process.cwd(), ".session"); 12 + 13 + export interface SessionData { 14 + accessJwt: string; 15 + refreshJwt: string; 16 + did: string; 17 + handle: string; 18 + email?: string; 19 + emailConfirmed?: boolean; 20 + emailAuthFactor?: boolean; 21 + active: boolean; 22 + status?: string; 23 + } 24 + 25 + export function loadSession(): SessionData | null { 26 + try { 27 + if (!existsSync(SESSION_FILE_PATH)) { 28 + logger.debug("No session file found"); 29 + return null; 30 + } 31 + 32 + const data = readFileSync(SESSION_FILE_PATH, "utf-8"); 33 + const session = JSON.parse(data) as SessionData; 34 + 35 + if (!session.accessJwt || !session.refreshJwt || !session.did) { 36 + logger.warn("Session file is missing required fields, ignoring"); 37 + return null; 38 + } 39 + 40 + logger.info("Loaded existing session from file"); 41 + return session; 42 + } catch (error) { 43 + logger.error( 44 + { error }, 45 + "Failed to load session file, will authenticate fresh", 46 + ); 47 + return null; 48 + } 49 + } 50 + 51 + export function saveSession(session: SessionData): void { 52 + try { 53 + const data = JSON.stringify(session, null, 2); 54 + writeFileSync(SESSION_FILE_PATH, data, "utf-8"); 55 + chmodSync(SESSION_FILE_PATH, 0o600); 56 + logger.info("Session saved to file"); 57 + } catch (error) { 58 + logger.error({ error }, "Failed to save session to file"); 59 + } 60 + } 61 + 62 + export function clearSession(): void { 63 + try { 64 + if (existsSync(SESSION_FILE_PATH)) { 65 + unlinkSync(SESSION_FILE_PATH); 66 + logger.info("Session file cleared"); 67 + } 68 + } catch (error) { 69 + logger.error({ error }, "Failed to clear session file"); 70 + } 71 + }
+296
src/tests/accountThreshold.test.ts
··· 1 + import { afterEach, describe, expect, it, vi } from "vitest"; 2 + import { 3 + createAccountComment, 4 + createAccountLabel, 5 + createAccountReport, 6 + } from "../accountModeration.js"; 7 + import { 8 + checkAccountThreshold, 9 + loadThresholdConfigs, 10 + } from "../accountThreshold.js"; 11 + import { logger } from "../logger.js"; 12 + import { 13 + accountLabelsThresholdAppliedCounter, 14 + accountThresholdChecksCounter, 15 + accountThresholdMetCounter, 16 + } from "../metrics.js"; 17 + import { 18 + getPostLabelCountInWindow, 19 + trackPostLabelForAccount, 20 + } from "../redis.js"; 21 + 22 + vi.mock("../logger.js", () => ({ 23 + logger: { 24 + info: vi.fn(), 25 + warn: vi.fn(), 26 + error: vi.fn(), 27 + debug: vi.fn(), 28 + }, 29 + })); 30 + 31 + vi.mock("../../rules/accountThreshold.js", () => ({ 32 + ACCOUNT_THRESHOLD_CONFIGS: [ 33 + { 34 + labels: ["test-label"], 35 + threshold: 3, 36 + accountLabel: "test-account-label", 37 + accountComment: "Test comment", 38 + windowDays: 5, 39 + reportAcct: false, 40 + commentAcct: false, 41 + toLabel: true, 42 + }, 43 + { 44 + labels: ["label-1", "label-2", "label-3"], 45 + threshold: 5, 46 + accountLabel: "multi-label-account", 47 + accountComment: "Multi label comment", 48 + windowDays: 7, 49 + reportAcct: true, 50 + commentAcct: true, 51 + toLabel: true, 52 + }, 53 + { 54 + labels: "monitor-only-label", 55 + threshold: 2, 56 + accountLabel: "monitored", 57 + accountComment: "Monitoring comment", 58 + windowDays: 3, 59 + reportAcct: true, 60 + commentAcct: false, 61 + toLabel: false, 62 + }, 63 + { 64 + labels: ["label-1", "shared-label"], 65 + threshold: 2, 66 + accountLabel: "shared-config", 67 + accountComment: "Shared config comment", 68 + windowDays: 4, 69 + reportAcct: false, 70 + commentAcct: false, 71 + toLabel: true, 72 + }, 73 + ], 74 + })); 75 + 76 + vi.mock("../redis.js", () => ({ 77 + trackPostLabelForAccount: vi.fn(), 78 + getPostLabelCountInWindow: vi.fn(), 79 + })); 80 + 81 + vi.mock("../accountModeration.js", () => ({ 82 + createAccountLabel: vi.fn(), 83 + createAccountReport: vi.fn(), 84 + createAccountComment: vi.fn(), 85 + })); 86 + 87 + vi.mock("../metrics.js", () => ({ 88 + accountLabelsThresholdAppliedCounter: { 89 + inc: vi.fn(), 90 + }, 91 + accountThresholdChecksCounter: { 92 + inc: vi.fn(), 93 + }, 94 + accountThresholdMetCounter: { 95 + inc: vi.fn(), 96 + }, 97 + })); 98 + 99 + describe("Account Threshold Logic", () => { 100 + afterEach(() => { 101 + vi.clearAllMocks(); 102 + }); 103 + 104 + describe("loadThresholdConfigs", () => { 105 + it("should load and cache configs successfully", () => { 106 + const configs = loadThresholdConfigs(); 107 + expect(configs).toHaveLength(4); 108 + expect(configs[0].labels).toEqual(["test-label"]); 109 + expect(configs[1].labels).toEqual(["label-1", "label-2", "label-3"]); 110 + }); 111 + 112 + it("should return cached configs on subsequent calls", () => { 113 + const configs1 = loadThresholdConfigs(); 114 + const configs2 = loadThresholdConfigs(); 115 + expect(configs1).toBe(configs2); 116 + }); 117 + }); 118 + 119 + describe("checkAccountThreshold", () => { 120 + const testDid = "did:plc:test123"; 121 + const testTimestamp = 1640000000000000; 122 + 123 + it("should not check threshold for non-matching labels", async () => { 124 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 125 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(0); 126 + 127 + await checkAccountThreshold(testDid, "non-matching-label", testTimestamp); 128 + 129 + expect(trackPostLabelForAccount).not.toHaveBeenCalled(); 130 + expect(getPostLabelCountInWindow).not.toHaveBeenCalled(); 131 + }); 132 + 133 + it("should track and check threshold for matching single label", async () => { 134 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 135 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2); 136 + 137 + await checkAccountThreshold(testDid, "test-label", testTimestamp); 138 + 139 + expect(accountThresholdChecksCounter.inc).toHaveBeenCalledWith({ 140 + post_label: "test-label", 141 + }); 142 + expect(trackPostLabelForAccount).toHaveBeenCalledWith( 143 + testDid, 144 + "test-label", 145 + testTimestamp, 146 + 5, 147 + ); 148 + expect(getPostLabelCountInWindow).toHaveBeenCalledWith( 149 + testDid, 150 + ["test-label"], 151 + 5, 152 + testTimestamp, 153 + ); 154 + }); 155 + 156 + it("should apply account label when threshold is met", async () => { 157 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 158 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(3); 159 + vi.mocked(createAccountLabel).mockResolvedValue(); 160 + 161 + await checkAccountThreshold(testDid, "test-label", testTimestamp); 162 + 163 + expect(accountThresholdMetCounter.inc).toHaveBeenCalledWith({ 164 + account_label: "test-account-label", 165 + }); 166 + expect(createAccountLabel).toHaveBeenCalledWith( 167 + testDid, 168 + "test-account-label", 169 + "Test comment", 170 + ); 171 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 172 + account_label: "test-account-label", 173 + action: "label", 174 + }); 175 + }); 176 + 177 + it("should not apply label when threshold not met", async () => { 178 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 179 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2); 180 + 181 + await checkAccountThreshold(testDid, "test-label", testTimestamp); 182 + 183 + expect(accountThresholdMetCounter.inc).not.toHaveBeenCalled(); 184 + expect(createAccountLabel).not.toHaveBeenCalled(); 185 + }); 186 + 187 + it("should handle multi-label config with OR logic", async () => { 188 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 189 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(5); 190 + vi.mocked(createAccountLabel).mockResolvedValue(); 191 + vi.mocked(createAccountReport).mockResolvedValue(); 192 + vi.mocked(createAccountComment).mockResolvedValue(); 193 + 194 + await checkAccountThreshold(testDid, "label-2", testTimestamp); 195 + 196 + expect(getPostLabelCountInWindow).toHaveBeenCalledWith( 197 + testDid, 198 + ["label-1", "label-2", "label-3"], 199 + 7, 200 + testTimestamp, 201 + ); 202 + expect(createAccountLabel).toHaveBeenCalledWith( 203 + testDid, 204 + "multi-label-account", 205 + "Multi label comment", 206 + ); 207 + expect(createAccountReport).toHaveBeenCalledWith( 208 + testDid, 209 + "Multi label comment", 210 + ); 211 + expect(createAccountComment).toHaveBeenCalled(); 212 + }); 213 + 214 + it("should track but not label when toLabel is false", async () => { 215 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 216 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2); 217 + vi.mocked(createAccountReport).mockResolvedValue(); 218 + 219 + await checkAccountThreshold(testDid, "monitor-only-label", testTimestamp); 220 + 221 + expect(trackPostLabelForAccount).toHaveBeenCalled(); 222 + expect(getPostLabelCountInWindow).toHaveBeenCalled(); 223 + expect(createAccountLabel).not.toHaveBeenCalled(); 224 + expect(createAccountReport).toHaveBeenCalled(); 225 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 226 + account_label: "monitored", 227 + action: "report", 228 + }); 229 + }); 230 + 231 + it("should increment all action metrics when threshold met", async () => { 232 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 233 + vi.mocked(getPostLabelCountInWindow) 234 + .mockResolvedValueOnce(5) 235 + .mockResolvedValueOnce(1); 236 + vi.mocked(createAccountLabel).mockResolvedValue(); 237 + vi.mocked(createAccountReport).mockResolvedValue(); 238 + vi.mocked(createAccountComment).mockResolvedValue(); 239 + 240 + await checkAccountThreshold(testDid, "label-1", testTimestamp); 241 + 242 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledTimes(3); 243 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 244 + account_label: "multi-label-account", 245 + action: "label", 246 + }); 247 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 248 + account_label: "multi-label-account", 249 + action: "report", 250 + }); 251 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 252 + account_label: "multi-label-account", 253 + action: "comment", 254 + }); 255 + }); 256 + 257 + it("should handle Redis errors in trackPostLabelForAccount", async () => { 258 + const redisError = new Error("Redis connection failed"); 259 + vi.mocked(trackPostLabelForAccount).mockRejectedValue(redisError); 260 + 261 + await expect( 262 + checkAccountThreshold(testDid, "test-label", testTimestamp), 263 + ).rejects.toThrow("Redis connection failed"); 264 + 265 + expect(logger.error).toHaveBeenCalled(); 266 + }); 267 + 268 + it("should handle Redis errors in getPostLabelCountInWindow", async () => { 269 + const redisError = new Error("Redis query failed"); 270 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 271 + vi.mocked(getPostLabelCountInWindow).mockRejectedValue(redisError); 272 + 273 + await expect( 274 + checkAccountThreshold(testDid, "test-label", testTimestamp), 275 + ).rejects.toThrow("Redis query failed"); 276 + 277 + expect(logger.error).toHaveBeenCalled(); 278 + }); 279 + 280 + it("should handle multiple matching configs", async () => { 281 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 282 + vi.mocked(getPostLabelCountInWindow) 283 + .mockResolvedValueOnce(5) 284 + .mockResolvedValueOnce(3); 285 + vi.mocked(createAccountLabel).mockResolvedValue(); 286 + vi.mocked(createAccountReport).mockResolvedValue(); 287 + vi.mocked(createAccountComment).mockResolvedValue(); 288 + 289 + await checkAccountThreshold(testDid, "label-1", testTimestamp); 290 + 291 + expect(trackPostLabelForAccount).toHaveBeenCalledTimes(2); 292 + expect(getPostLabelCountInWindow).toHaveBeenCalledTimes(2); 293 + expect(createAccountLabel).toHaveBeenCalledTimes(2); 294 + }); 295 + }); 296 + });
+17 -4
src/tests/agent.test.ts
··· 13 13 OZONE_PDS: "pds.test.com", 14 14 })); 15 15 16 + // Mock session 17 + const mockSession = { 18 + did: "did:plc:test123", 19 + handle: "test.bsky.social", 20 + accessJwt: "test-access-jwt", 21 + refreshJwt: "test-refresh-jwt", 22 + }; 23 + 16 24 // Mock the AtpAgent 17 - const mockLogin = vi.fn(() => Promise.resolve()); 25 + const mockLogin = vi.fn(() => 26 + Promise.resolve({ success: true, data: mockSession }), 27 + ); 18 28 const mockConstructor = vi.fn(); 19 29 vi.doMock("@atproto/api", () => ({ 20 30 AtpAgent: class { 21 31 login = mockLogin; 22 32 service: URL; 33 + session = mockSession; 23 34 constructor(options: { service: string }) { 24 35 mockConstructor(options); 25 36 this.service = new URL(options.service); ··· 30 41 const { agent, login } = await import("../agent.js"); 31 42 32 43 // Check that the agent was created with the correct service URL 33 - expect(mockConstructor).toHaveBeenCalledWith({ 34 - service: "https://pds.test.com", 35 - }); 44 + expect(mockConstructor).toHaveBeenCalledWith( 45 + expect.objectContaining({ 46 + service: "https://pds.test.com", 47 + }), 48 + ); 36 49 expect(agent.service.toString()).toBe("https://pds.test.com/"); 37 50 38 51 // Check that the login function calls the mockLogin function
+3 -3
src/tests/metrics.test.ts
··· 1 - import { Server } from "http"; 1 + import type { Server } from "http"; 2 2 import request from "supertest"; 3 - import { describe, expect, it } from "vitest"; 3 + import { afterEach, describe, expect, it } from "vitest"; 4 4 import { startMetricsServer } from "../metrics.js"; 5 5 6 6 describe("Metrics Server", () => { 7 - let server: Server; 7 + let server: Server | undefined; 8 8 9 9 afterEach(() => { 10 10 if (server) {
+92 -90
src/tests/moderation.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + // --- Imports Second --- 3 + import { checkAccountLabels } from "../accountModeration.js"; 2 4 import { agent } from "../agent.js"; 3 - import { logger } from "../logger.js"; 4 - import { checkAccountLabels } from "../moderation.js"; 5 + import { createPostLabel } from "../moderation.js"; 6 + import { tryClaimPostLabel } from "../redis.js"; 5 7 6 - // Mock dependencies 8 + // --- Mocks First --- 9 + 7 10 vi.mock("../agent.js", () => ({ 8 11 agent: { 9 12 tools: { 10 13 ozone: { 11 14 moderation: { 12 15 getRepo: vi.fn(), 16 + getRecord: vi.fn(), 17 + emitEvent: vi.fn(), 13 18 }, 14 19 }, 15 20 }, ··· 17 22 isLoggedIn: Promise.resolve(true), 18 23 })); 19 24 25 + vi.mock("../redis.js", () => ({ 26 + tryClaimPostLabel: vi.fn(), 27 + tryClaimAccountLabel: vi.fn(), 28 + })); 29 + 20 30 vi.mock("../logger.js", () => ({ 21 31 logger: { 22 32 info: vi.fn(), ··· 34 44 limit: vi.fn((fn) => fn()), 35 45 })); 36 46 37 - describe("checkAccountLabels", () => { 47 + describe("Moderation Logic", () => { 38 48 beforeEach(() => { 39 49 vi.clearAllMocks(); 40 50 }); 41 51 42 - it("should return true if label exists on account", async () => { 43 - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 44 - data: { 45 - labels: [ 46 - { val: "spam" }, 47 - { val: "harassment" }, 48 - { val: "window-reply" }, 49 - ], 50 - }, 51 - }); 52 - 53 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 54 - 55 - expect(result).toBe(true); 56 - expect(agent.tools.ozone.moderation.getRepo).toHaveBeenCalledWith( 57 - { did: "did:plc:test123" }, 58 - { 59 - headers: { 60 - "atproto-proxy": "did:plc:moderator123#atproto_labeler", 61 - "atproto-accept-labelers": "did:plc:ar7c4by46qjdydhdevvrndac;redact", 52 + describe("checkAccountLabels", () => { 53 + it("should return true if label exists on account", async () => { 54 + vi.mocked(agent.tools.ozone.moderation.getRepo).mockResolvedValueOnce({ 55 + data: { 56 + labels: [ 57 + { 58 + val: "spam", 59 + src: "did:plc:test", 60 + uri: "at://test", 61 + cts: "2024-01-01T00:00:00Z", 62 + }, 63 + { 64 + val: "window-reply", 65 + src: "did:plc:test", 66 + uri: "at://test", 67 + cts: "2024-01-01T00:00:00Z", 68 + }, 69 + ], 62 70 }, 63 - }, 64 - ); 65 - }); 66 - 67 - it("should return false if label does not exist on account", async () => { 68 - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 69 - data: { 70 - labels: [{ val: "spam" }, { val: "harassment" }], 71 - }, 71 + } as any); 72 + const result = await checkAccountLabels( 73 + "did:plc:test123", 74 + "window-reply", 75 + ); 76 + expect(result).toBe(true); 72 77 }); 73 - 74 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 75 - 76 - expect(result).toBe(false); 77 78 }); 78 79 79 - it("should return false if account has no labels", async () => { 80 - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 81 - data: { 82 - labels: [], 83 - }, 84 - }); 80 + describe("createPostLabel with Caching", () => { 81 + const URI = "at://did:plc:test/app.bsky.feed.post/123"; 82 + const CID = "bafybeig6xv5nwph5j7grrlp3pdeolqptpep5nfljmdkmtcf2l4wisa2mfa"; 83 + const LABEL = "test-label"; 84 + const COMMENT = "test comment"; 85 85 86 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 86 + it("should skip if claim fails (already claimed)", async () => { 87 + vi.mocked(tryClaimPostLabel).mockResolvedValue(false); 87 88 88 - expect(result).toBe(false); 89 - }); 89 + await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 90 90 91 - it("should return false if labels property is undefined", async () => { 92 - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 93 - data: {}, 91 + expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 92 + expect( 93 + vi.mocked(agent.tools.ozone.moderation.getRecord), 94 + ).not.toHaveBeenCalled(); 95 + expect( 96 + vi.mocked(agent.tools.ozone.moderation.emitEvent), 97 + ).not.toHaveBeenCalled(); 94 98 }); 95 99 96 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 100 + it("should skip event if claimed but already labeled via API", async () => { 101 + vi.mocked(tryClaimPostLabel).mockResolvedValue(true); 102 + vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 103 + data: { 104 + labels: [ 105 + { 106 + val: LABEL, 107 + src: "did:plc:test", 108 + uri: URI, 109 + cts: "2024-01-01T00:00:00Z", 110 + }, 111 + ], 112 + }, 113 + } as any); 97 114 98 - expect(result).toBe(false); 99 - }); 115 + await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 100 116 101 - it("should handle API errors gracefully", async () => { 102 - (agent.tools.ozone.moderation.getRepo as any).mockRejectedValueOnce( 103 - new Error("API Error"), 104 - ); 117 + expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 118 + expect( 119 + vi.mocked(agent.tools.ozone.moderation.getRecord), 120 + ).toHaveBeenCalledWith({ uri: URI }, expect.any(Object)); 121 + expect( 122 + vi.mocked(agent.tools.ozone.moderation.emitEvent), 123 + ).not.toHaveBeenCalled(); 124 + }); 105 125 106 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 126 + it("should emit event if claimed and not labeled anywhere", async () => { 127 + vi.mocked(tryClaimPostLabel).mockResolvedValue(true); 128 + vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 129 + data: { labels: [] }, 130 + } as any); 131 + vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({ 132 + success: true, 133 + } as any); 107 134 108 - expect(result).toBe(false); 109 - expect(logger.error).toHaveBeenCalledWith( 110 - { 111 - process: "MODERATION", 112 - did: "did:plc:test123", 113 - error: expect.any(Error), 114 - }, 115 - "Failed to check account labels", 116 - ); 117 - }); 135 + await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 118 136 119 - it("should perform case-sensitive label matching", async () => { 120 - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 121 - data: { 122 - labels: [{ val: "window-reply" }], 123 - }, 137 + expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 138 + expect( 139 + vi.mocked(agent.tools.ozone.moderation.getRecord), 140 + ).toHaveBeenCalledWith({ uri: URI }, expect.any(Object)); 141 + expect( 142 + vi.mocked(agent.tools.ozone.moderation.emitEvent), 143 + ).toHaveBeenCalled(); 124 144 }); 125 - 126 - const resultLower = await checkAccountLabels( 127 - "did:plc:test123", 128 - "window-reply", 129 - ); 130 - expect(resultLower).toBe(true); 131 - 132 - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 133 - data: { 134 - labels: [{ val: "window-reply" }], 135 - }, 136 - }); 137 - 138 - const resultUpper = await checkAccountLabels( 139 - "did:plc:test123", 140 - "Window-Reply", 141 - ); 142 - expect(resultUpper).toBe(false); 143 145 }); 144 146 });
+233
src/tests/redis.test.ts
··· 1 + // Import the mocked redis first to get a reference to the mock client 2 + import { createClient } from "redis"; 3 + import { afterEach, describe, expect, it, vi } from "vitest"; 4 + import { logger } from "../logger.js"; 5 + // Import the modules to be tested 6 + import { 7 + connectRedis, 8 + disconnectRedis, 9 + getPostLabelCountInWindow, 10 + trackPostLabelForAccount, 11 + tryClaimAccountLabel, 12 + tryClaimPostLabel, 13 + } from "../redis.js"; 14 + 15 + // Mock the 'redis' module in a way that avoids hoisting issues. 16 + // The mock implementation is self-contained. 17 + vi.mock("redis", () => { 18 + const mockClient = { 19 + on: vi.fn(), 20 + connect: vi.fn(), 21 + quit: vi.fn(), 22 + exists: vi.fn(), 23 + set: vi.fn(), 24 + zAdd: vi.fn(), 25 + zRemRangeByScore: vi.fn(), 26 + zCount: vi.fn(), 27 + expire: vi.fn(), 28 + }; 29 + return { 30 + createClient: vi.fn(() => mockClient), 31 + }; 32 + }); 33 + 34 + const mockRedisClient = createClient(); 35 + 36 + // Suppress logger output during tests 37 + vi.mock("../logger.js", () => ({ 38 + logger: { 39 + info: vi.fn(), 40 + warn: vi.fn(), 41 + error: vi.fn(), 42 + debug: vi.fn(), 43 + }, 44 + })); 45 + 46 + describe("Redis Cache Logic", () => { 47 + afterEach(() => { 48 + vi.clearAllMocks(); 49 + }); 50 + 51 + describe("Connection", () => { 52 + it("should call redisClient.connect on connectRedis", async () => { 53 + await connectRedis(); 54 + expect(mockRedisClient.connect).toHaveBeenCalled(); 55 + }); 56 + 57 + it("should call redisClient.quit on disconnectRedis", async () => { 58 + await disconnectRedis(); 59 + expect(mockRedisClient.quit).toHaveBeenCalled(); 60 + }); 61 + }); 62 + 63 + describe("tryClaimPostLabel", () => { 64 + it("should return true and set key if key does not exist", async () => { 65 + vi.mocked(mockRedisClient.set).mockResolvedValue("OK"); 66 + const result = await tryClaimPostLabel("at://uri", "test-label"); 67 + expect(result).toBe(true); 68 + expect(mockRedisClient.set).toHaveBeenCalledWith( 69 + "post-label:at://uri:test-label", 70 + "1", 71 + { NX: true, EX: 60 * 60 * 24 * 7 }, 72 + ); 73 + }); 74 + 75 + it("should return false if key already exists", async () => { 76 + vi.mocked(mockRedisClient.set).mockResolvedValue(null); 77 + const result = await tryClaimPostLabel("at://uri", "test-label"); 78 + expect(result).toBe(false); 79 + }); 80 + 81 + it("should return true and log warning on Redis error", async () => { 82 + const redisError = new Error("Redis down"); 83 + vi.mocked(mockRedisClient.set).mockRejectedValue(redisError); 84 + const result = await tryClaimPostLabel("at://uri", "test-label"); 85 + expect(result).toBe(true); 86 + expect(logger.warn).toHaveBeenCalledWith( 87 + { err: redisError, atURI: "at://uri", label: "test-label" }, 88 + "Error claiming post label in Redis, allowing through", 89 + ); 90 + }); 91 + }); 92 + 93 + describe("tryClaimAccountLabel", () => { 94 + it("should return true and set key if key does not exist", async () => { 95 + vi.mocked(mockRedisClient.set).mockResolvedValue("OK"); 96 + const result = await tryClaimAccountLabel("did:plc:123", "test-label"); 97 + expect(result).toBe(true); 98 + expect(mockRedisClient.set).toHaveBeenCalledWith( 99 + "account-label:did:plc:123:test-label", 100 + "1", 101 + { NX: true, EX: 60 * 60 * 24 * 7 }, 102 + ); 103 + }); 104 + 105 + it("should return false if key already exists", async () => { 106 + vi.mocked(mockRedisClient.set).mockResolvedValue(null); 107 + const result = await tryClaimAccountLabel("did:plc:123", "test-label"); 108 + expect(result).toBe(false); 109 + }); 110 + }); 111 + 112 + describe("trackPostLabelForAccount", () => { 113 + it("should track post label with correct timestamp and TTL", async () => { 114 + vi.mocked(mockRedisClient.zRemRangeByScore).mockResolvedValue(0); 115 + vi.mocked(mockRedisClient.zAdd).mockResolvedValue(1); 116 + vi.mocked(mockRedisClient.expire).mockResolvedValue(true); 117 + 118 + const timestamp = 1640000000000000; // microseconds 119 + const windowDays = 5; 120 + 121 + await trackPostLabelForAccount( 122 + "did:plc:123", 123 + "test-label", 124 + timestamp, 125 + windowDays, 126 + ); 127 + 128 + const expectedKey = "account-post-labels:did:plc:123:test-label:5"; 129 + const windowStartTime = timestamp - windowDays * 24 * 60 * 60 * 1000000; 130 + 131 + expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith( 132 + expectedKey, 133 + "-inf", 134 + windowStartTime, 135 + ); 136 + expect(mockRedisClient.zAdd).toHaveBeenCalledWith(expectedKey, { 137 + score: timestamp, 138 + value: timestamp.toString(), 139 + }); 140 + expect(mockRedisClient.expire).toHaveBeenCalledWith( 141 + expectedKey, 142 + (windowDays + 1) * 24 * 60 * 60, 143 + ); 144 + }); 145 + 146 + it("should throw error on Redis failure", async () => { 147 + const redisError = new Error("Redis down"); 148 + vi.mocked(mockRedisClient.zRemRangeByScore).mockRejectedValue(redisError); 149 + 150 + await expect( 151 + trackPostLabelForAccount( 152 + "did:plc:123", 153 + "test-label", 154 + 1640000000000000, 155 + 5, 156 + ), 157 + ).rejects.toThrow("Redis down"); 158 + 159 + expect(logger.error).toHaveBeenCalled(); 160 + }); 161 + }); 162 + 163 + describe("getPostLabelCountInWindow", () => { 164 + it("should count posts for single label", async () => { 165 + vi.mocked(mockRedisClient.zCount).mockResolvedValue(3); 166 + 167 + const currentTime = 1640000000000000; 168 + const windowDays = 5; 169 + const count = await getPostLabelCountInWindow( 170 + "did:plc:123", 171 + ["test-label"], 172 + windowDays, 173 + currentTime, 174 + ); 175 + 176 + expect(count).toBe(3); 177 + const windowStartTime = currentTime - windowDays * 24 * 60 * 60 * 1000000; 178 + expect(mockRedisClient.zCount).toHaveBeenCalledWith( 179 + "account-post-labels:did:plc:123:test-label:5", 180 + windowStartTime, 181 + "+inf", 182 + ); 183 + }); 184 + 185 + it("should sum counts for multiple labels (OR logic)", async () => { 186 + vi.mocked(mockRedisClient.zCount) 187 + .mockResolvedValueOnce(3) 188 + .mockResolvedValueOnce(2) 189 + .mockResolvedValueOnce(1); 190 + 191 + const currentTime = 1640000000000000; 192 + const windowDays = 5; 193 + const count = await getPostLabelCountInWindow( 194 + "did:plc:123", 195 + ["label-1", "label-2", "label-3"], 196 + windowDays, 197 + currentTime, 198 + ); 199 + 200 + expect(count).toBe(6); 201 + expect(mockRedisClient.zCount).toHaveBeenCalledTimes(3); 202 + }); 203 + 204 + it("should return 0 when no posts in window", async () => { 205 + vi.mocked(mockRedisClient.zCount).mockResolvedValue(0); 206 + 207 + const count = await getPostLabelCountInWindow( 208 + "did:plc:123", 209 + ["test-label"], 210 + 5, 211 + 1640000000000000, 212 + ); 213 + 214 + expect(count).toBe(0); 215 + }); 216 + 217 + it("should throw error on Redis failure", async () => { 218 + const redisError = new Error("Redis down"); 219 + vi.mocked(mockRedisClient.zCount).mockRejectedValue(redisError); 220 + 221 + await expect( 222 + getPostLabelCountInWindow( 223 + "did:plc:123", 224 + ["test-label"], 225 + 5, 226 + 1640000000000000, 227 + ), 228 + ).rejects.toThrow("Redis down"); 229 + 230 + expect(logger.error).toHaveBeenCalled(); 231 + }); 232 + }); 233 + });
+183
src/tests/session.test.ts
··· 1 + import { 2 + chmodSync, 3 + existsSync, 4 + mkdirSync, 5 + readFileSync, 6 + rmSync, 7 + unlinkSync, 8 + writeFileSync, 9 + } from "node:fs"; 10 + import { join } from "node:path"; 11 + import { afterEach, beforeEach, describe, expect, it } from "vitest"; 12 + import type { SessionData } from "../session.js"; 13 + 14 + const TEST_DIR = join(process.cwd(), ".test-session"); 15 + const TEST_SESSION_PATH = join(TEST_DIR, ".session"); 16 + 17 + // Helper functions that mimic session.ts but use TEST_SESSION_PATH 18 + function testLoadSession(): SessionData | null { 19 + try { 20 + if (!existsSync(TEST_SESSION_PATH)) { 21 + return null; 22 + } 23 + 24 + const data = readFileSync(TEST_SESSION_PATH, "utf-8"); 25 + const session = JSON.parse(data) as SessionData; 26 + 27 + if (!session.accessJwt || !session.refreshJwt || !session.did) { 28 + return null; 29 + } 30 + 31 + return session; 32 + } catch (error) { 33 + return null; 34 + } 35 + } 36 + 37 + function testSaveSession(session: SessionData): void { 38 + try { 39 + const data = JSON.stringify(session, null, 2); 40 + writeFileSync(TEST_SESSION_PATH, data, "utf-8"); 41 + chmodSync(TEST_SESSION_PATH, 0o600); 42 + } catch (error) { 43 + // Ignore errors for test 44 + } 45 + } 46 + 47 + function testClearSession(): void { 48 + try { 49 + if (existsSync(TEST_SESSION_PATH)) { 50 + unlinkSync(TEST_SESSION_PATH); 51 + } 52 + } catch (error) { 53 + // Ignore errors for test 54 + } 55 + } 56 + 57 + describe("session", () => { 58 + beforeEach(() => { 59 + // Create test directory 60 + if (!existsSync(TEST_DIR)) { 61 + mkdirSync(TEST_DIR, { recursive: true }); 62 + } 63 + }); 64 + 65 + afterEach(() => { 66 + // Clean up test directory 67 + if (existsSync(TEST_DIR)) { 68 + rmSync(TEST_DIR, { recursive: true, force: true }); 69 + } 70 + }); 71 + 72 + describe("saveSession", () => { 73 + it("should save session to file with proper permissions", () => { 74 + const session: SessionData = { 75 + accessJwt: "access-token", 76 + refreshJwt: "refresh-token", 77 + did: "did:plc:test123", 78 + handle: "test.bsky.social", 79 + active: true, 80 + }; 81 + 82 + testSaveSession(session); 83 + 84 + expect(existsSync(TEST_SESSION_PATH)).toBe(true); 85 + }); 86 + 87 + it("should save all session fields correctly", () => { 88 + const session: SessionData = { 89 + accessJwt: "access-token", 90 + refreshJwt: "refresh-token", 91 + did: "did:plc:test123", 92 + handle: "test.bsky.social", 93 + email: "test@example.com", 94 + emailConfirmed: true, 95 + emailAuthFactor: false, 96 + active: true, 97 + status: "active", 98 + }; 99 + 100 + testSaveSession(session); 101 + 102 + const loaded = testLoadSession(); 103 + expect(loaded).toEqual(session); 104 + }); 105 + }); 106 + 107 + describe("loadSession", () => { 108 + it("should return null if session file does not exist", () => { 109 + const session = testLoadSession(); 110 + expect(session).toBeNull(); 111 + }); 112 + 113 + it("should load valid session from file", () => { 114 + const session: SessionData = { 115 + accessJwt: "access-token", 116 + refreshJwt: "refresh-token", 117 + did: "did:plc:test123", 118 + handle: "test.bsky.social", 119 + active: true, 120 + }; 121 + 122 + testSaveSession(session); 123 + const loaded = testLoadSession(); 124 + 125 + expect(loaded).toEqual(session); 126 + }); 127 + 128 + it("should return null for corrupted session file", () => { 129 + writeFileSync(TEST_SESSION_PATH, "{ invalid json", "utf-8"); 130 + 131 + const session = testLoadSession(); 132 + expect(session).toBeNull(); 133 + }); 134 + 135 + it("should return null for session missing required fields", () => { 136 + writeFileSync( 137 + TEST_SESSION_PATH, 138 + JSON.stringify({ accessJwt: "token" }), 139 + "utf-8", 140 + ); 141 + 142 + const session = testLoadSession(); 143 + expect(session).toBeNull(); 144 + }); 145 + 146 + it("should return null for session missing did", () => { 147 + writeFileSync( 148 + TEST_SESSION_PATH, 149 + JSON.stringify({ 150 + accessJwt: "access", 151 + refreshJwt: "refresh", 152 + handle: "test.bsky.social", 153 + }), 154 + "utf-8", 155 + ); 156 + 157 + const session = testLoadSession(); 158 + expect(session).toBeNull(); 159 + }); 160 + }); 161 + 162 + describe("clearSession", () => { 163 + it("should remove session file if it exists", () => { 164 + const session: SessionData = { 165 + accessJwt: "access-token", 166 + refreshJwt: "refresh-token", 167 + did: "did:plc:test123", 168 + handle: "test.bsky.social", 169 + active: true, 170 + }; 171 + 172 + testSaveSession(session); 173 + expect(existsSync(TEST_SESSION_PATH)).toBe(true); 174 + 175 + testClearSession(); 176 + expect(existsSync(TEST_SESSION_PATH)).toBe(false); 177 + }); 178 + 179 + it("should not throw if session file does not exist", () => { 180 + expect(() => testClearSession()).not.toThrow(); 181 + }); 182 + }); 183 + });
+19 -15
src/types.ts
··· 1 + import type * as AppBskyRichtextFacet from "@atproto/ozone/dist/lexicon/types/app/bsky/richtext/facet.js"; 2 + 1 3 export interface Checks { 2 4 language?: string[]; 3 5 label: string; ··· 38 40 description?: string; 39 41 } 40 42 41 - // Define the type for the link feature 42 - export interface LinkFeature { 43 - $type: "app.bsky.richtext.facet#link"; 44 - uri: string; 45 - } 46 - 47 43 export interface List { 48 44 label: string; 49 45 rkey: string; 50 46 } 51 47 52 - export interface FacetIndex { 53 - byteStart: number; 54 - byteEnd: number; 55 - } 56 - 57 - export interface Facet { 58 - index: FacetIndex; 59 - features: Array<{ $type: string; [key: string]: any }>; 60 - } 48 + // Re-export facet types from @atproto/ozone for convenience 49 + export type Facet = AppBskyRichtextFacet.Main; 50 + export type FacetIndex = AppBskyRichtextFacet.ByteSlice; 51 + export type FacetMention = AppBskyRichtextFacet.Mention; 52 + export type LinkFeature = AppBskyRichtextFacet.Link; 53 + export type FacetTag = AppBskyRichtextFacet.Tag; 61 54 62 55 export interface AccountAgeCheck { 63 56 monitoredDIDs?: string[]; // DIDs to monitor for replies (optional if monitoredPostURIs is provided) ··· 68 61 comment: string; // Comment for the label 69 62 expires?: string; // Optional expiration date (ISO 8601) - check will be skipped after this date 70 63 } 64 + 65 + export interface AccountThresholdConfig { 66 + labels: string | string[]; // Single label or array for OR matching 67 + threshold: number; // Number of labeled posts required to trigger account action 68 + accountLabel: string; // Label to apply to the account 69 + accountComment: string; // Comment for the account action 70 + windowDays: number; // Rolling window in days 71 + reportAcct: boolean; // Whether to report the account 72 + commentAcct: boolean; // Whether to comment on the account 73 + toLabel?: boolean; // Whether to apply label (defaults to true) 74 + }
+7 -3
src/utils/getFinalUrl.ts
··· 2 2 3 3 export async function getFinalUrl(url: string): Promise<string> { 4 4 const controller = new AbortController(); 5 - const timeoutId = setTimeout(() => controller.abort(), 15000); // 15-second timeout 5 + const timeoutId = setTimeout(() => { 6 + controller.abort(); 7 + }, 15000); // 15-second timeout 6 8 7 9 const headers = { 8 10 "User-Agent": ··· 19 21 }); 20 22 clearTimeout(timeoutId); 21 23 return response.url; 22 - } catch (headError) { 24 + } catch { 23 25 clearTimeout(timeoutId); 24 26 25 27 // Some services block HEAD requests, try GET as fallback 26 28 const getController = new AbortController(); 27 - const getTimeoutId = setTimeout(() => getController.abort(), 15000); 29 + const getTimeoutId = setTimeout(() => { 30 + getController.abort(); 31 + }, 15000); 28 32 29 33 try { 30 34 logger.debug(
-2
src/utils/homoglyphs.ts
··· 1 - /* eslint-disable no-misleading-character-class */ 2 - 3 1 export const homoglyphMap: Record<string, string> = { 4 2 // Confusables for 'a' 5 3 á: "a",
-1
src/utils/normalizeUnicode.ts
··· 1 - import { logger } from "../logger.js"; 2 1 import { homoglyphMap } from "./homoglyphs.js"; 3 2 4 3 /**
+3
vitest.config.ts
··· 13 13 "**/*.config.*", 14 14 "**/main.ts", 15 15 "**/*.test.ts", 16 + "**/*.example.ts", 17 + "**/constants.ts", 18 + "**/ageConstants.ts", 16 19 ], 17 20 thresholds: { 18 21 lines: 60,