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