+4
-1
.claude/settings.local.json
+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
+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
+1
-2
.gitignore
+196
-1
bun.lock
+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
bun.lockb
···
1
+
+12
compose.dev.yaml
+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
+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
+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
+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
+10
prometheus.yml
+17
rules/accountAge.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-4
src/constants.example.ts
-25
src/developing_checks.md
-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
+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
src/logger.ts
+106
-80
src/main.ts
+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
+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
+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
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
-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
+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
-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
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-2
src/utils/homoglyphs.ts