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 -9
.github/workflows/ci.yml
··· 21 21 - name: Install dependencies 22 22 run: bun install 23 23 24 - - name: Setup example config files for CI 25 - run: | 26 - cp src/constants.example.ts src/constants.ts 27 - cp src/rules/handles/constants.example.ts src/rules/handles/constants.ts 28 - cp src/rules/posts/constants.example.ts src/rules/posts/constants.ts 29 - cp src/rules/profiles/constants.example.ts src/rules/profiles/constants.ts 30 - 31 - # - name: Run linter 32 - # run: npm run lint 24 + - name: Run linter 25 + run: bun run lint 33 26 34 27 - name: Type check 35 28 run: bun run type-check
+1 -2
.gitignore
··· 4 4 *.log 5 5 labels.db* 6 6 .DS_Store 7 - src/constants.ts 8 - constants.ts 9 7 coverage/ 8 + .session
+196 -1
bun.lock
··· 40 40 "@vitest/ui": "^1.6.0", 41 41 "eslint": "^9.34.0", 42 42 "eslint-config-prettier": "^10.1.8", 43 + "eslint-plugin-import": "^2.32.0", 43 44 "prettier": "^3.6.2", 44 45 "supertest": "^7.1.4", 45 46 "tsx": "^4.20.5", ··· 421 422 422 423 "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="], 423 424 425 + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], 426 + 424 427 "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], 425 428 426 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=="], ··· 462 465 "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], 463 466 464 467 "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], 468 + 469 + "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], 465 470 466 471 "@types/methods": ["@types/methods@1.1.4", "", {}, "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ=="], 467 472 ··· 539 544 540 545 "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 541 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 + 542 549 "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], 543 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 + 544 561 "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], 545 562 546 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=="], 547 564 548 565 "assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="], 549 566 567 + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], 568 + 550 569 "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], 551 570 552 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=="], 553 574 554 575 "avvio": ["avvio@8.4.0", "", { "dependencies": { "@fastify/error": "^3.3.0", "fastq": "^1.17.1" } }, "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA=="], 555 576 ··· 582 603 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 583 604 584 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=="], 585 608 586 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=="], 587 610 ··· 649 672 650 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=="], 651 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 + 652 681 "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], 653 682 654 683 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], ··· 657 686 658 687 "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], 659 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=="], 692 + 660 693 "delay": ["delay@5.0.0", "", {}, "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw=="], 661 694 662 695 "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], ··· 672 705 "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], 673 706 674 707 "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], 708 + 709 + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], 675 710 676 711 "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], 677 712 ··· 693 728 694 729 "error-causes": ["error-causes@3.0.2", "", {}, "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="], 695 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 + 696 733 "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], 697 734 698 735 "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], ··· 701 738 702 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=="], 703 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=="], 744 + 704 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=="], 705 746 706 747 "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], ··· 712 753 "eslint": ["eslint@9.38.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.1", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.38.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw=="], 713 754 714 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=="], 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=="], 715 762 716 763 "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], 717 764 ··· 795 842 796 843 "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], 797 844 845 + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], 846 + 798 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=="], 799 848 800 849 "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], ··· 811 860 812 861 "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 813 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 + 814 869 "generic-pool": ["generic-pool@3.9.0", "", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="], 815 870 816 871 "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], ··· 824 879 "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], 825 880 826 881 "get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], 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=="], 827 884 828 885 "get-tsconfig": ["get-tsconfig@4.12.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw=="], 829 886 ··· 833 890 834 891 "globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], 835 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 + 836 895 "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], 837 896 838 897 "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], 839 898 899 + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], 900 + 840 901 "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], 841 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 + 842 907 "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], 843 908 844 909 "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], ··· 875 940 876 941 "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], 877 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=="], 944 + 878 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=="], 879 946 880 947 "ip3country": ["ip3country@5.0.0", "", {}, "sha512-lcFLMFU4eO1Z7tIpbVFZkaZ5ltqpeaRx7L9NsAbA9uA7/O/rj3RF8+evE5gDitooaTTIqjdzZrenFO/OOxQ2ew=="], 881 948 882 949 "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], 883 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 + 884 953 "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], 885 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 + 886 969 "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], 887 970 971 + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], 972 + 888 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=="], 889 976 890 977 "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], 891 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 + 892 983 "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], 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=="], 893 992 894 993 "is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], 895 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 + 896 1009 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 897 1010 898 1011 "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], ··· 924 1037 "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], 925 1038 926 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=="], 927 1042 928 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=="], 929 1044 ··· 1035 1150 1036 1151 "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], 1037 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 + 1038 1163 "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], 1039 1164 1040 1165 "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], ··· 1048 1173 "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], 1049 1174 1050 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=="], 1051 1178 1052 1179 "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], 1053 1180 ··· 1075 1202 1076 1203 "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 1077 1204 1205 + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], 1206 + 1078 1207 "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], 1079 1208 1080 1209 "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], ··· 1115 1244 1116 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=="], 1117 1246 1247 + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], 1248 + 1118 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=="], 1119 1250 1120 1251 "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], ··· 1177 1308 1178 1309 "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], 1179 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 + 1180 1315 "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], 1181 1316 1182 1317 "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], 1183 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=="], 1320 + 1184 1321 "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], 1185 1322 1186 1323 "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], ··· 1201 1338 1202 1339 "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], 1203 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 + 1204 1343 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], 1205 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 + 1206 1349 "safe-regex2": ["safe-regex2@3.1.0", "", { "dependencies": { "ret": "~0.4.0" } }, "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug=="], 1207 1350 1208 1351 "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], ··· 1211 1354 1212 1355 "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], 1213 1356 1214 - "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=="], 1215 1358 1216 1359 "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], 1217 1360 ··· 1221 1364 1222 1365 "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], 1223 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=="], 1372 + 1224 1373 "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], 1225 1374 1226 1375 "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], ··· 1267 1416 1268 1417 "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], 1269 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 + 1270 1421 "stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="], 1271 1422 1272 1423 "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], 1273 1424 1274 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=="], 1275 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 + 1276 1433 "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], 1277 1434 1278 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=="], 1279 1438 1280 1439 "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], 1281 1440 ··· 1291 1450 1292 1451 "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], 1293 1452 1453 + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], 1454 + 1294 1455 "tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="], 1295 1456 1296 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=="], ··· 1325 1486 1326 1487 "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], 1327 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 + 1328 1491 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 1329 1492 1330 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=="], ··· 1336 1499 "type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], 1337 1500 1338 1501 "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], 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=="], 1339 1510 1340 1511 "typed-emitter": ["typed-emitter@2.1.0", "", { "optionalDependencies": { "rxjs": "*" } }, "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA=="], 1341 1512 ··· 1349 1520 1350 1521 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1351 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=="], 1524 + 1352 1525 "undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="], 1353 1526 1354 1527 "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], ··· 1378 1551 "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], 1379 1552 1380 1553 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 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=="], 1381 1562 1382 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=="], 1383 1564 ··· 1474 1655 "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], 1475 1656 1476 1657 "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 1658 + 1659 + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 1477 1660 1478 1661 "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], 1479 1662 ··· 1491 1674 1492 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=="], 1493 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 + 1494 1683 "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1495 1684 1496 1685 "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], ··· 1503 1692 1504 1693 "fastify/secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], 1505 1694 1695 + "fastify/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 1696 + 1506 1697 "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1507 1698 1508 1699 "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], ··· 1516 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=="], 1517 1708 1518 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=="], 1519 1712 1520 1713 "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], 1521 1714 ··· 1542 1735 "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], 1543 1736 1544 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=="], 1545 1740 1546 1741 "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], 1547 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
+22 -1
compose.yaml
··· 13 13 image: redis:7-alpine 14 14 container_name: skywatch-automod-redis 15 15 restart: unless-stopped 16 + command: redis-server --appendonly yes --appendfsync everysec 16 17 volumes: 17 18 - redis-data:/data 18 19 networks: ··· 33 34 34 35 # Expose the metrics server port to the host machine. 35 36 ports: 36 - - "4100:4101" 37 + - "4101:4101" 37 38 38 39 # Load environment variables from a .env file in the same directory. 39 40 # This is where you should put your BSKY_HANDLE, BSKY_PASSWORD, etc. ··· 55 56 volumes: 56 57 - ./cursor.txt:/app/cursor.txt 57 58 - ./.session:/app/.session 59 + - ./rules:/app/rules 58 60 59 61 environment: 60 62 - NODE_ENV=production 61 63 - REDIS_URL=redis://redis:6379 62 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 + 63 82 volumes: 64 83 redis-data: 84 + prometheus-data: 65 85 66 86 networks: 67 87 skywatch-network: 68 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 );
+2 -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", ··· 31 31 "@vitest/ui": "^1.6.0", 32 32 "eslint": "^9.34.0", 33 33 "eslint-config-prettier": "^10.1.8", 34 + "eslint-plugin-import": "^2.32.0", 34 35 "prettier": "^3.6.2", 35 36 "supertest": "^7.1.4", 36 37 "tsx": "^4.20.5",
+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 + }
+70 -15
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 { loadSession, saveSession, type SessionData } from "./session.js"; 5 4 import { updateRateLimitState } from "./limits.js"; 6 5 import { logger } from "./logger.js"; 6 + import { type SessionData, loadSession, saveSession } from "./session.js"; 7 7 8 - 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 + ); 9 15 10 16 const customFetch: typeof fetch = async (input, init) => { 11 17 const response = await fetch(input, init); ··· 21 27 limit: parseInt(limitHeader, 10), 22 28 remaining: parseInt(remainingHeader, 10), 23 29 reset: parseInt(resetHeader, 10), 24 - policy: policyHeader || undefined, 30 + policy: policyHeader ?? undefined, 25 31 }); 26 32 } 27 33 ··· 40 46 async function refreshSession(): Promise<void> { 41 47 try { 42 48 logger.info("Refreshing session tokens"); 43 - await agent.resumeSession(agent.session!); 49 + if (!agent.session) { 50 + throw new Error("No active session to refresh"); 51 + } 52 + await agent.resumeSession(agent.session); 44 53 45 - if (agent.session) { 46 - saveSession(agent.session as SessionData); 47 - scheduleSessionRefresh(); 48 - } 49 - } catch (error) { 54 + saveSession(agent.session as SessionData); 55 + scheduleSessionRefresh(); 56 + } catch (error: unknown) { 50 57 logger.error({ error }, "Failed to refresh session, will re-authenticate"); 51 58 await performLogin(); 52 59 } ··· 58 65 } 59 66 60 67 const refreshIn = JWT_LIFETIME_MS * REFRESH_AT_PERCENT; 61 - logger.debug(`Scheduling session refresh in ${(refreshIn / 1000 / 60).toFixed(1)} minutes`); 68 + logger.debug( 69 + `Scheduling session refresh in ${(refreshIn / 1000 / 60).toFixed(1)} minutes`, 70 + ); 62 71 63 72 refreshTimer = setTimeout(() => { 64 - refreshSession().catch((error) => { 73 + refreshSession().catch((error: unknown) => { 65 74 logger.error({ error }, "Scheduled session refresh failed"); 66 75 }); 67 76 }, refreshIn); ··· 90 99 } 91 100 } 92 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 + 93 111 async function authenticate(): Promise<boolean> { 94 112 const savedSession = loadSession(); 95 113 ··· 112 130 return performLogin(); 113 131 } 114 132 115 - export const login = authenticate; 116 - export const isLoggedIn = authenticate() 117 - .then((success) => success) 118 - .catch(() => false); 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 -5
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; 26 - export const REDIS_URL = process.env.REDIS_URL || "redis://redis:6379"; 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";
-4
src/constants.example.ts
··· 1 - /** 2 - * Global allowlist of DIDs that should never be moderated 3 - */ 4 - export const GLOBAL_ALLOW: string[] = [];
-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`.
+3 -3
src/limits.ts
··· 1 1 import { pRateLimit } from "p-ratelimit"; 2 - import { logger } from "./logger.js"; 3 2 import { Counter, Gauge, Histogram } from "prom-client"; 3 + import { logger } from "./logger.js"; 4 4 5 5 interface RateLimitState { 6 6 limit: number; ··· 76 76 remaining: rateLimitState.remaining, 77 77 resetIn: rateLimitState.reset - Math.floor(Date.now() / 1000), 78 78 }, 79 - "Rate limit state updated" 79 + "Rate limit state updated", 80 80 ); 81 81 } 82 82 ··· 93 93 94 94 if (delayMs > 0) { 95 95 logger.warn( 96 - `Rate limit critical (${state.remaining}/${state.limit} remaining). Waiting ${delaySeconds}s until reset...` 96 + `Rate limit critical (${state.remaining.toString()}/${state.limit.toString()} remaining). Waiting ${delaySeconds.toString()}s until reset...`, 97 97 ); 98 98 99 99 const waitStart = Date.now();
+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 };
+106 -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, ··· 22 23 checkDescription, 23 24 checkDisplayName, 24 25 } from "./rules/profiles/checkProfiles.js"; 25 - import { Handle, LinkFeature, Post } from "./types.js"; 26 + import type { Post } from "./types.js"; 26 27 27 28 let cursor = 0; 28 29 let cursorUpdateInterval: NodeJS.Timeout; ··· 55 56 const jetstream = new Jetstream({ 56 57 wantedCollections: WANTED_COLLECTION, 57 58 endpoint: FIREHOSE_URL, 58 - cursor: cursor, 59 + cursor, 59 60 }); 60 61 61 62 jetstream.on("open", () => { ··· 111 112 "app.bsky.feed.post", 112 113 (event: CommitCreateEvent<"app.bsky.feed.post">) => { 113 114 const atURI = `at://${event.did}/app.bsky.feed.post/${event.commit.rkey}`; 114 - const hasEmbed = event.commit.record.hasOwnProperty("embed"); 115 - const hasFacets = event.commit.record.hasOwnProperty("facets"); 116 - 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 + ); 117 127 118 128 const tasks: Promise<void>[] = []; 119 129 ··· 135 145 136 146 // Check account age for quote posts 137 147 if (hasEmbed) { 138 - const embed = event.commit.record.embed; 148 + const { embed } = event.commit.record; 139 149 if ( 140 150 embed && 151 + typeof embed === "object" && 152 + "$type" in embed && 141 153 (embed.$type === "app.bsky.embed.record" || 142 154 embed.$type === "app.bsky.embed.recordWithMedia") 143 155 ) { 144 156 const record = 145 157 embed.$type === "app.bsky.embed.record" 146 - ? embed.record 147 - : embed.record.record; 148 - 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") { 149 161 const quotedPostURI = record.uri; 150 162 const quotedDid = quotedPostURI.split("/")[2]; // Extract DID from at://did/... 151 - 152 - tasks.push( 153 - checkAccountAge({ 154 - actorDid: event.did, 155 - quotedDid, 156 - quotedPostURI, 157 - atURI, 158 - time: event.time_us, 159 - }), 160 - ); 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 + } 161 174 } 162 175 } 163 176 } ··· 165 178 // Check if the record has facets 166 179 if (hasFacets) { 167 180 // Check for facet spam (hidden mentions with duplicate byte positions) 168 - tasks.push( 169 - checkFacetSpam( 170 - event.did, 171 - event.time_us, 172 - atURI, 173 - event.commit.record.facets!, 174 - ), 175 - ); 181 + const facets = event.commit.record.facets ?? null; 182 + tasks.push(checkFacetSpam(event.did, event.time_us, atURI, facets)); 176 183 177 - const hasLinkType = event.commit.record.facets!.some((facet) => 184 + const hasLinkType = facets?.some((facet) => 178 185 facet.features.some( 179 186 (feature) => feature.$type === "app.bsky.richtext.facet#link", 180 187 ), 181 188 ); 182 189 183 - if (hasLinkType) { 184 - const urls = event.commit.record 185 - .facets!.flatMap((facet) => 186 - facet.features.filter( 187 - (feature) => feature.$type === "app.bsky.richtext.facet#link", 188 - ), 189 - ) 190 - .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 + ); 191 195 192 - urls.forEach((url) => { 193 - const posts: Post[] = [ 194 - { 195 - did: event.did, 196 - time: event.time_us, 197 - rkey: event.commit.rkey, 198 - atURI: atURI, 199 - text: url, 200 - cid: event.commit.cid, 201 - }, 202 - ]; 203 - tasks.push(checkPosts(posts)); 204 - }); 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 + } 205 212 } 206 213 } 207 214 ··· 211 218 did: event.did, 212 219 time: event.time_us, 213 220 rkey: event.commit.rkey, 214 - atURI: atURI, 221 + atURI, 215 222 text: event.commit.record.text, 216 223 cid: event.commit.cid, 217 224 }, ··· 220 227 } 221 228 222 229 if (hasEmbed) { 223 - const embed = event.commit.record.embed; 224 - 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 } }; 225 238 const posts: Post[] = [ 226 239 { 227 240 did: event.did, 228 241 time: event.time_us, 229 242 rkey: event.commit.rkey, 230 - atURI: atURI, 231 - text: embed.external.uri, 243 + atURI, 244 + text: external.uri, 232 245 cid: event.commit.cid, 233 246 }, 234 247 ]; 235 248 tasks.push(checkPosts(posts)); 236 249 } 237 250 238 - if (embed && embed.$type === "app.bsky.embed.recordWithMedia") { 239 - 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) { 240 261 const posts: Post[] = [ 241 262 { 242 263 did: event.did, 243 264 time: event.time_us, 244 265 rkey: event.commit.rkey, 245 - atURI: atURI, 246 - text: embed.media.external.uri, 266 + atURI, 267 + text: media.external.uri, 247 268 cid: event.commit.cid, 248 269 }, 249 270 ]; ··· 257 278 // Check for profile updates 258 279 jetstream.onUpdate( 259 280 "app.bsky.actor.profile", 281 + // eslint-disable-next-line @typescript-eslint/no-misused-promises, @typescript-eslint/require-await 260 282 async (event: CommitUpdateEvent<"app.bsky.actor.profile">) => { 261 283 try { 262 284 if (event.commit.record.displayName || event.commit.record.description) { 263 - checkDescription( 285 + void checkDescription( 264 286 event.did, 265 287 event.time_us, 266 288 event.commit.record.displayName as string, 267 289 event.commit.record.description as string, 268 290 ); 269 - checkDisplayName( 291 + void checkDisplayName( 270 292 event.did, 271 293 event.time_us, 272 294 event.commit.record.displayName as string, ··· 283 305 284 306 jetstream.onCreate( 285 307 "app.bsky.actor.profile", 308 + // eslint-disable-next-line @typescript-eslint/no-misused-promises, @typescript-eslint/require-await 286 309 async (event: CommitCreateEvent<"app.bsky.actor.profile">) => { 287 310 try { 288 311 if (event.commit.record.displayName || event.commit.record.description) { 289 - checkDescription( 312 + void checkDescription( 290 313 event.did, 291 314 event.time_us, 292 315 event.commit.record.displayName as string, 293 316 event.commit.record.description as string, 294 317 ); 295 - checkDisplayName( 318 + void checkDisplayName( 296 319 event.did, 297 320 event.time_us, 298 321 event.commit.record.displayName as string, ··· 306 329 ); 307 330 308 331 // Check for handle updates 309 - jetstream.on("identity", async (event: IdentityEvent) => { 310 - if (event.identity.handle) { 311 - checkHandle(event.identity.did, event.identity.handle, event.time_us); 312 - } 313 - }); 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 + ); 314 342 315 343 const metricsServer = startMetricsServer(METRICS_PORT); 316 344 317 - /* labelerServer.app.listen({ port: PORT, host: HOST }, (error, address) => { 318 - if (error) { 319 - logger.error("Error starting server: %s", error); 320 - } else { 321 - logger.info(`Labeler server listening on ${address}`); 322 - } 323 - });*/ 324 - 325 345 logger.info({ process: "MAIN" }, "Connecting to Redis"); 326 346 await connectRedis(); 327 347 348 + logger.info({ process: "MAIN" }, "Authenticating with Bluesky"); 349 + await login(); 350 + logger.info({ process: "MAIN" }, "Authentication complete, starting Jetstream"); 351 + 328 352 jetstream.start(); 329 353 330 354 async function shutdown() { 331 355 try { 332 356 logger.info({ process: "MAIN" }, "Shutting down gracefully"); 333 - fs.writeFileSync("cursor.txt", jetstream.cursor!.toString(), "utf8"); 357 + if (jetstream.cursor !== undefined) { 358 + fs.writeFileSync("cursor.txt", jetstream.cursor.toString(), "utf8"); 359 + } 334 360 jetstream.close(); 335 361 metricsServer.close(); 336 362 await disconnectRedis(); ··· 340 366 } 341 367 } 342 368 343 - process.on("SIGINT", shutdown); 344 - 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 });
+44 -211
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 { 6 - tryClaimAccountComment, 7 - tryClaimAccountLabel, 8 - tryClaimPostLabel, 9 - } from "./redis.js"; 5 + import { labelsAppliedCounter, labelsCachedCounter } from "./metrics.js"; 6 + import { tryClaimPostLabel } from "./redis.js"; 10 7 11 8 const doesLabelExist = ( 12 9 labels: { val: string }[] | undefined, ··· 25 22 comment: string, 26 23 duration: number | undefined, 27 24 did?: string, 25 + time?: number, 28 26 ) => { 29 27 await isLoggedIn; 30 28 ··· 34 32 { process: "MODERATION", uri, label }, 35 33 "Post label already claimed in Redis, skipping", 36 34 ); 35 + labelsCachedCounter.inc({ 36 + label_type: label, 37 + target_type: "post", 38 + reason: "redis_cache", 39 + }); 37 40 return; 38 41 } 39 42 ··· 43 46 { process: "MODERATION", uri, label }, 44 47 "Post already has label, skipping", 45 48 ); 49 + labelsCachedCounter.inc({ 50 + label_type: label, 51 + target_type: "post", 52 + reason: "existing_label", 53 + }); 46 54 return; 47 55 } 48 56 ··· 50 58 { process: "MODERATION", label, did, atURI: uri }, 51 59 "Labeling post", 52 60 ); 61 + labelsAppliedCounter.inc({ label_type: label, target_type: "post" }); 53 62 54 63 await limit(async () => { 55 64 try { ··· 61 70 durationInHours?: number; 62 71 } = { 63 72 $type: "tools.ozone.moderation.defs#modEventLabel", 64 - comment: comment, 73 + comment, 65 74 createLabelVals: [label], 66 75 negateLabelVals: [], 67 76 }; ··· 72 81 73 82 await agent.tools.ozone.moderation.emitEvent( 74 83 { 75 - event: event, 84 + event, 76 85 // specify the labeled post by strongRef 77 86 subject: { 78 87 $type: "com.atproto.repo.strongRef", 79 - uri: uri, 80 - cid: cid, 88 + uri, 89 + cid, 81 90 }, 82 91 // put in the rest of the metadata 83 - createdBy: `${agent.did}`, 92 + createdBy: agent.did ?? "", 84 93 createdAt: new Date().toISOString(), 85 94 modTool: { 86 95 name: "skywatch/skywatch-automod", ··· 89 98 { 90 99 encoding: "application/json", 91 100 headers: { 92 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 101 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 93 102 "atproto-accept-labelers": 94 103 "did:plc:ar7c4by46qjdydhdevvrndac;redact", 95 104 }, 96 105 }, 97 106 ); 98 - } catch (e) { 99 - logger.error( 100 - { process: "MODERATION", error: e }, 101 - "Failed to create post label", 102 - ); 103 - } 104 - }); 105 - }; 106 - 107 - export const createAccountLabel = async ( 108 - did: string, 109 - label: string, 110 - comment: string, 111 - ) => { 112 - await isLoggedIn; 113 - 114 - const claimed = await tryClaimAccountLabel(did, label); 115 - if (!claimed) { 116 - logger.debug( 117 - { process: "MODERATION", did, label }, 118 - "Account label already claimed in Redis, skipping", 119 - ); 120 - return; 121 - } 122 - 123 - const hasLabel = await checkAccountLabels(did, label); 124 - if (hasLabel) { 125 - logger.debug( 126 - { process: "MODERATION", did, label }, 127 - "Account already has label, skipping", 128 - ); 129 - return; 130 - } 131 107 132 - logger.info({ process: "MODERATION", did, label }, "Labeling account"); 133 - 134 - await limit(async () => { 135 - try { 136 - await agent.tools.ozone.moderation.emitEvent( 137 - { 138 - event: { 139 - $type: "tools.ozone.moderation.defs#modEventLabel", 140 - comment: comment, 141 - createLabelVals: [label], 142 - negateLabelVals: [], 143 - }, 144 - // specify the labeled post by strongRef 145 - subject: { 146 - $type: "com.atproto.admin.defs#repoRef", 147 - did: did, 148 - }, 149 - // put in the rest of the metadata 150 - createdBy: `${agent.did}`, 151 - createdAt: new Date().toISOString(), 152 - modTool: { 153 - name: "skywatch/skywatch-automod", 154 - }, 155 - }, 156 - { 157 - encoding: "application/json", 158 - headers: { 159 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 160 - "atproto-accept-labelers": 161 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 162 - }, 163 - }, 164 - ); 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 + } 165 124 } catch (e) { 166 125 logger.error( 167 126 { process: "MODERATION", error: e }, 168 - "Failed to create account label", 127 + "Failed to create post label", 169 128 ); 170 129 } 171 130 }); ··· 179 138 await isLoggedIn; 180 139 await limit(async () => { 181 140 try { 182 - return agent.tools.ozone.moderation.emitEvent( 141 + return await agent.tools.ozone.moderation.emitEvent( 183 142 { 184 143 event: { 185 144 $type: "tools.ozone.moderation.defs#modEventReport", 186 - comment: comment, 145 + comment, 187 146 reportType: "com.atproto.moderation.defs#reasonOther", 188 147 }, 189 148 // specify the labeled post by strongRef 190 149 subject: { 191 150 $type: "com.atproto.repo.strongRef", 192 - uri: uri, 193 - cid: cid, 151 + uri, 152 + cid, 194 153 }, 195 154 // put in the rest of the metadata 196 - createdBy: `${agent.did}`, 155 + createdBy: agent.did ?? "", 197 156 createdAt: new Date().toISOString(), 198 157 modTool: { 199 158 name: "skywatch/skywatch-automod", ··· 202 161 { 203 162 encoding: "application/json", 204 163 headers: { 205 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 164 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 206 165 "atproto-accept-labelers": 207 166 "did:plc:ar7c4by46qjdydhdevvrndac;redact", 208 167 }, ··· 217 176 }); 218 177 }; 219 178 220 - export const createAccountComment = async ( 221 - did: string, 222 - comment: string, 223 - atURI: string, 224 - ) => { 225 - await isLoggedIn; 226 - 227 - const claimed = await tryClaimAccountComment(did, atURI); 228 - if (!claimed) { 229 - logger.debug( 230 - { process: "MODERATION", did, atURI }, 231 - "Account comment already claimed in Redis, skipping", 232 - ); 233 - return; 234 - } 235 - 236 - logger.info({ process: "MODERATION", did, atURI }, "Commenting on account"); 237 - 238 - await limit(async () => { 239 - try { 240 - await agent.tools.ozone.moderation.emitEvent( 241 - { 242 - event: { 243 - $type: "tools.ozone.moderation.defs#modEventComment", 244 - comment: comment, 245 - }, 246 - // specify the labeled post by strongRef 247 - subject: { 248 - $type: "com.atproto.admin.defs#repoRef", 249 - did: did, 250 - }, 251 - // put in the rest of the metadata 252 - createdBy: `${agent.did}`, 253 - createdAt: new Date().toISOString(), 254 - modTool: { 255 - name: "skywatch/skywatch-automod", 256 - }, 257 - }, 258 - { 259 - encoding: "application/json", 260 - headers: { 261 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 262 - "atproto-accept-labelers": 263 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 264 - }, 265 - }, 266 - ); 267 - } catch (e) { 268 - logger.error( 269 - { process: "MODERATION", error: e }, 270 - "Failed to create account comment", 271 - ); 272 - } 273 - }); 274 - }; 275 - 276 - export const createAccountReport = async (did: string, comment: string) => { 277 - await isLoggedIn; 278 - await limit(async () => { 279 - try { 280 - await agent.tools.ozone.moderation.emitEvent( 281 - { 282 - event: { 283 - $type: "tools.ozone.moderation.defs#modEventReport", 284 - comment: comment, 285 - reportType: "com.atproto.moderation.defs#reasonOther", 286 - }, 287 - // specify the labeled post by strongRef 288 - subject: { 289 - $type: "com.atproto.admin.defs#repoRef", 290 - did: did, 291 - }, 292 - // put in the rest of the metadata 293 - createdBy: `${agent.did}`, 294 - createdAt: new Date().toISOString(), 295 - modTool: { 296 - name: "skywatch/skywatch-automod", 297 - }, 298 - }, 299 - { 300 - encoding: "application/json", 301 - headers: { 302 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 303 - "atproto-accept-labelers": 304 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 305 - }, 306 - }, 307 - ); 308 - } catch (e) { 309 - logger.error( 310 - { process: "MODERATION", error: e }, 311 - "Failed to create account report", 312 - ); 313 - } 314 - }); 315 - }; 316 - 317 - export const checkAccountLabels = async ( 318 - did: string, 319 - label: string, 320 - ): Promise<boolean> => { 321 - await isLoggedIn; 322 - return await limit(async () => { 323 - try { 324 - const response = await agent.tools.ozone.moderation.getRepo( 325 - { did }, 326 - { 327 - headers: { 328 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 329 - "atproto-accept-labelers": 330 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 331 - }, 332 - }, 333 - ); 334 - 335 - return doesLabelExist(response.data.labels, label); 336 - } catch (e) { 337 - logger.error( 338 - { process: "MODERATION", did, error: e }, 339 - "Failed to check account labels", 340 - ); 341 - return false; 342 - } 343 - }); 344 - }; 345 - 346 179 export const checkRecordLabels = async ( 347 180 uri: string, 348 181 label: string, ··· 354 187 { uri }, 355 188 { 356 189 headers: { 357 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 190 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 358 191 "atproto-accept-labelers": 359 192 "did:plc:ar7c4by46qjdydhdevvrndac;redact", 360 193 },
+72
src/redis.ts
··· 107 107 return true; 108 108 } 109 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
-78
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 - { 16 - monitoredDIDs: [ 17 - "did:plc:b2ecyhl2z2tro25ltrcyiytd", // DHS 18 - "did:plc:iw2wxg46hm4ezguswhwej6t6", // actual whitehouse 19 - "did:plc:fhnl65q3us5evynqc4f2qak6", // HHS 20 - "did:plc:wrz4athzuf2u5js2ltrktiqk", // DOL 21 - "did:plc:3mqcgvyu4exg3pkx4bkfppih", // VA 22 - "did:plc:pqn2sfkx5klnytms4uwqt5wo", // Treasurer 23 - "did:plc:v4kvjftk6kr5ci3zqmfawwpb", // State 24 - "did:plc:rlymk4d5qmq5udjdznojmvel", // Interior 25 - "did:plc:f7a5etif42x56oyrbzuek6so", // USDA 26 - "did:plc:7kusimwlnf4v5jo757jvkeaj", // DOE 27 - "did:plc:jgq3vko3g6zg72457bda2snd", // SBA 28 - "did:plc:h2iujdjlry6fpniofjtiqqmb", // DoD 29 - "did:plc:jwncvpznkwe4luzvdroes45b", // CBP 30 - "did:plc:azfxx5mdxcuoc2bkuqizs4kd", 31 - "did:plc:vostkism5vbzjqfcmllmd6gz", 32 - "did:plc:etthv4ychwti4b6i2hhe76c2", 33 - "did:plc:swf7zddjselkcpbn6iw323gy", 34 - "did:plc:h3zq65wioggctyxpovfpi6ec", 35 - "did:plc:nofnc2xpdihktxkufkq7tn3w", 36 - "did:plc:quezcqejcqw6g5t3om7wldns", 37 - "did:plc:vlvqht2v3nsc4k7xaho6bjaf", 38 - "did:plc:syyfuvqiabipi5mf3x632qij", 39 - "did:plc:6vpxzm6mxjzcfvccnuw2pyd7", 40 - "did:plc:yxqdgravj27gtxkpqhrnzhlx", 41 - "did:plc:nrhrdxqa2v7hfxw2jnuy7rk7", 42 - "did:plc:pr27argcmniiwxp7d7facqwy", 43 - "did:plc:azfxx5mdxcuoc2bkuqizs4kd", 44 - "did:plc:y42muzveli3sjyr3tufaq765", 45 - "did:plc:22wazjq4e4yjafxlew2c6kov", 46 - "did:plc:iw64z65wzkmqvftssb2nldj5", 47 - ], 48 - anchorDate: "2025-10-17", // Date when harassment campaign started 49 - maxAgeDays: 7, // Flag accounts less than 7 days old 50 - label: "suspect-inauthentic", 51 - comment: "New account replying to monitored user during campaign", 52 - }, 53 - // Example: Monitor replies to specific accounts 54 - // { 55 - // monitoredDIDs: [ 56 - // "did:plc:example123", // High-profile account 1 57 - // "did:plc:example456", // High-profile account 2 58 - // ], 59 - // anchorDate: "2025-01-15", // Date when harassment campaign started 60 - // maxAgeDays: 7, // Flag accounts less than 7 days old 61 - // label: "new-account-reply", 62 - // comment: "New account replying to monitored user during campaign", 63 - // expires: "2025-02-15", // Optional: automatically stop this check after this date 64 - // }, 65 - // 66 - // Example: Monitor replies to specific posts 67 - // { 68 - // monitoredPostURIs: [ 69 - // "at://did:plc:example123/app.bsky.feed.post/abc123", 70 - // "at://did:plc:example456/app.bsky.feed.post/def456", 71 - // ], 72 - // anchorDate: "2025-01-15", 73 - // maxAgeDays: 7, 74 - // label: "brigading-suspect", 75 - // comment: "New account replying to specific targeted post", 76 - // expires: "2025-02-15", 77 - // }, 78 - ];
+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
+6 -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", ··· 222 222 it("should process all matching rules", async () => { 223 223 vi.resetModules(); 224 224 // Re-import with a mock that has overlapping patterns 225 - vi.doMock("./constants.js", () => ({ 225 + vi.doMock("../../../rules/handles.js", () => ({ 226 226 HANDLE_CHECKS: [ 227 227 { 228 228 label: "pattern1", ··· 270 270 }); 271 271 272 272 it("should handle very long handles", async () => { 273 - const longHandle = "spam-" + "a".repeat(1000); 273 + const longHandle = `spam-${"a".repeat(1000)}`; 274 274 const time = Date.now(); 275 275 await checkHandle("did:plc:user1", longHandle, time); 276 276
+18 -15
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 - createAccountLabel( 48 + if (checkList.toLabel) { 49 + void createAccountLabel( 50 50 did, 51 - `${checkList.label}`, 52 - `${time}: ${checkList.comment} - ${handle}`, 51 + checkList.label, 52 + `${time.toString()}: ${checkList.comment} - ${handle}`, 53 53 ); 54 54 } 55 55 56 - if (checkList.reportAcct === true) { 56 + if (checkList.reportAcct) { 57 57 logger.info( 58 58 { process: "CHECKHANDLE", did, handle, time, label: checkList.label }, 59 59 "Reporting account", 60 60 ); 61 - createAccountReport(did, `${time}: ${checkList.comment} - ${handle}`); 61 + void createAccountReport( 62 + did, 63 + `${time.toString()}: ${checkList.comment} - ${handle}`, 64 + ); 62 65 } 63 66 64 - if (checkList.commentAcct === true) { 65 - createAccountComment( 67 + if (checkList.commentAcct) { 68 + void createAccountComment( 66 69 did, 67 - `${time}: ${checkList.comment} - ${handle}`, 70 + `${time.toString()}: ${checkList.comment} - ${handle}`, 68 71 `handle:${did}:${handle}`, 69 72 ); 70 73 }
-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 - ];
+20 -20
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 - createPostLabel( 107 + if (checkPost.toLabel) { 108 + void createPostLabel( 110 109 post[0].atURI, 111 110 post[0].cid, 112 - `${checkPost.label}`, 113 - `${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}"`, 114 113 checkPost.duration, 115 114 post[0].did, 115 + post[0].time, 116 116 ); 117 117 } 118 118 ··· 126 126 }, 127 127 "Reporting post", 128 128 ); 129 - createPostReport( 129 + void createPostReport( 130 130 post[0].atURI, 131 131 post[0].cid, 132 - `${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}"`, 133 133 ); 134 134 } 135 135 136 - if (checkPost.reportAcct === true) { 136 + if (checkPost.reportAcct) { 137 137 logger.info( 138 138 { 139 139 process: "CHECKPOSTS", ··· 143 143 }, 144 144 "Reporting account", 145 145 ); 146 - createAccountReport( 146 + void createAccountReport( 147 147 post[0].did, 148 - `${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}"`, 149 149 ); 150 150 } 151 151 152 - if (checkPost.commentAcct === true) { 153 - createAccountComment( 152 + if (checkPost.commentAcct) { 153 + void createAccountComment( 154 154 post[0].did, 155 - `${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 156 post[0].atURI, 157 157 ); 158 158 }
-31
src/rules/posts/constants.example.ts
··· 1 - import type { Checks } from "../../types.js"; 2 - 3 - export const LINK_SHORTENER = /bit\.ly|tinyurl\.com|ow\.ly/i; 4 - 5 - export const POST_CHECKS: Checks[] = [ 6 - // Example 1: Spam detection 7 - { 8 - label: "spam", 9 - comment: "Post contains spam indicators", 10 - reportPost: true, 11 - reportAcct: false, 12 - commentAcct: false, 13 - toLabel: true, 14 - check: new RegExp( 15 - "click.*?here|limited.*?time.*?offer|act.*?now|100%.*?free", 16 - "i", 17 - ), 18 - whitelist: new RegExp("legitimate.*?offer", "i"), 19 - }, 20 - 21 - // Example 2: Promotional content 22 - { 23 - label: "promotional", 24 - comment: "Promotional content detected", 25 - reportPost: false, 26 - reportAcct: false, 27 - commentAcct: false, 28 - toLabel: true, 29 - check: new RegExp("buy.*?now|discount.*?code|promo.*?link", "i"), 30 - }, 31 - ];
+19 -12
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 83 - vi.mock("../../../moderation.js", () => ({ 84 - createPostLabel: vi.fn(), 86 + vi.mock("../../../accountModeration.js", () => ({ 85 87 createAccountReport: vi.fn(), 86 88 createAccountComment: vi.fn(), 89 + })); 90 + 91 + vi.mock("../../../moderation.js", () => ({ 92 + createPostLabel: vi.fn(), 87 93 createPostReport: vi.fn(), 88 94 })); 89 95 ··· 95 101 getFinalUrl: vi.fn(), 96 102 })); 97 103 98 - vi.mock("../../../constants.js", () => ({ 99 - GLOBAL_ALLOW: ["did:plc:globalallow"], 100 - })); 101 - 102 104 describe("checkPosts", () => { 103 105 beforeEach(() => { 104 106 vi.clearAllMocks(); ··· 251 253 expect.stringContaining("Test comment"), 252 254 undefined, 253 255 post[0].did, 256 + post[0].time, 254 257 ); 255 258 }); 256 259 ··· 285 288 expect.any(String), 286 289 undefined, 287 290 post[0].did, 291 + post[0].time, 288 292 ); 289 293 }); 290 294 ··· 339 343 expect.any(String), 340 344 undefined, 341 345 post[0].did, 346 + post[0].time, 342 347 ); 343 348 }); 344 349 }); ··· 384 389 expect.any(String), 385 390 undefined, 386 391 "did:plc:notignored", 392 + post[0].time, 387 393 ); 388 394 }); 389 395 }); ··· 401 407 expect.any(String), 402 408 undefined, 403 409 post[0].did, 410 + post[0].time, 404 411 ); 405 412 expect(createPostReport).toHaveBeenCalledWith( 406 413 post[0].atURI,
+26 -26
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}`, 70 + checkProfiles.label, 71 + `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 72 72 ); 73 73 } 74 74 75 - if (checkProfiles.reportAcct === true) { 76 - createAccountReport( 75 + if (checkProfiles.reportAcct) { 76 + void createAccountReport( 77 77 did, 78 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 78 + `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 79 79 ); 80 80 logger.info( 81 81 { ··· 90 90 ); 91 91 } 92 92 93 - if (checkProfiles.commentAcct === true) { 94 - createAccountComment( 93 + if (checkProfiles.commentAcct) { 94 + void createAccountComment( 95 95 did, 96 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 97 - `profile:${did}:${time}`, 96 + `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 97 + `profile:${did}:${time.toString()}`, 98 98 ); 99 99 } 100 100 } ··· 159 159 } 160 160 } 161 161 162 - if (checkProfiles.toLabel === true) { 163 - createAccountLabel( 162 + if (checkProfiles.toLabel) { 163 + void createAccountLabel( 164 164 did, 165 - `${checkProfiles.label}`, 166 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 165 + checkProfiles.label, 166 + `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 167 167 ); 168 168 } 169 169 170 - if (checkProfiles.reportAcct === true) { 171 - createAccountReport( 170 + if (checkProfiles.reportAcct) { 171 + void createAccountReport( 172 172 did, 173 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 173 + `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 174 174 ); 175 175 logger.info( 176 176 { ··· 185 185 ); 186 186 } 187 187 188 - if (checkProfiles.commentAcct === true) { 189 - createAccountComment( 188 + if (checkProfiles.commentAcct) { 189 + void createAccountComment( 190 190 did, 191 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 192 - `profile:${did}:${time}`, 191 + `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 192 + `profile:${did}:${time.toString()}`, 193 193 ); 194 194 } 195 195 }
-55
src/rules/profiles/constants.example.ts
··· 1 - import type { Checks } from "../../types.js"; 2 - 3 - /** 4 - * Example profile check configurations 5 - * 6 - * This file demonstrates how to configure profile moderation rules. 7 - * Copy this file to constants.ts and customize for your labeler's needs. 8 - * 9 - * Profile checks can match against display names and/or descriptions. 10 - */ 11 - 12 - export const PROFILE_CHECKS: Checks[] = [ 13 - // Example 1: Suspicious bio patterns 14 - { 15 - label: "suspicious-bio", 16 - comment: "Profile contains suspicious patterns", 17 - description: true, 18 - displayName: false, 19 - reportAcct: false, 20 - commentAcct: false, 21 - toLabel: true, 22 - check: new RegExp( 23 - "dm.*?for.*?promo|follow.*?for.*?follow|gain.*?followers", 24 - "i", 25 - ), 26 - }, 27 - 28 - // Example 2: Display name checks 29 - { 30 - label: "impersonation-risk", 31 - comment: "Display name may indicate impersonation", 32 - description: false, 33 - displayName: true, 34 - reportAcct: false, 35 - commentAcct: false, 36 - toLabel: true, 37 - check: new RegExp("official|verified|admin|support", "i"), 38 - whitelist: new RegExp("unofficial|parody|fan", "i"), 39 - ignoredDIDs: [ 40 - "did:plc:example123", // Actual official account 41 - ], 42 - }, 43 - 44 - // Example 3: Both display name and description 45 - { 46 - label: "crypto-spam", 47 - comment: "Profile suggests crypto spam activity", 48 - description: true, 49 - displayName: true, 50 - reportAcct: false, 51 - commentAcct: false, 52 - toLabel: true, 53 - check: new RegExp("crypto.*?giveaway|nft.*?drop|airdrop", "i"), 54 - }, 55 - ];
+5 -6
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 ··· 357 357 expect.any(String), 358 358 ); 359 359 }); 360 - 361 360 }); 362 361 }); 363 362
+11 -2
src/session.ts
··· 1 - import { readFileSync, writeFileSync, unlinkSync, chmodSync, existsSync } from "node:fs"; 1 + import { 2 + chmodSync, 3 + existsSync, 4 + readFileSync, 5 + unlinkSync, 6 + writeFileSync, 7 + } from "node:fs"; 2 8 import { join } from "node:path"; 3 9 import { logger } from "./logger.js"; 4 10 ··· 34 40 logger.info("Loaded existing session from file"); 35 41 return session; 36 42 } catch (error) { 37 - logger.error({ error }, "Failed to load session file, will authenticate fresh"); 43 + logger.error( 44 + { error }, 45 + "Failed to load session file, will authenticate fresh", 46 + ); 38 47 return null; 39 48 } 40 49 }
+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) {
+54 -26
src/tests/moderation.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + // --- Imports Second --- 3 + import { checkAccountLabels } from "../accountModeration.js"; 4 + import { agent } from "../agent.js"; 5 + import { createPostLabel } from "../moderation.js"; 6 + import { tryClaimPostLabel } from "../redis.js"; 2 7 3 8 // --- Mocks First --- 4 9 ··· 39 44 limit: vi.fn((fn) => fn()), 40 45 })); 41 46 42 - // --- Imports Second --- 43 - 44 - import { agent } from "../agent.js"; 45 - import { checkAccountLabels, createPostLabel } from "../moderation.js"; 46 - import { tryClaimPostLabel } from "../redis.js"; 47 - import { logger } from "../logger.js"; 48 - 49 47 describe("Moderation Logic", () => { 50 48 beforeEach(() => { 51 49 vi.clearAllMocks(); ··· 56 54 vi.mocked(agent.tools.ozone.moderation.getRepo).mockResolvedValueOnce({ 57 55 data: { 58 56 labels: [ 59 - { val: "spam", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" }, 60 - { val: "window-reply", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" } 61 - ] 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 71 } as any); 64 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 72 + const result = await checkAccountLabels( 73 + "did:plc:test123", 74 + "window-reply", 75 + ); 65 76 expect(result).toBe(true); 66 77 }); 67 78 }); ··· 78 89 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 79 90 80 91 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 81 - expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).not.toHaveBeenCalled(); 82 - expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled(); 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(); 83 98 }); 84 99 85 100 it("should skip event if claimed but already labeled via API", async () => { 86 101 vi.mocked(tryClaimPostLabel).mockResolvedValue(true); 87 102 vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 88 - data: { labels: [{ val: LABEL, src: "did:plc:test", uri: URI, cts: "2024-01-01T00:00:00Z" }] }, 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 + }, 89 113 } as any); 90 114 91 115 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 92 116 93 117 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 94 - expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith( 95 - { uri: URI }, 96 - expect.any(Object), 97 - ); 98 - expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled(); 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(); 99 124 }); 100 125 101 126 it("should emit event if claimed and not labeled anywhere", async () => { ··· 103 128 vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 104 129 data: { labels: [] }, 105 130 } as any); 106 - vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({ success: true } as any); 131 + vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({ 132 + success: true, 133 + } as any); 107 134 108 135 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 109 136 110 137 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 111 - expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith( 112 - { uri: URI }, 113 - expect.any(Object), 114 - ); 115 - expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).toHaveBeenCalled(); 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(); 116 144 }); 117 145 }); 118 - }); 146 + });
+169 -42
src/tests/redis.test.ts
··· 1 - import { afterEach, describe, expect, it, vi } from 'vitest'; 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"; 2 14 3 15 // Mock the 'redis' module in a way that avoids hoisting issues. 4 16 // The mock implementation is self-contained. 5 - vi.mock('redis', () => { 17 + vi.mock("redis", () => { 6 18 const mockClient = { 7 19 on: vi.fn(), 8 20 connect: vi.fn(), 9 21 quit: vi.fn(), 10 22 exists: vi.fn(), 11 23 set: vi.fn(), 24 + zAdd: vi.fn(), 25 + zRemRangeByScore: vi.fn(), 26 + zCount: vi.fn(), 27 + expire: vi.fn(), 12 28 }; 13 29 return { 14 30 createClient: vi.fn(() => mockClient), 15 31 }; 16 32 }); 17 33 18 - // Import the mocked redis first to get a reference to the mock client 19 - import { createClient } from 'redis'; 20 34 const mockRedisClient = createClient(); 21 35 22 - // Import the modules to be tested 23 - import { 24 - tryClaimPostLabel, 25 - tryClaimAccountLabel, 26 - connectRedis, 27 - disconnectRedis, 28 - } from '../redis.js'; 29 - import { logger } from '../logger.js'; 30 - 31 36 // Suppress logger output during tests 32 - vi.mock('../logger.js', () => ({ 37 + vi.mock("../logger.js", () => ({ 33 38 logger: { 34 39 info: vi.fn(), 35 40 warn: vi.fn(), ··· 38 43 }, 39 44 })); 40 45 41 - describe('Redis Cache Logic', () => { 46 + describe("Redis Cache Logic", () => { 42 47 afterEach(() => { 43 48 vi.clearAllMocks(); 44 49 }); 45 50 46 - describe('Connection', () => { 47 - it('should call redisClient.connect on connectRedis', async () => { 51 + describe("Connection", () => { 52 + it("should call redisClient.connect on connectRedis", async () => { 48 53 await connectRedis(); 49 54 expect(mockRedisClient.connect).toHaveBeenCalled(); 50 55 }); 51 56 52 - it('should call redisClient.quit on disconnectRedis', async () => { 57 + it("should call redisClient.quit on disconnectRedis", async () => { 53 58 await disconnectRedis(); 54 59 expect(mockRedisClient.quit).toHaveBeenCalled(); 55 60 }); 56 61 }); 57 62 58 - describe('tryClaimPostLabel', () => { 59 - it('should return true and set key if key does not exist', async () => { 60 - vi.mocked(mockRedisClient.set).mockResolvedValue('OK'); 61 - const result = await tryClaimPostLabel('at://uri', 'test-label'); 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"); 62 67 expect(result).toBe(true); 63 68 expect(mockRedisClient.set).toHaveBeenCalledWith( 64 - 'post-label:at://uri:test-label', 65 - '1', 66 - { NX: true, EX: 60 * 60 * 24 * 7 } 69 + "post-label:at://uri:test-label", 70 + "1", 71 + { NX: true, EX: 60 * 60 * 24 * 7 }, 67 72 ); 68 73 }); 69 74 70 - it('should return false if key already exists', async () => { 75 + it("should return false if key already exists", async () => { 71 76 vi.mocked(mockRedisClient.set).mockResolvedValue(null); 72 - const result = await tryClaimPostLabel('at://uri', 'test-label'); 77 + const result = await tryClaimPostLabel("at://uri", "test-label"); 73 78 expect(result).toBe(false); 74 79 }); 75 80 76 - it('should return true and log warning on Redis error', async () => { 77 - const redisError = new Error('Redis down'); 81 + it("should return true and log warning on Redis error", async () => { 82 + const redisError = new Error("Redis down"); 78 83 vi.mocked(mockRedisClient.set).mockRejectedValue(redisError); 79 - const result = await tryClaimPostLabel('at://uri', 'test-label'); 84 + const result = await tryClaimPostLabel("at://uri", "test-label"); 80 85 expect(result).toBe(true); 81 86 expect(logger.warn).toHaveBeenCalledWith( 82 - { err: redisError, atURI: 'at://uri', label: 'test-label' }, 83 - 'Error claiming post label in Redis, allowing through' 87 + { err: redisError, atURI: "at://uri", label: "test-label" }, 88 + "Error claiming post label in Redis, allowing through", 84 89 ); 85 90 }); 86 91 }); 87 92 88 - describe('tryClaimAccountLabel', () => { 89 - it('should return true and set key if key does not exist', async () => { 90 - vi.mocked(mockRedisClient.set).mockResolvedValue('OK'); 91 - const result = await tryClaimAccountLabel('did:plc:123', 'test-label'); 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"); 92 97 expect(result).toBe(true); 93 98 expect(mockRedisClient.set).toHaveBeenCalledWith( 94 - 'account-label:did:plc:123:test-label', 95 - '1', 96 - { NX: true, EX: 60 * 60 * 24 * 7 } 99 + "account-label:did:plc:123:test-label", 100 + "1", 101 + { NX: true, EX: 60 * 60 * 24 * 7 }, 97 102 ); 98 103 }); 99 104 100 - it('should return false if key already exists', async () => { 105 + it("should return false if key already exists", async () => { 101 106 vi.mocked(mockRedisClient.set).mockResolvedValue(null); 102 - const result = await tryClaimAccountLabel('did:plc:123', 'test-label'); 107 + const result = await tryClaimAccountLabel("did:plc:123", "test-label"); 103 108 expect(result).toBe(false); 104 109 }); 105 110 }); 106 - }); 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 /**