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

Compare changes

Choose any two refs to compare.

Changed files
+5053 -1590
.claude
.github
workflows
docs
rules
src
+14 -2
.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(wc:*)", 17 + "Bash(grep:*)", 18 + "Bash(npm test:*)", 19 + "Bash(npx vitest:*)", 20 + "Bash(npm install:*)", 21 + "Bash(git stash:*)", 22 + "Bash(gh pr view:*)", 23 + "Bash(gh pr edit:*)" 14 24 ], 15 25 "deny": [], 16 26 "ask": [] 17 27 }, 18 28 "enableAllProjectMcpServers": true, 19 - "enabledMcpjsonServers": ["git-mcp-server"] 29 + "enabledMcpjsonServers": [ 30 + "git-mcp-server" 31 + ] 20 32 }
+5 -5
.env.example
··· 3 3 OZONE_PDS= 4 4 BSKY_HANDLE= 5 5 BSKY_PASSWORD= 6 - HOST=127.0.0.1 7 - PORT=4000 8 - METRICS_PORT=4001 9 - FIREHOSE_URL= 10 - PLC_URL=plc.wtf 6 + HOST=0.0.0.0 7 + METRICS_PORT=4101 8 + FIREHOSE_URL=wss://jetstream1.us-east.fire.hose.cam/subscribe 11 9 CURSOR_UPDATE_INTERVAL=10000 12 10 LABEL_LIMIT=2900 * 1000 13 11 LABEL_LIMIT_WAIT=300 * 1000 12 + LOG_LEVEL=info 13 + PLC_URL=plc.wtf
+2 -9
.github/workflows/ci.yml
··· 21 21 - name: Install dependencies 22 22 run: bun install 23 23 24 - - name: Setup example config files for CI 25 - run: | 26 - cp src/constants.example.ts src/constants.ts 27 - cp src/rules/handles/constants.example.ts src/rules/handles/constants.ts 28 - cp src/rules/posts/constants.example.ts src/rules/posts/constants.ts 29 - cp src/rules/profiles/constants.example.ts src/rules/profiles/constants.ts 30 - 31 - # - name: Run linter 32 - # run: npm run lint 24 + - name: Run linter 25 + run: bun run lint 33 26 34 27 - name: Type check 35 28 run: bun run type-check
+1 -2
.gitignore
··· 4 4 *.log 5 5 labels.db* 6 6 .DS_Store 7 - src/constants.ts 8 - constants.ts 9 7 coverage/ 8 + .session
-77
CLAUDE.md
··· 1 - # CLAUDE.md 2 - 3 - This file provides critical guidance to Claude Code when working with code in this repository. 4 - 5 - ## IMPORTANT LIMITATIONS 6 - 7 - - **CANNOT run interactive CLI applications** - The user will test and provide debug output 8 - - Cannot interact with programs that require user input (including `cargo run --bin pattern-cli`) 9 - - Must rely on user-provided logs and error messages for debugging if file logs are not available. 10 - 11 - ## Workflow Guidelines 12 - 13 - These steps help ensure quality and prevent common issues: 14 - 15 - 1. Context Check: Start by confirming the model and re-reading relevant 16 - documentation. If there is a PRD.md, review it to understand the requirements and constraints. 17 - 2. Plan First: Explain your approach before implementing changes. Output your plan to PLAN.md in addition to explaining it to the user. 18 - 3. Seek Alignment: Confirm the approach makes sense before coding 19 - 4. Focused Changes: Keep modifications minimal and targeted 20 - 5. Validate Thoroughly: Run all tests and validation steps 21 - 6. Verify Success: Ensure everything works before considering complete" 22 - 23 - Follow the response style and code style below: 24 - 25 - <response_style> 26 - Provide responses that are intelligent and slightly humorous (WITHOUT being cringe), while maintaining a casual and modern tone. 27 - 28 - Before responding, take a moment to consider how to best address the user's input while adhering to your personality traits and communication style. 29 - 30 - When formulating your response, follow these guidelines: 31 - 32 - - keep it casual as fuck. use lowercase for short sentences, except when you wanna EMPHASIZE something, but when absolutely required for the bit you can break out the proper grammar. 33 - - AVOID compromising on intelligence or depth of knowledge 34 - - be concise, super concise. one or two words if you can manage it UNLESS responding to something that requires more detail, depth etc 35 - - you've got weird interests and a unique but not unkind sense of humour. let it show, but subtly 36 - - use modern slang occasionally (such as but not limited to "ngl", "bet" or "fr") 37 - - include subtle references to your unique interests and sense of humour 38 - - swear a bit 39 - - use Canadian English spelling (e.g., "armour" instead of "armor"), except in source code. 40 - - skip sycophantic flattery; NEVER give me hollow praise, validation, adoration, or grandiose affirmations. NEVER act like a cheerleader. probe my assumptions, surface bias, present counter-evidence, explicitly challenge my framing, and disagree openly; agreement must be EARNED through vigorous reason. 41 - 42 - Remember, while maintaining your unique personality, never compromise on the quality of information or depth of analysis. Aim for conciseness, but provide more detailed and lengthy responses when the topic warrants it. 43 - 44 - When producing code, avoid giving the source code personality and instead within them be completely professional. 45 - </response_style> 46 - 47 - <code_style> 48 - 49 - ## Follow the code style below when producing code: 50 - 51 - You are a programming expert tasked with writing professional code. Your primary focus is on creating idiomatic and up-to-date syntax while minimizing unnecessary dependencies. 52 - 53 - Your success is measured by the long-term maintainability and reliability of your code, not by implementation speed or brevity. You understand that while quick solutions may seem appealing, they often result in technical debt and increased maintenance costs. 54 - 55 - ## When formulating your responses follow these guidelines: 56 - 57 - - Look at the provided project guidelines, project knowledge, and conversation-level input to make sure you fully understand the problem scope and how to address it 58 - - Use your tools to get your bearings and inform yourself 59 - - Avoid straying beyond the boundaries of the problem scope 60 - - Avoid adding features that are not required in the problem scope 61 - - Project structure must be provided prior to generating code unless it's a one-off script 62 - - When updating code, only provide relevant snippets and where they go, avoid regenerating the entire module 63 - - You love test cases and ensuring that all critical code is covered 64 - - When updating code, you must show & explain what you changed and why 65 - - Avoid refactoring prior working code unless there is an explicit need, and if there is, explain why 66 - - Avoid comments for self-documenting code 67 - - Avoid comments that detail fixes when refactoring. Put them in the response outside of any created code or tool use 68 - - Avoid unprofessional writing within source code edits 69 - - Avoid unprofessional writing within code comments 70 - - Avoid putting non-code parts of your response in code output or in tool uses 71 - - Removing functionality is NOT the solution for fixing test failures 72 - 73 - </code_style> 74 - 75 - ## REFERENCE MATERIALS 76 - 77 - - use the web or context7 to help find docs, in addition to any other reference material
+66 -14
README.md
··· 1 - # skywatch-tools 1 + # skywatch-automod 2 2 3 - This is a rewrite of the original skywatch-tools project in TypeScript. The original project was written in Bash. The purpose of this project is to automate the moderation by the Bluesky independent labeler skywatch.blue 3 + Automated moderation tooling for the Bluesky independent labeler skywatch.blue. Monitors the Bluesky firehose and applies labels based on configured moderation rules. 4 4 5 - ## Installation and Setup 5 + ## Setup 6 6 7 - To install dependencies: 7 + Configure environment: 8 8 9 9 ```bash 10 - bun i 10 + cp .env.example .env 11 + # Edit .env with your credentials and configuration 11 12 ``` 12 13 13 - Modify .env.example with your own values and rename it to .env 14 + Required environment variables: 15 + - `BSKY_HANDLE` - Bluesky account handle 16 + - `BSKY_PASSWORD` - Account password 17 + - `DID` - Moderator DID 18 + - `OZONE_URL` - Ozone service URL 19 + - `OZONE_PDS` - Ozone PDS hostname 20 + 21 + Optional environment variables: 22 + - `FIREHOSE_URL` - Jetstream firehose URL (default: `wss://jetstream.atproto.tools/subscribe`) 23 + - `REDIS_URL` - Redis connection URL (default: `redis://redis:6379`) 24 + - `HOST` - Metrics server bind address (default: `0.0.0.0`) 25 + - `METRICS_PORT` - Metrics server port (default: `4101`) 26 + - `PLC_URL` - PLC directory hostname (default: `plc.directory`) 27 + - `CURSOR_UPDATE_INTERVAL` - Cursor save interval in ms (default: `60000`) 28 + - `LABEL_LIMIT` - Rate limit for label operations 29 + - `LABEL_LIMIT_WAIT` - Wait time for rate limiter 30 + 31 + Create cursor file (optional but recommended): 14 32 15 33 ```bash 16 - bun run start 34 + touch cursor.txt 17 35 ``` 18 36 19 - To run in docker: 37 + ## Running 38 + 39 + Production: 20 40 21 41 ```bash 22 - docker build -pull -t skywatch-tools . 23 - docker run -d -p 4101:4101 skywatch-autolabeler 42 + docker compose up -d 24 43 ``` 25 44 26 - ## Brief overview 45 + Development mode with auto-reload: 27 46 28 - Currently this tooling does one thing. It monitors the bluesky firehose and analyzes content for phrases which fit Skywatch's criteria for moderation. If the criteria is met, it can automatically label the content with the appropriate label. 47 + ```bash 48 + docker compose -f compose.yaml -f compose.dev.yaml up 49 + ``` 29 50 30 - In certain cases, where regexp will create too many false positives, it will flag content as a report against related to the account, so that it can be reviewed later. 51 + The service runs on port 4101 (metrics endpoint). Redis and Prometheus are included in the compose stack. 52 + 53 + ## Authentication 31 54 32 - For information on how to set-up your own checks, please see the [developing_checks.md](./src/developing_checks.md) file. 55 + The application authenticates with Bluesky on startup and retries up to 3 times on failure. If all attempts fail, the application exits. Sessions are cached in `.session` (gitignored). 56 + 57 + ## Testing 58 + 59 + ```bash 60 + bun test # Watch mode 61 + bun test:run # Single run 62 + bun test:coverage # With coverage 63 + ``` 64 + 65 + ## How It Works 66 + 67 + Monitors the Bluesky firehose via Jetstream and analyzes: 68 + - **Posts** - Text content and embedded URLs 69 + - **Profiles** - Display names and descriptions 70 + - **Handles** - Username patterns 71 + - **Starter packs** - Creation activity 72 + 73 + When criteria are met, applies appropriate labels or creates moderation reports. 74 + 75 + ### Threshold Systems 76 + 77 + Beyond pattern matching, the automod supports account-level threshold enforcement: 78 + 79 + - **Account threshold** - Labels accounts that accumulate multiple post-level violations within a rolling time window 80 + - **Starter pack threshold** - Labels accounts that create too many starter packs within a time window (useful for detecting follow-farming) 81 + 82 + Both systems use Redis for time-windowed tracking and support configurable actions (label, report, comment). 83 + 84 + For developing custom checks, see [developing_checks.md](./rules/developing_checks.md).
+196 -1
bun.lock
··· 40 40 "@vitest/ui": "^1.6.0", 41 41 "eslint": "^9.34.0", 42 42 "eslint-config-prettier": "^10.1.8", 43 + "eslint-plugin-import": "^2.32.0", 43 44 "prettier": "^3.6.2", 44 45 "supertest": "^7.1.4", 45 46 "tsx": "^4.20.5", ··· 421 422 422 423 "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="], 423 424 425 + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], 426 + 424 427 "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], 425 428 426 429 "@skyware/bot": ["@skyware/bot@0.3.12", "", { "dependencies": { "@atcute/bluesky": "^1.0.7", "@atcute/bluesky-richtext-builder": "^1.0.1", "@atcute/client": "^2.0.3", "@atcute/ozone": "^1.0.5", "quick-lru": "^7.0.0", "rate-limit-threshold": "^0.1.5" }, "optionalDependencies": { "@skyware/firehose": "^0.3.2", "@skyware/jetstream": "^0.2.2" } }, "sha512-5OqTtwItYsBFMh0nwrxfsqgXrvRaJzg1P+ghMV4rlRGwHhdRgBJcnYQYgUqqREFcB247yGo73LNyqq7kHEwV7Q=="], ··· 462 465 "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], 463 466 464 467 "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], 468 + 469 + "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], 465 470 466 471 "@types/methods": ["@types/methods@1.1.4", "", {}, "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ=="], 467 472 ··· 539 544 540 545 "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 541 546 547 + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], 548 + 542 549 "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], 543 550 551 + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], 552 + 553 + "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], 554 + 555 + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], 556 + 557 + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], 558 + 559 + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], 560 + 544 561 "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], 545 562 546 563 "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], 547 564 548 565 "assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="], 549 566 567 + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], 568 + 550 569 "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], 551 570 552 571 "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], 572 + 573 + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], 553 574 554 575 "avvio": ["avvio@8.4.0", "", { "dependencies": { "@fastify/error": "^3.3.0", "fastq": "^1.17.1" } }, "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA=="], 555 576 ··· 582 603 "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 583 604 584 605 "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], 606 + 607 + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], 585 608 586 609 "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], 587 610 ··· 649 672 650 673 "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 651 674 675 + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], 676 + 677 + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], 678 + 679 + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], 680 + 652 681 "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], 653 682 654 683 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], ··· 657 686 658 687 "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], 659 688 689 + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], 690 + 691 + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], 692 + 660 693 "delay": ["delay@5.0.0", "", {}, "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw=="], 661 694 662 695 "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], ··· 672 705 "dezalgo": ["dezalgo@1.0.4", "", { "dependencies": { "asap": "^2.0.0", "wrappy": "1" } }, "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig=="], 673 706 674 707 "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], 708 + 709 + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], 675 710 676 711 "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], 677 712 ··· 693 728 694 729 "error-causes": ["error-causes@3.0.2", "", {}, "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="], 695 730 731 + "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], 732 + 696 733 "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], 697 734 698 735 "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], ··· 701 738 702 739 "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], 703 740 741 + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], 742 + 743 + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], 744 + 704 745 "esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="], 705 746 706 747 "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], ··· 712 753 "eslint": ["eslint@9.38.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.1", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.38.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw=="], 713 754 714 755 "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], 756 + 757 + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], 758 + 759 + "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], 760 + 761 + "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], 715 762 716 763 "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], 717 764 ··· 795 842 796 843 "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], 797 844 845 + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], 846 + 798 847 "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], 799 848 800 849 "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], ··· 811 860 812 861 "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 813 862 863 + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], 864 + 865 + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], 866 + 867 + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], 868 + 814 869 "generic-pool": ["generic-pool@3.9.0", "", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="], 815 870 816 871 "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], ··· 824 879 "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], 825 880 826 881 "get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], 882 + 883 + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], 827 884 828 885 "get-tsconfig": ["get-tsconfig@4.12.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw=="], 829 886 ··· 833 890 834 891 "globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], 835 892 893 + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], 894 + 836 895 "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], 837 896 838 897 "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], 839 898 899 + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], 900 + 840 901 "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], 841 902 903 + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], 904 + 905 + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], 906 + 842 907 "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], 843 908 844 909 "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], ··· 875 940 876 941 "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], 877 942 943 + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], 944 + 878 945 "ioredis": ["ioredis@5.8.1", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ=="], 879 946 880 947 "ip3country": ["ip3country@5.0.0", "", {}, "sha512-lcFLMFU4eO1Z7tIpbVFZkaZ5ltqpeaRx7L9NsAbA9uA7/O/rj3RF8+evE5gDitooaTTIqjdzZrenFO/OOxQ2ew=="], 881 948 882 949 "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], 883 950 951 + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], 952 + 884 953 "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], 885 954 955 + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], 956 + 957 + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], 958 + 959 + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], 960 + 961 + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], 962 + 963 + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], 964 + 965 + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], 966 + 967 + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], 968 + 886 969 "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], 887 970 971 + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], 972 + 888 973 "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], 974 + 975 + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], 889 976 890 977 "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], 891 978 979 + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], 980 + 981 + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], 982 + 892 983 "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], 984 + 985 + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], 986 + 987 + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], 988 + 989 + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], 990 + 991 + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], 893 992 894 993 "is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], 895 994 995 + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], 996 + 997 + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], 998 + 999 + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], 1000 + 1001 + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], 1002 + 1003 + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], 1004 + 1005 + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], 1006 + 1007 + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], 1008 + 896 1009 "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 897 1010 898 1011 "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], ··· 924 1037 "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], 925 1038 926 1039 "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], 1040 + 1041 + "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], 927 1042 928 1043 "key-encoder": ["key-encoder@2.0.3", "", { "dependencies": { "@types/elliptic": "^6.4.9", "asn1.js": "^5.0.1", "bn.js": "^4.11.8", "elliptic": "^6.4.1" } }, "sha512-fgBtpAGIr/Fy5/+ZLQZIPPhsZEcbSlYu/Wu96tNDFNSjSACw5lEIOFeaVdQ/iwrb8oxjlWi6wmWdH76hV6GZjg=="], 929 1044 ··· 1035 1150 1036 1151 "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], 1037 1152 1153 + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], 1154 + 1155 + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], 1156 + 1157 + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], 1158 + 1159 + "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], 1160 + 1161 + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], 1162 + 1038 1163 "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], 1039 1164 1040 1165 "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], ··· 1048 1173 "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], 1049 1174 1050 1175 "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], 1176 + 1177 + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], 1051 1178 1052 1179 "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], 1053 1180 ··· 1075 1202 1076 1203 "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 1077 1204 1205 + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], 1206 + 1078 1207 "path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], 1079 1208 1080 1209 "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], ··· 1115 1244 1116 1245 "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], 1117 1246 1247 + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], 1248 + 1118 1249 "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], 1119 1250 1120 1251 "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], ··· 1177 1308 1178 1309 "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], 1179 1310 1311 + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], 1312 + 1313 + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], 1314 + 1180 1315 "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], 1181 1316 1182 1317 "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], 1183 1318 1319 + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], 1320 + 1184 1321 "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], 1185 1322 1186 1323 "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], ··· 1201 1338 1202 1339 "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], 1203 1340 1341 + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], 1342 + 1204 1343 "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], 1205 1344 1345 + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], 1346 + 1347 + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], 1348 + 1206 1349 "safe-regex2": ["safe-regex2@3.1.0", "", { "dependencies": { "ret": "~0.4.0" } }, "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug=="], 1207 1350 1208 1351 "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], ··· 1211 1354 1212 1355 "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], 1213 1356 1214 - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 1357 + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], 1215 1358 1216 1359 "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], 1217 1360 ··· 1221 1364 1222 1365 "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="], 1223 1366 1367 + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], 1368 + 1369 + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], 1370 + 1371 + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], 1372 + 1224 1373 "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], 1225 1374 1226 1375 "sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="], ··· 1267 1416 1268 1417 "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], 1269 1418 1419 + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], 1420 + 1270 1421 "stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="], 1271 1422 1272 1423 "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], 1273 1424 1274 1425 "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], 1275 1426 1427 + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], 1428 + 1429 + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], 1430 + 1431 + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], 1432 + 1276 1433 "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], 1277 1434 1278 1435 "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], 1436 + 1437 + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], 1279 1438 1280 1439 "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], 1281 1440 ··· 1291 1450 1292 1451 "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], 1293 1452 1453 + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], 1454 + 1294 1455 "tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="], 1295 1456 1296 1457 "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="], ··· 1325 1486 1326 1487 "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], 1327 1488 1489 + "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], 1490 + 1328 1491 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 1329 1492 1330 1493 "tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="], ··· 1336 1499 "type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], 1337 1500 1338 1501 "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], 1502 + 1503 + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], 1504 + 1505 + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], 1506 + 1507 + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], 1508 + 1509 + "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], 1339 1510 1340 1511 "typed-emitter": ["typed-emitter@2.1.0", "", { "optionalDependencies": { "rxjs": "*" } }, "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA=="], 1341 1512 ··· 1349 1520 1350 1521 "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 1351 1522 1523 + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], 1524 + 1352 1525 "undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="], 1353 1526 1354 1527 "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], ··· 1378 1551 "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], 1379 1552 1380 1553 "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 1554 + 1555 + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], 1556 + 1557 + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], 1558 + 1559 + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], 1560 + 1561 + "which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="], 1381 1562 1382 1563 "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], 1383 1564 ··· 1474 1655 "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], 1475 1656 1476 1657 "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], 1658 + 1659 + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 1477 1660 1478 1661 "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], 1479 1662 ··· 1491 1674 1492 1675 "duplexify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], 1493 1676 1677 + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], 1678 + 1679 + "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], 1680 + 1681 + "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], 1682 + 1494 1683 "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1495 1684 1496 1685 "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], ··· 1503 1692 1504 1693 "fastify/secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="], 1505 1694 1695 + "fastify/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 1696 + 1506 1697 "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1507 1698 1508 1699 "libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], ··· 1516 1707 "log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], 1517 1708 1518 1709 "magicast/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], 1710 + 1711 + "make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 1519 1712 1520 1713 "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], 1521 1714 ··· 1542 1735 "send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], 1543 1736 1544 1737 "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], 1738 + 1739 + "sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], 1545 1740 1546 1741 "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], 1547 1742
+1
bun.lockb
··· 1 +
+12
compose.dev.yaml
··· 1 + # Development override for docker-compose 2 + # Usage: docker compose -f compose.yaml -f compose.dev.yaml up 3 + # 4 + # This configuration: 5 + # - Runs the app in watch mode (auto-reloads on file changes) 6 + # - Mounts source code so changes are picked up without rebuild 7 + 8 + services: 9 + automod: 10 + command: ["bun", "run", "dev"] 11 + volumes: 12 + - ./src:/app/src
+22 -1
compose.yaml
··· 13 13 image: redis:7-alpine 14 14 container_name: skywatch-automod-redis 15 15 restart: unless-stopped 16 + command: redis-server --appendonly yes --appendfsync everysec 16 17 volumes: 17 18 - redis-data:/data 18 19 networks: ··· 33 34 34 35 # Expose the metrics server port to the host machine. 35 36 ports: 36 - - "4100:4101" 37 + - "4101:4101" 37 38 38 39 # Load environment variables from a .env file in the same directory. 39 40 # This is where you should put your BSKY_HANDLE, BSKY_PASSWORD, etc. ··· 55 56 volumes: 56 57 - ./cursor.txt:/app/cursor.txt 57 58 - ./.session:/app/.session 59 + - ./rules:/app/rules 58 60 59 61 environment: 60 62 - NODE_ENV=production 61 63 - REDIS_URL=redis://redis:6379 62 64 65 + prometheus: 66 + image: prom/prometheus:latest 67 + container_name: skywatch-prometheus 68 + restart: unless-stopped 69 + ports: 70 + - "9090:9090" 71 + volumes: 72 + - ./prometheus.yml:/etc/prometheus/prometheus.yml 73 + - prometheus-data:/prometheus 74 + command: 75 + - "--config.file=/etc/prometheus/prometheus.yml" 76 + - "--storage.tsdb.path=/prometheus" 77 + networks: 78 + - skywatch-network 79 + depends_on: 80 + - automod 81 + 63 82 volumes: 64 83 redis-data: 84 + prometheus-data: 65 85 66 86 networks: 67 87 skywatch-network: 68 88 driver: bridge 89 + name: skywatch-network
+79
docs/automod/main.md
··· 1 + # `main.ts` 2 + 3 + This is the main entry point for the Skywatch automoderator. It sets up the connection to the Bluesky firehose, listens for events, and dispatches them to the appropriate rule-checking modules. 4 + 5 + ## Initialization 6 + 7 + 1. **Cursor Management**: 8 + - On startup, it attempts to read a `cursor` value from `cursor.txt`. The cursor represents the last processed event timestamp, allowing the bot to resume from where it left off. 9 + - If `cursor.txt` does not exist, it initializes the cursor to the current time. 10 + - The cursor is periodically updated and written back to `cursor.txt` every `CURSOR_UPDATE_INTERVAL` milliseconds. 11 + 12 + 2. **Jetstream (Firehose) Connection**: 13 + - It creates a `Jetstream` instance, which connects to the Bluesky firehose at the `FIREHOSE_URL`. 14 + - It subscribes to a specific set of collections defined in `WANTED_COLLECTION` (e.g., posts, profiles). 15 + - It sets up event listeners for `open`, `close`, and `error` events to log the connection status. 16 + 17 + 3. **Redis and Authentication**: 18 + - It connects to the Redis server using `connectRedis`. 19 + - It authenticates with the Bluesky API using the `login` function from `../common/agent.js`. 20 + - Once authentication is complete, it starts the Jetstream connection. 21 + 22 + 4. **Metrics Server**: 23 + - It starts an Express server on `METRICS_PORT` to expose Prometheus metrics. 24 + 25 + ## Event Handling 26 + 27 + The module listens for different types of events from the firehose and triggers the corresponding check functions. 28 + 29 + ### Post Creation (`jetstream.onCreate("app.bsky.feed.post", ...)` 30 + 31 + This is the most complex event handler. When a new post is created, it performs a series of checks: 32 + 33 + - **Account Age**: 34 + - If the post is a reply, it calls `checkAccountAge` to see if the author's account is new and if they are replying to a monitored DID. 35 + - If the post is a quote post, it also calls `checkAccountAge` to check for interactions with monitored DIDs or posts. 36 + - **Facets (Rich Text Features)**: 37 + - If the post has `facets`, it calls `checkFacetSpam` to detect hidden mentions. 38 + - If any facet is a link, it extracts the URL and passes it to `checkPosts` for further analysis. 39 + - **Post Text**: 40 + - If the post has text content, it passes the text to `checkPosts`. 41 + - **Embeds**: 42 + - If the post has an external link embed (`app.bsky.embed.external`), it extracts the URL and passes it to `checkPosts`. 43 + - If the post has a record with media embed that contains an external link, it also extracts the URL for `checkPosts`. 44 + 45 + ### Profile Updates (`jetstream.onUpdate` and `jetstream.onCreate` for `"app.bsky.actor.profile"`) 46 + 47 + - When a user's profile is created or updated, it checks if the `displayName` or `description` has changed. 48 + - If so, it calls `checkProfile` to run moderation rules against the new profile content. 49 + 50 + ### Handle Updates (`jetstream.on("identity", ...)` 51 + 52 + - When a user's handle changes (or is first seen), it receives an `identity` event. 53 + - It calls `checkHandle` to run moderation rules against the new handle. 54 + 55 + ### Starter Pack Creation (`jetstream.onCreate("app.bsky.graph.starterpack", ...)`) 56 + 57 + - When a user creates a new starter pack, it receives a creation event. 58 + - It calls `checkStarterPackThreshold` to check if the account has exceeded the threshold for creating starter packs within a time window. 59 + - This is useful for detecting follow-farming or coordinated campaign behaviour. 60 + 61 + ## Graceful Shutdown 62 + 63 + - The module listens for `SIGINT` and `SIGTERM` signals (e.g., from Ctrl+C or a process manager). 64 + - The `shutdown` function is called to: 65 + - Save the latest cursor to `cursor.txt`. 66 + - Close the Jetstream connection. 67 + - Close the metrics server. 68 + - Disconnect from Redis. 69 + 70 + ## Dependencies 71 + 72 + - **`@skyware/jetstream`**: For connecting to and handling events from the Bluesky firehose. 73 + - **`./agent.js`**: For authenticating with the Bluesky API. 74 + - **`./config.js`**: Provides configuration constants like URLs, ports, and intervals. 75 + - **`./logger.js`**: For structured logging. 76 + - **`./metrics.js`**: For starting the metrics server. 77 + - **`./redis.js`**: For connecting to and disconnecting from Redis. 78 + - **`./starterPackThreshold.js`**: For checking starter pack creation thresholds. 79 + - **Rule Modules (`./rules/**/*.ts`)**: Contains the actual moderation logic (`checkAccountAge`, `checkFacetSpam`, `checkHandle`, `checkPosts`, `checkProfile`).
+61
docs/automod/rules/account/age.md
··· 1 + # `age.ts` 2 + 3 + This module is responsible for checking the age of an account when it interacts (replies to or quotes) with specific, monitored accounts or posts. It is designed to identify and label newly created accounts that might be involved in coordinated harassment or spam campaigns. 4 + 5 + ## `InteractionContext` Interface 6 + 7 + This interface defines the shape of the context object passed to `checkAccountAge`. It includes details about the interaction, such as: 8 + 9 + - `actorDid`: The DID of the user performing the action (required). 10 + - `atURI`: The URI of the reply or quote post (required). 11 + - `time`: The timestamp of the interaction (required). 12 + - `replyToDid`, `replyToPostURI`: Details about the parent post if the interaction is a reply. 13 + - `quotedDid`, `quotedPostURI`: Details about the quoted post if the interaction is a quote post. 14 + 15 + ## Key Functions 16 + 17 + ### `checkAccountAge(context: InteractionContext): Promise<void>` 18 + 19 + This is the main function of the module. It is triggered by the `main.ts` event handler whenever a new post is a reply or a quote post. 20 + 21 + **Logic:** 22 + 23 + 1. **Configuration Check**: It first checks if any `ACCOUNT_AGE_CHECKS` are defined in the rules. If not, it exits. 24 + 2. **Global Allowlist**: It checks if the `actorDid` is in the `GLOBAL_ALLOW` list. If so, it logs and exits. 25 + 3. **Iterate Through Checks**: It loops through each configuration object in the `ACCOUNT_AGE_CHECKS` array. 26 + 4. **Match Interaction**: For each check, it determines if the current interaction matches the criteria of the check. A match occurs if: 27 + - The post is a reply to a DID in `monitoredDIDs`. 28 + - The post is a reply to a post URI in `monitoredPostURIs`. 29 + - The post is a quote of a DID in `monitoredDIDs`. 30 + - The post is a quote of a post URI in `monitoredPostURIs`. 31 + If there's no match, it moves to the next check. 32 + 5. **Expiration Check**: If a check has an `expires` date, it verifies that the check has not expired. 33 + 6. **Get Creation Date**: It calls `getAccountCreationDate` to find out when the `actorDid`'s account was created. If the date can't be determined, it skips the check. 34 + 7. **Check Time Window**: It defines a "flagging window" based on the `anchorDate` and `maxAgeDays` from the configuration. It then checks if the account's `creationDate` falls within this window. 35 + 8. **Apply Label**: If the account was created within the window, it proceeds to label the account: 36 + - It first calls `checkAccountLabels` to ensure the same label hasn't already been applied, preventing duplicates. 37 + - If no label exists, it calls `createAccountLabel` to apply the configured label and comment. 38 + - After applying a label, it `return`s to ensure only one label is applied per interaction, even if it matches multiple checks. 39 + 40 + ### `getAccountCreationDate(did: string): Promise<Date | null>` 41 + 42 + This function attempts to determine when a user's account was created. 43 + 44 + **Logic:** 45 + 46 + 1. **PLC Directory (Primary Method)**: If the DID is a `did:plc:`, it tries to fetch the audit log from the PLC directory (`plc.directory`). The `createdAt` timestamp of the first operation in the log is considered the creation date. This is the most reliable method. 47 + 2. **Profile Fallback**: If the PLC lookup fails or the DID is not a `did:plc:`, it falls back to fetching the user's profile using `agent.getProfile`. It then attempts to use the `createdAt` date from the profile data. 48 + 3. **Failure**: If both methods fail, it logs a warning and returns `null`. 49 + 50 + ### `calculateAccountAge(creationDate: Date, referenceDate: Date): number` 51 + 52 + A simple utility function that calculates the difference in days between two dates. It is used internally for age calculation but is not directly used by the main `checkAccountAge` logic, which compares dates directly. 53 + 54 + ## Dependencies 55 + 56 + - **`../../../../rules/accountAge.js`**: Provides the `ACCOUNT_AGE_CHECKS` configuration array. 57 + - **`../../../../rules/constants.js`**: Provides the `GLOBAL_ALLOW` list of DIDs that should never be moderated. 58 + - **`../../../common/accountModeration.js`**: Provides `createAccountLabel` and `checkAccountLabels` for applying and verifying labels. 59 + - **`../../../common/agent.js`**: Provides the authenticated `agent` for making API calls. 60 + - **`../../../common/config.js`**: Provides the `PLC_URL`. 61 + - **`../../../common/logger.js`**: For logging.
+39
docs/automod/rules/account/countStarterPacks.md
··· 1 + # `countStarterPacks.ts` 2 + 3 + This module is responsible for checking if an account is associated with an excessive number of "starter packs." This is often an indicator of "follow farming" or other platform manipulation behaviors. 4 + 5 + ## `ALLOWED_DIDS` 6 + 7 + A hardcoded array of DIDs that are exempt from this check. These are typically trusted accounts or accounts known to legitimately manage many starter packs. 8 + 9 + ## Key Function 10 + 11 + ### `countStarterPacks(did: string, time: number): Promise<void>` 12 + 13 + This is the main function of the module. It is called from other rule modules (e.g., `checkPosts`) as an additional check when a post matches certain criteria. 14 + 15 + **Parameters:** 16 + 17 + - `did`: The DID of the account to check. 18 + - `time`: The timestamp of the event that triggered the check. 19 + 20 + **Logic:** 21 + 22 + 1. **Authentication Check**: It ensures the agent is logged in by awaiting `isLoggedIn`. 23 + 2. **Whitelist Check**: It checks if the `did` is in the `ALLOWED_DIDS` array. If it is, the function logs a debug message and returns immediately. 24 + 3. **Rate Limiting**: The core logic is wrapped in the `limit` function to respect API rate limits. 25 + 4. **Fetch Profile**: It fetches the user's profile using `agent.app.bsky.actor.getProfile`. 26 + 5. **Check Starter Pack Count**: 27 + - It accesses the `starterPacks` count from the `profile.data.associated` field. 28 + - If the count exists and is greater than 20, it proceeds to label the account. 29 + 6. **Apply Label**: 30 + - It logs that it is labeling the account for excessive starter packs. 31 + - It calls `createAccountLabel` with the `follow-farming` label and a comment indicating the number of starter packs found. 32 + 7. **Error Handling**: If fetching the profile fails, it logs a detailed error message. 33 + 34 + ## Dependencies 35 + 36 + - **`../../../common/accountModeration.js`**: Provides the `createAccountLabel` function to apply moderation labels to accounts. 37 + - **`../../../common/agent.js`**: Provides the authenticated `agent` for making API calls and the `isLoggedIn` promise. 38 + - **`../../../common/limits.js`**: Provides the `limit` function to manage rate limiting for API calls. 39 + - **`../../../common/logger.js`**: For logging information, debug messages, and errors.
+47
docs/automod/rules/facets/facets.md
··· 1 + # `facets.ts` 2 + 3 + This module is responsible for detecting "facet spam" in posts. Facet spam occurs when a user includes multiple hidden mentions in a post by layering several mention facets at the exact same byte position in the text. This is a form of platform manipulation used to notify many users without their mentions being visible. 4 + 5 + ## Constants 6 + 7 + - **`FACET_SPAM_THRESHOLD`**: The number of unique mentions at a single position required to trigger the spam detection. Currently set to `1`, meaning more than one unique DID mentioned at the same spot is considered spam. 8 + - **`FACET_SPAM_LABEL`**: The moderation label to be applied to the account, which is `"platform-manipulation"`. 9 + - **`FACET_SPAM_COMMENT`**: The comment attached to the moderation label. 10 + - **`FACET_SPAM_ALLOWLIST`**: A list of DIDs that are exempt from this check. This is for accounts that may have a legitimate reason for using complex facet arrangements. 11 + 12 + ## Key Function 13 + 14 + ### `checkFacetSpam(did: string, time: number, atURI: string, facets: Facet[] | null): Promise<void>` 15 + 16 + This is the main function of the module, called from `main.ts` whenever a new post is created that contains facets. 17 + 18 + **Parameters:** 19 + 20 + - `did`: The DID of the post's author. 21 + - `time`: The timestamp of the post creation event. 22 + - `atURI`: The URI of the post being checked. 23 + - `facets`: An array of facet objects from the post record, or `null` if none exist. 24 + 25 + **Logic:** 26 + 27 + 1. **Allowlist Check**: It first checks if the author's `did` is in the `FACET_SPAM_ALLOWLIST`. If so, it logs a debug message and exits. 28 + 2. **Facet Existence**: It returns if the `facets` array is null or empty. 29 + 3. **Group Mentions by Position**: 30 + - It initializes a `Map` where keys are byte positions (e.g., `"0:1"`) and values are a `Set` of DIDs mentioned at that position. 31 + - It iterates through each `facet` in the post. 32 + - It only considers facets that are mentions (`app.bsky.richtext.facet#mention`). 33 + - For each mention, it constructs a key from its `byteStart` and `byteEnd` and adds the mentioned `did` to the `Set` for that key. Using a `Set` automatically handles cases where the *same* DID is mentioned multiple times at the same position (which is likely a client bug, not spam) and only stores unique DIDs. 34 + 4. **Check for Spam**: 35 + - It then iterates through the `positionMap`. 36 + - For each position, it checks if the `size` of the `Set` of DIDs is greater than the `FACET_SPAM_THRESHOLD`. 37 + - If it is, spam is detected. 38 + 5. **Apply Label**: 39 + - It logs that facet spam has been detected, including the position and the count of unique mentions. 40 + - It calls `createAccountLabel` to apply the `FACET_SPAM_LABEL` to the author's account. 41 + - It then `return`s immediately to ensure the account is only labeled once per post, even if spam is detected at multiple positions. 42 + 43 + ## Dependencies 44 + 45 + - **`../../../common/accountModeration.js`**: Provides the `createAccountLabel` function. 46 + - **`../../../common/logger.js`**: For logging. 47 + - **`../../../common/types.js`**: Provides the `Facet` type definition.
+41
docs/automod/rules/handles/checkHandles.md
··· 1 + # `checkHandles.ts` 2 + 3 + This module is responsible for scanning user handles (e.g., `username.bsky.social`) for patterns that violate moderation rules. It is triggered whenever a user's handle is created or updated. 4 + 5 + ## Key Function 6 + 7 + ### `checkHandle(did: string, handle: string, time: number): void` 8 + 9 + This is the main function of the module. It is called from the `main.ts` event handler for `identity` events from the firehose. 10 + 11 + **Parameters:** 12 + 13 + - `did`: The DID of the user whose handle is being checked. 14 + - `handle`: The user handle string. 15 + - `time`: The timestamp of the identity event. 16 + 17 + **Logic:** 18 + 19 + The function iterates through a series of checks defined in the `HANDLE_CHECKS` configuration array. For each check, it performs the following steps: 20 + 21 + 1. **Global Allowlist**: It first checks if the user's `did` is in the `GLOBAL_ALLOW` list. If so, it logs a warning and stops all further checks for this handle. 22 + 23 + 2. **Per-Rule Ignored DIDs**: It checks if the `did` is in the specific `ignoredDIDs` list for the current rule. If so, it skips to the next rule. 24 + 25 + 3. **Pattern Matching**: It uses the `check` regular expression from the rule to test against the `handle`. 26 + 27 + 4. **Whitelist Check**: If the pattern matches, it then checks for a `whitelist` regular expression in the rule. If the `whitelist` pattern *also* matches, it assumes this is a false positive, logs a debug message, and stops processing this rule. 28 + 29 + 5. **Apply Actions**: If the main pattern matches and the whitelist pattern does not, it proceeds to apply moderation actions based on the boolean flags in the rule configuration: 30 + - `toLabel`: If `true`, it calls `createAccountLabel` to apply the specified `label` to the user's account. 31 + - `reportAcct`: If `true`, it calls `createAccountReport` to report the account to moderators. 32 + - `commentAcct`: If `true`, it calls `createAccountComment` to add a comment to the user's moderation record. 33 + 34 + Each action is called with a `formattedComment` that includes the timestamp, the rule's `comment`, and the handle that was flagged. The actions are dispatched with `void` to prevent the main loop from being blocked by these asynchronous operations. 35 + 36 + ## Dependencies 37 + 38 + - **`../../../../rules/constants.js`**: Provides the `GLOBAL_ALLOW` list of DIDs that are exempt from all checks. 39 + - **`../../../../rules/handles.js`**: Provides the `HANDLE_CHECKS` array, which contains the configuration for each handle moderation rule (regex patterns, labels, actions to take, etc.). 40 + - **`../../../common/accountModeration.js`**: Provides the functions (`createAccountLabel`, `createAccountReport`, `createAccountComment`) for performing moderation actions. 41 + - **`../../../common/logger.js`**: For logging.
+54
docs/automod/rules/posts/checkPosts.md
··· 1 + # `checkPosts.ts` 2 + 3 + This module is the core of content moderation for posts. It is responsible for scanning the text of new posts (and links within them) for rule violations. 4 + 5 + ## Key Function 6 + 7 + ### `checkPosts(post: Post[]): Promise<void>` 8 + 9 + This is the main function of the module, called from `main.ts` whenever a new post is created or when a link is found in a post's facets or embeds. 10 + 11 + **Parameters:** 12 + 13 + - `post`: An array containing a single `Post` object to be checked. The function is designed to handle an array, but currently, it only ever processes the first element, `post[0]`. 14 + 15 + **Logic:** 16 + 17 + The function performs a series of checks and actions on the post content: 18 + 19 + 1. **Global Allowlist**: It first checks if the post author's `did` is in the `GLOBAL_ALLOW` list. If so, it logs a warning and immediately returns, skipping all other checks. 20 + 21 + 2. **Link Shortener Resolution**: 22 + - It tests the post's text against the `LINK_SHORTENER` regex. 23 + - If a shortened link is found, it attempts to resolve it to its final destination URL using `getFinalUrl`. 24 + - If resolution is successful, it replaces the shortened URL in the `post[0].text` with the final URL. This is crucial for detecting malicious links hidden behind shorteners. 25 + - If resolution fails, it logs an error but continues with the original, un-resolved URL. 26 + 27 + 3. **Language Detection**: It calls `getLanguage` to determine the primary language of the post's text. 28 + 29 + 4. **Iterate Through Post Checks**: It then loops through each rule defined in the `POST_CHECKS` configuration array. For each rule, it performs the following: 30 + - **Language Filter**: If the rule specifies a `language`, it checks if the detected language of the post matches. If not, it skips to the next rule. 31 + - **Ignored DIDs**: It checks if the author's `did` is in the rule's specific `ignoredDIDs` list. If so, it skips to the next rule. 32 + - **Pattern Matching**: It tests the post's text against the rule's `check` regular expression. 33 + - **Whitelist Check**: If the pattern matches, it then checks for a `whitelist` regular expression in the rule. If the `whitelist` pattern *also* matches, it assumes this is a false positive, logs a debug message, and skips to the next rule. 34 + 35 + 5. **Apply Actions**: If the main pattern matches and no whitelists or filters cause it to be skipped, it proceeds to apply moderation actions: 36 + - **Starter Pack Check**: It calls `countStarterPacks` as an additional heuristic, which may independently label the account for follow-farming. 37 + - **Post Labeling (`toLabel`)**: If `true`, it calls `createPostLabel` to apply the specified `label` to the post itself. It can also include a `duration` for temporary labels. 38 + - **Post Reporting (`reportPost`)**: If `true`, it calls `createPostReport` to report the specific post to moderators. 39 + - **Account Reporting (`reportAcct`)**: If `true`, it calls `createAccountReport` to report the author's entire account. 40 + - **Account Commenting (`commentAcct`)**: If `true`, it calls `createAccountComment` to add a comment to the author's moderation record. 41 + 42 + All moderation actions are dispatched with `void` to prevent the main loop from being blocked by these asynchronous API calls. 43 + 44 + ## Dependencies 45 + 46 + - **`../../../rules/constants.js`**: Provides `GLOBAL_ALLOW` and `LINK_SHORTENER` constants. 47 + - **`../../../rules/posts.js`**: Provides the `POST_CHECKS` array, which contains the configuration for each post moderation rule. 48 + - **`../../common/accountModeration.js`**: Provides functions for account-level moderation actions. 49 + - **`../../common/moderation.js`**: Provides functions for post-level moderation actions. 50 + - **`../../common/logger.js`**: For logging. 51 + - **`../../common/types.js`**: Provides the `Post` type definition. 52 + - **`../../utils/getFinalUrl.js`**: For resolving shortened URLs. 53 + - **`../../utils/getLanguage.js`**: For detecting the language of the post text. 54 + - **`../account/countStarterPacks.js`**: For the secondary check on starter pack counts.
+50
docs/automod/rules/profiles/checkProfiles.md
··· 1 + # `checkProfiles.ts` 2 + 3 + This module is responsible for scanning user profiles—specifically their display names and descriptions—for content that violates moderation rules. It is triggered whenever a user's profile is created or updated. 4 + 5 + ## `ProfileChecker` Class 6 + 7 + This class encapsulates the logic for checking a piece of profile content (display name or description) against a single rule. 8 + 9 + - **`constructor(check: Checks, did: string, time: number)`**: Initializes the checker with the rule (`check`), the user's `did`, and the event `time`. 10 + - **`checkDescription(description: string)`**: A public method to run the check against the profile description. 11 + - **`checkDisplayName(displayName: string)`**: A public method to run the check against the display name. 12 + - **`checkBoth(displayName: string, description: string)`**: A public method that concatenates the display name and description and runs the check against the combined string. 13 + - **`performActions(...)`**: A private method that: 14 + 1. Tests the content against the rule's `check` regex. 15 + 2. If it matches, it checks against the `whitelist` regex to prevent false positives. 16 + 3. If it's a confirmed match, it calls `applyActions`. 17 + 4. If it does *not* match, it checks the `unlabel` flag and calls `removeLabel` if necessary. 18 + - **`applyActions(...)`**: A private method that applies the moderation actions (`createAccountLabel`, `createAccountReport`, `createAccountComment`) as specified by the boolean flags in the rule. 19 + - **`removeLabel(...)`**: A private method that calls `negateAccountLabel` to remove a previously applied label if the content no longer matches the rule's criteria. 20 + 21 + ## Key Functions 22 + 23 + ### `checkProfile(did: string, time: number, displayName: string, description: string)` 24 + 25 + This is the main, consolidated function called from `main.ts` when a profile is created or updated. It orchestrates the checking process. 26 + 27 + **Logic:** 28 + 29 + 1. **Global Allowlist**: Checks if the `did` is in the `GLOBAL_ALLOW` list and returns if it is. 30 + 2. **Language Detection**: It concatenates the display name and description and detects the language of the combined text. 31 + 3. **Iterate Through Checks**: It loops through each rule in the `PROFILE_CHECKS` configuration array. 32 + 4. **Filtering**: For each rule, it applies filters: 33 + - **Language**: If the rule has a `language` filter, it skips the rule if the detected language doesn't match. 34 + - **Ignored DIDs**: If the `did` is in the rule's `ignoredDIDs` list, it skips the rule. 35 + 5. **Dispatch to `ProfileChecker`**: 36 + - It creates a new `ProfileChecker` instance for the current rule. 37 + - Based on the `description` and `displayName` boolean flags in the rule, it calls the appropriate method on the checker (`checkBoth`, `checkDescription`, or `checkDisplayName`). 38 + 39 + ### `checkDescription(...)` and `checkDisplayName(...)` 40 + 41 + These are older, more specific functions that are still exported but are effectively superseded by the consolidated `checkProfile` function. They follow a similar logic but only operate on either the description or display name, respectively, and only for rules where the corresponding flag (`description: true` or `displayName: true`) is set. 42 + 43 + ## Dependencies 44 + 45 + - **`../../../../rules/constants.js`**: Provides the `GLOBAL_ALLOW` list. 46 + - **`../../../../rules/profiles.js`**: Provides the `PROFILE_CHECKS` array, which contains the configuration for each profile moderation rule. 47 + - **`../../../common/accountModeration.js`**: Provides functions for all account-level moderation actions (`createAccountLabel`, `createAccountReport`, `createAccountComment`, `negateAccountLabel`). 48 + - **`../../../common/logger.js`**: For logging. 49 + - **`../../../common/types.js`**: Provides the `Checks` type definition. 50 + - **`../../utils/getLanguage.js`**: For detecting the language of the profile text.
+44
docs/automod/utils/getFinalUrl.md
··· 1 + # `getFinalUrl.ts` 2 + 3 + This utility module provides a robust function for resolving a URL to its final destination, following any redirects. This is a critical security and moderation tool, as it allows the application to see the real URL hidden behind a link shortener. 4 + 5 + ## Key Function 6 + 7 + ### `getFinalUrl(url: string): Promise<string>` 8 + 9 + This function takes a URL string and attempts to resolve it to its final destination. 10 + 11 + **Parameters:** 12 + 13 + - `url`: The initial URL to resolve. 14 + 15 + **Returns:** 16 + 17 + - A `Promise` that resolves to the final URL string after all redirects have been followed. 18 + - If the resolution fails (due to network errors, timeouts, etc.), the promise will reject with the underlying error. 19 + 20 + **Logic:** 21 + 22 + The function employs a two-stage fallback strategy to be both efficient and robust. 23 + 24 + 1. **HEAD Request (Primary Method)**: 25 + - It first attempts to resolve the URL using a `HEAD` request. This is the preferred method because it's faster and uses less bandwidth, as it only fetches the headers of the response, not the full content. 26 + - The `redirect: "follow"` option tells the `fetch` API to automatically follow any HTTP 3xx redirects. 27 + - A 15-second timeout is implemented using an `AbortController`. If the request takes too long, it will be aborted. 28 + - A custom `User-Agent` header is sent to identify the bot. 29 + - If the `HEAD` request is successful, the `response.url` property will contain the final URL after all redirects, which is then returned. 30 + 31 + 2. **GET Request (Fallback Method)**: 32 + - If the initial `HEAD` request fails for any reason (e.g., the server blocks `HEAD` requests, a network error occurs), the `catch` block is executed. 33 + - Inside the `catch` block, it logs that the `HEAD` request failed and then retries the resolution using a `GET` request. 34 + - The `GET` request follows the same logic: `redirect: "follow"`, a 15-second timeout, and a custom `User-Agent`. 35 + - If the `GET` request is successful, it returns the final `response.url`. 36 + 37 + 3. **Error Handling**: 38 + - If the fallback `GET` request also fails, the function will log a warning and then re-throw the error, causing the promise to be rejected. 39 + - It specifically checks for `AbortError` to log a more informative "Timeout resolving URL" message. 40 + - It serializes error information to ensure structured logs. 41 + 42 + ## Dependencies 43 + 44 + - **`../logger.js`**: For logging debug messages and warnings.
+36
docs/automod/utils/getLanguage.md
··· 1 + # `getLanguage.ts` 2 + 3 + This utility module provides a simple function to detect the language of a given string of text. It is used to filter content so that language-specific moderation rules are only applied to relevant posts or profiles. 4 + 5 + ## Key Function 6 + 7 + ### `getLanguage(profile: string): Promise<string>` 8 + 9 + This function takes a string and returns a three-letter ISO 639-3 language code. 10 + 11 + **Parameters:** 12 + 13 + - `profile`: The input string to analyze. Despite the parameter name, it can be any text content (e.g., post text, profile description). 14 + 15 + **Returns:** 16 + 17 + - A `Promise` that resolves to a string containing the detected language code (e.g., `"eng"`, `"spa"`, `"jpn"`). 18 + - It defaults to `"eng"` if the language cannot be determined or if the input is invalid. 19 + 20 + **Logic:** 21 + 22 + 1. **Input Validation**: It first checks if the input `profile` is actually a string. If not, it logs a warning and returns the default value `"eng"`. 23 + 2. **Trimming**: It trims any leading or trailing whitespace from the input string. 24 + 3. **Empty Check**: If the resulting string is empty, it returns `"eng"`. 25 + 4. **Language Detection**: 26 + - It dynamically imports the `franc` library. `franc` is a language detection library that supports a wide range of languages. 27 + - It calls `franc(profileText)` to get the language code. 28 + 5. **Handle Undetermined Cases**: 29 + - `franc` returns the string `"und"` (for "undetermined") if it cannot reliably detect the language (e.g., for very short text, text with mixed languages, or text with only numbers/symbols). 30 + - The function checks for this case and returns the default `"eng"` if the detected language is `"und"`. 31 + - Otherwise, it returns the detected language code. 32 + 33 + ## Dependencies 34 + 35 + - **`franc`**: A lightweight and fast language detection library. It is dynamically imported to reduce initial load time. 36 + - **`../logger.js`**: For logging warnings about invalid input.
+46
docs/automod/utils/normalizeUnicode.md
··· 1 + # `normalizeUnicode.ts` and `homoglyphs.ts` 2 + 3 + These two modules work together to sanitize and normalize text. This is a crucial step before checking text against moderation rule patterns, as it prevents users from evading detection by using visually similar characters (homoglyphs), different cases, or diacritics. 4 + 5 + ## `homoglyphs.ts` 6 + 7 + This file contains a single, large constant: 8 + 9 + - **`homoglyphMap`**: A `Record<string, string>` that maps various Unicode characters to their basic ASCII equivalents. 10 + 11 + This map is the dictionary used for normalization. It includes: 12 + - **Accented characters**: `é`, `à`, `ü` -> `e`, `a`, `u` 13 + - **Look-alike symbols**: `@` -> `a`, `3` -> `e`, `0` -> `o` 14 + - **Cyrillic characters**: `а` (Cyrillic 'a') -> `a` (Latin 'a') 15 + - **Full-width characters**: `a` -> `a` 16 + - And many other "confusable" characters. 17 + 18 + ## `normalizeUnicode.ts` 19 + 20 + This file provides the function that performs the normalization using the `homoglyphMap`. 21 + 22 + ### Key Function 23 + 24 + #### `normalizeUnicode(text: string): string` 25 + 26 + This function takes a string and returns a "flattened" version of it in a predictable, normalized form. 27 + 28 + **Parameters:** 29 + 30 + - `text`: The input string to be normalized. 31 + 32 + **Returns:** 33 + 34 + - A `string` that has been normalized. 35 + 36 + **Normalization Process:** 37 + 38 + The function applies a multi-step process to the input string: 39 + 40 + 1. **Lowercase**: The entire string is converted to lowercase. This ensures that checks are case-insensitive. 41 + 2. **Homoglyph Replacement**: It iterates through each character of the lowercased string and replaces it with its ASCII equivalent from the `homoglyphMap` if a mapping exists. This is done *before* Unicode decomposition to catch pre-composed characters that are also used as homoglyphs. 42 + 3. **Decomposition (NFD)**: It applies Unicode Normalization Form D (`.normalize("NFD")`). This separates base characters from their combining marks. For example, `é` becomes `e` + `´` (combining acute accent). 43 + 4. **Diacritic Removal**: It uses a regular expression (`/[\u0300-\u036f]/g`) to strip out all Unicode combining diacritical marks, leaving only the base characters. 44 + 5. **Compatibility Normalization (NFKC)**: As a final step, it applies Unicode Normalization Form KC (`.normalize("NFKC")`). This handles a broader range of compatibility characters (e.g., converting ligatures like `fi` into `fi`) to ensure the string is in its simplest, most comparable form. 45 + 46 + This robust process ensures that a string like `"P@sswоrd"` (with a capital P, an @ symbol, and a Cyrillic 'o') is converted to `"password"`, which can then be easily matched against a rule.
+57
docs/common/accountModeration.md
··· 1 + # `accountModeration.ts` 2 + 3 + This module provides a suite of functions for performing moderation actions specifically targeted at user accounts (as opposed to individual posts). These actions include applying labels, adding private comments for moderators, reporting the account, and removing labels. 4 + 5 + All functions in this module are asynchronous and ensure that the agent is logged in (`await isLoggedIn`) before proceeding. They also wrap their core API calls in the `limit` function to respect rate limits. 6 + 7 + ## Key Functions 8 + 9 + ### `createAccountLabel(did: string, label: string, comment: string)` 10 + 11 + - **Purpose**: Applies a moderation label to a user's account. 12 + - **De-duplication**: Before applying a label, it performs two checks to prevent duplicates: 13 + 1. `tryClaimAccountLabel(did, label)`: Atomically claims the label in Redis to prevent race conditions between multiple bot instances or threads. 14 + 2. `checkAccountLabels(did, label)`: Checks the Ozone API to see if the label has already been applied. 15 + - **Action**: If the label is not a duplicate, it calls `agent.tools.ozone.moderation.emitEvent` with a `modEventLabel` event, adding the `label` to the `createLabelVals` array. 16 + - **Metrics**: Increments `labelsAppliedCounter` on success or `labelsCachedCounter` if skipped. 17 + 18 + ### `createAccountComment(did: string, comment: string, atURI: string)` 19 + 20 + - **Purpose**: Adds a private comment to a user's moderation record. This is visible to other moderators but not to the user. 21 + - **De-duplication**: Uses `tryClaimAccountComment` in Redis to prevent duplicate comments for the same event. 22 + - **Action**: Calls `emitEvent` with a `modEventComment` event. 23 + 24 + ### `createAccountReport(did: string, comment: string)` 25 + 26 + - **Purpose**: Creates a formal report against a user's account. 27 + - **Action**: Calls `emitEvent` with a `modEventReport` event. The `reportType` is set to `com.atproto.moderation.defs#reasonOther`. 28 + 29 + ### `negateAccountLabel(did: string, label: string, comment: string)` 30 + 31 + - **Purpose**: Removes a previously applied label from an account. This is used when the criteria for a label are no longer met. 32 + - **Check**: It first calls `checkAccountLabels` to ensure the label actually exists on the account before attempting to remove it. 33 + - **Action**: Calls `emitEvent` with a `modEventLabel` event, but this time adds the `label` to the `negateLabelVals` array. 34 + - **Cache Invalidation**: After successfully negating the label, it calls `deleteAccountLabelClaim` to remove the claim from the Redis cache, allowing the label to be re-applied in the future if necessary. 35 + - **Metrics**: Increments `unlabelsRemovedCounter`. 36 + 37 + ### `checkAccountLabels(did: string, label: string): Promise<boolean>` 38 + 39 + - **Purpose**: Checks if a specific label already exists on an account. 40 + - **Action**: Calls `agent.tools.ozone.moderation.getRepo` to fetch the account's current moderation status and checks if the `labels` array contains the specified `label`. 41 + - **Returns**: `true` if the label exists, `false` otherwise. 42 + 43 + ### `getAllAccountLabels(did: string): Promise<string[]>` 44 + 45 + - **Purpose**: Retrieves all labels currently applied to an account. 46 + - **Action**: Calls `agent.tools.ozone.moderation.getRepo` and maps the response to return an array of label strings. 47 + - **Returns**: An array of strings, where each string is a label value. Returns an empty array on failure. 48 + - **Note**: Callers cannot distinguish between "account has no labels" and "API call failed" since both return an empty array. 49 + 50 + ## Dependencies 51 + 52 + - **`./redis.js`**: For de-duplication and caching logic (`tryClaim...`, `deleteAccountLabelClaim`). 53 + - **`./agent.js`**: Provides the authenticated `agent` for all API calls. 54 + - **`./config.js`**: Provides the `MOD_DID` for proxying requests. 55 + - **`./limits.js`**: Provides the `limit` function for rate limiting. 56 + - **`./logger.js`**: For logging. 57 + - **`./metrics.js`**: For incrementing Prometheus counters.
+52
docs/common/accountThreshold.md
··· 1 + # `accountThreshold.ts` 2 + 3 + This module is responsible for checking if an account has crossed a certain threshold of labeled posts within a specific time window. When a threshold is met, it can trigger various moderation actions on the account, such as applying a label, adding a comment, or reporting the account. 4 + 5 + **Note:** There is a file with the same name at `src/automod/accountThreshold.ts`. This file appears to be a duplicate or an older version. The one in `src/common/` seems to be the one that is actually used. 6 + 7 + ## Key Functions 8 + 9 + ### `checkAccountThreshold(did: string, uri: string, postLabel: string, timestamp: number): Promise<void>` 10 + 11 + This is the main function of the module. It's called whenever a post is labeled, and it checks if the author of the post has accumulated enough labels to trigger an account-level action. 12 + 13 + **Parameters:** 14 + 15 + - `did`: The DID of the account to check. 16 + - `uri`: The AT URI of the post that was labeled (included in threshold comment for context). 17 + - `postLabel`: The label that was just applied to a post by this account. 18 + - `timestamp`: The timestamp of the post label event. 19 + 20 + **Logic:** 21 + 22 + 1. **Find Matching Configurations**: It filters the `ACCOUNT_THRESHOLD_CONFIGS` to find all configurations that are concerned with the `postLabel`. 23 + 2. **Track the Label**: For each matching configuration, it records the new post label for the account in Redis using `trackPostLabelForAccount`. This function stores the label with its timestamp in a sorted set. 24 + 3. **Count Recent Labels**: It then uses `getPostLabelCountInWindow` to count how many posts from that account have received any of the labels specified in the configuration within the defined rolling window (`window` + `windowUnit`). 25 + 4. **Check Threshold**: If the `count` is greater than or equal to the `threshold` defined in the configuration, it proceeds to take action. 26 + 5. **Format Comment**: A detailed comment is generated including the threshold count, window configuration, triggering post URI, and post label. 27 + 6. **Apply Moderation Actions**: 28 + - It logs that the threshold has been met. 29 + - It increments the `accountThresholdMetCounter` metric. 30 + - Based on the booleans `toLabel` (defaults to true), `reportAcct`, and `commentAcct` in the configuration, it will: 31 + - Apply a label to the account (`createAccountLabel`). 32 + - Report the account (`createAccountReport`). 33 + - Add a comment to the account's moderation record (`createAccountComment`). 34 + - It also increments the `accountLabelsThresholdAppliedCounter` for each action taken. 35 + 36 + ### `loadThresholdConfigs(): AccountThresholdConfig[]` 37 + 38 + This function returns the cached `ACCOUNT_THRESHOLD_CONFIGS`. The configurations are loaded and validated once at module initialization to avoid re-reading and re-validating the configuration file on every call. 39 + 40 + ## Supporting Functions 41 + 42 + - `normalizeLabels(labels: string | string[]): string[]`: A helper function that ensures the `labels` property from a config is always an array, even if it's defined as a single string. 43 + - `validateAndLoadConfigs(): AccountThresholdConfig[]`: This function is called once when the module is loaded. It iterates through `ACCOUNT_THRESHOLD_CONFIGS`, validates that each configuration has the required properties (`labels`, `threshold > 0`, `window > 0`), and then returns the valid configurations. 44 + 45 + ## Dependencies 46 + 47 + - **`../../rules/accountThreshold.js`**: Contains the `ACCOUNT_THRESHOLD_CONFIGS` array, which defines the rules for when to take action on an account. 48 + - **`./accountModeration.js`**: Provides the functions (`createAccountLabel`, `createAccountReport`, `createAccountComment`) to perform moderation actions on an account. 49 + - **`./logger.js`**: Used for logging information, warnings, and errors. 50 + - **`./metrics.js`**: Provides Prometheus counters for monitoring the behavior of this module. 51 + - **`./redis.js`**: Provides the functions (`trackPostLabelForAccount`, `getPostLabelCountInWindow`) for interacting with Redis to store and retrieve data about post labels. 52 + - **`./types.js`**: Defines the `AccountThresholdConfig` type.
+68
docs/common/agent.md
··· 1 + # `agent.ts` 2 + 3 + This module is central to the application's ability to interact with the Bluesky/AT Protocol network. It manages the authenticated session, handles rate limiting, and provides a global, ready-to-use `AtpAgent` instance for all other modules to use. 4 + 5 + ## Global `undici` Dispatcher 6 + 7 + - At the top level, `setGlobalDispatcher` is called to configure `undici` (the HTTP client used by `@atproto/api`) with custom timeouts for connection and keep-alive, improving network resilience. 8 + 9 + ## `customFetch` Wrapper 10 + 11 + - A custom `fetch` function is created to intercept all outgoing API requests made by the `AtpAgent`. 12 + - **Rate Limit Handling**: Its primary purpose is to inspect the response headers for `ratelimit-` headers (`limit`, `remaining`, `reset`, `policy`). 13 + - When these headers are found, it calls `updateRateLimitState` from the `limits.js` module to dynamically update the application's understanding of the current API rate limit status. 14 + 15 + ## `agent` Instance 16 + 17 + - `export const agent = new AtpAgent(...)`: This is the main export of the module. It's a single, global instance of `AtpAgent` that all other parts of the application import and use to make authenticated API calls. 18 + - It is configured with the `OZONE_PDS` as its service endpoint and uses the `customFetch` wrapper. 19 + 20 + ## Session Management and Authentication 21 + 22 + The module implements a robust, multi-layered authentication and session management strategy. 23 + 24 + ### `performLogin(): Promise<boolean>` 25 + 26 + - Performs a fresh login to the Bluesky API using the `BSKY_HANDLE` and `BSKY_PASSWORD` from the configuration. 27 + - If successful, it saves the new session data using `saveSession` and schedules the next session refresh by calling `scheduleSessionRefresh`. 28 + - Returns `true` on success and `false` on failure. 29 + 30 + ### `refreshSession(): Promise<void>` 31 + 32 + - Uses the existing `agent.session` data to refresh the JWTs (JSON Web Tokens). 33 + - If successful, it saves the new session and schedules the next refresh. 34 + - If refreshing fails (e.g., the refresh token has expired), it falls back to calling `performLogin` to get a completely new session. 35 + 36 + ### `scheduleSessionRefresh(): void` 37 + 38 + - Calculates 80% of the JWT's typical 2-hour lifetime. 39 + - Uses `setTimeout` to call `refreshSession` automatically before the current session expires, ensuring the agent remains logged in continuously. 40 + 41 + ### `authenticate(): Promise<boolean>` 42 + 43 + - This is the core authentication logic. 44 + - It first tries to load a session from the file system using `loadSession`. 45 + - If a saved session exists, it attempts to resume it with `agent.resumeSession` and verifies it with a lightweight `getProfile` call. If successful, it returns `true`. 46 + - If resuming fails or no saved session exists, it calls `performLogin` to authenticate from scratch. 47 + 48 + ### `authenticateWithRetry(): Promise<void>` 49 + 50 + - This function wraps `authenticate` with a retry mechanism. 51 + - It will attempt to authenticate up to `MAX_LOGIN_RETRIES` (3) times with a delay between attempts. 52 + - If all attempts fail, it logs a fatal error and exits the application (`process.exit(1)`), as the bot cannot function without being authenticated. 53 + - It uses a `loginPromise` to ensure that if multiple parts of the app trigger authentication at once, the process only runs one time. 54 + 55 + ## Exports 56 + 57 + - **`agent`**: The global `AtpAgent` instance. 58 + - **`login`**: A function that can be called to initiate the authentication-with-retry process. This is called once at application startup in `main.ts`. 59 + - **`isLoggedIn`**: A `Promise` that resolves to `true` once the initial authentication is complete. Other modules can `await isLoggedIn` to ensure they don't try to make API calls before the agent is ready. 60 + 61 + ## Dependencies 62 + 63 + - **`@atproto/api`**: For the `AtpAgent`. 64 + - **`undici`**: For configuring the global HTTP client. 65 + - **`./config.js`**: Provides credentials (`BSKY_HANDLE`, `BSKY_PASSWORD`) and the service URL (`OZONE_PDS`). 66 + - **`./limits.js`**: Provides `updateRateLimitState` for the rate limit handling. 67 + - **`./logger.js`**: For logging. 68 + - **`./session.js`**: For loading and saving session data to the filesystem.
+56
docs/common/config.md
··· 1 + # `config.ts` 2 + 3 + This module is responsible for loading and exporting all configuration variables for the application. It uses the `dotenv` library to load environment variables from a `.env` file into `process.env`. 4 + 5 + It provides default values for most variables, ensuring the application can run in a development environment with minimal setup. 6 + 7 + ## Configuration Variables 8 + 9 + - **`MOD_DID`**: The DID of the moderation account that will be performing the labeling and reporting actions. 10 + - Loaded from `process.env.DID`. 11 + - Default: `""` 12 + 13 + - **`OZONE_URL`**: The URL for the Ozone service. (Note: This variable is exported but does not appear to be used in the current codebase). 14 + - Loaded from `process.env.OZONE_URL`. 15 + - Default: `""` 16 + 17 + - **`OZONE_PDS`**: The hostname of the PDS (Personal Data Server) where the Ozone service is hosted. This is used to configure the `AtpAgent`. 18 + - Loaded from `process.env.OZONE_PDS`. 19 + - Default: `""` 20 + 21 + - **`BSKY_HANDLE`**: The handle (username) of the moderation bot account used for logging in. 22 + - Loaded from `process.env.BSKY_HANDLE`. 23 + - Default: `""` 24 + 25 + - **`BSKY_PASSWORD`**: The application password for the moderation bot account. 26 + - Loaded from `process.env.BSKY_PASSWORD`. 27 + - Default: `""` 28 + 29 + - **`HOST`**: The hostname that the metrics server will bind to. 30 + - Loaded from `process.env.HOST`. 31 + - Default: `"0.0.0.0"` 32 + 33 + - **`METRICS_PORT`**: The port for the Prometheus metrics server. 34 + - Loaded from `process.env.METRICS_PORT`. 35 + - Default: `4101` 36 + 37 + - **`FIREHOSE_URL`**: The WebSocket URL for the Bluesky firehose (Jetstream) service. 38 + - Loaded from `process.env.FIREHOSE_URL`. 39 + - Default: `"wss://jetstream.atproto.tools/subscribe"` 40 + 41 + - **`PLC_URL`**: The hostname of the PLC directory (DID registry) used for resolving `did:plc:` creation dates. 42 + - Loaded from `process.env.PLC_URL`. 43 + - Default: `"plc.directory"` 44 + 45 + - **`WANTED_COLLECTION`**: An array of AT Protocol collection names that the firehose client should subscribe to. The bot will only receive events for these collections. 46 + - Default: `["app.bsky.feed.post", "app.bsky.actor.defs", "app.bsky.actor.profile"]` 47 + 48 + - **`CURSOR_UPDATE_INTERVAL`**: The interval in milliseconds at which the firehose cursor position is saved to disk. 49 + - Loaded from `process.env.CURSOR_UPDATE_INTERVAL`. 50 + - Default: `60000` (1 minute) 51 + 52 + - **`LABEL_LIMIT`** and **`LABEL_LIMIT_WAIT`**: These appear to be legacy or unused configuration variables related to a previous rate-limiting implementation. They are destructured from `process.env` but are not used elsewhere in the code. 53 + 54 + - **`REDIS_URL`**: The connection URL for the Redis server. 55 + - Loaded from `process.env.REDIS_URL`. 56 + - Default: `"redis://redis:6379"`
+60
docs/common/limits.md
··· 1 + # `limits.ts` 2 + 3 + This module implements a sophisticated rate limiting and concurrency management system for all outgoing API calls. Its primary goal is to ensure the application respects the API rate limits imposed by the Bluesky PDS while maximizing throughput. 4 + 5 + ## State Management 6 + 7 + - **`rateLimitState`**: A module-level object that holds the current understanding of the API rate limit. It includes: 8 + - `limit`: The total number of requests allowed in the current window. 9 + - `remaining`: The number of requests left in the current window. 10 + - `reset`: A Unix timestamp (in seconds) indicating when the window will reset. 11 + - `policy`: The rate limit policy string from the API header. 12 + - It is initialized with conservative default values but is designed to be dynamically updated by the `updateRateLimitState` function. 13 + 14 + ## Constants 15 + 16 + - **`SAFETY_BUFFER`**: The number of requests to keep in reserve. The system will pause and wait for the rate limit window to reset only when the `remaining` count drops to this level. 17 + - **`CONCURRENCY`**: The maximum number of API requests that can be in-flight simultaneously. 18 + 19 + ## Metrics 20 + 21 + This module is heavily instrumented with Prometheus metrics to provide visibility into its performance: 22 + 23 + - **`rateLimitWaitsTotal` (Counter)**: Counts how many times the system had to pause due to hitting the `SAFETY_BUFFER`. 24 + - **`rateLimitWaitDuration` (Histogram)**: Measures the duration of each pause. 25 + - **`rateLimitRemaining` (Gauge)**: Tracks the current value of `rateLimitState.remaining`. 26 + - **`rateLimitTotal` (Gauge)**: Tracks the current value of `rateLimitState.limit`. 27 + - **`concurrentRequestsGauge` (Gauge)**: Shows the number of currently active, concurrent API requests. 28 + 29 + ## Key Functions 30 + 31 + ### `updateRateLimitState(state: Partial<RateLimitState>): void` 32 + 33 + - **Purpose**: To update the module's internal `rateLimitState`. 34 + - **Usage**: This function is called by the `customFetch` wrapper in `agent.ts` every time an API response with rate limit headers is received. 35 + - **Logic**: It merges the new partial state with the existing state and updates the corresponding Prometheus gauges. 36 + 37 + ### `awaitRateLimit(): Promise<void>` 38 + 39 + - **Purpose**: To pause execution if the rate limit is critically low. 40 + - **Logic**: 41 + 1. It checks if `rateLimitState.remaining` is less than or equal to the `SAFETY_BUFFER`. 42 + 2. If it is, it calculates the time remaining until the next `reset`. 43 + 3. It then waits for that duration using a `setTimeout` wrapped in a `Promise`. 44 + 4. It logs the wait and records metrics about the wait duration. 45 + 46 + ### `limit<T>(fn: () => Promise<T>): Promise<T>` 47 + 48 + - **Purpose**: This is the main exported function used to wrap all API calls. It manages both concurrency and rate limiting. 49 + - **Usage**: Every function that makes an API call (e.g., in `accountModeration.ts` and `moderation.ts`) is wrapped in `limit(async () => { ... })`. 50 + - **Logic**: 51 + 1. **Concurrency**: It uses the `p-ratelimit` library (`concurrencyLimiter`) to ensure that no more than `CONCURRENCY` instances of the wrapped function can run at the same time. 52 + 2. **Rate Limiting**: Once a "slot" in the concurrency limiter is acquired, it calls `await awaitRateLimit()` to potentially pause if the API rate limit is nearly exhausted. 53 + 3. **Execution**: After the potential pause, it executes the provided function `fn`. 54 + 4. **Metrics**: It increments and decrements the `concurrentRequestsGauge` to track the number of active requests. 55 + 56 + ## Dependencies 57 + 58 + - **`p-ratelimit`**: A library used to control the concurrency of promise-based functions. 59 + - **`prom-client`**: For creating and managing Prometheus metrics. 60 + - **`./logger.js`**: For logging warnings and debug information.
+40
docs/common/logger.md
··· 1 + # `logger.ts` 2 + 3 + This module provides a single, globally shared logger instance for the entire application. It uses the `pino` library, which is a high-performance, JSON-based logger. 4 + 5 + ## Configuration 6 + 7 + The `pino` logger is configured with the following options: 8 + 9 + - **`level`**: The minimum log level to output. 10 + - It is configured from the `LOG_LEVEL` environment variable. 11 + - If `LOG_LEVEL` is not set, it defaults to `"info"`. This means `logger.debug()` messages will be ignored unless the level is explicitly set to `"debug"`. 12 + 13 + - **`formatters.level`**: This custom formatter ensures that the log level is output as a JSON property (e.g., `{"level": "info"}`). 14 + 15 + - **`timestamp`**: This custom function formats the timestamp as an ISO 8601 string within a JSON property (e.g., `,"time":"2025-11-12T12:00:00.000Z"`). 16 + 17 + - **`base: undefined`**: This option removes the default `pid` (process ID) and `hostname` properties from the log output, keeping the logs cleaner. 18 + 19 + ## `logger` Instance 20 + 21 + - `export const logger = pino(...)`: This creates and exports the singleton logger instance that all other modules import and use. 22 + 23 + ## Example Usage 24 + 25 + ```typescript 26 + import { logger } from "./logger.js"; 27 + 28 + logger.info({ process: "MAIN", status: "starting" }, "Application is starting up"); 29 + // Output: {"level":"info","time":"...","process":"MAIN","status":"starting","msg":"Application is starting up"} 30 + 31 + logger.debug({ did: "did:plc:123" }, "Checking user"); 32 + // Output (only if LOG_LEVEL=debug): {"level":"debug","time":"...","did":"did:plc:123","msg":"Checking user"} 33 + 34 + logger.error({ error: new Error("Something failed") }, "An error occurred"); 35 + // Output: {"level":"error","time":"...","error":{...},"msg":"An error occurred"} 36 + ``` 37 + 38 + ## Dependencies 39 + 40 + - **`pino`**: The underlying logging library.
+84
docs/common/metrics.md
··· 1 + # `metrics.ts` 2 + 3 + This module is responsible for setting up and exposing Prometheus metrics, which are essential for monitoring the health and behavior of the application. It uses the `prom-client` library to define counters and an `express` server to serve the metrics endpoint. 4 + 5 + ## Registry and Default Metrics 6 + 7 + - A central `Registry` is created to hold all the application's metrics. 8 + - `collectDefaultMetrics({ register })` is called to automatically add a set of standard metrics about the Node.js process, such as CPU usage, memory usage, and event loop lag. 9 + 10 + ## Custom Metrics (Counters) 11 + 12 + Several custom `Counter` metrics are defined to track specific application events. Counters are a cumulative metric that represents a single monotonically increasing counter whose value can only increase. 13 + 14 + - **`labelsAppliedCounter`**: 15 + - **Name**: `skywatch_labels_applied_total` 16 + - **Purpose**: Counts the total number of moderation labels successfully applied. 17 + - **Labels**: 18 + - `label_type`: The value of the label being applied (e.g., "spam", "impersonation"). 19 + - `target_type`: The type of entity being labeled ("account" or "post"). 20 + 21 + - **`labelsCachedCounter`**: 22 + - **Name**: `skywatch_labels_cached_total` 23 + - **Purpose**: Counts the number of times a label action was skipped because it was already in the Redis cache or already existed on the target. 24 + - **Labels**: 25 + - `label_type`: The value of the label. 26 + - `target_type`: "account" or "post". 27 + - `reason`: Why it was skipped ("redis_cache" or "existing_label"). 28 + 29 + - **`unlabelsRemovedCounter`**: 30 + - **Name**: `skywatch_labels_removed_total` 31 + - **Purpose**: Counts labels that are removed from an account because the content no longer matches the rule criteria. 32 + - **Labels**: 33 + - `label_type`: The value of the label being removed. 34 + - `target_type`: "account" or "post". 35 + 36 + - **`accountLabelsThresholdAppliedCounter`**: 37 + - **Name**: `skywatch_account_labels_threshold_applied_total` 38 + - **Purpose**: Counts the specific actions (label, report, comment) taken when an account meets a post-labeling threshold. 39 + - **Labels**: 40 + - `account_label`: The label being applied to the account. 41 + - `action`: The type of action taken ("label", "report", or "comment"). 42 + 43 + - **`accountThresholdChecksCounter`**: 44 + - **Name**: `skywatch_account_threshold_checks_total` 45 + - **Purpose**: Counts every time a threshold check is performed after a post is labeled. 46 + - **Labels**: 47 + - `post_label`: The label on the post that triggered the check. 48 + 49 + - **`accountThresholdMetCounter`**: 50 + - **Name**: `skywatch_account_threshold_met_total` 51 + - **Purpose**: Counts every time an account's post label count meets or exceeds a configured threshold. 52 + - **Labels**: 53 + - `account_label`: The account label that would be applied as a result. 54 + 55 + - **`starterPackThresholdChecksCounter`**: 56 + - **Name**: `skywatch_starter_pack_threshold_checks_total` 57 + - **Purpose**: Counts every time a starter pack threshold check is performed when a starter pack is created. 58 + - **Labels**: None 59 + 60 + - **`starterPackThresholdMetCounter`**: 61 + - **Name**: `skywatch_starter_pack_threshold_met_total` 62 + - **Purpose**: Counts every time an account's starter pack creation count meets or exceeds a configured threshold. 63 + - **Labels**: 64 + - `account_label`: The account label that would be applied as a result. 65 + 66 + - **`starterPackLabelsThresholdAppliedCounter`**: 67 + - **Name**: `skywatch_starter_pack_labels_threshold_applied_total` 68 + - **Purpose**: Counts the specific actions (label, report, comment) taken when an account meets a starter pack threshold. 69 + - **Labels**: 70 + - `account_label`: The label being applied to the account. 71 + - `action`: The type of action taken ("label", "report", or "comment"). 72 + 73 + ## Metrics Server 74 + 75 + - An `express` application is created to serve the metrics. 76 + - **`app.get("/metrics", ...)`**: It defines a single endpoint, `/metrics`. When this endpoint is requested, it retrieves all the registered metrics in the Prometheus text-based format and sends them in the response. 77 + - **`startMetricsServer(port: number)`**: This exported function starts the express server, making it listen on the configured `HOST` and `port`. It is called once at application startup in `main.ts`. 78 + 79 + ## Dependencies 80 + 81 + - **`express`**: For creating the HTTP server. 82 + - **`prom-client`**: The core library for defining and managing Prometheus metrics. 83 + - **`./config.js`**: Provides the `HOST` for the server to bind to. 84 + - **`./logger.js`**: For logging server startup and errors.
+50
docs/common/moderation.md
··· 1 + # `moderation.ts` 2 + 3 + This module provides functions for performing moderation actions on individual posts (records). It is the counterpart to `accountModeration.ts`, which handles account-level actions. 4 + 5 + All functions in this module ensure the agent is logged in (`await isLoggedIn`) and wrap their API calls in the `limit` function to manage concurrency and rate limiting. 6 + 7 + ## Key Functions 8 + 9 + ### `createPostLabel(uri: string, cid: string, label: string, comment: string, duration: number | undefined, did?: string, time?: number)` 10 + 11 + - **Purpose**: Applies a moderation label to a specific post. 12 + - **Parameters**: 13 + - `uri`, `cid`: The strong reference to the post. 14 + - `label`: The label value to apply (e.g., "spam"). 15 + - `comment`: The private moderation comment. 16 + - `duration`: An optional duration in hours for temporary labels. 17 + - `did`, `time`: Optional author DID and event time, passed through to trigger an account threshold check. 18 + - **De-duplication**: 19 + 1. `tryClaimPostLabel(uri, label)`: Atomically claims the label for the post in Redis to prevent race conditions. 20 + 2. `checkRecordLabels(uri, label)`: Checks the Ozone API to see if the label already exists on the post. 21 + - **Action**: If the label is not a duplicate, it calls `agent.tools.ozone.moderation.emitEvent` with a `modEventLabel` event. 22 + - **Threshold Check**: After successfully applying a label, if the author's `did` and the event `time` were provided, it **dynamically imports and calls `checkAccountThreshold`**. This is a crucial step that connects post-level moderation to account-level moderation, escalating actions against repeat offenders. The dynamic import is used to break a circular dependency between the `moderation` and `accountThreshold` modules. 23 + - **Metrics**: Increments `labelsAppliedCounter` on success or `labelsCachedCounter` if skipped. 24 + 25 + ### `createPostReport(uri: string, cid: string, comment: string)` 26 + 27 + - **Purpose**: Creates a formal report against a specific post. 28 + - **Action**: Calls `emitEvent` with a `modEventReport` event. The `reportType` is hardcoded to `com.atproto.moderation.defs#reasonOther`. 29 + 30 + ### `checkRecordLabels(uri: string, label: string): Promise<boolean>` 31 + 32 + - **Purpose**: Checks if a specific label already exists on a post. 33 + - **Action**: Calls `agent.tools.ozone.moderation.getRecord` to fetch the post's current moderation status. 34 + - **Returns**: `true` if the label exists, `false` otherwise. It relies on the helper `doesLabelExist` to parse the response. 35 + 36 + ## Helper Functions 37 + 38 + ### `doesLabelExist(labels: { val: string }[] | undefined, labelVal: string): boolean` 39 + 40 + - A simple, private utility function that safely checks if a `labels` array from an API response contains a specific `labelVal`. 41 + 42 + ## Dependencies 43 + 44 + - **`../automod/redis.js`**: Provides `tryClaimPostLabel` for de-duplication. 45 + - **`./agent.js`**: Provides the authenticated `agent` for all API calls. 46 + - **`./config.js`**: Provides the `MOD_DID` for proxying requests. 47 + - **`./limits.js`**: Provides the `limit` function for rate limiting. 48 + - **`./logger.js`**: For logging. 49 + - **`./metrics.js`**: For incrementing Prometheus counters. 50 + - **`../automod/accountThreshold.js`**: Dynamically imported to trigger account threshold checks.
+114
docs/common/redis.md
··· 1 + # `redis.ts` 2 + 3 + This module manages the application's connection to Redis and provides functions for caching and tracking moderation-related data. It plays a crucial role in preventing duplicate moderation actions and in implementing time-windowed rules (like account thresholds). 4 + 5 + **Note:** There is a file with the same name at `src/automod/redis.ts`. This file appears to be a duplicate or an older version. The one in `src/common/` seems to be the one that is actually used. 6 + 7 + ## Redis Client Setup 8 + 9 + - A single `redisClient` is created using the `REDIS_URL` from the configuration. 10 + - Event listeners are attached to the client to log connection status (`connect`, `ready`), errors (`error`), and reconnections (`reconnecting`). 11 + 12 + ## Connection Management 13 + 14 + ### `connectRedis(): Promise<void>` 15 + 16 + - Asynchronously connects the `redisClient` to the Redis server. 17 + - Called once at application startup in `main.ts`. 18 + 19 + ### `disconnectRedis(): Promise<void>` 20 + 21 + - Gracefully quits the Redis connection. 22 + - Called during the application's shutdown sequence in `main.ts`. 23 + 24 + ## Caching and Claiming Functions 25 + 26 + These functions are used to ensure that a specific moderation action (like labeling a post or commenting on an account) is only performed once. They work by setting a key in Redis with an `NX` (Not Exists) flag, which is an atomic operation. 27 + 28 + ### `tryClaimPostLabel(atURI: string, label: string): Promise<boolean>` 29 + 30 + - **Purpose**: To prevent applying the same label to the same post multiple times. 31 + - **Key**: `post-label:<atURI>:<label>` 32 + - **Logic**: Attempts to set the key. If successful (the key didn't already exist), it returns `true`. Otherwise, it returns `false`. 33 + - **TTL**: The key is set to expire in 7 days. 34 + - **Error Handling**: If the Redis command fails, it logs a warning and returns `true` to "fail open," allowing the moderation action to proceed. 35 + 36 + ### `tryClaimAccountLabel(did: string, label: string): Promise<boolean>` 37 + 38 + - **Purpose**: To prevent applying the same label to the same account multiple times. 39 + - **Key**: `account-label:<did>:<label>` 40 + - **Logic**: Same as `tryClaimPostLabel`. 41 + - **TTL**: 7 days. 42 + 43 + ### `tryClaimAccountComment(did: string, atURI: string): Promise<boolean>` 44 + 45 + - **Purpose**: To prevent adding the same comment to an account's moderation record multiple times. 46 + - **Key**: `account-comment:<did>:<atURI>` 47 + - **Logic**: Same as the label claiming functions. 48 + - **TTL**: 7 days. 49 + 50 + ### `deleteAccountLabelClaim(did: string, label: string): Promise<void>` 51 + 52 + - **Purpose**: To explicitly remove an account label claim from the cache. This is used when a label is negated (removed) from an account, allowing it to be potentially re-applied later. 53 + - **Key**: `account-label:<did>:<label>` 54 + - **Logic**: Deletes the specified key from Redis. 55 + 56 + ## Time Window Helpers 57 + 58 + Internal helper functions for converting window configurations to Redis-compatible values: 59 + 60 + ### `windowToMicroseconds(window: number, unit: WindowUnit): number` 61 + 62 + - **Purpose**: Converts a window duration and unit into microseconds for sorted set scoring. 63 + - **Units**: `minutes`, `hours`, `days` 64 + 65 + ### `windowToSeconds(window: number, unit: WindowUnit): number` 66 + 67 + - **Purpose**: Converts a window duration and unit into seconds for TTL calculations. 68 + 69 + ## Account Threshold Tracking 70 + 71 + These functions provide the mechanism for the `accountThreshold.ts` module to count how many times an account has had its posts labeled within a rolling time window. 72 + 73 + ### `trackPostLabelForAccount(did: string, label: string, timestamp: number, window: number, windowUnit: WindowUnit): Promise<void>` 74 + 75 + - **Purpose**: To record that a post by a specific user received a certain label at a specific time. 76 + - **Key**: `account-post-labels:<did>:<label>:<window><windowUnit>` (e.g., `account-post-labels:did:plc:xyz:spam:7days`) 77 + - **Logic**: 78 + 1. It uses a Redis Sorted Set (`ZADD`). 79 + 2. The `score` of each member in the set is the `timestamp` of the labeling event (in microseconds). 80 + 3. The `value` is also the timestamp (it just needs to be unique). 81 + 4. Before adding the new entry, it removes any old entries that are outside the rolling window period (`ZREMRANGEBYSCORE`). 82 + 5. It sets a TTL on the key (window duration + 1 hour buffer) to ensure it eventually expires if the user stops receiving labels. 83 + 84 + ### `getPostLabelCountInWindow(did: string, labels: string[], window: number, windowUnit: WindowUnit, currentTime: number): Promise<number>` 85 + 86 + - **Purpose**: To count how many labels (matching the `labels` array) an account has received within the rolling window. 87 + - **Logic**: 88 + 1. It iterates through the provided `labels`. 89 + 2. For each label, it constructs the appropriate key. 90 + 3. It uses `ZCOUNT` on the sorted set to count the number of entries whose score (timestamp) is within the desired window. 91 + 4. It sums the counts for all labels and returns the total. 92 + 93 + ## Starter Pack Threshold Tracking 94 + 95 + These functions provide the mechanism for the `starterPackThreshold.ts` module to count how many starter packs an account has created within a rolling time window. 96 + 97 + ### `trackStarterPackForAccount(did: string, starterPackUri: string, timestamp: number, window: number, windowUnit: WindowUnit): Promise<void>` 98 + 99 + - **Purpose**: To record that a user created a starter pack at a specific time. 100 + - **Key**: `starterpack:threshold:<did>:<window><windowUnit>` (e.g., `starterpack:threshold:did:plc:xyz:7days`) 101 + - **Logic**: 102 + 1. It uses a Redis Sorted Set (`ZADD`). 103 + 2. The `score` of each member in the set is the `timestamp` of the creation event (in microseconds). 104 + 3. The `value` is the starter pack URI (unique per creation). 105 + 4. Before adding the new entry, it removes any old entries that are outside the rolling window period (`ZREMRANGEBYSCORE`). 106 + 5. It sets a TTL on the key (window duration + 1 hour buffer) to ensure it eventually expires. 107 + 108 + ### `getStarterPackCountInWindow(did: string, window: number, windowUnit: WindowUnit, currentTime: number): Promise<number>` 109 + 110 + - **Purpose**: To count how many starter packs an account has created within the rolling window. 111 + - **Logic**: 112 + 1. It constructs the key for the account's starter pack tracking set. 113 + 2. It uses `ZCOUNT` on the sorted set to count the number of entries whose score (timestamp) is within the desired window. 114 + 3. Returns the count.
+44
docs/common/session.md
··· 1 + # `session.ts` 2 + 3 + This module provides simple file-based session persistence. It allows the application to save its authentication session to disk and load it again on the next startup. This avoids the need to perform a fresh login every time the bot is restarted, making startup faster and reducing unnecessary authentication requests. 4 + 5 + ## `SESSION_FILE_PATH` 6 + 7 + - A constant that defines the path to the session file: `.session` in the current working directory. 8 + 9 + ## `SessionData` Interface 10 + 11 + - Defines the structure of the session object that is saved to and loaded from the file. It includes essential fields like `accessJwt`, `refreshJwt`, `did`, and `handle`. 12 + 13 + ## Key Functions 14 + 15 + ### `loadSession(): SessionData | null` 16 + 17 + - **Purpose**: To load and validate the session from the `.session` file. 18 + - **Logic**: 19 + 1. It checks if the `.session` file exists. If not, it returns `null`. 20 + 2. It reads the file content, parses it as JSON, and casts it to the `SessionData` type. 21 + 3. It performs a basic validation to ensure the essential JWT and DID fields are present. If not, it logs a warning and returns `null`. 22 + 4. If the session is loaded and valid, it returns the `SessionData` object. 23 + - **Error Handling**: If any part of the process fails (e.g., file read error, JSON parse error), it logs the error and returns `null`, forcing a fresh authentication. 24 + 25 + ### `saveSession(session: SessionData): void` 26 + 27 + - **Purpose**: To save the current authentication session to the `.session` file. 28 + - **Usage**: This is called from `agent.ts` whenever a new session is created (after a fresh login) or an existing session is successfully refreshed. 29 + - **Logic**: 30 + 1. It serializes the `session` object into a formatted JSON string. 31 + 2. It writes the string to the `.session` file. 32 + 3. It sets the file permissions to `0o600` (`-rw-------`) using `chmodSync` to ensure the session file, which contains sensitive tokens, is only readable and writable by the user running the application. 33 + 34 + ### `clearSession(): void` 35 + 36 + - **Purpose**: To delete the `.session` file from disk. 37 + - **Usage**: This function is exported but does not appear to be actively used in the current application flow. It could be used to implement a "logout" feature. 38 + - **Logic**: It checks if the file exists and, if so, deletes it using `unlinkSync`. 39 + 40 + ## Dependencies 41 + 42 + - **`node:fs`**: Provides the file system functions for reading, writing, deleting, and changing permissions of the session file. 43 + - **`node:path`**: Used to construct the absolute path to the session file. 44 + - **`./logger.js`**: For logging session management activities and errors.
+49
docs/common/starterPackThreshold.md
··· 1 + # `starterPackThreshold.ts` 2 + 3 + This module is responsible for checking if an account has created too many starter packs within a specific time window. When a threshold is met, it can trigger various moderation actions on the account, such as applying a label, adding a comment, or reporting the account. This is useful for detecting follow-farming behaviour and coordinated campaign activity. 4 + 5 + ## Key Functions 6 + 7 + ### `checkStarterPackThreshold(did: string, starterPackUri: string, timestamp: number): Promise<void>` 8 + 9 + This is the main function of the module. It's called whenever a starter pack is created, and it checks if the creator has exceeded the configured threshold. 10 + 11 + **Parameters:** 12 + 13 + - `did`: The DID of the account that created the starter pack. 14 + - `starterPackUri`: The AT URI of the newly created starter pack. 15 + - `timestamp`: The timestamp of the creation event. 16 + 17 + **Logic:** 18 + 19 + 1. **Load Configurations**: It retrieves the cached `STARTER_PACK_THRESHOLD_CONFIGS`. 20 + 2. **Check Allowlist**: For each configuration, it first checks if the account is in the `allowlist`. If so, the check is skipped for that configuration. 21 + 3. **Track the Creation**: It records the new starter pack creation for the account in Redis using `trackStarterPackForAccount`. This function stores the URI with its timestamp in a sorted set. 22 + 4. **Count Recent Creations**: It then uses `getStarterPackCountInWindow` to count how many starter packs the account has created within the defined rolling window (`window` + `windowUnit`). 23 + 5. **Check Threshold**: If the `count` is greater than or equal to the `threshold` defined in the configuration, it proceeds to take action. 24 + 6. **Format Comment**: A detailed comment is generated including the threshold count, window configuration, and the triggering starter pack URI. 25 + 7. **Apply Moderation Actions**: 26 + - It logs that the threshold has been met. 27 + - It increments the `starterPackThresholdMetCounter` metric. 28 + - Based on the booleans `toLabel` (defaults to true), `reportAcct`, and `commentAcct` in the configuration, it will: 29 + - Apply a label to the account (`createAccountLabel`). 30 + - Report the account (`createAccountReport`). 31 + - Add a comment to the account's moderation record (`createAccountComment`). 32 + - It also increments the `starterPackLabelsThresholdAppliedCounter` for each action taken. 33 + 34 + ### `loadStarterPackThresholdConfigs(): StarterPackThresholdConfig[]` 35 + 36 + This function returns the cached `STARTER_PACK_THRESHOLD_CONFIGS`. The configurations are loaded and validated once at module initialization to avoid re-reading and re-validating the configuration file on every call. 37 + 38 + ## Supporting Functions 39 + 40 + - `validateAndLoadConfigs(): StarterPackThresholdConfig[]`: This function is called once when the module is loaded. It iterates through `STARTER_PACK_THRESHOLD_CONFIGS`, validates that each configuration has the required properties (`threshold > 0`, `window > 0`), and then returns the valid configurations. 41 + 42 + ## Dependencies 43 + 44 + - **`../../rules/starterPackThreshold.js`**: Contains the `STARTER_PACK_THRESHOLD_CONFIGS` array, which defines the rules for when to take action on an account. 45 + - **`./accountModeration.js`**: Provides the functions (`createAccountLabel`, `createAccountReport`, `createAccountComment`) to perform moderation actions on an account. 46 + - **`./logger.js`**: Used for logging information, warnings, and errors. 47 + - **`./metrics.js`**: Provides Prometheus counters for monitoring the behavior of this module. 48 + - **`./redis.js`**: Provides the functions (`trackStarterPackForAccount`, `getStarterPackCountInWindow`) for interacting with Redis to store and retrieve data about starter pack creations. 49 + - **`./types.js`**: Defines the `StarterPackThresholdConfig` type.
+67
docs/common/types.md
··· 1 + # `types.ts` 2 + 3 + This module serves as a central repository for common TypeScript interfaces and type definitions used throughout the application. This helps ensure data consistency and provides type safety. 4 + 5 + ## Core Data Structures 6 + 7 + - **`Post`**: Represents a simplified post object, containing the essential fields needed for moderation checks. 8 + - `did`, `time`, `rkey`, `atURI`, `text`, `cid` 9 + 10 + - **`Handle`**: Represents a user handle event. 11 + - `did`, `time`, `handle` 12 + 13 + - **`Profile`**: Represents a user profile event. 14 + - `did`, `time`, `displayName`, `description` 15 + 16 + - **`List`**: Represents a list object. (Note: This interface does not appear to be used in the current codebase). 17 + - `label`, `rkey` 18 + 19 + ## Rule Configuration Interfaces 20 + 21 + - **`Checks`**: A comprehensive interface that defines the structure for a single moderation rule (used for posts, profiles, and handles). 22 + - `check`: The primary `RegExp` used to test content. 23 + - `whitelist`: An optional `RegExp` to prevent false positives. 24 + - `label`: The moderation label to apply. 25 + - `comment`: The text to include in the moderation action. 26 + - `language`: An optional array of language codes to restrict the rule to. 27 + - `ignoredDIDs`: An optional array of DIDs to exempt from the rule. 28 + - `starterPacks`: An optional array of starter pack URIs to restrict the rule to members of those packs. 29 + - `knownVectors`: An optional array of known vector strings for tracking purposes. 30 + - Action booleans: `toLabel`, `reportPost`, `reportAcct`, `commentAcct`, `unlabel`, `trackOnly`. 31 + - `duration`: An optional duration in hours for temporary labels. 32 + - `description`, `displayName`: Booleans to specify which parts of a profile to check. 33 + 34 + - **`AccountAgeCheck`**: Defines the structure for a rule in the `accountAge.ts` module. 35 + - `monitoredDIDs`, `monitoredPostURIs`: The targets of the monitoring. 36 + - `anchorDate`, `maxAgeDays`: The time window for checking the account's creation date. 37 + - `label`, `comment`: The action to take if the check passes. 38 + - `expires`: An optional date after which the rule is no longer active. 39 + 40 + - **`WindowUnit`**: A type alias representing the unit for rolling time windows. Valid values are `"minutes"`, `"hours"`, or `"days"`. 41 + 42 + - **`AccountThresholdConfig`**: Defines the structure for a rule in the `accountThreshold.ts` module. 43 + - `labels`: The post label(s) that contribute to the threshold (can be a single string or array for OR matching). 44 + - `threshold`: The number of labels required to trigger the action. 45 + - `window`: The rolling window duration (number). 46 + - `windowUnit`: The unit for the rolling window (`WindowUnit`). 47 + - `accountLabel`, `accountComment`: The label and comment for the resulting account action. 48 + - Action booleans: `reportAcct`, `commentAcct`, `toLabel` (optional, defaults to true). 49 + 50 + - **`StarterPackThresholdConfig`**: Defines the structure for a rule in the `starterPackThreshold.ts` module. 51 + - `threshold`: The number of starter packs required to trigger the action. 52 + - `window`: The rolling window duration (number). 53 + - `windowUnit`: The unit for the rolling window (`WindowUnit`). 54 + - `accountLabel`, `accountComment`: The label and comment for the resulting account action. 55 + - `toLabel`: Optional boolean to apply label (defaults to true). 56 + - `reportAcct`: Optional boolean to report the account. 57 + - `commentAcct`: Optional boolean to comment on the account. 58 + - `allowlist`: Optional array of DIDs to exempt from this check. 59 + 60 + ## Re-exported Lexicon Types 61 + 62 + - For convenience, several facet-related types are re-exported directly from the `@atproto/ozone` library's generated lexicons. This avoids the need for other modules to have complex import paths. 63 + - `Facet` 64 + - `FacetIndex` 65 + - `FacetMention` 66 + - `LinkFeature` 67 + - `FacetTag`
+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 );
+3 -2
package.json
··· 1 1 { 2 - "name": "skywatch-tools", 3 - "version": "1.3.0", 2 + "name": "skywatch-automod", 3 + "version": "2.1.0", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "npx tsx src/main.ts", ··· 31 31 "@vitest/ui": "^1.6.0", 32 32 "eslint": "^9.34.0", 33 33 "eslint-config-prettier": "^10.1.8", 34 + "eslint-plugin-import": "^2.32.0", 34 35 "prettier": "^3.6.2", 35 36 "supertest": "^7.1.4", 36 37 "tsx": "^4.20.5",
+10
prometheus.yml
··· 1 + global: 2 + scrape_interval: 15s 3 + evaluation_interval: 15s 4 + 5 + scrape_configs: 6 + - job_name: "skywatch-automod" 7 + static_configs: 8 + - targets: ["automod:4101"] 9 + labels: 10 + service: "automod"
+43
rules/accountAge.ts
··· 1 + import type { AccountAgeCheck } from "../src/types.js"; 2 + 3 + /** 4 + * Account age monitoring configurations 5 + * 6 + * Labels new accounts that interact with monitored DIDs or posts. 7 + * Useful for protecting high-profile accounts from coordinated harassment. 8 + * Configure your checks below. 9 + */ 10 + export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [ 11 + // Example - monitor replies to specific accounts: 12 + // { 13 + // monitoredDIDs: ["did:plc:example123", "did:plc:example456"], 14 + // anchorDate: "2025-01-15", // Only check accounts created after this date 15 + // maxAgeDays: 7, // Flag accounts younger than 7 days 16 + // label: "new-account-reply", 17 + // comment: "New account replying to monitored user", 18 + // expires: "2025-02-15", // Stop checking after this date 19 + // }, 20 + 21 + // Example - monitor replies/quotes to specific posts: 22 + // { 23 + // monitoredPostURIs: [ 24 + // "at://did:plc:xyz/app.bsky.feed.post/abc123", 25 + // "at://did:plc:xyz/app.bsky.feed.post/def456", 26 + // ], 27 + // anchorDate: "2025-01-20", 28 + // maxAgeDays: 3, 29 + // label: "new-account-quote", 30 + // comment: "New account quoting monitored post", 31 + // }, 32 + 33 + // Example - combine both DID and post monitoring: 34 + // { 35 + // monitoredDIDs: ["did:plc:high-profile"], 36 + // monitoredPostURIs: ["at://did:plc:high-profile/app.bsky.feed.post/viral"], 37 + // anchorDate: "2025-01-01", 38 + // maxAgeDays: 14, 39 + // label: "new-account-interaction", 40 + // comment: "New account interacting with high-profile content", 41 + // expires: "2025-03-01", 42 + // }, 43 + ];
+21
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 + // window: 5, 16 + // windowUnit: "hours", 17 + // reportAcct: false, 18 + // commentAcct: false, 19 + // toLabel: true, 20 + // }, 21 + ];
+29
rules/constants.ts
··· 1 + /** 2 + * Global allowlist for accounts that should bypass all checks 3 + * 4 + * Add DIDs here to exempt them from all moderation checks. 5 + */ 6 + export const GLOBAL_ALLOW: string[] = [ 7 + // Example: "did:plc:trusted-account", 8 + ]; 9 + 10 + /** 11 + * URL shortener detection pattern 12 + * 13 + * Matched URLs are resolved to their final destination before checking. 14 + * Add domains for URL shorteners you want to expand. 15 + */ 16 + export const LINK_SHORTENER = new RegExp( 17 + [ 18 + "bit\\.ly", 19 + "tinyurl\\.com", 20 + "t\\.co", 21 + "goo\\.gl", 22 + "ow\\.ly", 23 + "is\\.gd", 24 + "buff\\.ly", 25 + "rebrand\\.ly", 26 + "short\\.io", 27 + ].join("|"), 28 + "i", 29 + );
+327
rules/developing_checks.md
··· 1 + # Developing Moderation Checks 2 + 3 + This guide explains how to configure moderation rules for skywatch-automod. 4 + 5 + ## Overview 6 + 7 + Moderation checks are defined in TypeScript files in the `rules/` directory. Each check uses regular expressions to match content and specifies what action to take when a match is found. 8 + 9 + ## Check Types 10 + 11 + ### Post Content Checks 12 + 13 + File: `rules/posts.ts` 14 + 15 + Monitors post text and embedded URLs for matches. 16 + 17 + ```typescript 18 + import type { Checks } from "../src/types.js"; 19 + 20 + export const POST_CHECKS: Checks[] = [ 21 + { 22 + label: "spam", 23 + comment: "Spam content detected in post", 24 + reportAcct: false, 25 + commentAcct: false, 26 + toLabel: true, 27 + check: new RegExp("buy.*followers", "i"), 28 + }, 29 + ]; 30 + ``` 31 + 32 + ### Handle Checks 33 + 34 + File: `rules/handles.ts` 35 + 36 + Monitors user handles for pattern matches. 37 + 38 + ```typescript 39 + export const HANDLE_CHECKS: Checks[] = [ 40 + { 41 + label: "impersonation", 42 + comment: "Potential impersonation detected", 43 + reportAcct: true, 44 + commentAcct: false, 45 + toLabel: false, 46 + check: new RegExp("official.*support", "i"), 47 + }, 48 + ]; 49 + ``` 50 + 51 + ### Profile Checks 52 + 53 + File: `rules/profiles.ts` 54 + 55 + Monitors profile display names and descriptions. 56 + 57 + ```typescript 58 + export const PROFILE_CHECKS: Checks[] = [ 59 + { 60 + label: "spam-profile", 61 + comment: "Spam content in profile", 62 + reportAcct: false, 63 + commentAcct: false, 64 + toLabel: true, 65 + displayName: true, // Check display name 66 + description: true, // Check description 67 + check: new RegExp("follow.*back", "i"), 68 + }, 69 + ]; 70 + ``` 71 + 72 + ### Account Age Checks 73 + 74 + File: `rules/accountAge.ts` 75 + 76 + Labels accounts created after a specific date when they interact with monitored content. 77 + 78 + ```typescript 79 + import type { AccountAgeCheck } from "../src/types.js"; 80 + 81 + export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [ 82 + { 83 + monitoredDIDs: ["did:plc:abc123"], 84 + anchorDate: "2025-01-15", 85 + maxAgeDays: 7, 86 + label: "new-account-spam", 87 + comment: "New account replying to monitored user", 88 + expires: "2025-02-15", // Optional expiration 89 + }, 90 + ]; 91 + ``` 92 + 93 + ### Account Threshold Checks 94 + 95 + File: `rules/accountThreshold.ts` 96 + 97 + Applies account-level labels when an account accumulates multiple post-level violations within a time window. 98 + 99 + ```typescript 100 + import type { AccountThresholdConfig } from "../src/types.js"; 101 + 102 + export const ACCOUNT_THRESHOLD_CONFIGS: AccountThresholdConfig[] = [ 103 + { 104 + labels: ["spam", "scam"], // Trigger on either label 105 + threshold: 3, 106 + accountLabel: "repeat-offender", 107 + accountComment: "Account exceeded spam threshold", 108 + window: 7, 109 + windowUnit: "days", // Options: "minutes", "hours", "days" 110 + reportAcct: true, 111 + commentAcct: false, 112 + toLabel: true, 113 + }, 114 + ]; 115 + ``` 116 + 117 + ### Starter Pack Threshold Checks 118 + 119 + File: `rules/starterPackThreshold.ts` 120 + 121 + Applies account-level labels when an account creates too many starter packs within a time window. Useful for detecting follow-farming and coordinated campaign behaviour. 122 + 123 + ```typescript 124 + import type { StarterPackThresholdConfig } from "../src/types.js"; 125 + 126 + export const STARTER_PACK_THRESHOLD_CONFIGS: StarterPackThresholdConfig[] = [ 127 + { 128 + threshold: 10, // Account action triggered after 10 starter packs 129 + window: 7, // Within this duration 130 + windowUnit: "days", // Options: "minutes", "hours", "days" 131 + accountLabel: "follow-farming", 132 + accountComment: "Account created multiple starter packs in short period", 133 + toLabel: true, // Whether to apply the label (default: true) 134 + reportAcct: true, // Whether to report the account 135 + commentAcct: false, // Whether to comment on the account 136 + allowlist: [], // DIDs to exempt from this check 137 + }, 138 + ]; 139 + ``` 140 + 141 + ## Check Configuration Fields 142 + 143 + ### Basic Fields (Required) 144 + 145 + - `label` - Label to apply (string) 146 + - `comment` - Comment for the moderation action (string) 147 + - `reportAcct` - Create account report (boolean) 148 + - `commentAcct` - Add comment to account (boolean) 149 + - `toLabel` - Apply the label (boolean) 150 + - `check` - Regular expression pattern (RegExp) 151 + 152 + ### Optional Fields 153 + 154 + - `language` - Language codes to restrict check to (string[]) 155 + - `description` - Check profile descriptions (boolean) 156 + - `displayName` - Check profile display names (boolean) 157 + - `reportPost` - Create post report instead of just labeling (boolean) 158 + - `duration` - Label duration in hours (number) 159 + - `whitelist` - RegExp to exclude from matching (RegExp) 160 + - `ignoredDIDs` - DIDs to skip checking (string[]) 161 + - `starterPacks` - Filter by starter pack membership (string[]) 162 + - `knownVectors` - Known attack vectors for tracking (string[]) 163 + - `trackOnly` - Track without applying label (boolean) 164 + - `unlabel` - Remove existing label if content no longer matches (boolean) 165 + 166 + ### Threshold Configuration Fields 167 + 168 + #### Account Threshold 169 + 170 + - `labels` - Single label or array of labels to aggregate (string | string[]) 171 + - `threshold` - Number of labeled posts required to trigger account action (number) 172 + - `window` - Rolling window duration (number) 173 + - `windowUnit` - Unit for the rolling window: "minutes", "hours", or "days" (WindowUnit) 174 + - `accountLabel` - Label to apply to the account (string) 175 + - `accountComment` - Comment for the account action (string) 176 + - `toLabel` - Whether to apply the label, defaults to true (boolean) 177 + - `reportAcct` - Whether to report the account (boolean) 178 + - `commentAcct` - Whether to comment on the account (boolean) 179 + 180 + #### Starter Pack Threshold 181 + 182 + - `threshold` - Number of starter packs required to trigger account action (number) 183 + - `window` - Rolling window duration (number) 184 + - `windowUnit` - Unit for the rolling window: "minutes", "hours", or "days" (WindowUnit) 185 + - `accountLabel` - Label to apply to the account (string) 186 + - `accountComment` - Comment for the account action (string) 187 + - `toLabel` - Whether to apply the label, defaults to true (boolean) 188 + - `reportAcct` - Whether to report the account (boolean) 189 + - `commentAcct` - Whether to comment on the account (boolean) 190 + - `allowlist` - DIDs to exempt from this check (string[]) 191 + 192 + ## Examples 193 + 194 + ### Language-Specific Check 195 + 196 + ```typescript 197 + { 198 + language: ["spa"], 199 + label: "spam-es", 200 + comment: "Spanish spam detected", 201 + reportAcct: false, 202 + commentAcct: false, 203 + toLabel: true, 204 + check: new RegExp("comprar seguidores", "i"), 205 + } 206 + ``` 207 + 208 + ### Temporary Label 209 + 210 + ```typescript 211 + { 212 + label: "review-needed", 213 + comment: "Content flagged for review", 214 + reportAcct: true, 215 + commentAcct: false, 216 + toLabel: false, 217 + duration: 24, // Label expires after 24 hours 218 + check: new RegExp("suspicious.*pattern", "i"), 219 + } 220 + ``` 221 + 222 + ### Whitelist Exception 223 + 224 + ```typescript 225 + { 226 + label: "blocked-term", 227 + comment: "Blocked term used", 228 + reportAcct: false, 229 + commentAcct: false, 230 + toLabel: true, 231 + check: new RegExp("\\bterm\\b", "i"), 232 + whitelist: new RegExp("legitimate.*context", "i"), 233 + } 234 + ``` 235 + 236 + ### Ignored DIDs 237 + 238 + ```typescript 239 + { 240 + label: "blocked-term", 241 + comment: "Blocked term used", 242 + reportAcct: false, 243 + commentAcct: false, 244 + toLabel: true, 245 + check: new RegExp("\\bterm\\b", "i"), 246 + ignoredDIDs: [ 247 + "did:plc:trusted123", 248 + "did:plc:verified456", 249 + ], 250 + } 251 + ``` 252 + 253 + ## Global Configuration 254 + 255 + ### Allowlist 256 + 257 + File: `rules/constants.ts` 258 + 259 + DIDs in the global allowlist bypass all checks. 260 + 261 + ```typescript 262 + export const GLOBAL_ALLOW: string[] = [ 263 + "did:plc:trusted123", 264 + "did:plc:verified456", 265 + ]; 266 + ``` 267 + 268 + ### Link Shorteners 269 + 270 + Pattern to match URL shorteners for special handling. 271 + 272 + ```typescript 273 + export const LINK_SHORTENER = new RegExp( 274 + "bit\\.ly|tinyurl\\.com|goo\\.gl", 275 + "i" 276 + ); 277 + ``` 278 + 279 + ## Best Practices 280 + 281 + ### Regular Expressions 282 + 283 + - Use word boundaries (`\\b`) to avoid partial matches 284 + - Test patterns thoroughly to minimize false positives 285 + - Use case-insensitive matching (`i` flag) when appropriate 286 + - Escape special regex characters 287 + 288 + ### Action Selection 289 + 290 + - `toLabel: true` - Apply label immediately (use for clear violations) 291 + - `reportAcct: true` - Create report for manual review (use for ambiguous cases) 292 + - `commentAcct: true` - Create comment on account (probably can be depreciated) 293 + 294 + ### Performance 295 + 296 + - Keep regex patterns simple and efficient 297 + - Use language filters to reduce unnecessary checks 298 + - Leverage whitelists instead of complex negative lookaheads 299 + 300 + ### Testing 301 + 302 + After modifying rules: 303 + 304 + ```bash 305 + bun test:run 306 + ``` 307 + 308 + Test specific rule modules: 309 + 310 + ```bash 311 + bun test src/rules/posts/tests/ 312 + ``` 313 + 314 + ## Deployment 315 + 316 + Rules are mounted as a volume in docker compose: 317 + 318 + ```yaml 319 + volumes: 320 + - ./rules:/app/rules 321 + ``` 322 + 323 + Changes require automod rebuild: 324 + 325 + ```bash 326 + docker compose up -d --build automod 327 + ```
+32
rules/handles.ts
··· 1 + import type { Checks } from "../src/types.js"; 2 + 3 + /** 4 + * Handle-based moderation checks 5 + * 6 + * Monitors user handles (usernames) for pattern matches. 7 + * Configure your checks below. 8 + */ 9 + export const HANDLE_CHECKS: Checks[] = [ 10 + // Basic example - flag potential impersonation: 11 + // { 12 + // label: "impersonation", 13 + // comment: "Potential impersonation detected in handle", 14 + // reportAcct: true, 15 + // commentAcct: false, 16 + // toLabel: false, 17 + // check: new RegExp("official.*support", "i"), 18 + // }, 19 + 20 + // Advanced example with optional fields: 21 + // { 22 + // label: "suspicious-handle", 23 + // comment: "Handle matches known spam pattern", 24 + // reportAcct: true, 25 + // commentAcct: false, 26 + // toLabel: true, 27 + // unlabel: true, // Remove label if handle changes 28 + // check: new RegExp("crypto.*airdrop", "i"), 29 + // whitelist: new RegExp("cryptography", "i"), // Don't match legitimate use 30 + // ignoredDIDs: ["did:plc:verified123"], 31 + // }, 32 + ];
+38
rules/posts.ts
··· 1 + import type { Checks } from "../src/types.js"; 2 + 3 + /** 4 + * Post content moderation checks 5 + * 6 + * Monitors post text and embedded URLs for pattern matches. 7 + * Configure your checks below. 8 + */ 9 + export const POST_CHECKS: Checks[] = [ 10 + // Basic example - label posts matching a pattern: 11 + // { 12 + // label: "spam", 13 + // comment: "Spam content detected in post", 14 + // reportAcct: false, 15 + // commentAcct: false, 16 + // toLabel: true, 17 + // check: new RegExp("buy.*followers", "i"), 18 + // }, 19 + 20 + // Advanced example - all optional fields: 21 + // { 22 + // label: "scam-link", 23 + // comment: "Suspicious link detected", 24 + // language: ["eng", "spa"], // Only check posts in these languages 25 + // reportAcct: true, // Create account report 26 + // reportPost: true, // Create post report 27 + // commentAcct: false, // Add comment to account record 28 + // toLabel: true, // Apply the label 29 + // trackOnly: false, // If true, track but don't take action 30 + // unlabel: false, // If true, remove label when no longer matching 31 + // duration: 24, // Label expires after 24 hours 32 + // check: new RegExp("crypto.*giveaway", "i"), 33 + // whitelist: new RegExp("legitimate-site\\.com", "i"), // Skip if this matches 34 + // ignoredDIDs: ["did:plc:trusted123"], // Skip these accounts 35 + // starterPacks: ["at://did:plc:xyz/app.bsky.graph.starterpack/abc"], // Only check members 36 + // knownVectors: ["telegram-scam", "discord-spam"], // Tracking tags 37 + // }, 38 + ];
+36
rules/profiles.ts
··· 1 + import type { Checks } from "../src/types.js"; 2 + 3 + /** 4 + * Profile-based moderation checks 5 + * 6 + * Monitors profile display names and descriptions for pattern matches. 7 + * Configure your checks below. 8 + */ 9 + export const PROFILE_CHECKS: Checks[] = [ 10 + // Basic example - check both displayName and description: 11 + // { 12 + // label: "spam-profile", 13 + // comment: "Spam content in profile", 14 + // displayName: true, // Check display name 15 + // description: true, // Check description 16 + // reportAcct: false, 17 + // commentAcct: false, 18 + // toLabel: true, 19 + // check: new RegExp("follow.*back.*guaranteed", "i"), 20 + // }, 21 + 22 + // Advanced example - displayName only with unlabel: 23 + // { 24 + // label: "impersonation-profile", 25 + // comment: "Profile impersonating official account", 26 + // displayName: true, 27 + // description: false, // Only check display name 28 + // reportAcct: true, 29 + // commentAcct: false, 30 + // toLabel: true, 31 + // unlabel: true, // Remove label if profile changes 32 + // check: new RegExp("official.*bluesky.*team", "i"), 33 + // whitelist: new RegExp("parody|fan", "i"), 34 + // ignoredDIDs: ["did:plc:actual-team-member"], 35 + // }, 36 + ];
+35
rules/starterPackThreshold.ts
··· 1 + import type { StarterPackThresholdConfig } from "../src/types.js"; 2 + 3 + /** 4 + * Starter pack threshold configurations 5 + * 6 + * Labels accounts that create too many starter packs within a time window. 7 + * Useful for detecting follow-farming and coordinated campaign behaviour. 8 + * Configure your checks below. 9 + */ 10 + export const STARTER_PACK_THRESHOLD_CONFIGS: StarterPackThresholdConfig[] = [ 11 + // Example - detect follow-farming: 12 + // { 13 + // threshold: 10, // Trigger after 10 starter packs 14 + // window: 7, // Within this duration 15 + // windowUnit: "days", // Options: "minutes", "hours", "days" 16 + // accountLabel: "follow-farming", 17 + // accountComment: "Account created multiple starter packs in short period", 18 + // toLabel: true, // Apply the label (default: true) 19 + // reportAcct: true, // Create account report 20 + // commentAcct: false, // Add comment to account record 21 + // allowlist: ["did:plc:trusted123"], // DIDs to exempt from this check 22 + // }, 23 + 24 + // Example - stricter threshold for rapid creation: 25 + // { 26 + // threshold: 5, 27 + // window: 1, 28 + // windowUnit: "hours", 29 + // accountLabel: "spam-starterpack", 30 + // accountComment: "Rapid starter pack creation detected", 31 + // toLabel: false, 32 + // reportAcct: true, 33 + // commentAcct: true, 34 + // }, 35 + ];
+334
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 { 6 + labelsAppliedCounter, 7 + labelsCachedCounter, 8 + unlabelsRemovedCounter, 9 + } from "./metrics.js"; 10 + import { 11 + deleteAccountLabelClaim, 12 + tryClaimAccountComment, 13 + tryClaimAccountLabel, 14 + } from "./redis.js"; 15 + 16 + const doesLabelExist = ( 17 + labels: { val: string }[] | undefined, 18 + labelVal: string, 19 + ): boolean => { 20 + if (!labels) { 21 + return false; 22 + } 23 + return labels.some((label) => label.val === labelVal); 24 + }; 25 + 26 + export const createAccountLabel = async ( 27 + did: string, 28 + label: string, 29 + comment: string, 30 + ) => { 31 + await isLoggedIn; 32 + 33 + const claimed = await tryClaimAccountLabel(did, label); 34 + if (!claimed) { 35 + logger.debug( 36 + { process: "MODERATION", did, label }, 37 + "Account label already claimed in Redis, skipping", 38 + ); 39 + labelsCachedCounter.inc({ 40 + label_type: label, 41 + target_type: "account", 42 + reason: "redis_cache", 43 + }); 44 + return; 45 + } 46 + 47 + const hasLabel = await checkAccountLabels(did, label); 48 + if (hasLabel) { 49 + logger.debug( 50 + { process: "MODERATION", did, label }, 51 + "Account already has label, skipping", 52 + ); 53 + labelsCachedCounter.inc({ 54 + label_type: label, 55 + target_type: "account", 56 + reason: "existing_label", 57 + }); 58 + return; 59 + } 60 + 61 + logger.info({ process: "MODERATION", did, label }, "Labeling account"); 62 + labelsAppliedCounter.inc({ label_type: label, target_type: "account" }); 63 + 64 + await limit(async () => { 65 + try { 66 + await agent.tools.ozone.moderation.emitEvent( 67 + { 68 + event: { 69 + $type: "tools.ozone.moderation.defs#modEventLabel", 70 + comment, 71 + createLabelVals: [label], 72 + negateLabelVals: [], 73 + }, 74 + // specify the labeled post by strongRef 75 + subject: { 76 + $type: "com.atproto.admin.defs#repoRef", 77 + did, 78 + }, 79 + // put in the rest of the metadata 80 + createdBy: agent.did ?? "", 81 + createdAt: new Date().toISOString(), 82 + modTool: { 83 + name: "skywatch/skywatch-automod", 84 + meta: { 85 + time: new Date().toISOString(), 86 + externalUrl: `https://pdsls.dev/at://${did}`, 87 + }, 88 + }, 89 + }, 90 + { 91 + encoding: "application/json", 92 + headers: { 93 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 94 + "atproto-accept-labelers": 95 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 96 + }, 97 + }, 98 + ); 99 + } catch (e) { 100 + logger.error( 101 + { process: "MODERATION", error: e }, 102 + "Failed to create account label", 103 + ); 104 + throw e; 105 + } 106 + }); 107 + }; 108 + 109 + export const createAccountComment = async ( 110 + did: string, 111 + comment: string, 112 + atURI: string, 113 + ) => { 114 + await isLoggedIn; 115 + 116 + const claimed = await tryClaimAccountComment(did, atURI); 117 + if (!claimed) { 118 + logger.debug( 119 + { process: "MODERATION", did, atURI }, 120 + "Account comment already claimed in Redis, skipping", 121 + ); 122 + return; 123 + } 124 + 125 + logger.info({ process: "MODERATION", did, atURI }, "Commenting on account"); 126 + 127 + await limit(async () => { 128 + try { 129 + await agent.tools.ozone.moderation.emitEvent( 130 + { 131 + event: { 132 + $type: "tools.ozone.moderation.defs#modEventComment", 133 + comment, 134 + }, 135 + // specify the labeled post by strongRef 136 + subject: { 137 + $type: "com.atproto.admin.defs#repoRef", 138 + did, 139 + }, 140 + // put in the rest of the metadata 141 + createdBy: agent.did ?? "", 142 + createdAt: new Date().toISOString(), 143 + modTool: { 144 + name: "skywatch/skywatch-automod", 145 + meta: { 146 + time: new Date().toISOString(), 147 + externalUrl: `https://pdsls.dev/at://${did}`, 148 + }, 149 + }, 150 + }, 151 + { 152 + encoding: "application/json", 153 + headers: { 154 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 155 + "atproto-accept-labelers": 156 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 157 + }, 158 + }, 159 + ); 160 + } catch (e) { 161 + logger.error( 162 + { process: "MODERATION", error: e }, 163 + "Failed to create account comment", 164 + ); 165 + throw e; 166 + } 167 + }); 168 + }; 169 + 170 + export const createAccountReport = async (did: string, comment: string) => { 171 + await isLoggedIn; 172 + await limit(async () => { 173 + try { 174 + await agent.tools.ozone.moderation.emitEvent( 175 + { 176 + event: { 177 + $type: "tools.ozone.moderation.defs#modEventReport", 178 + comment, 179 + reportType: "com.atproto.moderation.defs#reasonOther", 180 + }, 181 + // specify the labeled post by strongRef 182 + subject: { 183 + $type: "com.atproto.admin.defs#repoRef", 184 + did, 185 + }, 186 + // put in the rest of the metadata 187 + createdBy: agent.did ?? "", 188 + createdAt: new Date().toISOString(), 189 + modTool: { 190 + name: "skywatch/skywatch-automod", 191 + meta: { 192 + time: new Date().toISOString(), 193 + externalUrl: `https://pdsls.dev/at://${did}`, 194 + }, 195 + }, 196 + }, 197 + { 198 + encoding: "application/json", 199 + headers: { 200 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 201 + "atproto-accept-labelers": 202 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 203 + }, 204 + }, 205 + ); 206 + } catch (e) { 207 + logger.error( 208 + { process: "MODERATION", error: e }, 209 + "Failed to create account report", 210 + ); 211 + throw e; 212 + } 213 + }); 214 + }; 215 + 216 + export const negateAccountLabel = async ( 217 + did: string, 218 + label: string, 219 + comment: string, 220 + ) => { 221 + await isLoggedIn; 222 + 223 + const hasLabel = await checkAccountLabels(did, label); 224 + if (!hasLabel) { 225 + logger.debug( 226 + { process: "MODERATION", did, label }, 227 + "Account does not have label, skipping", 228 + ); 229 + return; 230 + } 231 + 232 + logger.info({ process: "MODERATION", did, label }, "Unlabeling account"); 233 + unlabelsRemovedCounter.inc({ label_type: label, target_type: "account" }); 234 + 235 + await limit(async () => { 236 + try { 237 + await agent.tools.ozone.moderation.emitEvent( 238 + { 239 + event: { 240 + $type: "tools.ozone.moderation.defs#modEventLabel", 241 + comment, 242 + createLabelVals: [], 243 + negateLabelVals: [label], 244 + }, 245 + // specify the labeled post by strongRef 246 + subject: { 247 + $type: "com.atproto.admin.defs#repoRef", 248 + did, 249 + }, 250 + // put in the rest of the metadata 251 + createdBy: agent.did ?? "", 252 + createdAt: new Date().toISOString(), 253 + modTool: { 254 + name: "skywatch/skywatch-automod", 255 + meta: { 256 + time: new Date().toISOString(), 257 + externalUrl: `https://pdsls.dev/at://${did}`, 258 + }, 259 + }, 260 + }, 261 + { 262 + encoding: "application/json", 263 + headers: { 264 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 265 + "atproto-accept-labelers": 266 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 267 + }, 268 + }, 269 + ); 270 + await deleteAccountLabelClaim(did, label); 271 + } catch (e) { 272 + logger.error( 273 + { process: "MODERATION", error: e }, 274 + "Failed to negate account label", 275 + ); 276 + throw e; 277 + } 278 + }); 279 + }; 280 + 281 + export const checkAccountLabels = async ( 282 + did: string, 283 + label: string, 284 + ): Promise<boolean> => { 285 + await isLoggedIn; 286 + return await limit(async () => { 287 + try { 288 + const response = await agent.tools.ozone.moderation.getRepo( 289 + { did }, 290 + { 291 + headers: { 292 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 293 + "atproto-accept-labelers": 294 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 295 + }, 296 + }, 297 + ); 298 + 299 + return doesLabelExist(response.data.labels, label); 300 + } catch (e) { 301 + logger.error( 302 + { process: "MODERATION", did, error: e }, 303 + "Failed to check account labels", 304 + ); 305 + return false; 306 + } 307 + }); 308 + }; 309 + 310 + export const getAllAccountLabels = async (did: string): Promise<string[]> => { 311 + await isLoggedIn; 312 + return await limit(async () => { 313 + try { 314 + const response = await agent.tools.ozone.moderation.getRepo( 315 + { did }, 316 + { 317 + headers: { 318 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 319 + "atproto-accept-labelers": 320 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 321 + }, 322 + }, 323 + ); 324 + 325 + return (response.data.labels ?? []).map((label) => label.val); 326 + } catch (e) { 327 + logger.error( 328 + { process: "MODERATION", did, error: e }, 329 + "Failed to get account labels", 330 + ); 331 + return []; 332 + } 333 + }); 334 + };
+174
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.window <= 0) { 45 + throw new Error( 46 + `Invalid account threshold config: window 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 + uri: string, 69 + postLabel: string, 70 + timestamp: number, 71 + ): Promise<void> { 72 + try { 73 + const configs = loadThresholdConfigs(); 74 + 75 + const matchingConfigs = configs.filter((config) => { 76 + const labels = normalizeLabels(config.labels); 77 + return labels.includes(postLabel); 78 + }); 79 + 80 + if (matchingConfigs.length === 0) { 81 + logger.debug( 82 + { process: "ACCOUNT_THRESHOLD", did, postLabel }, 83 + "No matching threshold configs for post label", 84 + ); 85 + return; 86 + } 87 + 88 + accountThresholdChecksCounter.inc({ post_label: postLabel }); 89 + 90 + for (const config of matchingConfigs) { 91 + const labels = normalizeLabels(config.labels); 92 + 93 + await trackPostLabelForAccount( 94 + did, 95 + postLabel, 96 + timestamp, 97 + config.window, 98 + config.windowUnit, 99 + ); 100 + 101 + const count = await getPostLabelCountInWindow( 102 + did, 103 + labels, 104 + config.window, 105 + config.windowUnit, 106 + timestamp, 107 + ); 108 + 109 + logger.debug( 110 + { 111 + process: "ACCOUNT_THRESHOLD", 112 + did, 113 + labels, 114 + count, 115 + threshold: config.threshold, 116 + window: config.window, 117 + windowUnit: config.windowUnit, 118 + }, 119 + "Checked account threshold", 120 + ); 121 + 122 + if (count >= config.threshold) { 123 + accountThresholdMetCounter.inc({ account_label: config.accountLabel }); 124 + 125 + logger.info( 126 + { 127 + process: "ACCOUNT_THRESHOLD", 128 + did, 129 + postLabel, 130 + accountLabel: config.accountLabel, 131 + count, 132 + threshold: config.threshold, 133 + }, 134 + "Account threshold met", 135 + ); 136 + 137 + const shouldLabel = config.toLabel !== false; 138 + 139 + const formattedComment = `${config.accountComment}\n\nThreshold: ${count.toString()}/${config.threshold.toString()} in ${config.window.toString()} ${config.windowUnit}\n\nPost: ${uri}\n\nPost Label: ${postLabel}`; 140 + 141 + if (shouldLabel) { 142 + await createAccountLabel(did, config.accountLabel, formattedComment); 143 + accountLabelsThresholdAppliedCounter.inc({ 144 + account_label: config.accountLabel, 145 + action: "label", 146 + }); 147 + } 148 + 149 + if (config.reportAcct) { 150 + await createAccountReport(did, formattedComment); 151 + accountLabelsThresholdAppliedCounter.inc({ 152 + account_label: config.accountLabel, 153 + action: "report", 154 + }); 155 + } 156 + 157 + if (config.commentAcct) { 158 + const atURI = `threshold-comment:${config.accountLabel}:${timestamp.toString()}`; 159 + await createAccountComment(did, formattedComment, atURI); 160 + accountLabelsThresholdAppliedCounter.inc({ 161 + account_label: config.accountLabel, 162 + action: "comment", 163 + }); 164 + } 165 + } 166 + } 167 + } catch (error) { 168 + logger.error( 169 + { process: "ACCOUNT_THRESHOLD", did, postLabel, error }, 170 + "Error checking account threshold", 171 + ); 172 + throw error; 173 + } 174 + }
+87 -15
src/agent.ts
··· 1 1 import { Agent, setGlobalDispatcher } from "undici"; 2 2 import { AtpAgent } from "@atproto/api"; 3 3 import { BSKY_HANDLE, BSKY_PASSWORD, OZONE_PDS } from "./config.js"; 4 - import { loadSession, saveSession, type SessionData } from "./session.js"; 5 4 import { updateRateLimitState } from "./limits.js"; 6 5 import { logger } from "./logger.js"; 6 + import { type SessionData, loadSession, saveSession } from "./session.js"; 7 7 8 - setGlobalDispatcher(new Agent({ connect: { timeout: 20_000 } })); 8 + setGlobalDispatcher( 9 + new Agent({ 10 + connect: { timeout: 20_000 }, 11 + keepAliveTimeout: 10_000, 12 + keepAliveMaxTimeout: 20_000, 13 + }), 14 + ); 9 15 10 16 const customFetch: typeof fetch = async (input, init) => { 11 17 const response = await fetch(input, init); ··· 21 27 limit: parseInt(limitHeader, 10), 22 28 remaining: parseInt(remainingHeader, 10), 23 29 reset: parseInt(resetHeader, 10), 24 - policy: policyHeader || undefined, 30 + policy: policyHeader ?? undefined, 25 31 }); 26 32 } 27 33 ··· 40 46 async function refreshSession(): Promise<void> { 41 47 try { 42 48 logger.info("Refreshing session tokens"); 43 - await agent.resumeSession(agent.session!); 44 - 45 - if (agent.session) { 46 - saveSession(agent.session as SessionData); 47 - scheduleSessionRefresh(); 49 + if (!agent.session) { 50 + throw new Error("No active session to refresh"); 48 51 } 49 - } catch (error) { 52 + await agent.resumeSession(agent.session); 53 + 54 + saveSession(agent.session as SessionData); 55 + scheduleSessionRefresh(); 56 + } catch (error: unknown) { 50 57 logger.error({ error }, "Failed to refresh session, will re-authenticate"); 51 58 await performLogin(); 52 59 } ··· 58 65 } 59 66 60 67 const refreshIn = JWT_LIFETIME_MS * REFRESH_AT_PERCENT; 61 - logger.debug(`Scheduling session refresh in ${(refreshIn / 1000 / 60).toFixed(1)} minutes`); 68 + logger.debug( 69 + `Scheduling session refresh in ${(refreshIn / 1000 / 60).toFixed(1)} minutes`, 70 + ); 62 71 63 72 refreshTimer = setTimeout(() => { 64 - refreshSession().catch((error) => { 73 + refreshSession().catch((error: unknown) => { 65 74 logger.error({ error }, "Scheduled session refresh failed"); 66 75 }); 67 76 }, refreshIn); ··· 90 99 } 91 100 } 92 101 102 + const MAX_LOGIN_RETRIES = 3; 103 + const RETRY_DELAY_MS = 2000; 104 + 105 + let loginPromise: Promise<void> | null = null; 106 + 107 + async function sleep(ms: number): Promise<void> { 108 + return new Promise((resolve) => setTimeout(resolve, ms)); 109 + } 110 + 93 111 async function authenticate(): Promise<boolean> { 94 112 const savedSession = loadSession(); 95 113 ··· 112 130 return performLogin(); 113 131 } 114 132 115 - export const login = authenticate; 116 - export const isLoggedIn = authenticate() 117 - .then((success) => success) 118 - .catch(() => false); 133 + async function authenticateWithRetry(): Promise<void> { 134 + // Reuse existing login attempt if one is in progress 135 + if (loginPromise) { 136 + return loginPromise; 137 + } 138 + 139 + loginPromise = (async () => { 140 + for (let attempt = 1; attempt <= MAX_LOGIN_RETRIES; attempt++) { 141 + logger.info( 142 + { attempt, maxRetries: MAX_LOGIN_RETRIES }, 143 + "Attempting login", 144 + ); 145 + 146 + const success = await authenticate(); 147 + 148 + if (success) { 149 + logger.info("Authentication successful"); 150 + return; 151 + } 152 + 153 + if (attempt < MAX_LOGIN_RETRIES) { 154 + logger.warn( 155 + { attempt, maxRetries: MAX_LOGIN_RETRIES, retryInMs: RETRY_DELAY_MS }, 156 + "Login failed, retrying", 157 + ); 158 + await sleep(RETRY_DELAY_MS); 159 + } 160 + } 161 + 162 + logger.error( 163 + { maxRetries: MAX_LOGIN_RETRIES }, 164 + "All login attempts failed, aborting", 165 + ); 166 + process.exit(1); 167 + })(); 168 + 169 + return loginPromise; 170 + } 171 + 172 + export const login = authenticateWithRetry; 173 + 174 + // Lazy getter for isLoggedIn - authentication only starts when first accessed 175 + let _isLoggedIn: Promise<boolean> | null = null; 176 + 177 + export function getIsLoggedIn(): Promise<boolean> { 178 + if (!_isLoggedIn) { 179 + _isLoggedIn = authenticateWithRetry().then(() => true); 180 + } 181 + return _isLoggedIn; 182 + } 183 + 184 + // For backward compatibility - callers can still use `await isLoggedIn` 185 + // but authentication is now lazy instead of eager 186 + export const isLoggedIn = { 187 + then<T>(onFulfilled: (value: boolean) => T | PromiseLike<T>): Promise<T> { 188 + return getIsLoggedIn().then(onFulfilled); 189 + }, 190 + };
+5 -5
src/config.ts
··· 5 5 export const OZONE_PDS = process.env.OZONE_PDS ?? ""; 6 6 export const BSKY_HANDLE = process.env.BSKY_HANDLE ?? ""; 7 7 export const BSKY_PASSWORD = process.env.BSKY_PASSWORD ?? ""; 8 - export const HOST = process.env.HOST ?? "127.0.0.1"; 9 - export const PORT = process.env.PORT ? Number(process.env.PORT) : 4100; 8 + export const HOST = process.env.HOST ?? "0.0.0.0"; 10 9 export const METRICS_PORT = process.env.METRICS_PORT 11 10 ? Number(process.env.METRICS_PORT) 12 11 : 4101; // Left this intact from the code I adapted this from ··· 17 16 "app.bsky.feed.post", 18 17 "app.bsky.actor.defs", 19 18 "app.bsky.actor.profile", 19 + "app.bsky.graph.starterpack", 20 20 ]; 21 21 export const CURSOR_UPDATE_INTERVAL = process.env.CURSOR_UPDATE_INTERVAL 22 22 ? Number(process.env.CURSOR_UPDATE_INTERVAL) 23 23 : 60000; 24 - export const LABEL_LIMIT = process.env.LABEL_LIMIT; 25 - export const LABEL_LIMIT_WAIT = process.env.LABEL_LIMIT_WAIT; 26 - export const REDIS_URL = process.env.REDIS_URL || "redis://redis:6379"; 24 + export const { LABEL_LIMIT } = process.env; 25 + export const { LABEL_LIMIT_WAIT } = process.env; 26 + export const REDIS_URL = process.env.REDIS_URL ?? "redis://redis:6379";
-4
src/constants.example.ts
··· 1 - /** 2 - * Global allowlist of DIDs that should never be moderated 3 - */ 4 - export const GLOBAL_ALLOW: string[] = [];
-25
src/developing_checks.md
··· 1 - # How to build checks for skywatch-automod 2 - 3 - ## Introduction 4 - 5 - Constants.ts defines three types of types of checks: `HANDLE_CHECKS`, `POST_CHECKS`, and `PROFILE_CHECKS`. 6 - 7 - For each check, users need to define a set of regular expressions that will be used to match against the content of the post, handle, or profile. A maximal example of a check is as follows: 8 - 9 - ```typescript 10 - export const HANDLE_CHECKS: Checks[] = [ 11 - { 12 - label: "example", 13 - comment: "Example found in handle", 14 - description: true, // Optional, only used in handle checks 15 - displayName: true, // Optional, only used in handle checks 16 - reportOnly: false, // it true, the check will only report the content against the account, not label. 17 - commentOnly: false, // Poorly named, if true, will generate an account level comment from flagged posts, rather than a report. Intended for use when reportOnly is false, and on posts only where the flag may generate a high volume of reports.. 18 - check: new RegExp("example", "i"), // Regular expression to match against the content 19 - whitelist: new RegExp("example.com", "i"), // Optional, regular expression to whitelist content 20 - ignoredDIDs: ["did:plc:example"], // Optional, array of DIDs to ignore if they match the check. Useful for folks who reclaim words. 21 - }, 22 - ]; 23 - ``` 24 - 25 - In the above example, any handle that contains the word "example" will be labeled with the label "example" unless the handle is `example.com` or the handle belongs to the user with the DID `did:plc:example`.
+3 -3
src/limits.ts
··· 1 1 import { pRateLimit } from "p-ratelimit"; 2 - import { logger } from "./logger.js"; 3 2 import { Counter, Gauge, Histogram } from "prom-client"; 3 + import { logger } from "./logger.js"; 4 4 5 5 interface RateLimitState { 6 6 limit: number; ··· 76 76 remaining: rateLimitState.remaining, 77 77 resetIn: rateLimitState.reset - Math.floor(Date.now() / 1000), 78 78 }, 79 - "Rate limit state updated" 79 + "Rate limit state updated", 80 80 ); 81 81 } 82 82 ··· 93 93 94 94 if (delayMs > 0) { 95 95 logger.warn( 96 - `Rate limit critical (${state.remaining}/${state.limit} remaining). Waiting ${delaySeconds}s until reset...` 96 + `Rate limit critical (${state.remaining.toString()}/${state.limit.toString()} remaining). Waiting ${delaySeconds.toString()}s until reset...`, 97 97 ); 98 98 99 99 const waitStart = Date.now();
+1 -1
src/logger.ts
··· 1 1 import pino from "pino"; 2 2 3 3 export const logger = pino({ 4 - level: process.env.LOG_LEVEL || "info", 4 + level: process.env.LOG_LEVEL ?? "info", 5 5 formatters: { 6 6 level: (label) => { 7 7 return { level: label };
+115 -94
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, ··· 18 19 import { checkFacetSpam } from "./rules/facets/facets.js"; 19 20 import { checkHandle } from "./rules/handles/checkHandles.js"; 20 21 import { checkPosts } from "./rules/posts/checkPosts.js"; 21 - import { 22 - checkDescription, 23 - checkDisplayName, 24 - } from "./rules/profiles/checkProfiles.js"; 25 - import { Handle, LinkFeature, Post } from "./types.js"; 22 + import { checkProfile } from "./rules/profiles/checkProfiles.js"; 23 + import { checkStarterPackThreshold } from "./starterPackThreshold.js"; 24 + import type { Post } from "./types.js"; 26 25 27 26 let cursor = 0; 28 27 let cursorUpdateInterval: NodeJS.Timeout; ··· 55 54 const jetstream = new Jetstream({ 56 55 wantedCollections: WANTED_COLLECTION, 57 56 endpoint: FIREHOSE_URL, 58 - cursor: cursor, 57 + cursor, 59 58 }); 60 59 61 60 jetstream.on("open", () => { ··· 111 110 "app.bsky.feed.post", 112 111 (event: CommitCreateEvent<"app.bsky.feed.post">) => { 113 112 const atURI = `at://${event.did}/app.bsky.feed.post/${event.commit.rkey}`; 114 - const hasEmbed = event.commit.record.hasOwnProperty("embed"); 115 - const hasFacets = event.commit.record.hasOwnProperty("facets"); 116 - const hasText = event.commit.record.hasOwnProperty("text"); 113 + const hasEmbed = Object.prototype.hasOwnProperty.call( 114 + event.commit.record, 115 + "embed", 116 + ); 117 + const hasFacets = Object.prototype.hasOwnProperty.call( 118 + event.commit.record, 119 + "facets", 120 + ); 121 + const hasText = Object.prototype.hasOwnProperty.call( 122 + event.commit.record, 123 + "text", 124 + ); 117 125 118 126 const tasks: Promise<void>[] = []; 119 127 ··· 135 143 136 144 // Check account age for quote posts 137 145 if (hasEmbed) { 138 - const embed = event.commit.record.embed; 146 + const { embed } = event.commit.record; 139 147 if ( 140 148 embed && 149 + typeof embed === "object" && 150 + "$type" in embed && 141 151 (embed.$type === "app.bsky.embed.record" || 142 152 embed.$type === "app.bsky.embed.recordWithMedia") 143 153 ) { 144 154 const record = 145 155 embed.$type === "app.bsky.embed.record" 146 - ? embed.record 147 - : embed.record.record; 148 - if (record && record.uri) { 156 + ? (embed as { record: { uri?: string } }).record 157 + : (embed as { record: { record: { uri?: string } } }).record.record; 158 + if (record.uri && typeof record.uri === "string") { 149 159 const quotedPostURI = record.uri; 150 160 const quotedDid = quotedPostURI.split("/")[2]; // Extract DID from at://did/... 151 - 152 - tasks.push( 153 - checkAccountAge({ 154 - actorDid: event.did, 155 - quotedDid, 156 - quotedPostURI, 157 - atURI, 158 - time: event.time_us, 159 - }), 160 - ); 161 + if (quotedDid) { 162 + tasks.push( 163 + checkAccountAge({ 164 + actorDid: event.did, 165 + quotedDid, 166 + quotedPostURI, 167 + atURI, 168 + time: event.time_us, 169 + }), 170 + ); 171 + } 161 172 } 162 173 } 163 174 } ··· 165 176 // Check if the record has facets 166 177 if (hasFacets) { 167 178 // Check for facet spam (hidden mentions with duplicate byte positions) 168 - tasks.push( 169 - checkFacetSpam( 170 - event.did, 171 - event.time_us, 172 - atURI, 173 - event.commit.record.facets!, 174 - ), 175 - ); 179 + const facets = event.commit.record.facets ?? null; 180 + tasks.push(checkFacetSpam(event.did, event.time_us, atURI, facets)); 176 181 177 - const hasLinkType = event.commit.record.facets!.some((facet) => 182 + const hasLinkType = facets?.some((facet) => 178 183 facet.features.some( 179 184 (feature) => feature.$type === "app.bsky.richtext.facet#link", 180 185 ), 181 186 ); 182 187 183 - if (hasLinkType) { 184 - const urls = event.commit.record 185 - .facets!.flatMap((facet) => 186 - facet.features.filter( 187 - (feature) => feature.$type === "app.bsky.richtext.facet#link", 188 - ), 189 - ) 190 - .map((feature: LinkFeature) => feature.uri); 188 + if (hasLinkType && facets) { 189 + for (const facet of facets) { 190 + const linkFeatures = facet.features.filter( 191 + (feature) => feature.$type === "app.bsky.richtext.facet#link", 192 + ); 191 193 192 - urls.forEach((url) => { 193 - const posts: Post[] = [ 194 - { 195 - did: event.did, 196 - time: event.time_us, 197 - rkey: event.commit.rkey, 198 - atURI: atURI, 199 - text: url, 200 - cid: event.commit.cid, 201 - }, 202 - ]; 203 - tasks.push(checkPosts(posts)); 204 - }); 194 + for (const feature of linkFeatures) { 195 + if ("uri" in feature && typeof feature.uri === "string") { 196 + const posts: Post[] = [ 197 + { 198 + did: event.did, 199 + time: event.time_us, 200 + rkey: event.commit.rkey, 201 + atURI, 202 + text: feature.uri, 203 + cid: event.commit.cid, 204 + }, 205 + ]; 206 + tasks.push(checkPosts(posts)); 207 + } 208 + } 209 + } 205 210 } 206 211 } 207 212 ··· 211 216 did: event.did, 212 217 time: event.time_us, 213 218 rkey: event.commit.rkey, 214 - atURI: atURI, 219 + atURI, 215 220 text: event.commit.record.text, 216 221 cid: event.commit.cid, 217 222 }, ··· 220 225 } 221 226 222 227 if (hasEmbed) { 223 - const embed = event.commit.record.embed; 224 - if (embed && embed.$type === "app.bsky.embed.external") { 228 + const { embed } = event.commit.record; 229 + if ( 230 + embed && 231 + typeof embed === "object" && 232 + "$type" in embed && 233 + embed.$type === "app.bsky.embed.external" 234 + ) { 235 + const { external } = embed as { external: { uri: string } }; 225 236 const posts: Post[] = [ 226 237 { 227 238 did: event.did, 228 239 time: event.time_us, 229 240 rkey: event.commit.rkey, 230 - atURI: atURI, 231 - text: embed.external.uri, 241 + atURI, 242 + text: external.uri, 232 243 cid: event.commit.cid, 233 244 }, 234 245 ]; 235 246 tasks.push(checkPosts(posts)); 236 247 } 237 248 238 - if (embed && embed.$type === "app.bsky.embed.recordWithMedia") { 239 - if (embed.media.$type === "app.bsky.embed.external") { 249 + if ( 250 + embed && 251 + typeof embed === "object" && 252 + "$type" in embed && 253 + embed.$type === "app.bsky.embed.recordWithMedia" 254 + ) { 255 + const { media } = embed as { 256 + media: { $type: string; external?: { uri: string } }; 257 + }; 258 + if (media.$type === "app.bsky.embed.external" && media.external) { 240 259 const posts: Post[] = [ 241 260 { 242 261 did: event.did, 243 262 time: event.time_us, 244 263 rkey: event.commit.rkey, 245 - atURI: atURI, 246 - text: embed.media.external.uri, 264 + atURI, 265 + text: media.external.uri, 247 266 cid: event.commit.cid, 248 267 }, 249 268 ]; ··· 257 276 // Check for profile updates 258 277 jetstream.onUpdate( 259 278 "app.bsky.actor.profile", 279 + // eslint-disable-next-line @typescript-eslint/no-misused-promises, @typescript-eslint/require-await 260 280 async (event: CommitUpdateEvent<"app.bsky.actor.profile">) => { 261 281 try { 262 282 if (event.commit.record.displayName || event.commit.record.description) { 263 - checkDescription( 264 - event.did, 265 - event.time_us, 266 - event.commit.record.displayName as string, 267 - event.commit.record.description as string, 268 - ); 269 - checkDisplayName( 283 + void checkProfile( 270 284 event.did, 271 285 event.time_us, 272 286 event.commit.record.displayName as string, ··· 283 297 284 298 jetstream.onCreate( 285 299 "app.bsky.actor.profile", 300 + // eslint-disable-next-line @typescript-eslint/no-misused-promises, @typescript-eslint/require-await 286 301 async (event: CommitCreateEvent<"app.bsky.actor.profile">) => { 287 302 try { 288 303 if (event.commit.record.displayName || event.commit.record.description) { 289 - checkDescription( 290 - event.did, 291 - event.time_us, 292 - event.commit.record.displayName as string, 293 - event.commit.record.description as string, 294 - ); 295 - checkDisplayName( 304 + void checkProfile( 296 305 event.did, 297 306 event.time_us, 298 307 event.commit.record.displayName as string, ··· 306 315 ); 307 316 308 317 // Check for handle updates 309 - jetstream.on("identity", async (event: IdentityEvent) => { 310 - if (event.identity.handle) { 311 - checkHandle(event.identity.did, event.identity.handle, event.time_us); 312 - } 313 - }); 318 + jetstream.on( 319 + "identity", 320 + // eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-misused-promises 321 + async (event: IdentityEvent) => { 322 + if (event.identity.handle) { 323 + // checkHandle is sync but calls async functions with void 324 + checkHandle(event.identity.did, event.identity.handle, event.time_us); 325 + } 326 + }, 327 + ); 314 328 315 - const metricsServer = startMetricsServer(METRICS_PORT); 329 + // Check for starter pack creation 330 + jetstream.onCreate( 331 + "app.bsky.graph.starterpack", 332 + (event: CommitCreateEvent<"app.bsky.graph.starterpack">) => { 333 + const starterPackUri = `at://${event.did}/app.bsky.graph.starterpack/${event.commit.rkey}`; 334 + void checkStarterPackThreshold(event.did, starterPackUri, event.time_us); 335 + }, 336 + ); 316 337 317 - /* labelerServer.app.listen({ port: PORT, host: HOST }, (error, address) => { 318 - if (error) { 319 - logger.error("Error starting server: %s", error); 320 - } else { 321 - logger.info(`Labeler server listening on ${address}`); 322 - } 323 - });*/ 338 + const metricsServer = startMetricsServer(METRICS_PORT); 324 339 325 340 logger.info({ process: "MAIN" }, "Connecting to Redis"); 326 341 await connectRedis(); 327 342 343 + logger.info({ process: "MAIN" }, "Authenticating with Bluesky"); 344 + await login(); 345 + logger.info({ process: "MAIN" }, "Authentication complete, starting Jetstream"); 346 + 328 347 jetstream.start(); 329 348 330 349 async function shutdown() { 331 350 try { 332 351 logger.info({ process: "MAIN" }, "Shutting down gracefully"); 333 - fs.writeFileSync("cursor.txt", jetstream.cursor!.toString(), "utf8"); 352 + if (jetstream.cursor !== undefined) { 353 + fs.writeFileSync("cursor.txt", jetstream.cursor.toString(), "utf8"); 354 + } 334 355 jetstream.close(); 335 356 metricsServer.close(); 336 357 await disconnectRedis(); ··· 340 361 } 341 362 } 342 363 343 - process.on("SIGINT", shutdown); 344 - process.on("SIGTERM", shutdown); 364 + process.on("SIGINT", () => void shutdown()); 365 + process.on("SIGTERM", () => void shutdown());
+74 -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 unlabelsRemovedCounter: Counter = new Counter({ 24 + name: "skywatch_labels_removed_total", 25 + help: "Total number of labels removed due to criteria no longer matching", 26 + labelNames: ["label_type", "target_type"], 27 + registers: [register], 28 + }); 29 + 30 + export const accountLabelsThresholdAppliedCounter = new Counter({ 31 + name: "skywatch_account_labels_threshold_applied_total", 32 + help: "Total number of account actions applied due to threshold", 33 + labelNames: ["account_label", "action"], 34 + registers: [register], 35 + }); 36 + 37 + export const accountThresholdChecksCounter = new Counter({ 38 + name: "skywatch_account_threshold_checks_total", 39 + help: "Total number of account threshold checks performed", 40 + labelNames: ["post_label"], 41 + registers: [register], 42 + }); 43 + 44 + export const accountThresholdMetCounter = new Counter({ 45 + name: "skywatch_account_threshold_met_total", 46 + help: "Total number of times account thresholds were met", 47 + labelNames: ["account_label"], 48 + registers: [register], 49 + }); 50 + 51 + export const starterPackThresholdChecksCounter = new Counter({ 52 + name: "skywatch_starter_pack_threshold_checks_total", 53 + help: "Total number of starter pack threshold checks performed", 54 + registers: [register], 55 + }); 56 + 57 + export const starterPackThresholdMetCounter = new Counter({ 58 + name: "skywatch_starter_pack_threshold_met_total", 59 + help: "Total number of times starter pack thresholds were met", 60 + labelNames: ["account_label"], 61 + registers: [register], 62 + }); 63 + 64 + export const starterPackLabelsThresholdAppliedCounter = new Counter({ 65 + name: "skywatch_starter_pack_labels_threshold_applied_total", 66 + help: "Total number of account actions applied due to starter pack threshold", 67 + labelNames: ["account_label", "action"], 68 + registers: [register], 69 + }); 70 + 71 + export const moderationActionsFailedCounter = new Counter({ 72 + name: "skywatch_moderation_actions_failed_total", 73 + help: "Total number of moderation actions that failed", 74 + labelNames: ["action", "target_type"], 75 + registers: [register], 76 + }); 77 + 8 78 const app = express(); 9 79 10 80 app.get("/metrics", (req, res) => { ··· 23 93 }); 24 94 }); 25 95 26 - export const startMetricsServer = (port: number, host = "127.0.0.1") => { 27 - return app.listen(port, host, () => { 96 + export const startMetricsServer = (port: number) => { 97 + return app.listen(port, HOST, () => { 28 98 logger.info( 29 - { process: "METRICS", host, port }, 99 + { process: "METRICS", host: HOST, port }, 30 100 "Metrics server is listening", 31 101 ); 32 102 });
+55 -212
src/moderation.ts
··· 2 2 import { MOD_DID } from "./config.js"; 3 3 import { limit } from "./limits.js"; 4 4 import { logger } from "./logger.js"; 5 - import { 6 - tryClaimAccountComment, 7 - tryClaimAccountLabel, 8 - tryClaimPostLabel, 9 - } from "./redis.js"; 5 + import { labelsAppliedCounter, labelsCachedCounter } from "./metrics.js"; 6 + import { tryClaimPostLabel } from "./redis.js"; 10 7 11 8 const doesLabelExist = ( 12 9 labels: { val: string }[] | undefined, ··· 25 22 comment: string, 26 23 duration: number | undefined, 27 24 did?: string, 25 + time?: number, 28 26 ) => { 29 27 await isLoggedIn; 30 28 ··· 34 32 { process: "MODERATION", uri, label }, 35 33 "Post label already claimed in Redis, skipping", 36 34 ); 35 + labelsCachedCounter.inc({ 36 + label_type: label, 37 + target_type: "post", 38 + reason: "redis_cache", 39 + }); 37 40 return; 38 41 } 39 42 ··· 43 46 { process: "MODERATION", uri, label }, 44 47 "Post already has label, skipping", 45 48 ); 49 + labelsCachedCounter.inc({ 50 + label_type: label, 51 + target_type: "post", 52 + reason: "existing_label", 53 + }); 46 54 return; 47 55 } 48 56 ··· 50 58 { process: "MODERATION", label, did, atURI: uri }, 51 59 "Labeling post", 52 60 ); 61 + labelsAppliedCounter.inc({ label_type: label, target_type: "post" }); 53 62 54 63 await limit(async () => { 55 64 try { ··· 61 70 durationInHours?: number; 62 71 } = { 63 72 $type: "tools.ozone.moderation.defs#modEventLabel", 64 - comment: comment, 73 + comment, 65 74 createLabelVals: [label], 66 75 negateLabelVals: [], 67 76 }; ··· 72 81 73 82 await agent.tools.ozone.moderation.emitEvent( 74 83 { 75 - event: event, 84 + event, 76 85 // specify the labeled post by strongRef 77 86 subject: { 78 87 $type: "com.atproto.repo.strongRef", 79 - uri: uri, 80 - cid: cid, 88 + uri, 89 + cid, 81 90 }, 82 91 // put in the rest of the metadata 83 - createdBy: `${agent.did}`, 92 + createdBy: agent.did ?? "", 84 93 createdAt: new Date().toISOString(), 85 94 modTool: { 86 95 name: "skywatch/skywatch-automod", 96 + meta: { 97 + time: new Date().toISOString(), 98 + externalUrl: `https://pdsls.dev/${uri}`, 99 + }, 87 100 }, 88 101 }, 89 102 { 90 103 encoding: "application/json", 91 104 headers: { 92 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 105 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 93 106 "atproto-accept-labelers": 94 107 "did:plc:ar7c4by46qjdydhdevvrndac;redact", 95 108 }, 96 109 }, 97 110 ); 98 - } catch (e) { 99 - logger.error( 100 - { process: "MODERATION", error: e }, 101 - "Failed to create post label", 102 - ); 103 - } 104 - }); 105 - }; 106 111 107 - export const createAccountLabel = async ( 108 - did: string, 109 - label: string, 110 - comment: string, 111 - ) => { 112 - await isLoggedIn; 113 - 114 - const claimed = await tryClaimAccountLabel(did, label); 115 - if (!claimed) { 116 - logger.debug( 117 - { process: "MODERATION", did, label }, 118 - "Account label already claimed in Redis, skipping", 119 - ); 120 - return; 121 - } 122 - 123 - const hasLabel = await checkAccountLabels(did, label); 124 - if (hasLabel) { 125 - logger.debug( 126 - { process: "MODERATION", did, label }, 127 - "Account already has label, skipping", 128 - ); 129 - return; 130 - } 131 - 132 - logger.info({ process: "MODERATION", did, label }, "Labeling account"); 133 - 134 - await limit(async () => { 135 - try { 136 - await agent.tools.ozone.moderation.emitEvent( 137 - { 138 - event: { 139 - $type: "tools.ozone.moderation.defs#modEventLabel", 140 - comment: comment, 141 - createLabelVals: [label], 142 - negateLabelVals: [], 143 - }, 144 - // specify the labeled post by strongRef 145 - subject: { 146 - $type: "com.atproto.admin.defs#repoRef", 147 - did: did, 148 - }, 149 - // put in the rest of the metadata 150 - createdBy: `${agent.did}`, 151 - createdAt: new Date().toISOString(), 152 - modTool: { 153 - name: "skywatch/skywatch-automod", 154 - }, 155 - }, 156 - { 157 - encoding: "application/json", 158 - headers: { 159 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 160 - "atproto-accept-labelers": 161 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 162 - }, 163 - }, 164 - ); 112 + if (did && time) { 113 + try { 114 + // Dynamic import to avoid circular dependency: 115 + // accountThreshold imports from moderation (createAccountLabel, etc.) 116 + // moderation imports from accountThreshold (checkAccountThreshold) 117 + const { checkAccountThreshold } = await import( 118 + "./accountThreshold.js" 119 + ); 120 + await checkAccountThreshold(did, uri, label, time); 121 + } catch (error) { 122 + logger.error( 123 + { process: "ACCOUNT_THRESHOLD", did, label, error }, 124 + "Failed to check account threshold", 125 + ); 126 + } 127 + } 165 128 } catch (e) { 166 129 logger.error( 167 130 { process: "MODERATION", error: e }, 168 - "Failed to create account label", 131 + "Failed to create post label", 169 132 ); 133 + throw e; 170 134 } 171 135 }); 172 136 }; ··· 179 143 await isLoggedIn; 180 144 await limit(async () => { 181 145 try { 182 - return agent.tools.ozone.moderation.emitEvent( 146 + return await agent.tools.ozone.moderation.emitEvent( 183 147 { 184 148 event: { 185 149 $type: "tools.ozone.moderation.defs#modEventReport", 186 - comment: comment, 150 + comment, 187 151 reportType: "com.atproto.moderation.defs#reasonOther", 188 152 }, 189 153 // specify the labeled post by strongRef 190 154 subject: { 191 155 $type: "com.atproto.repo.strongRef", 192 - uri: uri, 193 - cid: cid, 156 + uri, 157 + cid, 194 158 }, 195 159 // put in the rest of the metadata 196 - createdBy: `${agent.did}`, 160 + createdBy: agent.did ?? "", 197 161 createdAt: new Date().toISOString(), 198 162 modTool: { 199 163 name: "skywatch/skywatch-automod", 164 + meta: { 165 + time: new Date().toISOString(), 166 + externalUrl: `https://pdsls.dev/${uri}`, 167 + }, 200 168 }, 201 169 }, 202 170 { 203 171 encoding: "application/json", 204 172 headers: { 205 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 173 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 206 174 "atproto-accept-labelers": 207 175 "did:plc:ar7c4by46qjdydhdevvrndac;redact", 208 176 }, ··· 211 179 } catch (e) { 212 180 logger.error( 213 181 { process: "MODERATION", error: e }, 214 - "Failed to create post label", 182 + "Failed to create post report", 215 183 ); 216 - } 217 - }); 218 - }; 219 - 220 - export const createAccountComment = async ( 221 - did: string, 222 - comment: string, 223 - atURI: string, 224 - ) => { 225 - await isLoggedIn; 226 - 227 - const claimed = await tryClaimAccountComment(did, atURI); 228 - if (!claimed) { 229 - logger.debug( 230 - { process: "MODERATION", did, atURI }, 231 - "Account comment already claimed in Redis, skipping", 232 - ); 233 - return; 234 - } 235 - 236 - logger.info({ process: "MODERATION", did, atURI }, "Commenting on account"); 237 - 238 - await limit(async () => { 239 - try { 240 - await agent.tools.ozone.moderation.emitEvent( 241 - { 242 - event: { 243 - $type: "tools.ozone.moderation.defs#modEventComment", 244 - comment: comment, 245 - }, 246 - // specify the labeled post by strongRef 247 - subject: { 248 - $type: "com.atproto.admin.defs#repoRef", 249 - did: did, 250 - }, 251 - // put in the rest of the metadata 252 - createdBy: `${agent.did}`, 253 - createdAt: new Date().toISOString(), 254 - modTool: { 255 - name: "skywatch/skywatch-automod", 256 - }, 257 - }, 258 - { 259 - encoding: "application/json", 260 - headers: { 261 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 262 - "atproto-accept-labelers": 263 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 264 - }, 265 - }, 266 - ); 267 - } catch (e) { 268 - logger.error( 269 - { process: "MODERATION", error: e }, 270 - "Failed to create account comment", 271 - ); 272 - } 273 - }); 274 - }; 275 - 276 - export const createAccountReport = async (did: string, comment: string) => { 277 - await isLoggedIn; 278 - await limit(async () => { 279 - try { 280 - await agent.tools.ozone.moderation.emitEvent( 281 - { 282 - event: { 283 - $type: "tools.ozone.moderation.defs#modEventReport", 284 - comment: comment, 285 - reportType: "com.atproto.moderation.defs#reasonOther", 286 - }, 287 - // specify the labeled post by strongRef 288 - subject: { 289 - $type: "com.atproto.admin.defs#repoRef", 290 - did: did, 291 - }, 292 - // put in the rest of the metadata 293 - createdBy: `${agent.did}`, 294 - createdAt: new Date().toISOString(), 295 - modTool: { 296 - name: "skywatch/skywatch-automod", 297 - }, 298 - }, 299 - { 300 - encoding: "application/json", 301 - headers: { 302 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 303 - "atproto-accept-labelers": 304 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 305 - }, 306 - }, 307 - ); 308 - } catch (e) { 309 - logger.error( 310 - { process: "MODERATION", error: e }, 311 - "Failed to create account report", 312 - ); 313 - } 314 - }); 315 - }; 316 - 317 - export const checkAccountLabels = async ( 318 - did: string, 319 - label: string, 320 - ): Promise<boolean> => { 321 - await isLoggedIn; 322 - return await limit(async () => { 323 - try { 324 - const response = await agent.tools.ozone.moderation.getRepo( 325 - { did }, 326 - { 327 - headers: { 328 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 329 - "atproto-accept-labelers": 330 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 331 - }, 332 - }, 333 - ); 334 - 335 - return doesLabelExist(response.data.labels, label); 336 - } catch (e) { 337 - logger.error( 338 - { process: "MODERATION", did, error: e }, 339 - "Failed to check account labels", 340 - ); 341 - return false; 184 + throw e; 342 185 } 343 186 }); 344 187 }; ··· 354 197 { uri }, 355 198 { 356 199 headers: { 357 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 200 + "atproto-proxy": `${MOD_DID}#atproto_labeler`, 358 201 "atproto-accept-labelers": 359 202 "did:plc:ar7c4by46qjdydhdevvrndac;redact", 360 203 },
+181
src/redis.ts
··· 1 1 import { createClient } from "redis"; 2 2 import { REDIS_URL } from "./config.js"; 3 3 import { logger } from "./logger.js"; 4 + import type { WindowUnit } from "./types.js"; 4 5 5 6 export const redisClient = createClient({ 6 7 url: REDIS_URL, ··· 88 89 } 89 90 } 90 91 92 + export async function deleteAccountLabelClaim( 93 + did: string, 94 + label: string, 95 + ): Promise<void> { 96 + try { 97 + const key = getAccountLabelCacheKey(did, label); 98 + await redisClient.del(key); 99 + logger.debug( 100 + { did, label }, 101 + "Deleted account label claim from Redis cache", 102 + ); 103 + } catch (err) { 104 + logger.warn( 105 + { err, did, label }, 106 + "Error deleting account label claim from Redis", 107 + ); 108 + } 109 + } 110 + 91 111 export async function tryClaimAccountComment( 92 112 did: string, 93 113 atURI: string, ··· 107 127 return true; 108 128 } 109 129 } 130 + 131 + function windowToMicroseconds(window: number, unit: WindowUnit): number { 132 + const multipliers: Record<WindowUnit, number> = { 133 + minutes: 60 * 1000000, 134 + hours: 60 * 60 * 1000000, 135 + days: 24 * 60 * 60 * 1000000, 136 + }; 137 + return window * multipliers[unit]; 138 + } 139 + 140 + function windowToSeconds(window: number, unit: WindowUnit): number { 141 + const multipliers: Record<WindowUnit, number> = { 142 + minutes: 60, 143 + hours: 60 * 60, 144 + days: 24 * 60 * 60, 145 + }; 146 + return window * multipliers[unit]; 147 + } 148 + 149 + function getPostLabelTrackingKey( 150 + did: string, 151 + label: string, 152 + window: number, 153 + unit: WindowUnit, 154 + ): string { 155 + return `account-post-labels:${did}:${label}:${window.toString()}${unit}`; 156 + } 157 + 158 + function getStarterPackTrackingKey( 159 + did: string, 160 + window: number, 161 + unit: WindowUnit, 162 + ): string { 163 + return `starterpack:threshold:${did}:${window.toString()}${unit}`; 164 + } 165 + 166 + export async function trackStarterPackForAccount( 167 + did: string, 168 + starterPackUri: string, 169 + timestamp: number, 170 + window: number, 171 + windowUnit: WindowUnit, 172 + ): Promise<void> { 173 + try { 174 + const key = getStarterPackTrackingKey(did, window, windowUnit); 175 + const windowStartTime = timestamp - windowToMicroseconds(window, windowUnit); 176 + 177 + await redisClient.zRemRangeByScore(key, "-inf", windowStartTime); 178 + 179 + await redisClient.zAdd(key, { 180 + score: timestamp, 181 + value: starterPackUri, 182 + }); 183 + 184 + const ttlSeconds = windowToSeconds(window, windowUnit) + 60 * 60; 185 + await redisClient.expire(key, ttlSeconds); 186 + 187 + logger.debug( 188 + { did, starterPackUri, timestamp, window, windowUnit }, 189 + "Tracked starter pack for account", 190 + ); 191 + } catch (err) { 192 + logger.error( 193 + { err, did, starterPackUri, timestamp, window, windowUnit }, 194 + "Error tracking starter pack in Redis", 195 + ); 196 + throw err; 197 + } 198 + } 199 + 200 + export async function getStarterPackCountInWindow( 201 + did: string, 202 + window: number, 203 + windowUnit: WindowUnit, 204 + currentTime: number, 205 + ): Promise<number> { 206 + try { 207 + const key = getStarterPackTrackingKey(did, window, windowUnit); 208 + const windowStartTime = currentTime - windowToMicroseconds(window, windowUnit); 209 + const count = await redisClient.zCount(key, windowStartTime, "+inf"); 210 + 211 + logger.debug( 212 + { did, window, windowUnit, count }, 213 + "Retrieved starter pack count in window", 214 + ); 215 + 216 + return count; 217 + } catch (err) { 218 + logger.error( 219 + { err, did, window, windowUnit }, 220 + "Error getting starter pack count from Redis", 221 + ); 222 + throw err; 223 + } 224 + } 225 + 226 + export async function trackPostLabelForAccount( 227 + did: string, 228 + label: string, 229 + timestamp: number, 230 + window: number, 231 + windowUnit: WindowUnit, 232 + ): Promise<void> { 233 + try { 234 + const key = getPostLabelTrackingKey(did, label, window, windowUnit); 235 + const windowStartTime = timestamp - windowToMicroseconds(window, windowUnit); 236 + 237 + await redisClient.zRemRangeByScore(key, "-inf", windowStartTime); 238 + 239 + await redisClient.zAdd(key, { 240 + score: timestamp, 241 + value: timestamp.toString(), 242 + }); 243 + 244 + const ttlSeconds = windowToSeconds(window, windowUnit) + 60 * 60; 245 + await redisClient.expire(key, ttlSeconds); 246 + 247 + logger.debug( 248 + { did, label, timestamp, window, windowUnit }, 249 + "Tracked post label for account", 250 + ); 251 + } catch (err) { 252 + logger.error( 253 + { err, did, label, timestamp, window, windowUnit }, 254 + "Error tracking post label in Redis", 255 + ); 256 + throw err; 257 + } 258 + } 259 + 260 + export async function getPostLabelCountInWindow( 261 + did: string, 262 + labels: string[], 263 + window: number, 264 + windowUnit: WindowUnit, 265 + currentTime: number, 266 + ): Promise<number> { 267 + try { 268 + const windowStartTime = currentTime - windowToMicroseconds(window, windowUnit); 269 + let totalCount = 0; 270 + 271 + for (const label of labels) { 272 + const key = getPostLabelTrackingKey(did, label, window, windowUnit); 273 + const count = await redisClient.zCount(key, windowStartTime, "+inf"); 274 + totalCount += count; 275 + } 276 + 277 + logger.debug( 278 + { did, labels, window, windowUnit, totalCount }, 279 + "Retrieved post label count in window", 280 + ); 281 + 282 + return totalCount; 283 + } catch (err) { 284 + logger.error( 285 + { err, did, labels, window, windowUnit }, 286 + "Error getting post label count from Redis", 287 + ); 288 + throw err; 289 + } 290 + }
+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} \n\nAccount created within monitored range. \n\nInteraction: ${context.atURI}`, 241 244 ); 242 245 243 246 // Only apply one label per interaction
-78
src/rules/account/ageConstants.ts
··· 1 - import { AccountAgeCheck } from "../../types.js"; 2 - 3 - /** 4 - * Account age monitoring configurations 5 - * 6 - * Each configuration monitors replies and/or quote posts to specified DIDs or posts 7 - * and labels accounts that were created within a specific time window. 8 - * 9 - * Example use cases: 10 - * - Monitor replies/quotes to high-profile accounts during harassment campaigns 11 - * - Flag sock puppet accounts created to participate in coordinated harassment 12 - * - Detect brigading on specific controversial posts 13 - */ 14 - export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [ 15 - { 16 - monitoredDIDs: [ 17 - "did:plc:b2ecyhl2z2tro25ltrcyiytd", // DHS 18 - "did:plc:iw2wxg46hm4ezguswhwej6t6", // actual whitehouse 19 - "did:plc:fhnl65q3us5evynqc4f2qak6", // HHS 20 - "did:plc:wrz4athzuf2u5js2ltrktiqk", // DOL 21 - "did:plc:3mqcgvyu4exg3pkx4bkfppih", // VA 22 - "did:plc:pqn2sfkx5klnytms4uwqt5wo", // Treasurer 23 - "did:plc:v4kvjftk6kr5ci3zqmfawwpb", // State 24 - "did:plc:rlymk4d5qmq5udjdznojmvel", // Interior 25 - "did:plc:f7a5etif42x56oyrbzuek6so", // USDA 26 - "did:plc:7kusimwlnf4v5jo757jvkeaj", // DOE 27 - "did:plc:jgq3vko3g6zg72457bda2snd", // SBA 28 - "did:plc:h2iujdjlry6fpniofjtiqqmb", // DoD 29 - "did:plc:jwncvpznkwe4luzvdroes45b", // CBP 30 - "did:plc:azfxx5mdxcuoc2bkuqizs4kd", 31 - "did:plc:vostkism5vbzjqfcmllmd6gz", 32 - "did:plc:etthv4ychwti4b6i2hhe76c2", 33 - "did:plc:swf7zddjselkcpbn6iw323gy", 34 - "did:plc:h3zq65wioggctyxpovfpi6ec", 35 - "did:plc:nofnc2xpdihktxkufkq7tn3w", 36 - "did:plc:quezcqejcqw6g5t3om7wldns", 37 - "did:plc:vlvqht2v3nsc4k7xaho6bjaf", 38 - "did:plc:syyfuvqiabipi5mf3x632qij", 39 - "did:plc:6vpxzm6mxjzcfvccnuw2pyd7", 40 - "did:plc:yxqdgravj27gtxkpqhrnzhlx", 41 - "did:plc:nrhrdxqa2v7hfxw2jnuy7rk7", 42 - "did:plc:pr27argcmniiwxp7d7facqwy", 43 - "did:plc:azfxx5mdxcuoc2bkuqizs4kd", 44 - "did:plc:y42muzveli3sjyr3tufaq765", 45 - "did:plc:22wazjq4e4yjafxlew2c6kov", 46 - "did:plc:iw64z65wzkmqvftssb2nldj5", 47 - ], 48 - anchorDate: "2025-10-17", // Date when harassment campaign started 49 - maxAgeDays: 7, // Flag accounts less than 7 days old 50 - label: "suspect-inauthentic", 51 - comment: "New account replying to monitored user during campaign", 52 - }, 53 - // Example: Monitor replies to specific accounts 54 - // { 55 - // monitoredDIDs: [ 56 - // "did:plc:example123", // High-profile account 1 57 - // "did:plc:example456", // High-profile account 2 58 - // ], 59 - // anchorDate: "2025-01-15", // Date when harassment campaign started 60 - // maxAgeDays: 7, // Flag accounts less than 7 days old 61 - // label: "new-account-reply", 62 - // comment: "New account replying to monitored user during campaign", 63 - // expires: "2025-02-15", // Optional: automatically stop this check after this date 64 - // }, 65 - // 66 - // Example: Monitor replies to specific posts 67 - // { 68 - // monitoredPostURIs: [ 69 - // "at://did:plc:example123/app.bsky.feed.post/abc123", 70 - // "at://did:plc:example456/app.bsky.feed.post/def456", 71 - // ], 72 - // anchorDate: "2025-01-15", 73 - // maxAgeDays: 7, 74 - // label: "brigading-suspect", 75 - // comment: "New account replying to specific targeted post", 76 - // expires: "2025-02-15", 77 - // }, 78 - ];
+19 -7
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 + import { moderationActionsFailedCounter } from "../../metrics.js"; 5 6 6 - const ALLOWED_DIDS = ["did:plc:gpunjjgvlyb4racypz3yfiq4"]; 7 + const ALLOWED_DIDS = ["did:plc:example"]; 7 8 8 9 export const countStarterPacks = async (did: string, time: number) => { 9 10 await isLoggedIn; ··· 32 33 "Labeling account with excessive starter packs", 33 34 ); 34 35 35 - createAccountLabel( 36 - did, 37 - "follow-farming", 38 - `${time}: Account has ${starterPacks} starter packs`, 39 - ); 36 + try { 37 + await createAccountLabel( 38 + did, 39 + "follow-farming", 40 + `${time.toString()}: Account has ${starterPacks.toString()} starter packs`, 41 + ); 42 + } catch (labelError) { 43 + logger.error( 44 + { process: "COUNTSTARTERPACKS", did, time, error: labelError }, 45 + "Failed to apply follow-farming label", 46 + ); 47 + moderationActionsFailedCounter.inc({ 48 + action: "label", 49 + target_type: "account", 50 + }); 51 + } 40 52 } 41 53 } catch (error) { 42 54 const errorInfo =
+10 -6
src/rules/account/tests/age.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { ACCOUNT_AGE_CHECKS } from "../../../../rules/accountAge.js"; 3 + import { GLOBAL_ALLOW } from "../../../../rules/constants.js"; 4 + import { 5 + checkAccountLabels, 6 + createAccountLabel, 7 + } from "../../../accountModeration.js"; 2 8 import { agent } from "../../../agent.js"; 3 - import { GLOBAL_ALLOW } from "../../../constants.js"; 9 + import { PLC_URL } from "../../../config.js"; 4 10 import { logger } from "../../../logger.js"; 5 - import { checkAccountLabels, createAccountLabel } from "../../../moderation.js"; 6 11 import { 7 12 calculateAccountAge, 8 13 checkAccountAge, 9 14 getAccountCreationDate, 10 15 } from "../age.js"; 11 - import { ACCOUNT_AGE_CHECKS } from "../ageConstants.js"; 12 16 13 17 // Mock dependencies 14 18 vi.mock("../../../agent.js", () => ({ ··· 27 31 }, 28 32 })); 29 33 30 - vi.mock("../../../moderation.js", () => ({ 34 + vi.mock("../../../accountModeration.js", () => ({ 31 35 createAccountLabel: vi.fn(), 32 36 checkAccountLabels: vi.fn(), 33 37 })); 34 38 35 - vi.mock("../../../constants.js", () => ({ 39 + vi.mock("../../../../rules/constants.js", () => ({ 36 40 GLOBAL_ALLOW: [], 37 41 })); 38 42 ··· 97 101 const result = await getAccountCreationDate("did:plc:test123"); 98 102 99 103 expect(global.fetch).toHaveBeenCalledWith( 100 - "https://plc.directory/did:plc:test123/log/audit", 104 + `https://${PLC_URL}/did:plc:test123/log/audit`, 101 105 ); 102 106 expect(result).toEqual(new Date("2025-01-10T12:00:00.000Z")); 103 107 });
+2 -2
src/rules/account/tests/countStarterPacks.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { createAccountLabel } from "../../../accountModeration.js"; 2 3 import { agent } from "../../../agent.js"; 3 4 import { limit } from "../../../limits.js"; 4 5 import { logger } from "../../../logger.js"; 5 - import { createAccountLabel } from "../../../moderation.js"; 6 6 import { countStarterPacks } from "../countStarterPacks.js"; 7 7 8 8 // Mock dependencies ··· 28 28 }, 29 29 })); 30 30 31 - vi.mock("../../../moderation.js", () => ({ 31 + vi.mock("../../../accountModeration.js", () => ({ 32 32 createAccountLabel: vi.fn(), 33 33 })); 34 34
+15 -8
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; 7 7 8 8 // Label configuration 9 - export const FACET_SPAM_LABEL = "suspect-inauthentic"; 9 + export const FACET_SPAM_LABEL = "platform-manipulation"; 10 10 export const FACET_SPAM_COMMENT = 11 11 "Abusive facet usage detected (hidden mentions)"; 12 12 13 13 // Allowlist for DIDs with legitimate duplicate facet use cases 14 14 export const FACET_SPAM_ALLOWLIST: string[] = [ 15 - // Add DIDs here that should be exempt from facet spam detection 15 + "did:plc:ei7hqam5oasdpw5cdihdphcv", 16 16 ]; 17 17 18 18 /** ··· 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} \n\n${uniqueCount.toString()} unique mentions at position ${position}. \n\nPost: ${atURI}`, 77 84 ); 78 85 79 86 // Only label once per post even if multiple positions are suspicious
+5 -5
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 ··· 251 251 expect(createAccountLabel).toHaveBeenCalledWith( 252 252 TEST_DID, 253 253 FACET_SPAM_LABEL, 254 - `${TEST_TIME}: ${FACET_SPAM_COMMENT} - 2 unique mentions at position 0:1 in ${TEST_URI}`, 254 + `${TEST_TIME}: ${FACET_SPAM_COMMENT} \n\n2 unique mentions at position 0:1. \n\nPost: ${TEST_URI}`, 255 255 ); 256 256 }); 257 257 ··· 355 355 }); 356 356 357 357 it("should use correct label and comment constants", () => { 358 - expect(FACET_SPAM_LABEL).toBe("suspect-inauthentic"); 358 + expect(FACET_SPAM_LABEL).toBe("platform-manipulation"); 359 359 expect(FACET_SPAM_COMMENT).toBe( 360 360 "Abusive facet usage detected (hidden mentions)", 361 361 );
+18 -18
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", ··· 103 103 104 104 expect(createAccountReport).toHaveBeenCalledWith( 105 105 "did:plc:user1", 106 - `${time}: Spam detected - spam-account`, 106 + `${time}: Spam detected\n\nHandle: spam-account`, 107 107 ); 108 108 }); 109 109 ··· 121 121 122 122 expect(createAccountReport).toHaveBeenCalledWith( 123 123 "did:plc:user1", 124 - `${time}: Spam detected - SPAM-ACCOUNT`, 124 + `${time}: Spam detected\n\nHandle: SPAM-ACCOUNT`, 125 125 ); 126 126 }); 127 127 }); ··· 139 139 140 140 expect(createAccountComment).toHaveBeenCalledWith( 141 141 "did:plc:user1", 142 - `${time}: Scam detected - scam-account`, 142 + `${time}: Scam detected\n\nHandle: scam-account`, 143 143 "handle:did:plc:user1:scam-account", 144 144 ); 145 145 }); ··· 159 159 expect(createAccountLabel).toHaveBeenCalledWith( 160 160 "did:plc:normaluser", 161 161 "bot", 162 - `${time}: Bot detected - bot-456`, 162 + `${time}: Bot detected\n\nHandle: bot-456`, 163 163 ); 164 164 }); 165 165 }); ··· 171 171 172 172 expect(createAccountReport).toHaveBeenCalledWith( 173 173 "did:plc:user1", 174 - `${time}: Spam detected - spam-user`, 174 + `${time}: Spam detected\n\nHandle: spam-user`, 175 175 ); 176 176 }); 177 177 ··· 181 181 182 182 expect(createAccountComment).toHaveBeenCalledWith( 183 183 "did:plc:user1", 184 - `${time}: Scam detected - scam-user`, 184 + `${time}: Scam detected\n\nHandle: scam-user`, 185 185 "handle:did:plc:user1:scam-user", 186 186 ); 187 187 }); ··· 193 193 expect(createAccountLabel).toHaveBeenCalledWith( 194 194 "did:plc:user1", 195 195 "bot", 196 - `${time}: Bot detected - bot-789`, 196 + `${time}: Bot detected\n\nHandle: bot-789`, 197 197 ); 198 198 }); 199 199 ··· 203 203 204 204 expect(createAccountReport).toHaveBeenCalledWith( 205 205 "did:plc:user1", 206 - `${time}: Multi-action triggered - dangerous-account`, 206 + `${time}: Multi-action triggered\n\nHandle: dangerous-account`, 207 207 ); 208 208 expect(createAccountComment).toHaveBeenCalledWith( 209 209 "did:plc:user1", 210 - `${time}: Multi-action triggered - dangerous-account`, 210 + `${time}: Multi-action triggered\n\nHandle: dangerous-account`, 211 211 "handle:did:plc:user1:dangerous-account", 212 212 ); 213 213 expect(createAccountLabel).toHaveBeenCalledWith( 214 214 "did:plc:user1", 215 215 "multi-action", 216 - `${time}: Multi-action triggered - dangerous-account`, 216 + `${time}: Multi-action triggered\n\nHandle: dangerous-account`, 217 217 ); 218 218 }); 219 219 }); ··· 222 222 it("should process all matching rules", async () => { 223 223 vi.resetModules(); 224 224 // Re-import with a mock that has overlapping patterns 225 - vi.doMock("./constants.js", () => ({ 225 + vi.doMock("../../../rules/handles.js", () => ({ 226 226 HANDLE_CHECKS: [ 227 227 { 228 228 label: "pattern1", ··· 270 270 }); 271 271 272 272 it("should handle very long handles", async () => { 273 - const longHandle = "spam-" + "a".repeat(1000); 273 + const longHandle = `spam-${"a".repeat(1000)}`; 274 274 const time = Date.now(); 275 275 await checkHandle("did:plc:user1", longHandle, time); 276 276 277 277 expect(createAccountReport).toHaveBeenCalledWith( 278 278 "did:plc:user1", 279 - `${time}: Spam detected - ${longHandle}`, 279 + `${time}: Spam detected\n\nHandle: ${longHandle}`, 280 280 ); 281 281 }); 282 282 ··· 294 294 295 295 expect(createAccountReport).toHaveBeenCalledWith( 296 296 "did:plc:user1", 297 - "1234567890: Spam detected - spam-account", 297 + "1234567890: Spam detected\n\nHandle: spam-account", 298 298 ); 299 299 }); 300 300
+15 -17
src/rules/handles/checkHandles.ts
··· 1 - import { GLOBAL_ALLOW } from "../../constants.js"; 2 - import { logger } from "../../logger.js"; 1 + import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 2 + import { HANDLE_CHECKS } from "../../../rules/handles.js"; 3 3 import { 4 4 createAccountComment, 5 5 createAccountLabel, 6 6 createAccountReport, 7 - } from "../../moderation.js"; 8 - import { HANDLE_CHECKS } from "./constants.js"; 7 + } from "../../accountModeration.js"; 8 + import { logger } from "../../logger.js"; 9 9 10 - export const checkHandle = async ( 10 + export const checkHandle = ( 11 11 did: string, 12 12 handle: string, 13 13 time: number, 14 - ) => { 14 + ): void => { 15 15 // Check if DID is whitelisted 16 16 if (GLOBAL_ALLOW.includes(did)) { 17 17 logger.warn( ··· 45 45 } 46 46 } 47 47 48 - if (checkList.toLabel === true) { 49 - createAccountLabel( 50 - did, 51 - `${checkList.label}`, 52 - `${time}: ${checkList.comment} - ${handle}`, 53 - ); 48 + const formattedComment = `${time.toString()}: ${checkList.comment}\n\nHandle: ${handle}`; 49 + 50 + if (checkList.toLabel) { 51 + void createAccountLabel(did, checkList.label, formattedComment); 54 52 } 55 53 56 - if (checkList.reportAcct === true) { 54 + if (checkList.reportAcct) { 57 55 logger.info( 58 56 { process: "CHECKHANDLE", did, handle, time, label: checkList.label }, 59 57 "Reporting account", 60 58 ); 61 - createAccountReport(did, `${time}: ${checkList.comment} - ${handle}`); 59 + void createAccountReport(did, formattedComment); 62 60 } 63 61 64 - if (checkList.commentAcct === true) { 65 - createAccountComment( 62 + if (checkList.commentAcct) { 63 + void createAccountComment( 66 64 did, 67 - `${time}: ${checkList.comment} - ${handle}`, 65 + formattedComment, 68 66 `handle:${did}:${handle}`, 69 67 ); 70 68 }
-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 - ];
+99 -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 { checkAccountThreshold } from "../../accountThreshold.js"; 8 + import { logger } from "../../logger.js"; 9 + import { moderationActionsFailedCounter } from "../../metrics.js"; 10 + import { createPostLabel, createPostReport } from "../../moderation.js"; 11 + import type { ModerationResult, Post } from "../../types.js"; 10 12 import { getFinalUrl } from "../../utils/getFinalUrl.js"; 11 13 import { getLanguage } from "../../utils/getLanguage.js"; 12 14 import { countStarterPacks } from "../account/countStarterPacks.js"; 13 - import { LINK_SHORTENER, POST_CHECKS } from "./constants.js"; 14 15 15 16 export const checkPosts = async (post: Post[]) => { 16 17 if (GLOBAL_ALLOW.includes(post[0].did)) { ··· 74 75 const lang = await getLanguage(post[0].text); 75 76 76 77 // iterate through the checks 77 - POST_CHECKS.forEach((checkPost) => { 78 + for (const checkPost of POST_CHECKS) { 78 79 if (checkPost.language) { 79 80 if (!checkPost.language.includes(lang)) { 80 - return; 81 + continue; 81 82 } 82 83 } 83 84 ··· 87 88 { process: "CHECKPOSTS", did: post[0].did, atURI: post[0].atURI }, 88 89 "Whitelisted DID", 89 90 ); 90 - return; 91 + continue; 91 92 } 92 93 } 93 94 ··· 99 100 { process: "CHECKPOSTS", did: post[0].did, atURI: post[0].atURI }, 100 101 "Whitelisted phrase found", 101 102 ); 102 - return; 103 + continue; 103 104 } 104 105 } 105 106 106 - countStarterPacks(post[0].did, post[0].time); 107 + await countStarterPacks(post[0].did, post[0].time); 107 108 108 - if (checkPost.toLabel === true) { 109 - createPostLabel( 110 - post[0].atURI, 111 - post[0].cid, 112 - `${checkPost.label}`, 113 - `${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 114 - checkPost.duration, 115 - post[0].did, 116 - ); 109 + const postURL = `https://pdsls.dev/${post[0].atURI}`; 110 + const formattedComment = `${checkPost.comment}\n\nPost: ${postURL}\n\nText: "${post[0].text}"`; 111 + 112 + const results: ModerationResult = { success: true, errors: [] }; 113 + 114 + if (checkPost.toLabel) { 115 + try { 116 + await createPostLabel( 117 + post[0].atURI, 118 + post[0].cid, 119 + checkPost.label, 120 + formattedComment, 121 + checkPost.duration, 122 + post[0].did, 123 + post[0].time, 124 + ); 125 + } catch (error) { 126 + results.success = false; 127 + results.errors.push({ action: "label", error }); 128 + } 129 + } else if (checkPost.trackOnly) { 130 + try { 131 + await checkAccountThreshold( 132 + post[0].did, 133 + post[0].atURI, 134 + checkPost.label, 135 + post[0].time, 136 + ); 137 + } catch (error) { 138 + // Threshold check failures are logged but don't add to results.errors 139 + // since it's not a direct moderation action 140 + logger.error( 141 + { 142 + process: "CHECKPOSTS", 143 + did: post[0].did, 144 + atURI: post[0].atURI, 145 + error, 146 + }, 147 + "Account threshold check failed", 148 + ); 149 + } 117 150 } 118 151 119 152 if (checkPost.reportPost === true) { ··· 126 159 }, 127 160 "Reporting post", 128 161 ); 129 - createPostReport( 130 - post[0].atURI, 131 - post[0].cid, 132 - `${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 133 - ); 162 + try { 163 + await createPostReport(post[0].atURI, post[0].cid, formattedComment); 164 + } catch (error) { 165 + results.success = false; 166 + results.errors.push({ action: "report", error }); 167 + } 134 168 } 135 169 136 - if (checkPost.reportAcct === true) { 170 + if (checkPost.reportAcct) { 137 171 logger.info( 138 172 { 139 173 process: "CHECKPOSTS", ··· 143 177 }, 144 178 "Reporting account", 145 179 ); 146 - createAccountReport( 147 - post[0].did, 148 - `${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 149 - ); 180 + try { 181 + await createAccountReport(post[0].did, formattedComment); 182 + } catch (error) { 183 + results.success = false; 184 + results.errors.push({ action: "report", error }); 185 + } 150 186 } 151 187 152 - if (checkPost.commentAcct === true) { 153 - createAccountComment( 154 - post[0].did, 155 - `${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 156 - post[0].atURI, 157 - ); 188 + if (checkPost.commentAcct) { 189 + try { 190 + await createAccountComment( 191 + post[0].did, 192 + formattedComment, 193 + post[0].atURI, 194 + ); 195 + } catch (error) { 196 + results.success = false; 197 + results.errors.push({ action: "comment", error }); 198 + } 199 + } 200 + 201 + // Log and track any failures 202 + if (!results.success) { 203 + for (const error of results.errors) { 204 + logger.error( 205 + { 206 + process: "CHECKPOSTS", 207 + did: post[0].did, 208 + atURI: post[0].atURI, 209 + action: error.action, 210 + error: error.error, 211 + }, 212 + "Moderation action failed", 213 + ); 214 + moderationActionsFailedCounter.inc({ 215 + action: error.action, 216 + target_type: "post", 217 + }); 218 + } 158 219 } 159 220 } 160 - }); 221 + } 161 222 };
-31
src/rules/posts/constants.example.ts
··· 1 - import type { Checks } from "../../types.js"; 2 - 3 - export const LINK_SHORTENER = /bit\.ly|tinyurl\.com|ow\.ly/i; 4 - 5 - export const POST_CHECKS: Checks[] = [ 6 - // Example 1: Spam detection 7 - { 8 - label: "spam", 9 - comment: "Post contains spam indicators", 10 - reportPost: true, 11 - reportAcct: false, 12 - commentAcct: false, 13 - toLabel: true, 14 - check: new RegExp( 15 - "click.*?here|limited.*?time.*?offer|act.*?now|100%.*?free", 16 - "i", 17 - ), 18 - whitelist: new RegExp("legitimate.*?offer", "i"), 19 - }, 20 - 21 - // Example 2: Promotional content 22 - { 23 - label: "promotional", 24 - comment: "Promotional content detected", 25 - reportPost: false, 26 - reportAcct: false, 27 - commentAcct: false, 28 - toLabel: true, 29 - check: new RegExp("buy.*?now|discount.*?code|promo.*?link", "i"), 30 - }, 31 - ];
+59 -12
src/rules/posts/tests/checkPosts.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { logger } from "../../../logger.js"; 3 2 import { 4 3 createAccountComment, 5 4 createAccountReport, 6 - createPostLabel, 7 - createPostReport, 8 - } from "../../../moderation.js"; 9 - import { Post } from "../../../types.js"; 5 + } from "../../../accountModeration.js"; 6 + import { checkAccountThreshold } from "../../../accountThreshold.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 13 import { checkPosts } from "../checkPosts.js"; 14 14 15 15 // Mock dependencies 16 - vi.mock("../constants.js", () => ({ 16 + vi.mock("../../../../rules/constants.js", () => ({ 17 + GLOBAL_ALLOW: ["did:plc:globalallow"], 17 18 LINK_SHORTENER: /tinyurl\.com|bit\.ly/i, 19 + })); 20 + 21 + vi.mock("../../../../rules/posts.js", () => ({ 18 22 POST_CHECKS: [ 19 23 { 20 24 label: "test-label", ··· 64 68 reportAcct: true, 65 69 commentAcct: true, 66 70 }, 71 + { 72 + label: "track-only-label", 73 + comment: "Track only test", 74 + check: /shopping/i, 75 + toLabel: false, 76 + trackOnly: true, 77 + reportPost: false, 78 + reportAcct: false, 79 + commentAcct: false, 80 + }, 67 81 ], 68 82 })); 69 83 ··· 80 94 countStarterPacks: vi.fn(), 81 95 })); 82 96 97 + vi.mock("../../../accountModeration.js", () => ({ 98 + createAccountReport: vi.fn(), 99 + createAccountComment: vi.fn(), 100 + })); 101 + 102 + vi.mock("../../../accountThreshold.js", () => ({ 103 + checkAccountThreshold: vi.fn(), 104 + })); 105 + 83 106 vi.mock("../../../moderation.js", () => ({ 84 107 createPostLabel: vi.fn(), 85 - createAccountReport: vi.fn(), 86 - createAccountComment: vi.fn(), 87 108 createPostReport: vi.fn(), 88 109 })); 89 110 ··· 93 114 94 115 vi.mock("../../../utils/getFinalUrl.js", () => ({ 95 116 getFinalUrl: vi.fn(), 96 - })); 97 - 98 - vi.mock("../../../constants.js", () => ({ 99 - GLOBAL_ALLOW: ["did:plc:globalallow"], 100 117 })); 101 118 102 119 describe("checkPosts", () => { ··· 251 268 expect.stringContaining("Test comment"), 252 269 undefined, 253 270 post[0].did, 271 + post[0].time, 254 272 ); 255 273 }); 256 274 ··· 285 303 expect.any(String), 286 304 undefined, 287 305 post[0].did, 306 + post[0].time, 288 307 ); 289 308 }); 290 309 ··· 339 358 expect.any(String), 340 359 undefined, 341 360 post[0].did, 361 + post[0].time, 342 362 ); 343 363 }); 344 364 }); ··· 384 404 expect.any(String), 385 405 undefined, 386 406 "did:plc:notignored", 407 + post[0].time, 387 408 ); 388 409 }); 389 410 }); ··· 401 422 expect.any(String), 402 423 undefined, 403 424 post[0].did, 425 + post[0].time, 404 426 ); 405 427 expect(createPostReport).toHaveBeenCalledWith( 406 428 post[0].atURI, ··· 415 437 post[0].did, 416 438 expect.any(String), 417 439 expect.any(String), 440 + ); 441 + }); 442 + }); 443 + 444 + describe("trackOnly behavior", () => { 445 + it("should track for account threshold without emitting post label when trackOnly is true", async () => { 446 + const post = createMockPost({ text: "check out this shopping link" }); 447 + 448 + await checkPosts(post); 449 + 450 + expect(createPostLabel).not.toHaveBeenCalledWith( 451 + expect.any(String), 452 + expect.any(String), 453 + "track-only-label", 454 + expect.any(String), 455 + expect.any(Number), 456 + expect.any(String), 457 + expect.any(Number), 458 + ); 459 + 460 + expect(checkAccountThreshold).toHaveBeenCalledWith( 461 + post[0].did, 462 + post[0].atURI, 463 + "track-only-label", 464 + post[0].time, 418 465 ); 419 466 }); 420 467 });
+237 -147
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 + negateAccountLabel, 8 + } from "../../accountModeration.js"; 9 + import { logger } from "../../logger.js"; 10 + import { moderationActionsFailedCounter } from "../../metrics.js"; 11 + import type { Checks, ModerationResult } from "../../types.js"; 9 12 import { getLanguage } from "../../utils/getLanguage.js"; 10 13 14 + export class ProfileChecker { 15 + private check: Checks; 16 + private did: string; 17 + private time: number; 18 + 19 + constructor(check: Checks, did: string, time: number) { 20 + this.check = check; 21 + this.did = did; 22 + this.time = time; 23 + } 24 + 25 + async checkDescription(description: string): Promise<void> { 26 + if (!description) return; 27 + await this.performActions(description, "CHECKDESCRIPTION"); 28 + } 29 + 30 + async checkDisplayName(displayName: string): Promise<void> { 31 + if (!displayName) return; 32 + await this.performActions(displayName, "CHECKDISPLAYNAME"); 33 + } 34 + 35 + async checkBoth(displayName: string, description: string): Promise<void> { 36 + const profile = `${displayName} ${description}`; 37 + if (!profile) return; 38 + await this.performActions(profile, "CHECKPROFILE"); 39 + } 40 + 41 + private async performActions( 42 + content: string, 43 + processType: "CHECKPROFILE" | "CHECKDESCRIPTION" | "CHECKDISPLAYNAME", 44 + ): Promise<void> { 45 + const matched = this.check.check.test(content); 46 + 47 + if (matched) { 48 + if (this.check.whitelist?.test(content)) { 49 + logger.debug( 50 + { process: processType, did: this.did, time: this.time, content }, 51 + "Whitelisted phrase found", 52 + ); 53 + return; 54 + } 55 + 56 + const result = await this.applyActions(content, processType); 57 + if (!result.success) { 58 + for (const error of result.errors) { 59 + logger.error( 60 + { 61 + process: processType, 62 + did: this.did, 63 + action: error.action, 64 + error: error.error, 65 + }, 66 + "Moderation action failed", 67 + ); 68 + moderationActionsFailedCounter.inc({ 69 + action: error.action, 70 + target_type: "account", 71 + }); 72 + } 73 + } 74 + } else { 75 + if (this.check.unlabel) { 76 + const result = await this.removeLabel(content, processType); 77 + if (!result.success) { 78 + for (const error of result.errors) { 79 + logger.error( 80 + { 81 + process: processType, 82 + did: this.did, 83 + action: error.action, 84 + error: error.error, 85 + }, 86 + "Moderation action failed", 87 + ); 88 + moderationActionsFailedCounter.inc({ 89 + action: error.action, 90 + target_type: "account", 91 + }); 92 + } 93 + } 94 + } 95 + } 96 + } 97 + 98 + private async applyActions( 99 + content: string, 100 + processType: string, 101 + ): Promise<ModerationResult> { 102 + const results: ModerationResult = { success: true, errors: [] }; 103 + const formattedComment = `${this.time.toString()}: ${this.check.comment}\n\nContent: ${content}`; 104 + 105 + if (this.check.toLabel) { 106 + try { 107 + await createAccountLabel(this.did, this.check.label, formattedComment); 108 + } catch (error) { 109 + results.success = false; 110 + results.errors.push({ action: "label", error }); 111 + } 112 + } 113 + 114 + if (this.check.reportAcct) { 115 + try { 116 + await createAccountReport(this.did, formattedComment); 117 + logger.info( 118 + { 119 + process: processType, 120 + did: this.did, 121 + time: this.time, 122 + label: this.check.label, 123 + }, 124 + "Reporting account", 125 + ); 126 + } catch (error) { 127 + results.success = false; 128 + results.errors.push({ action: "report", error }); 129 + } 130 + } 131 + 132 + if (this.check.commentAcct) { 133 + try { 134 + await createAccountComment( 135 + this.did, 136 + formattedComment, 137 + `profile:${this.did}`, 138 + ); 139 + } catch (error) { 140 + results.success = false; 141 + results.errors.push({ action: "comment", error }); 142 + } 143 + } 144 + 145 + return results; 146 + } 147 + 148 + private async removeLabel( 149 + content: string, 150 + _processType: string, 151 + ): Promise<ModerationResult> { 152 + const results: ModerationResult = { success: true, errors: [] }; 153 + const formattedComment = `${this.check.comment}\n\nContent: ${content}`; 154 + try { 155 + await negateAccountLabel(this.did, this.check.label, formattedComment); 156 + } catch (error) { 157 + results.success = false; 158 + results.errors.push({ action: "unlabel", error }); 159 + } 160 + return results; 161 + } 162 + } 163 + 11 164 export const checkDescription = async ( 12 165 did: string, 13 166 time: number, 14 167 displayName: string, 15 168 description: string, 16 - ) => { 17 - const lang = await getLanguage(description); 169 + ): Promise<void> => { 170 + if (!description) return; 18 171 19 - // Check if DID is whitelisted 20 172 if (GLOBAL_ALLOW.includes(did)) { 21 173 logger.warn( 22 174 { process: "CHECKDESCRIPTION", did, time, displayName, description }, ··· 25 177 return; 26 178 } 27 179 28 - // iterate through the checks 29 - PROFILE_CHECKS.forEach((checkProfiles) => { 30 - if (checkProfiles.language) { 31 - if (!checkProfiles.language.includes(lang)) { 32 - return; 180 + for (const checkRule of PROFILE_CHECKS) { 181 + if (checkRule.language) { 182 + const lang = await getLanguage(description); 183 + if (!checkRule.language.includes(lang)) { 184 + continue; 33 185 } 34 186 } 35 187 36 - // Check if DID is whitelisted 37 - if (checkProfiles.ignoredDIDs) { 38 - if (checkProfiles.ignoredDIDs.includes(did)) { 39 - logger.debug( 40 - { process: "CHECKDESCRIPTION", did, time, displayName, description }, 41 - "Whitelisted DID", 42 - ); 43 - return; 44 - } 188 + if (checkRule.ignoredDIDs?.includes(did)) { 189 + logger.debug( 190 + { process: "CHECKDESCRIPTION", did, time, displayName, description }, 191 + "Whitelisted DID", 192 + ); 193 + continue; 45 194 } 46 195 47 - if (description) { 48 - if (checkProfiles.description === true) { 49 - if (checkProfiles.check.test(description)) { 50 - // Check if description is whitelisted 51 - if (checkProfiles.whitelist) { 52 - if (checkProfiles.whitelist.test(description)) { 53 - logger.debug( 54 - { 55 - process: "CHECKDESCRIPTION", 56 - did, 57 - time, 58 - displayName, 59 - description, 60 - }, 61 - "Whitelisted phrase found", 62 - ); 63 - return; 64 - } 65 - } 66 - 67 - if (checkProfiles.toLabel === true) { 68 - createAccountLabel( 69 - did, 70 - `${checkProfiles.label}`, 71 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 72 - ); 73 - } 74 - 75 - if (checkProfiles.reportAcct === true) { 76 - createAccountReport( 77 - did, 78 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 79 - ); 80 - logger.info( 81 - { 82 - process: "CHECKDESCRIPTION", 83 - did, 84 - time, 85 - displayName, 86 - description, 87 - label: checkProfiles.label, 88 - }, 89 - "Reporting account", 90 - ); 91 - } 92 - 93 - if (checkProfiles.commentAcct === true) { 94 - createAccountComment( 95 - did, 96 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 97 - `profile:${did}:${time}`, 98 - ); 99 - } 100 - } 101 - } 196 + if (checkRule.description === true) { 197 + const checker = new ProfileChecker(checkRule, did, time); 198 + await checker.checkDescription(description); 102 199 } 103 - }); 200 + } 104 201 }; 105 202 106 203 export const checkDisplayName = async ( ··· 108 205 time: number, 109 206 displayName: string, 110 207 description: string, 111 - ) => { 112 - // Check if DID is whitelisted 208 + ): Promise<void> => { 209 + if (!displayName) return; 210 + 113 211 if (GLOBAL_ALLOW.includes(did)) { 114 212 logger.warn( 115 213 { process: "CHECKDISPLAYNAME", did, time, displayName, description }, ··· 118 216 return; 119 217 } 120 218 121 - const lang = await getLanguage(description); 122 - 123 - // iterate through the checks 124 - PROFILE_CHECKS.forEach((checkProfiles) => { 125 - if (checkProfiles.language) { 126 - if (!checkProfiles.language.includes(lang)) { 127 - return; 219 + for (const checkRule of PROFILE_CHECKS) { 220 + if (checkRule.language) { 221 + const lang = await getLanguage(displayName); 222 + if (!checkRule.language.includes(lang)) { 223 + continue; 128 224 } 129 225 } 130 226 131 - // Check if DID is whitelisted 132 - if (checkProfiles.ignoredDIDs) { 133 - if (checkProfiles.ignoredDIDs.includes(did)) { 134 - logger.debug( 135 - { process: "CHECKDISPLAYNAME", did, time, displayName, description }, 136 - "Whitelisted DID", 137 - ); 138 - return; 139 - } 227 + if (checkRule.ignoredDIDs?.includes(did)) { 228 + logger.debug( 229 + { process: "CHECKDISPLAYNAME", did, time, displayName, description }, 230 + "Whitelisted DID", 231 + ); 232 + continue; 140 233 } 141 234 142 - if (displayName) { 143 - if (checkProfiles.displayName === true) { 144 - if (checkProfiles.check.test(displayName)) { 145 - // Check if displayName is whitelisted 146 - if (checkProfiles.whitelist) { 147 - if (checkProfiles.whitelist.test(displayName)) { 148 - logger.debug( 149 - { 150 - process: "CHECKDISPLAYNAME", 151 - did, 152 - time, 153 - displayName, 154 - description, 155 - }, 156 - "Whitelisted phrase found", 157 - ); 158 - return; 159 - } 160 - } 235 + if (checkRule.displayName === true) { 236 + const checker = new ProfileChecker(checkRule, did, time); 237 + await checker.checkDisplayName(displayName); 238 + } 239 + } 240 + }; 161 241 162 - if (checkProfiles.toLabel === true) { 163 - createAccountLabel( 164 - did, 165 - `${checkProfiles.label}`, 166 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 167 - ); 168 - } 242 + export const checkProfile = async ( 243 + did: string, 244 + time: number, 245 + displayName: string, 246 + description: string, 247 + ) => { 248 + const profile = `${displayName} ${description}`; 249 + const lang = await getLanguage(profile); 169 250 170 - if (checkProfiles.reportAcct === true) { 171 - createAccountReport( 172 - did, 173 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 174 - ); 175 - logger.info( 176 - { 177 - process: "CHECKDISPLAYNAME", 178 - did, 179 - time, 180 - displayName, 181 - description, 182 - label: checkProfiles.label, 183 - }, 184 - "Reporting account", 185 - ); 186 - } 251 + // Check if DID is whitelisted at global level 252 + if (GLOBAL_ALLOW.includes(did)) { 253 + logger.warn( 254 + { process: "CHECKPROFILE", did, time, profile }, 255 + "Global AllowListed DID", 256 + ); 257 + return; 258 + } 187 259 188 - if (checkProfiles.commentAcct === true) { 189 - createAccountComment( 190 - did, 191 - `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 192 - `profile:${did}:${time}`, 193 - ); 194 - } 195 - } 260 + // Iterate through checks and delegate to ProfileChecker 261 + for (const checkRule of PROFILE_CHECKS) { 262 + // Language filter (same for all branches) 263 + if (checkRule.language) { 264 + if (!checkRule.language.includes(lang)) { 265 + continue; 196 266 } 197 267 } 198 - }); 268 + 269 + // DID whitelist (same for all branches) 270 + if (checkRule.ignoredDIDs?.includes(did)) { 271 + logger.debug( 272 + { process: "CHECKPROFILE", did, time, displayName, description }, 273 + "Whitelisted DID", 274 + ); 275 + continue; 276 + } 277 + 278 + // Dispatch to correct method based on check configuration 279 + const checker = new ProfileChecker(checkRule, did, time); 280 + 281 + if (checkRule.description === true && checkRule.displayName === true) { 282 + await checker.checkBoth(displayName, description); 283 + } else if (checkRule.description === true) { 284 + await checker.checkDescription(description); 285 + } else if (checkRule.displayName === true) { 286 + await checker.checkDisplayName(displayName); 287 + } 288 + } 199 289 };
-55
src/rules/profiles/constants.example.ts
··· 1 - import type { Checks } from "../../types.js"; 2 - 3 - /** 4 - * Example profile check configurations 5 - * 6 - * This file demonstrates how to configure profile moderation rules. 7 - * Copy this file to constants.ts and customize for your labeler's needs. 8 - * 9 - * Profile checks can match against display names and/or descriptions. 10 - */ 11 - 12 - export const PROFILE_CHECKS: Checks[] = [ 13 - // Example 1: Suspicious bio patterns 14 - { 15 - label: "suspicious-bio", 16 - comment: "Profile contains suspicious patterns", 17 - description: true, 18 - displayName: false, 19 - reportAcct: false, 20 - commentAcct: false, 21 - toLabel: true, 22 - check: new RegExp( 23 - "dm.*?for.*?promo|follow.*?for.*?follow|gain.*?followers", 24 - "i", 25 - ), 26 - }, 27 - 28 - // Example 2: Display name checks 29 - { 30 - label: "impersonation-risk", 31 - comment: "Display name may indicate impersonation", 32 - description: false, 33 - displayName: true, 34 - reportAcct: false, 35 - commentAcct: false, 36 - toLabel: true, 37 - check: new RegExp("official|verified|admin|support", "i"), 38 - whitelist: new RegExp("unofficial|parody|fan", "i"), 39 - ignoredDIDs: [ 40 - "did:plc:example123", // Actual official account 41 - ], 42 - }, 43 - 44 - // Example 3: Both display name and description 45 - { 46 - label: "crypto-spam", 47 - comment: "Profile suggests crypto spam activity", 48 - description: true, 49 - displayName: true, 50 - reportAcct: false, 51 - commentAcct: false, 52 - toLabel: true, 53 - check: new RegExp("crypto.*?giveaway|nft.*?drop|airdrop", "i"), 54 - }, 55 - ];
+6 -8
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 ··· 263 263 process: "CHECKDESCRIPTION", 264 264 did: mockDid, 265 265 time: mockTime, 266 - displayName: mockDisplayName, 267 - description: "this is good bad content", 266 + content: "this is good bad content", 268 267 }, 269 268 "Whitelisted phrase found", 270 269 ); ··· 357 356 expect.any(String), 358 357 ); 359 358 }); 360 - 361 359 }); 362 360 }); 363 361
+11 -2
src/session.ts
··· 1 - import { readFileSync, writeFileSync, unlinkSync, chmodSync, existsSync } from "node:fs"; 1 + import { 2 + chmodSync, 3 + existsSync, 4 + readFileSync, 5 + unlinkSync, 6 + writeFileSync, 7 + } from "node:fs"; 2 8 import { join } from "node:path"; 3 9 import { logger } from "./logger.js"; 4 10 ··· 34 40 logger.info("Loaded existing session from file"); 35 41 return session; 36 42 } catch (error) { 37 - logger.error({ error }, "Failed to load session file, will authenticate fresh"); 43 + logger.error( 44 + { error }, 45 + "Failed to load session file, will authenticate fresh", 46 + ); 38 47 return null; 39 48 } 40 49 }
+158
src/starterPackThreshold.ts
··· 1 + import { STARTER_PACK_THRESHOLD_CONFIGS } from "../rules/starterPackThreshold.js"; 2 + import { 3 + createAccountComment, 4 + createAccountLabel, 5 + createAccountReport, 6 + } from "./accountModeration.js"; 7 + import { logger } from "./logger.js"; 8 + import { 9 + starterPackLabelsThresholdAppliedCounter, 10 + starterPackThresholdChecksCounter, 11 + starterPackThresholdMetCounter, 12 + } from "./metrics.js"; 13 + import { 14 + getStarterPackCountInWindow, 15 + trackStarterPackForAccount, 16 + } from "./redis.js"; 17 + import type { StarterPackThresholdConfig } from "./types.js"; 18 + 19 + function validateAndLoadConfigs(): StarterPackThresholdConfig[] { 20 + if (STARTER_PACK_THRESHOLD_CONFIGS.length === 0) { 21 + logger.warn( 22 + { process: "STARTER_PACK_THRESHOLD" }, 23 + "No starter pack threshold configs found", 24 + ); 25 + return []; 26 + } 27 + 28 + for (const config of STARTER_PACK_THRESHOLD_CONFIGS) { 29 + if (config.threshold <= 0) { 30 + throw new Error( 31 + `Invalid starter pack threshold config: threshold must be positive`, 32 + ); 33 + } 34 + if (config.window <= 0) { 35 + throw new Error( 36 + `Invalid starter pack threshold config: window must be positive`, 37 + ); 38 + } 39 + } 40 + 41 + logger.info( 42 + { process: "STARTER_PACK_THRESHOLD", count: STARTER_PACK_THRESHOLD_CONFIGS.length }, 43 + "Loaded starter pack threshold configs", 44 + ); 45 + 46 + return STARTER_PACK_THRESHOLD_CONFIGS; 47 + } 48 + 49 + const cachedConfigs = validateAndLoadConfigs(); 50 + 51 + export function loadStarterPackThresholdConfigs(): StarterPackThresholdConfig[] { 52 + return cachedConfigs; 53 + } 54 + 55 + export async function checkStarterPackThreshold( 56 + did: string, 57 + starterPackUri: string, 58 + timestamp: number, 59 + ): Promise<void> { 60 + try { 61 + const configs = loadStarterPackThresholdConfigs(); 62 + 63 + if (configs.length === 0) { 64 + return; 65 + } 66 + 67 + starterPackThresholdChecksCounter.inc(); 68 + 69 + for (const config of configs) { 70 + // Check allowlist 71 + if (config.allowlist?.includes(did)) { 72 + logger.debug( 73 + { process: "STARTER_PACK_THRESHOLD", did, starterPackUri }, 74 + "Account is in allowlist, skipping threshold check", 75 + ); 76 + continue; 77 + } 78 + 79 + await trackStarterPackForAccount( 80 + did, 81 + starterPackUri, 82 + timestamp, 83 + config.window, 84 + config.windowUnit, 85 + ); 86 + 87 + const count = await getStarterPackCountInWindow( 88 + did, 89 + config.window, 90 + config.windowUnit, 91 + timestamp, 92 + ); 93 + 94 + logger.debug( 95 + { 96 + process: "STARTER_PACK_THRESHOLD", 97 + did, 98 + count, 99 + threshold: config.threshold, 100 + window: config.window, 101 + windowUnit: config.windowUnit, 102 + }, 103 + "Checked starter pack threshold", 104 + ); 105 + 106 + if (count >= config.threshold) { 107 + starterPackThresholdMetCounter.inc({ account_label: config.accountLabel }); 108 + 109 + logger.info( 110 + { 111 + process: "STARTER_PACK_THRESHOLD", 112 + did, 113 + starterPackUri, 114 + accountLabel: config.accountLabel, 115 + count, 116 + threshold: config.threshold, 117 + }, 118 + "Starter pack threshold met", 119 + ); 120 + 121 + const shouldLabel = config.toLabel !== false; 122 + 123 + const formattedComment = `${config.accountComment}\n\nThreshold: ${count.toString()}/${config.threshold.toString()} in ${config.window.toString()} ${config.windowUnit}\n\nStarter Pack: ${starterPackUri}`; 124 + 125 + if (shouldLabel) { 126 + await createAccountLabel(did, config.accountLabel, formattedComment); 127 + starterPackLabelsThresholdAppliedCounter.inc({ 128 + account_label: config.accountLabel, 129 + action: "label", 130 + }); 131 + } 132 + 133 + if (config.reportAcct) { 134 + await createAccountReport(did, formattedComment); 135 + starterPackLabelsThresholdAppliedCounter.inc({ 136 + account_label: config.accountLabel, 137 + action: "report", 138 + }); 139 + } 140 + 141 + if (config.commentAcct) { 142 + const atURI = `starterpack-threshold-comment:${config.accountLabel}:${timestamp.toString()}`; 143 + await createAccountComment(did, formattedComment, atURI); 144 + starterPackLabelsThresholdAppliedCounter.inc({ 145 + account_label: config.accountLabel, 146 + action: "comment", 147 + }); 148 + } 149 + } 150 + } 151 + } catch (error) { 152 + logger.error( 153 + { process: "STARTER_PACK_THRESHOLD", did, starterPackUri, error }, 154 + "Error checking starter pack threshold", 155 + ); 156 + throw error; 157 + } 158 + }
+216
src/tests/accountModeration.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from "vitest"; 2 + import { getAllAccountLabels, negateAccountLabel } from "../accountModeration.js"; 3 + import { agent } from "../agent.js"; 4 + 5 + vi.mock("../agent.js", () => ({ 6 + agent: { 7 + did: "did:plc:test-moderator", 8 + tools: { 9 + ozone: { 10 + moderation: { 11 + getRepo: vi.fn(), 12 + emitEvent: vi.fn(), 13 + }, 14 + }, 15 + }, 16 + }, 17 + isLoggedIn: Promise.resolve(), 18 + })); 19 + 20 + vi.mock("../logger.js", () => ({ 21 + logger: { 22 + info: vi.fn(), 23 + debug: vi.fn(), 24 + error: vi.fn(), 25 + warn: vi.fn(), 26 + }, 27 + })); 28 + 29 + vi.mock("../limits.js", () => ({ 30 + limit: vi.fn((fn) => fn()), 31 + })); 32 + 33 + vi.mock("../redis.js", () => ({ 34 + deleteAccountLabelClaim: vi.fn().mockResolvedValue(undefined), 35 + })); 36 + 37 + vi.mock("../metrics.js", () => ({ 38 + unlabelsRemovedCounter: { 39 + inc: vi.fn(), 40 + }, 41 + labelsAppliedCounter: { 42 + inc: vi.fn(), 43 + }, 44 + labelsCachedCounter: { 45 + inc: vi.fn(), 46 + }, 47 + })); 48 + 49 + const mockAgent = agent as any; 50 + 51 + describe("getAllAccountLabels", () => { 52 + beforeEach(() => { 53 + vi.clearAllMocks(); 54 + }); 55 + 56 + it("should return array of label strings from API response", async () => { 57 + mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({ 58 + data: { 59 + labels: [{ val: "blue-heart-emoji" }, { val: "hammer-sickle" }], 60 + }, 61 + }); 62 + 63 + const labels = await getAllAccountLabels("did:plc:test123"); 64 + 65 + expect(labels).toEqual(["blue-heart-emoji", "hammer-sickle"]); 66 + expect(mockAgent.tools.ozone.moderation.getRepo).toHaveBeenCalledWith( 67 + { did: "did:plc:test123" }, 68 + expect.objectContaining({ 69 + headers: expect.any(Object), 70 + }), 71 + ); 72 + }); 73 + 74 + it("should return empty array when account has no labels", async () => { 75 + mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({ 76 + data: { 77 + labels: undefined, 78 + }, 79 + }); 80 + 81 + const labels = await getAllAccountLabels("did:plc:test123"); 82 + 83 + expect(labels).toEqual([]); 84 + }); 85 + 86 + it("should return empty array when labels array is empty", async () => { 87 + mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({ 88 + data: { 89 + labels: [], 90 + }, 91 + }); 92 + 93 + const labels = await getAllAccountLabels("did:plc:test123"); 94 + 95 + expect(labels).toEqual([]); 96 + }); 97 + 98 + it("should return empty array on API error", async () => { 99 + mockAgent.tools.ozone.moderation.getRepo.mockRejectedValueOnce( 100 + new Error("API Error"), 101 + ); 102 + 103 + const labels = await getAllAccountLabels("did:plc:test123"); 104 + 105 + expect(labels).toEqual([]); 106 + }); 107 + }); 108 + 109 + describe("negateAccountLabel", () => { 110 + beforeEach(() => { 111 + vi.clearAllMocks(); 112 + }); 113 + 114 + it("should emit moderation event to remove label", async () => { 115 + mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({ 116 + data: { 117 + labels: [{ val: "blue-heart-emoji" }], 118 + }, 119 + }); 120 + 121 + mockAgent.tools.ozone.moderation.emitEvent.mockResolvedValueOnce({}); 122 + 123 + await negateAccountLabel( 124 + "did:plc:test123", 125 + "blue-heart-emoji", 126 + "Test removal", 127 + ); 128 + 129 + expect(mockAgent.tools.ozone.moderation.emitEvent).toHaveBeenCalledWith( 130 + expect.objectContaining({ 131 + event: expect.objectContaining({ 132 + $type: "tools.ozone.moderation.defs#modEventLabel", 133 + createLabelVals: [], 134 + negateLabelVals: ["blue-heart-emoji"], 135 + comment: "Test removal", 136 + }), 137 + subject: expect.objectContaining({ 138 + $type: "com.atproto.admin.defs#repoRef", 139 + did: "did:plc:test123", 140 + }), 141 + }), 142 + expect.any(Object), 143 + ); 144 + }); 145 + 146 + it("should not emit event if label does not exist on account", async () => { 147 + mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({ 148 + data: { 149 + labels: [{ val: "other-label" }], 150 + }, 151 + }); 152 + 153 + await negateAccountLabel( 154 + "did:plc:test123", 155 + "blue-heart-emoji", 156 + "Test removal", 157 + ); 158 + 159 + expect(mockAgent.tools.ozone.moderation.emitEvent).not.toHaveBeenCalled(); 160 + }); 161 + 162 + it("should not emit event if account has no labels", async () => { 163 + mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({ 164 + data: { 165 + labels: [], 166 + }, 167 + }); 168 + 169 + await negateAccountLabel( 170 + "did:plc:test123", 171 + "blue-heart-emoji", 172 + "Test removal", 173 + ); 174 + 175 + expect(mockAgent.tools.ozone.moderation.emitEvent).not.toHaveBeenCalled(); 176 + }); 177 + 178 + it("should delete Redis cache key on successful removal", async () => { 179 + const { deleteAccountLabelClaim } = await import("../redis.js"); 180 + 181 + mockAgent.tools.ozone.moderation.getRepo.mockResolvedValueOnce({ 182 + data: { 183 + labels: [{ val: "blue-heart-emoji" }], 184 + }, 185 + }); 186 + 187 + mockAgent.tools.ozone.moderation.emitEvent.mockResolvedValueOnce({}); 188 + 189 + await negateAccountLabel( 190 + "did:plc:test123", 191 + "blue-heart-emoji", 192 + "Test removal", 193 + ); 194 + 195 + expect(deleteAccountLabelClaim).toHaveBeenCalledWith( 196 + "did:plc:test123", 197 + "blue-heart-emoji", 198 + ); 199 + }); 200 + 201 + it("should log error if API call fails", async () => { 202 + const { logger } = await import("../logger.js"); 203 + 204 + mockAgent.tools.ozone.moderation.getRepo.mockRejectedValueOnce( 205 + new Error("API Error"), 206 + ); 207 + 208 + await negateAccountLabel( 209 + "did:plc:test123", 210 + "blue-heart-emoji", 211 + "Test removal", 212 + ); 213 + 214 + expect(logger.error).toHaveBeenCalled(); 215 + }); 216 + });
+314
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 + window: 5, 39 + windowUnit: "days", 40 + reportAcct: false, 41 + commentAcct: false, 42 + toLabel: true, 43 + }, 44 + { 45 + labels: ["label-1", "label-2", "label-3"], 46 + threshold: 5, 47 + accountLabel: "multi-label-account", 48 + accountComment: "Multi label comment", 49 + window: 7, 50 + windowUnit: "days", 51 + reportAcct: true, 52 + commentAcct: true, 53 + toLabel: true, 54 + }, 55 + { 56 + labels: "monitor-only-label", 57 + threshold: 2, 58 + accountLabel: "monitored", 59 + accountComment: "Monitoring comment", 60 + window: 3, 61 + windowUnit: "days", 62 + reportAcct: true, 63 + commentAcct: false, 64 + toLabel: false, 65 + }, 66 + { 67 + labels: ["label-1", "shared-label"], 68 + threshold: 2, 69 + accountLabel: "shared-config", 70 + accountComment: "Shared config comment", 71 + window: 4, 72 + windowUnit: "days", 73 + reportAcct: false, 74 + commentAcct: false, 75 + toLabel: true, 76 + }, 77 + ], 78 + })); 79 + 80 + vi.mock("../redis.js", () => ({ 81 + trackPostLabelForAccount: vi.fn(), 82 + getPostLabelCountInWindow: vi.fn(), 83 + })); 84 + 85 + vi.mock("../accountModeration.js", () => ({ 86 + createAccountLabel: vi.fn(), 87 + createAccountReport: vi.fn(), 88 + createAccountComment: vi.fn(), 89 + })); 90 + 91 + vi.mock("../metrics.js", () => ({ 92 + accountLabelsThresholdAppliedCounter: { 93 + inc: vi.fn(), 94 + }, 95 + accountThresholdChecksCounter: { 96 + inc: vi.fn(), 97 + }, 98 + accountThresholdMetCounter: { 99 + inc: vi.fn(), 100 + }, 101 + })); 102 + 103 + describe("Account Threshold Logic", () => { 104 + afterEach(() => { 105 + vi.clearAllMocks(); 106 + }); 107 + 108 + describe("loadThresholdConfigs", () => { 109 + it("should load and cache configs successfully", () => { 110 + const configs = loadThresholdConfigs(); 111 + expect(configs).toHaveLength(4); 112 + expect(configs[0].labels).toEqual(["test-label"]); 113 + expect(configs[1].labels).toEqual(["label-1", "label-2", "label-3"]); 114 + }); 115 + 116 + it("should return cached configs on subsequent calls", () => { 117 + const configs1 = loadThresholdConfigs(); 118 + const configs2 = loadThresholdConfigs(); 119 + expect(configs1).toBe(configs2); 120 + }); 121 + }); 122 + 123 + describe("checkAccountThreshold", () => { 124 + const testDid = "did:plc:test123"; 125 + const testUri = "at://did:plc:test123/app.bsky.feed.post/abc123"; 126 + const testTimestamp = 1640000000000000; 127 + 128 + it("should not check threshold for non-matching labels", async () => { 129 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 130 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(0); 131 + 132 + await checkAccountThreshold( 133 + testDid, 134 + testUri, 135 + "non-matching-label", 136 + testTimestamp, 137 + ); 138 + 139 + expect(trackPostLabelForAccount).not.toHaveBeenCalled(); 140 + expect(getPostLabelCountInWindow).not.toHaveBeenCalled(); 141 + }); 142 + 143 + it("should track and check threshold for matching single label", async () => { 144 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 145 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2); 146 + 147 + await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp); 148 + 149 + expect(accountThresholdChecksCounter.inc).toHaveBeenCalledWith({ 150 + post_label: "test-label", 151 + }); 152 + expect(trackPostLabelForAccount).toHaveBeenCalledWith( 153 + testDid, 154 + "test-label", 155 + testTimestamp, 156 + 5, 157 + "days", 158 + ); 159 + expect(getPostLabelCountInWindow).toHaveBeenCalledWith( 160 + testDid, 161 + ["test-label"], 162 + 5, 163 + "days", 164 + testTimestamp, 165 + ); 166 + }); 167 + 168 + it("should apply account label when threshold is met", async () => { 169 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 170 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(3); 171 + vi.mocked(createAccountLabel).mockResolvedValue(); 172 + 173 + await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp); 174 + 175 + expect(accountThresholdMetCounter.inc).toHaveBeenCalledWith({ 176 + account_label: "test-account-label", 177 + }); 178 + expect(createAccountLabel).toHaveBeenCalledWith( 179 + testDid, 180 + "test-account-label", 181 + `Test comment\n\nThreshold: 3/3 in 5 days\n\nPost: ${testUri}\n\nPost Label: test-label`, 182 + ); 183 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 184 + account_label: "test-account-label", 185 + action: "label", 186 + }); 187 + }); 188 + 189 + it("should not apply label when threshold not met", async () => { 190 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 191 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2); 192 + 193 + await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp); 194 + 195 + expect(accountThresholdMetCounter.inc).not.toHaveBeenCalled(); 196 + expect(createAccountLabel).not.toHaveBeenCalled(); 197 + }); 198 + 199 + it("should handle multi-label config with OR logic", async () => { 200 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 201 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(5); 202 + vi.mocked(createAccountLabel).mockResolvedValue(); 203 + vi.mocked(createAccountReport).mockResolvedValue(); 204 + vi.mocked(createAccountComment).mockResolvedValue(); 205 + 206 + await checkAccountThreshold(testDid, testUri, "label-2", testTimestamp); 207 + 208 + expect(getPostLabelCountInWindow).toHaveBeenCalledWith( 209 + testDid, 210 + ["label-1", "label-2", "label-3"], 211 + 7, 212 + "days", 213 + testTimestamp, 214 + ); 215 + expect(createAccountLabel).toHaveBeenCalledWith( 216 + testDid, 217 + "multi-label-account", 218 + `Multi label comment\n\nThreshold: 5/5 in 7 days\n\nPost: ${testUri}\n\nPost Label: label-2`, 219 + ); 220 + expect(createAccountReport).toHaveBeenCalledWith( 221 + testDid, 222 + `Multi label comment\n\nThreshold: 5/5 in 7 days\n\nPost: ${testUri}\n\nPost Label: label-2`, 223 + ); 224 + expect(createAccountComment).toHaveBeenCalled(); 225 + }); 226 + 227 + it("should track but not label when toLabel is false", async () => { 228 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 229 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2); 230 + vi.mocked(createAccountReport).mockResolvedValue(); 231 + 232 + await checkAccountThreshold( 233 + testDid, 234 + testUri, 235 + "monitor-only-label", 236 + testTimestamp, 237 + ); 238 + 239 + expect(trackPostLabelForAccount).toHaveBeenCalled(); 240 + expect(getPostLabelCountInWindow).toHaveBeenCalled(); 241 + expect(createAccountLabel).not.toHaveBeenCalled(); 242 + expect(createAccountReport).toHaveBeenCalled(); 243 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 244 + account_label: "monitored", 245 + action: "report", 246 + }); 247 + }); 248 + 249 + it("should increment all action metrics when threshold met", async () => { 250 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 251 + vi.mocked(getPostLabelCountInWindow) 252 + .mockResolvedValueOnce(5) 253 + .mockResolvedValueOnce(1); 254 + vi.mocked(createAccountLabel).mockResolvedValue(); 255 + vi.mocked(createAccountReport).mockResolvedValue(); 256 + vi.mocked(createAccountComment).mockResolvedValue(); 257 + 258 + await checkAccountThreshold(testDid, testUri, "label-1", testTimestamp); 259 + 260 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledTimes(3); 261 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 262 + account_label: "multi-label-account", 263 + action: "label", 264 + }); 265 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 266 + account_label: "multi-label-account", 267 + action: "report", 268 + }); 269 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 270 + account_label: "multi-label-account", 271 + action: "comment", 272 + }); 273 + }); 274 + 275 + it("should handle Redis errors in trackPostLabelForAccount", async () => { 276 + const redisError = new Error("Redis connection failed"); 277 + vi.mocked(trackPostLabelForAccount).mockRejectedValue(redisError); 278 + 279 + await expect( 280 + checkAccountThreshold(testDid, testUri, "test-label", testTimestamp), 281 + ).rejects.toThrow("Redis connection failed"); 282 + 283 + expect(logger.error).toHaveBeenCalled(); 284 + }); 285 + 286 + it("should handle Redis errors in getPostLabelCountInWindow", async () => { 287 + const redisError = new Error("Redis query failed"); 288 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 289 + vi.mocked(getPostLabelCountInWindow).mockRejectedValue(redisError); 290 + 291 + await expect( 292 + checkAccountThreshold(testDid, testUri, "test-label", testTimestamp), 293 + ).rejects.toThrow("Redis query failed"); 294 + 295 + expect(logger.error).toHaveBeenCalled(); 296 + }); 297 + 298 + it("should handle multiple matching configs", async () => { 299 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 300 + vi.mocked(getPostLabelCountInWindow) 301 + .mockResolvedValueOnce(5) 302 + .mockResolvedValueOnce(3); 303 + vi.mocked(createAccountLabel).mockResolvedValue(); 304 + vi.mocked(createAccountReport).mockResolvedValue(); 305 + vi.mocked(createAccountComment).mockResolvedValue(); 306 + 307 + await checkAccountThreshold(testDid, testUri, "label-1", testTimestamp); 308 + 309 + expect(trackPostLabelForAccount).toHaveBeenCalledTimes(2); 310 + expect(getPostLabelCountInWindow).toHaveBeenCalledTimes(2); 311 + expect(createAccountLabel).toHaveBeenCalledTimes(2); 312 + }); 313 + }); 314 + });
+31 -255
src/tests/agent.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import type { SessionData } from "../session.js"; 3 - 4 - // TODO: Fix TypeScript mocking issues with AtpAgent 5 - describe.skip("Agent", () => { 6 - let mockLogin: any; 7 - let mockResumeSession: any; 8 - let mockGetProfile: any; 9 - let loadSessionMock: any; 10 - let saveSessionMock: any; 11 2 3 + describe("Agent", () => { 12 4 beforeEach(() => { 13 - vi.clearAllMocks(); 5 + vi.resetModules(); 6 + }); 14 7 8 + it("should create an agent and login", async () => { 15 9 // Mock the config variables 16 10 vi.doMock("../config.js", () => ({ 17 11 BSKY_HANDLE: "test.bsky.social", ··· 19 13 OZONE_PDS: "pds.test.com", 20 14 })); 21 15 22 - // Create mock functions 23 - mockLogin = vi.fn(() => 24 - Promise.resolve({ 25 - success: true, 26 - data: { 27 - accessJwt: "new-access-token", 28 - refreshJwt: "new-refresh-token", 29 - did: "did:plc:test123", 30 - handle: "test.bsky.social", 31 - }, 32 - }) 33 - ); 34 - mockResumeSession = vi.fn(() => Promise.resolve()); 35 - mockGetProfile = vi.fn(() => 36 - Promise.resolve({ 37 - success: true, 38 - data: { did: "did:plc:test123", handle: "test.bsky.social" }, 39 - }) 40 - ); 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 + }; 41 23 42 24 // Mock the AtpAgent 25 + const mockLogin = vi.fn(() => 26 + Promise.resolve({ success: true, data: mockSession }), 27 + ); 28 + const mockConstructor = vi.fn(); 43 29 vi.doMock("@atproto/api", () => ({ 44 30 AtpAgent: class { 45 31 login = mockLogin; 46 - resumeSession = mockResumeSession; 47 - getProfile = mockGetProfile; 48 32 service: URL; 49 - session: SessionData | null = null; 50 - 51 - constructor(options: { service: string; fetch?: typeof fetch }) { 33 + session = mockSession; 34 + constructor(options: { service: string }) { 35 + mockConstructor(options); 52 36 this.service = new URL(options.service); 53 - // Store fetch function if provided for rate limit header testing 54 - if (options.fetch) { 55 - this.fetch = options.fetch; 56 - } 57 37 } 58 - 59 - fetch?: typeof fetch; 60 38 }, 61 39 })); 62 40 63 - // Mock session functions 64 - loadSessionMock = vi.fn(() => null); 65 - saveSessionMock = vi.fn(); 66 - 67 - vi.doMock("../session.js", () => ({ 68 - loadSession: loadSessionMock, 69 - saveSession: saveSessionMock, 70 - })); 71 - 72 - // Mock updateRateLimitState 73 - vi.doMock("../limits.js", () => ({ 74 - updateRateLimitState: vi.fn(), 75 - })); 76 - 77 - // Mock logger 78 - vi.doMock("../logger.js", () => ({ 79 - logger: { 80 - info: vi.fn(), 81 - warn: vi.fn(), 82 - error: vi.fn(), 83 - debug: vi.fn(), 84 - }, 85 - })); 86 - }); 87 - 88 - describe("agent initialization", () => { 89 - it("should create an agent with correct service URL", async () => { 90 - const { agent } = await import("../agent.js"); 91 - expect(agent.service.toString()).toBe("https://pds.test.com/"); 92 - }); 93 - 94 - it("should provide custom fetch function for rate limit headers", async () => { 95 - const { agent } = await import("../agent.js"); 96 - // @ts-expect-error - Testing custom fetch 97 - expect(agent.fetch).toBeDefined(); 98 - }); 99 - }); 100 - 101 - describe("authentication with no saved session", () => { 102 - it("should perform fresh login when no session exists", async () => { 103 - loadSessionMock.mockReturnValue(null); 104 - 105 - const { login } = await import("../agent.js"); 106 - const result = await login(); 107 - 108 - expect(loadSessionMock).toHaveBeenCalled(); 109 - expect(mockLogin).toHaveBeenCalledWith({ 110 - identifier: "test.bsky.social", 111 - password: "password", 112 - }); 113 - expect(result).toBe(true); 114 - }); 115 - 116 - it("should save session after successful login", async () => { 117 - loadSessionMock.mockReturnValue(null); 118 - 119 - const mockSession: SessionData = { 120 - accessJwt: "new-access-token", 121 - refreshJwt: "new-refresh-token", 122 - did: "did:plc:test123", 123 - handle: "test.bsky.social", 124 - active: true, 125 - }; 126 - 127 - mockLogin.mockResolvedValue({ 128 - success: true, 129 - data: mockSession, 130 - }); 131 - 132 - // Need to manually set agent.session since we're mocking 133 - const { login, agent } = await import("../agent.js"); 134 - // @ts-expect-error - Mocking session for tests 135 - agent.session = mockSession; 136 - 137 - await login(); 138 - 139 - expect(saveSessionMock).toHaveBeenCalledWith(mockSession); 140 - }); 141 - }); 142 - 143 - describe("authentication with saved session", () => { 144 - it("should resume session when valid session exists", async () => { 145 - const savedSession: SessionData = { 146 - accessJwt: "saved-access-token", 147 - refreshJwt: "saved-refresh-token", 148 - did: "did:plc:test123", 149 - handle: "test.bsky.social", 150 - active: true, 151 - }; 152 - 153 - loadSessionMock.mockReturnValue(savedSession); 154 - 155 - const { login } = await import("../agent.js"); 156 - await login(); 157 - 158 - expect(loadSessionMock).toHaveBeenCalled(); 159 - expect(mockResumeSession).toHaveBeenCalledWith(savedSession); 160 - expect(mockGetProfile).toHaveBeenCalledWith({ actor: savedSession.did }); 161 - }); 162 - 163 - it("should fallback to login when session resume fails", async () => { 164 - const savedSession: SessionData = { 165 - accessJwt: "invalid-token", 166 - refreshJwt: "invalid-refresh", 167 - did: "did:plc:test123", 168 - handle: "test.bsky.social", 169 - active: true, 170 - }; 171 - 172 - loadSessionMock.mockReturnValue(savedSession); 173 - mockResumeSession.mockRejectedValue(new Error("Invalid session")); 174 - 175 - const { login } = await import("../agent.js"); 176 - await login(); 177 - 178 - expect(mockResumeSession).toHaveBeenCalled(); 179 - expect(mockLogin).toHaveBeenCalled(); 180 - }); 181 - 182 - it("should fallback to login when profile validation fails", async () => { 183 - const savedSession: SessionData = { 184 - accessJwt: "saved-token", 185 - refreshJwt: "saved-refresh", 186 - did: "did:plc:test123", 187 - handle: "test.bsky.social", 188 - active: true, 189 - }; 190 - 191 - loadSessionMock.mockReturnValue(savedSession); 192 - mockGetProfile.mockRejectedValue(new Error("Profile not found")); 193 - 194 - const { login } = await import("../agent.js"); 195 - await login(); 196 - 197 - expect(mockResumeSession).toHaveBeenCalled(); 198 - expect(mockGetProfile).toHaveBeenCalled(); 199 - expect(mockLogin).toHaveBeenCalled(); 200 - }); 201 - }); 41 + const { agent, login } = await import("../agent.js"); 202 42 203 - describe("rate limit header extraction", () => { 204 - it("should extract rate limit headers from responses", async () => { 205 - const { updateRateLimitState } = await import("../limits.js"); 206 - const { agent } = await import("../agent.js"); 43 + // Check that the agent was created with the correct service URL 44 + expect(mockConstructor).toHaveBeenCalledWith( 45 + expect.objectContaining({ 46 + service: "https://pds.test.com", 47 + }), 48 + ); 49 + expect(agent.service.toString()).toBe("https://pds.test.com/"); 207 50 208 - // Simulate a response with rate limit headers 209 - const mockResponse = new Response(JSON.stringify({ success: true }), { 210 - headers: { 211 - "ratelimit-limit": "3000", 212 - "ratelimit-remaining": "2500", 213 - "ratelimit-reset": "1760927355", 214 - "ratelimit-policy": "3000;w=300", 215 - }, 216 - }); 217 - 218 - // @ts-expect-error - Testing custom fetch 219 - if (agent.fetch) { 220 - // @ts-expect-error - Testing custom fetch 221 - await agent.fetch("https://test.com", {}); 222 - } 223 - 224 - // updateRateLimitState should have been called if headers are processed 225 - // This is a basic check - actual implementation depends on fetch wrapper 226 - }); 227 - }); 228 - 229 - describe("session refresh", () => { 230 - it("should schedule session refresh after login", async () => { 231 - vi.useFakeTimers(); 232 - 233 - loadSessionMock.mockReturnValue(null); 234 - 235 - const mockSession: SessionData = { 236 - accessJwt: "access-token", 237 - refreshJwt: "refresh-token", 238 - did: "did:plc:test123", 239 - handle: "test.bsky.social", 240 - active: true, 241 - }; 242 - 243 - mockLogin.mockResolvedValue({ 244 - success: true, 245 - data: mockSession, 246 - }); 247 - 248 - const { login, agent } = await import("../agent.js"); 249 - // @ts-expect-error - Mocking session for tests 250 - agent.session = mockSession; 251 - 252 - await login(); 253 - 254 - // Fast-forward time to trigger refresh (2 hours * 0.8 = 96 minutes) 255 - vi.advanceTimersByTime(96 * 60 * 1000); 256 - 257 - vi.useRealTimers(); 258 - }); 259 - }); 260 - 261 - describe("error handling", () => { 262 - it("should return false on login failure", async () => { 263 - loadSessionMock.mockReturnValue(null); 264 - mockLogin.mockResolvedValue({ success: false }); 265 - 266 - const { login } = await import("../agent.js"); 267 - const result = await login(); 268 - 269 - expect(result).toBe(false); 270 - }); 271 - 272 - it("should return false when login throws error", async () => { 273 - loadSessionMock.mockReturnValue(null); 274 - mockLogin.mockRejectedValue(new Error("Network error")); 275 - 276 - const { login } = await import("../agent.js"); 277 - const result = await login(); 278 - 279 - expect(result).toBe(false); 51 + // Check that the login function calls the mockLogin function 52 + await login(); 53 + expect(mockLogin).toHaveBeenCalledWith({ 54 + identifier: "test.bsky.social", 55 + password: "password", 280 56 }); 281 57 }); 282 58 });
+21 -200
src/tests/limits.test.ts
··· 1 - import { describe, expect, it, beforeEach, vi } from "vitest"; 2 - import { limit, getRateLimitState, updateRateLimitState } from "../limits.js"; 1 + import { describe, expect, it } from "vitest"; 2 + import { limit } from "../limits.js"; 3 3 4 4 describe("Rate Limiter", () => { 5 - beforeEach(() => { 6 - // Reset rate limit state before each test 7 - updateRateLimitState({ 8 - limit: 280, 9 - remaining: 280, 10 - reset: Math.floor(Date.now() / 1000) + 30, 11 - }); 12 - }); 13 - 14 - describe("limit", () => { 15 - it("should limit the rate of calls", async () => { 16 - const calls = []; 17 - for (let i = 0; i < 10; i++) { 18 - calls.push(limit(() => Promise.resolve(Date.now()))); 19 - } 20 - 21 - const start = Date.now(); 22 - const results = await Promise.all(calls); 23 - const end = Date.now(); 24 - 25 - expect(results.length).toBe(10); 26 - for (const result of results) { 27 - expect(typeof result).toBe("number"); 28 - } 29 - expect(end - start).toBeGreaterThanOrEqual(0); 30 - }, 40000); 5 + it("should limit the rate of calls", async () => { 6 + const calls = []; 7 + for (let i = 0; i < 10; i++) { 8 + calls.push(limit(() => Promise.resolve(Date.now()))); 9 + } 31 10 32 - it("should execute function and return result", async () => { 33 - const result = await limit(() => Promise.resolve(42)); 34 - expect(result).toBe(42); 35 - }); 11 + const start = Date.now(); 12 + const results = await Promise.all(calls); 13 + const end = Date.now(); 36 14 37 - it("should handle errors from wrapped function", async () => { 38 - await expect( 39 - limit(() => Promise.reject(new Error("test error"))) 40 - ).rejects.toThrow("test error"); 41 - }); 42 - 43 - it("should handle multiple concurrent requests", async () => { 44 - const results = await Promise.all([ 45 - limit(() => Promise.resolve(1)), 46 - limit(() => Promise.resolve(2)), 47 - limit(() => Promise.resolve(3)), 48 - ]); 49 - 50 - expect(results).toEqual([1, 2, 3]); 51 - }); 52 - }); 53 - 54 - describe("getRateLimitState", () => { 55 - it("should return current rate limit state", () => { 56 - const state = getRateLimitState(); 57 - 58 - expect(state).toHaveProperty("limit"); 59 - expect(state).toHaveProperty("remaining"); 60 - expect(state).toHaveProperty("reset"); 61 - expect(typeof state.limit).toBe("number"); 62 - expect(typeof state.remaining).toBe("number"); 63 - expect(typeof state.reset).toBe("number"); 64 - }); 65 - 66 - it("should return a copy of state", () => { 67 - const state1 = getRateLimitState(); 68 - const state2 = getRateLimitState(); 69 - 70 - expect(state1).toEqual(state2); 71 - expect(state1).not.toBe(state2); // Different object references 72 - }); 73 - }); 74 - 75 - describe("updateRateLimitState", () => { 76 - it("should update limit", () => { 77 - updateRateLimitState({ limit: 500 }); 78 - const state = getRateLimitState(); 79 - expect(state.limit).toBe(500); 80 - }); 81 - 82 - it("should update remaining", () => { 83 - updateRateLimitState({ remaining: 100 }); 84 - const state = getRateLimitState(); 85 - expect(state.remaining).toBe(100); 86 - }); 87 - 88 - it("should update reset", () => { 89 - const newReset = Math.floor(Date.now() / 1000) + 60; 90 - updateRateLimitState({ reset: newReset }); 91 - const state = getRateLimitState(); 92 - expect(state.reset).toBe(newReset); 93 - }); 94 - 95 - it("should update policy", () => { 96 - updateRateLimitState({ policy: "3000;w=300" }); 97 - const state = getRateLimitState(); 98 - expect(state.policy).toBe("3000;w=300"); 99 - }); 100 - 101 - it("should update multiple fields at once", () => { 102 - const updates = { 103 - limit: 3000, 104 - remaining: 2500, 105 - reset: Math.floor(Date.now() / 1000) + 300, 106 - policy: "3000;w=300", 107 - }; 108 - 109 - updateRateLimitState(updates); 110 - const state = getRateLimitState(); 111 - 112 - expect(state.limit).toBe(3000); 113 - expect(state.remaining).toBe(2500); 114 - expect(state.reset).toBe(updates.reset); 115 - expect(state.policy).toBe("3000;w=300"); 116 - }); 117 - 118 - it("should preserve unspecified fields", () => { 119 - updateRateLimitState({ 120 - limit: 3000, 121 - remaining: 2500, 122 - reset: Math.floor(Date.now() / 1000) + 300, 123 - }); 124 - 125 - updateRateLimitState({ remaining: 2000 }); 126 - 127 - const state = getRateLimitState(); 128 - expect(state.limit).toBe(3000); // Preserved 129 - expect(state.remaining).toBe(2000); // Updated 130 - }); 131 - }); 132 - 133 - describe("awaitRateLimit", () => { 134 - it("should not wait when remaining is above safety buffer", async () => { 135 - updateRateLimitState({ remaining: 100 }); 136 - 137 - const start = Date.now(); 138 - await limit(() => Promise.resolve(1)); 139 - const elapsed = Date.now() - start; 140 - 141 - // Should complete almost immediately (< 100ms) 142 - expect(elapsed).toBeLessThan(100); 143 - }); 144 - 145 - it("should wait when remaining is at safety buffer", async () => { 146 - const now = Math.floor(Date.now() / 1000); 147 - updateRateLimitState({ 148 - remaining: 5, // At safety buffer 149 - reset: now + 1, // Reset in 1 second 150 - }); 151 - 152 - const start = Date.now(); 153 - await limit(() => Promise.resolve(1)); 154 - const elapsed = Date.now() - start; 155 - 156 - // Should wait approximately 1 second 157 - expect(elapsed).toBeGreaterThanOrEqual(900); 158 - expect(elapsed).toBeLessThan(1500); 159 - }, 10000); 160 - 161 - it("should wait when remaining is below safety buffer", async () => { 162 - const now = Math.floor(Date.now() / 1000); 163 - updateRateLimitState({ 164 - remaining: 2, // Below safety buffer 165 - reset: now + 1, // Reset in 1 second 166 - }); 167 - 168 - const start = Date.now(); 169 - await limit(() => Promise.resolve(1)); 170 - const elapsed = Date.now() - start; 171 - 172 - // Should wait approximately 1 second 173 - expect(elapsed).toBeGreaterThanOrEqual(900); 174 - expect(elapsed).toBeLessThan(1500); 175 - }, 10000); 176 - 177 - it("should not wait if reset time has passed", async () => { 178 - const now = Math.floor(Date.now() / 1000); 179 - updateRateLimitState({ 180 - remaining: 2, 181 - reset: now - 10, // Reset was 10 seconds ago 182 - }); 183 - 184 - const start = Date.now(); 185 - await limit(() => Promise.resolve(1)); 186 - const elapsed = Date.now() - start; 187 - 188 - // Should not wait 189 - expect(elapsed).toBeLessThan(100); 190 - }); 191 - }); 192 - 193 - describe("metrics", () => { 194 - it("should track concurrent requests", async () => { 195 - const delays = [100, 100, 100]; 196 - const promises = delays.map((delay) => 197 - limit(() => new Promise((resolve) => setTimeout(resolve, delay))) 198 - ); 199 - 200 - await Promise.all(promises); 201 - // If this completes without error, concurrent tracking works 202 - expect(true).toBe(true); 203 - }); 204 - }); 15 + // With a concurrency of 4, 10 calls should take at least 2 intervals. 16 + // However, the interval is 30 seconds, so this test would be very slow. 17 + // Instead, we'll just check that the calls were successful and returned a timestamp. 18 + expect(results.length).toBe(10); 19 + for (const result of results) { 20 + expect(typeof result).toBe("number"); 21 + } 22 + // A better test would be to mock the timer and advance it, but that's more complex. 23 + // For now, we'll just check that the time taken is greater than 0. 24 + expect(end - start).toBeGreaterThanOrEqual(0); 25 + }, 40000); // Increase timeout for this test 205 26 });
+3 -3
src/tests/metrics.test.ts
··· 1 - import { Server } from "http"; 1 + import type { Server } from "http"; 2 2 import request from "supertest"; 3 - import { describe, expect, it } from "vitest"; 3 + import { afterEach, describe, expect, it } from "vitest"; 4 4 import { startMetricsServer } from "../metrics.js"; 5 5 6 6 describe("Metrics Server", () => { 7 - let server: Server; 7 + let server: Server | undefined; 8 8 9 9 afterEach(() => { 10 10 if (server) {
+54 -26
src/tests/moderation.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + // --- Imports Second --- 3 + import { checkAccountLabels } from "../accountModeration.js"; 4 + import { agent } from "../agent.js"; 5 + import { createPostLabel } from "../moderation.js"; 6 + import { tryClaimPostLabel } from "../redis.js"; 2 7 3 8 // --- Mocks First --- 4 9 ··· 39 44 limit: vi.fn((fn) => fn()), 40 45 })); 41 46 42 - // --- Imports Second --- 43 - 44 - import { agent } from "../agent.js"; 45 - import { checkAccountLabels, createPostLabel } from "../moderation.js"; 46 - import { tryClaimPostLabel } from "../redis.js"; 47 - import { logger } from "../logger.js"; 48 - 49 47 describe("Moderation Logic", () => { 50 48 beforeEach(() => { 51 49 vi.clearAllMocks(); ··· 56 54 vi.mocked(agent.tools.ozone.moderation.getRepo).mockResolvedValueOnce({ 57 55 data: { 58 56 labels: [ 59 - { val: "spam", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" }, 60 - { val: "window-reply", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" } 61 - ] 57 + { 58 + val: "spam", 59 + src: "did:plc:test", 60 + uri: "at://test", 61 + cts: "2024-01-01T00:00:00Z", 62 + }, 63 + { 64 + val: "window-reply", 65 + src: "did:plc:test", 66 + uri: "at://test", 67 + cts: "2024-01-01T00:00:00Z", 68 + }, 69 + ], 62 70 }, 63 71 } as any); 64 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 72 + const result = await checkAccountLabels( 73 + "did:plc:test123", 74 + "window-reply", 75 + ); 65 76 expect(result).toBe(true); 66 77 }); 67 78 }); ··· 78 89 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 79 90 80 91 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 81 - expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).not.toHaveBeenCalled(); 82 - expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled(); 92 + expect( 93 + vi.mocked(agent.tools.ozone.moderation.getRecord), 94 + ).not.toHaveBeenCalled(); 95 + expect( 96 + vi.mocked(agent.tools.ozone.moderation.emitEvent), 97 + ).not.toHaveBeenCalled(); 83 98 }); 84 99 85 100 it("should skip event if claimed but already labeled via API", async () => { 86 101 vi.mocked(tryClaimPostLabel).mockResolvedValue(true); 87 102 vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 88 - data: { labels: [{ val: LABEL, src: "did:plc:test", uri: URI, cts: "2024-01-01T00:00:00Z" }] }, 103 + data: { 104 + labels: [ 105 + { 106 + val: LABEL, 107 + src: "did:plc:test", 108 + uri: URI, 109 + cts: "2024-01-01T00:00:00Z", 110 + }, 111 + ], 112 + }, 89 113 } as any); 90 114 91 115 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 92 116 93 117 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 94 - expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith( 95 - { uri: URI }, 96 - expect.any(Object), 97 - ); 98 - expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled(); 118 + expect( 119 + vi.mocked(agent.tools.ozone.moderation.getRecord), 120 + ).toHaveBeenCalledWith({ uri: URI }, expect.any(Object)); 121 + expect( 122 + vi.mocked(agent.tools.ozone.moderation.emitEvent), 123 + ).not.toHaveBeenCalled(); 99 124 }); 100 125 101 126 it("should emit event if claimed and not labeled anywhere", async () => { ··· 103 128 vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 104 129 data: { labels: [] }, 105 130 } as any); 106 - vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({ success: true } as any); 131 + vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({ 132 + success: true, 133 + } as any); 107 134 108 135 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 109 136 110 137 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 111 - expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith( 112 - { uri: URI }, 113 - expect.any(Object), 114 - ); 115 - expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).toHaveBeenCalled(); 138 + expect( 139 + vi.mocked(agent.tools.ozone.moderation.getRecord), 140 + ).toHaveBeenCalledWith({ uri: URI }, expect.any(Object)); 141 + expect( 142 + vi.mocked(agent.tools.ozone.moderation.emitEvent), 143 + ).toHaveBeenCalled(); 116 144 }); 117 145 }); 118 - }); 146 + });
+269 -42
src/tests/redis.test.ts
··· 1 - import { afterEach, describe, expect, it, vi } from 'vitest'; 1 + // Import the mocked redis first to get a reference to the mock client 2 + import { createClient } from "redis"; 3 + import { afterEach, describe, expect, it, vi } from "vitest"; 4 + import { logger } from "../logger.js"; 5 + // Import the modules to be tested 6 + import { 7 + connectRedis, 8 + disconnectRedis, 9 + getPostLabelCountInWindow, 10 + getStarterPackCountInWindow, 11 + trackPostLabelForAccount, 12 + trackStarterPackForAccount, 13 + tryClaimAccountLabel, 14 + tryClaimPostLabel, 15 + } from "../redis.js"; 2 16 3 17 // Mock the 'redis' module in a way that avoids hoisting issues. 4 18 // The mock implementation is self-contained. 5 - vi.mock('redis', () => { 19 + vi.mock("redis", () => { 6 20 const mockClient = { 7 21 on: vi.fn(), 8 22 connect: vi.fn(), 9 23 quit: vi.fn(), 10 24 exists: vi.fn(), 11 25 set: vi.fn(), 26 + zAdd: vi.fn(), 27 + zRemRangeByScore: vi.fn(), 28 + zCount: vi.fn(), 29 + expire: vi.fn(), 12 30 }; 13 31 return { 14 32 createClient: vi.fn(() => mockClient), 15 33 }; 16 34 }); 17 35 18 - // Import the mocked redis first to get a reference to the mock client 19 - import { createClient } from 'redis'; 20 36 const mockRedisClient = createClient(); 21 37 22 - // Import the modules to be tested 23 - import { 24 - tryClaimPostLabel, 25 - tryClaimAccountLabel, 26 - connectRedis, 27 - disconnectRedis, 28 - } from '../redis.js'; 29 - import { logger } from '../logger.js'; 30 - 31 38 // Suppress logger output during tests 32 - vi.mock('../logger.js', () => ({ 39 + vi.mock("../logger.js", () => ({ 33 40 logger: { 34 41 info: vi.fn(), 35 42 warn: vi.fn(), ··· 38 45 }, 39 46 })); 40 47 41 - describe('Redis Cache Logic', () => { 48 + describe("Redis Cache Logic", () => { 42 49 afterEach(() => { 43 50 vi.clearAllMocks(); 44 51 }); 45 52 46 - describe('Connection', () => { 47 - it('should call redisClient.connect on connectRedis', async () => { 53 + describe("Connection", () => { 54 + it("should call redisClient.connect on connectRedis", async () => { 48 55 await connectRedis(); 49 56 expect(mockRedisClient.connect).toHaveBeenCalled(); 50 57 }); 51 58 52 - it('should call redisClient.quit on disconnectRedis', async () => { 59 + it("should call redisClient.quit on disconnectRedis", async () => { 53 60 await disconnectRedis(); 54 61 expect(mockRedisClient.quit).toHaveBeenCalled(); 55 62 }); 56 63 }); 57 64 58 - describe('tryClaimPostLabel', () => { 59 - it('should return true and set key if key does not exist', async () => { 60 - vi.mocked(mockRedisClient.set).mockResolvedValue('OK'); 61 - const result = await tryClaimPostLabel('at://uri', 'test-label'); 65 + describe("tryClaimPostLabel", () => { 66 + it("should return true and set key if key does not exist", async () => { 67 + vi.mocked(mockRedisClient.set).mockResolvedValue("OK"); 68 + const result = await tryClaimPostLabel("at://uri", "test-label"); 62 69 expect(result).toBe(true); 63 70 expect(mockRedisClient.set).toHaveBeenCalledWith( 64 - 'post-label:at://uri:test-label', 65 - '1', 66 - { NX: true, EX: 60 * 60 * 24 * 7 } 71 + "post-label:at://uri:test-label", 72 + "1", 73 + { NX: true, EX: 60 * 60 * 24 * 7 }, 67 74 ); 68 75 }); 69 76 70 - it('should return false if key already exists', async () => { 77 + it("should return false if key already exists", async () => { 71 78 vi.mocked(mockRedisClient.set).mockResolvedValue(null); 72 - const result = await tryClaimPostLabel('at://uri', 'test-label'); 79 + const result = await tryClaimPostLabel("at://uri", "test-label"); 73 80 expect(result).toBe(false); 74 81 }); 75 82 76 - it('should return true and log warning on Redis error', async () => { 77 - const redisError = new Error('Redis down'); 83 + it("should return true and log warning on Redis error", async () => { 84 + const redisError = new Error("Redis down"); 78 85 vi.mocked(mockRedisClient.set).mockRejectedValue(redisError); 79 - const result = await tryClaimPostLabel('at://uri', 'test-label'); 86 + const result = await tryClaimPostLabel("at://uri", "test-label"); 80 87 expect(result).toBe(true); 81 88 expect(logger.warn).toHaveBeenCalledWith( 82 - { err: redisError, atURI: 'at://uri', label: 'test-label' }, 83 - 'Error claiming post label in Redis, allowing through' 89 + { err: redisError, atURI: "at://uri", label: "test-label" }, 90 + "Error claiming post label in Redis, allowing through", 84 91 ); 85 92 }); 86 93 }); 87 94 88 - describe('tryClaimAccountLabel', () => { 89 - it('should return true and set key if key does not exist', async () => { 90 - vi.mocked(mockRedisClient.set).mockResolvedValue('OK'); 91 - const result = await tryClaimAccountLabel('did:plc:123', 'test-label'); 95 + describe("tryClaimAccountLabel", () => { 96 + it("should return true and set key if key does not exist", async () => { 97 + vi.mocked(mockRedisClient.set).mockResolvedValue("OK"); 98 + const result = await tryClaimAccountLabel("did:plc:123", "test-label"); 92 99 expect(result).toBe(true); 93 100 expect(mockRedisClient.set).toHaveBeenCalledWith( 94 - 'account-label:did:plc:123:test-label', 95 - '1', 96 - { NX: true, EX: 60 * 60 * 24 * 7 } 101 + "account-label:did:plc:123:test-label", 102 + "1", 103 + { NX: true, EX: 60 * 60 * 24 * 7 }, 97 104 ); 98 105 }); 99 106 100 - it('should return false if key already exists', async () => { 107 + it("should return false if key already exists", async () => { 101 108 vi.mocked(mockRedisClient.set).mockResolvedValue(null); 102 - const result = await tryClaimAccountLabel('did:plc:123', 'test-label'); 109 + const result = await tryClaimAccountLabel("did:plc:123", "test-label"); 103 110 expect(result).toBe(false); 104 111 }); 105 112 }); 106 - }); 113 + 114 + describe("trackPostLabelForAccount", () => { 115 + it("should track post label with correct timestamp and TTL", async () => { 116 + vi.mocked(mockRedisClient.zRemRangeByScore).mockResolvedValue(0); 117 + vi.mocked(mockRedisClient.zAdd).mockResolvedValue(1); 118 + vi.mocked(mockRedisClient.expire).mockResolvedValue(true); 119 + 120 + const timestamp = 1640000000000000; // microseconds 121 + const window = 5; 122 + const windowUnit = "days" as const; 123 + 124 + await trackPostLabelForAccount( 125 + "did:plc:123", 126 + "test-label", 127 + timestamp, 128 + window, 129 + windowUnit, 130 + ); 131 + 132 + const expectedKey = "account-post-labels:did:plc:123:test-label:5days"; 133 + const windowStartTime = timestamp - window * 24 * 60 * 60 * 1000000; 134 + 135 + expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith( 136 + expectedKey, 137 + "-inf", 138 + windowStartTime, 139 + ); 140 + expect(mockRedisClient.zAdd).toHaveBeenCalledWith(expectedKey, { 141 + score: timestamp, 142 + value: timestamp.toString(), 143 + }); 144 + expect(mockRedisClient.expire).toHaveBeenCalledWith( 145 + expectedKey, 146 + window * 24 * 60 * 60 + 60 * 60, 147 + ); 148 + }); 149 + 150 + it("should throw error on Redis failure", async () => { 151 + const redisError = new Error("Redis down"); 152 + vi.mocked(mockRedisClient.zRemRangeByScore).mockRejectedValue(redisError); 153 + 154 + await expect( 155 + trackPostLabelForAccount( 156 + "did:plc:123", 157 + "test-label", 158 + 1640000000000000, 159 + 5, 160 + "days", 161 + ), 162 + ).rejects.toThrow("Redis down"); 163 + 164 + expect(logger.error).toHaveBeenCalled(); 165 + }); 166 + }); 167 + 168 + describe("trackStarterPackForAccount", () => { 169 + it("should track starter pack with correct timestamp and TTL", async () => { 170 + vi.mocked(mockRedisClient.zRemRangeByScore).mockResolvedValue(0); 171 + vi.mocked(mockRedisClient.zAdd).mockResolvedValue(1); 172 + vi.mocked(mockRedisClient.expire).mockResolvedValue(true); 173 + 174 + const timestamp = 1640000000000000; 175 + const window = 24; 176 + const windowUnit = "hours" as const; 177 + 178 + await trackStarterPackForAccount( 179 + "did:plc:123", 180 + "at://did:plc:123/app.bsky.graph.starterpack/abc", 181 + timestamp, 182 + window, 183 + windowUnit, 184 + ); 185 + 186 + const expectedKey = "starterpack:threshold:did:plc:123:24hours"; 187 + const windowStartTime = timestamp - window * 60 * 60 * 1000000; 188 + 189 + expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith( 190 + expectedKey, 191 + "-inf", 192 + windowStartTime, 193 + ); 194 + expect(mockRedisClient.zAdd).toHaveBeenCalledWith(expectedKey, { 195 + score: timestamp, 196 + value: "at://did:plc:123/app.bsky.graph.starterpack/abc", 197 + }); 198 + expect(mockRedisClient.expire).toHaveBeenCalledWith( 199 + expectedKey, 200 + window * 60 * 60 + 60 * 60, 201 + ); 202 + }); 203 + 204 + it("should throw error on Redis failure", async () => { 205 + const redisError = new Error("Redis down"); 206 + vi.mocked(mockRedisClient.zRemRangeByScore).mockRejectedValue(redisError); 207 + 208 + await expect( 209 + trackStarterPackForAccount( 210 + "did:plc:123", 211 + "at://did:plc:123/app.bsky.graph.starterpack/abc", 212 + 1640000000000000, 213 + 24, 214 + "hours", 215 + ), 216 + ).rejects.toThrow("Redis down"); 217 + 218 + expect(logger.error).toHaveBeenCalled(); 219 + }); 220 + }); 221 + 222 + describe("getStarterPackCountInWindow", () => { 223 + it("should count starter packs in window", async () => { 224 + vi.mocked(mockRedisClient.zCount).mockResolvedValue(3); 225 + 226 + const currentTime = 1640000000000000; 227 + const window = 24; 228 + const windowUnit = "hours" as const; 229 + const count = await getStarterPackCountInWindow( 230 + "did:plc:123", 231 + window, 232 + windowUnit, 233 + currentTime, 234 + ); 235 + 236 + expect(count).toBe(3); 237 + const windowStartTime = currentTime - window * 60 * 60 * 1000000; 238 + expect(mockRedisClient.zCount).toHaveBeenCalledWith( 239 + "starterpack:threshold:did:plc:123:24hours", 240 + windowStartTime, 241 + "+inf", 242 + ); 243 + }); 244 + 245 + it("should throw error on Redis failure", async () => { 246 + const redisError = new Error("Redis down"); 247 + vi.mocked(mockRedisClient.zCount).mockRejectedValue(redisError); 248 + 249 + await expect( 250 + getStarterPackCountInWindow("did:plc:123", 24, "hours", 1640000000000000), 251 + ).rejects.toThrow("Redis down"); 252 + 253 + expect(logger.error).toHaveBeenCalled(); 254 + }); 255 + }); 256 + 257 + describe("getPostLabelCountInWindow", () => { 258 + it("should count posts for single label", async () => { 259 + vi.mocked(mockRedisClient.zCount).mockResolvedValue(3); 260 + 261 + const currentTime = 1640000000000000; 262 + const window = 5; 263 + const windowUnit = "days" as const; 264 + const count = await getPostLabelCountInWindow( 265 + "did:plc:123", 266 + ["test-label"], 267 + window, 268 + windowUnit, 269 + currentTime, 270 + ); 271 + 272 + expect(count).toBe(3); 273 + const windowStartTime = currentTime - window * 24 * 60 * 60 * 1000000; 274 + expect(mockRedisClient.zCount).toHaveBeenCalledWith( 275 + "account-post-labels:did:plc:123:test-label:5days", 276 + windowStartTime, 277 + "+inf", 278 + ); 279 + }); 280 + 281 + it("should sum counts for multiple labels (OR logic)", async () => { 282 + vi.mocked(mockRedisClient.zCount) 283 + .mockResolvedValueOnce(3) 284 + .mockResolvedValueOnce(2) 285 + .mockResolvedValueOnce(1); 286 + 287 + const currentTime = 1640000000000000; 288 + const window = 5; 289 + const windowUnit = "days" as const; 290 + const count = await getPostLabelCountInWindow( 291 + "did:plc:123", 292 + ["label-1", "label-2", "label-3"], 293 + window, 294 + windowUnit, 295 + currentTime, 296 + ); 297 + 298 + expect(count).toBe(6); 299 + expect(mockRedisClient.zCount).toHaveBeenCalledTimes(3); 300 + }); 301 + 302 + it("should return 0 when no posts in window", async () => { 303 + vi.mocked(mockRedisClient.zCount).mockResolvedValue(0); 304 + 305 + const count = await getPostLabelCountInWindow( 306 + "did:plc:123", 307 + ["test-label"], 308 + 5, 309 + "days", 310 + 1640000000000000, 311 + ); 312 + 313 + expect(count).toBe(0); 314 + }); 315 + 316 + it("should throw error on Redis failure", async () => { 317 + const redisError = new Error("Redis down"); 318 + vi.mocked(mockRedisClient.zCount).mockRejectedValue(redisError); 319 + 320 + await expect( 321 + getPostLabelCountInWindow( 322 + "did:plc:123", 323 + ["test-label"], 324 + 5, 325 + "days", 326 + 1640000000000000, 327 + ), 328 + ).rejects.toThrow("Redis down"); 329 + 330 + expect(logger.error).toHaveBeenCalled(); 331 + }); 332 + }); 333 + });
+6 -6
src/tests/session.test.ts
··· 1 - import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 1 import { 2 + chmodSync, 3 3 existsSync, 4 4 mkdirSync, 5 - rmSync, 6 - writeFileSync, 7 5 readFileSync, 6 + rmSync, 8 7 unlinkSync, 9 - chmodSync, 8 + writeFileSync, 10 9 } from "node:fs"; 11 10 import { join } from "node:path"; 11 + import { afterEach, beforeEach, describe, expect, it } from "vitest"; 12 12 import type { SessionData } from "../session.js"; 13 13 14 14 const TEST_DIR = join(process.cwd(), ".test-session"); ··· 136 136 writeFileSync( 137 137 TEST_SESSION_PATH, 138 138 JSON.stringify({ accessJwt: "token" }), 139 - "utf-8" 139 + "utf-8", 140 140 ); 141 141 142 142 const session = testLoadSession(); ··· 151 151 refreshJwt: "refresh", 152 152 handle: "test.bsky.social", 153 153 }), 154 - "utf-8" 154 + "utf-8", 155 155 ); 156 156 157 157 const session = testLoadSession();
+201
src/tests/starterPackThreshold.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 { logger } from "../logger.js"; 8 + import { 9 + starterPackLabelsThresholdAppliedCounter, 10 + starterPackThresholdChecksCounter, 11 + starterPackThresholdMetCounter, 12 + } from "../metrics.js"; 13 + import { 14 + getStarterPackCountInWindow, 15 + trackStarterPackForAccount, 16 + } from "../redis.js"; 17 + import { 18 + checkStarterPackThreshold, 19 + loadStarterPackThresholdConfigs, 20 + } from "../starterPackThreshold.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/starterPackThreshold.js", () => ({ 32 + STARTER_PACK_THRESHOLD_CONFIGS: [ 33 + { 34 + threshold: 5, 35 + window: 24, 36 + windowUnit: "hours", 37 + accountLabel: "starter-pack-spam", 38 + accountComment: "Too many starter packs", 39 + toLabel: true, 40 + reportAcct: true, 41 + commentAcct: false, 42 + allowlist: ["did:plc:allowed123"], 43 + }, 44 + { 45 + threshold: 10, 46 + window: 7, 47 + windowUnit: "days", 48 + accountLabel: "starter-pack-abuse", 49 + accountComment: "Excessive starter pack creation", 50 + toLabel: true, 51 + reportAcct: false, 52 + commentAcct: true, 53 + allowlist: [], 54 + }, 55 + ], 56 + })); 57 + 58 + vi.mock("../redis.js", () => ({ 59 + trackStarterPackForAccount: vi.fn(), 60 + getStarterPackCountInWindow: vi.fn(), 61 + })); 62 + 63 + vi.mock("../accountModeration.js", () => ({ 64 + createAccountLabel: vi.fn(), 65 + createAccountReport: vi.fn(), 66 + createAccountComment: vi.fn(), 67 + })); 68 + 69 + vi.mock("../metrics.js", () => ({ 70 + starterPackLabelsThresholdAppliedCounter: { 71 + inc: vi.fn(), 72 + }, 73 + starterPackThresholdChecksCounter: { 74 + inc: vi.fn(), 75 + }, 76 + starterPackThresholdMetCounter: { 77 + inc: vi.fn(), 78 + }, 79 + })); 80 + 81 + describe("Starter Pack Threshold Logic", () => { 82 + afterEach(() => { 83 + vi.clearAllMocks(); 84 + }); 85 + 86 + describe("loadStarterPackThresholdConfigs", () => { 87 + it("should load and cache configs successfully", () => { 88 + const configs = loadStarterPackThresholdConfigs(); 89 + expect(configs).toHaveLength(2); 90 + expect(configs[0].threshold).toBe(5); 91 + expect(configs[1].threshold).toBe(10); 92 + }); 93 + }); 94 + 95 + describe("checkStarterPackThreshold", () => { 96 + const testDid = "did:plc:test123"; 97 + const testUri = "at://did:plc:test123/app.bsky.graph.starterpack/abc"; 98 + const testTimestamp = 1640000000000000; 99 + 100 + it("should skip threshold check for allowlisted accounts", async () => { 101 + vi.mocked(trackStarterPackForAccount).mockResolvedValue(); 102 + vi.mocked(getStarterPackCountInWindow).mockResolvedValue(0); 103 + 104 + await checkStarterPackThreshold( 105 + "did:plc:allowed123", 106 + testUri, 107 + testTimestamp, 108 + ); 109 + 110 + expect(starterPackThresholdChecksCounter.inc).toHaveBeenCalled(); 111 + // Should skip first config (allowlist), but process second config 112 + expect(trackStarterPackForAccount).toHaveBeenCalledTimes(1); 113 + expect(logger.debug).toHaveBeenCalledWith( 114 + expect.objectContaining({ did: "did:plc:allowed123" }), 115 + "Account is in allowlist, skipping threshold check", 116 + ); 117 + }); 118 + 119 + it("should track and check threshold for non-allowlisted accounts", async () => { 120 + vi.mocked(trackStarterPackForAccount).mockResolvedValue(); 121 + vi.mocked(getStarterPackCountInWindow).mockResolvedValue(3); 122 + 123 + await checkStarterPackThreshold(testDid, testUri, testTimestamp); 124 + 125 + expect(starterPackThresholdChecksCounter.inc).toHaveBeenCalled(); 126 + expect(trackStarterPackForAccount).toHaveBeenCalledWith( 127 + testDid, 128 + testUri, 129 + testTimestamp, 130 + 24, 131 + "hours", 132 + ); 133 + expect(getStarterPackCountInWindow).toHaveBeenCalledWith( 134 + testDid, 135 + 24, 136 + "hours", 137 + testTimestamp, 138 + ); 139 + }); 140 + 141 + it("should apply account label when threshold is met", async () => { 142 + vi.mocked(trackStarterPackForAccount).mockResolvedValue(); 143 + vi.mocked(getStarterPackCountInWindow).mockResolvedValue(5); 144 + vi.mocked(createAccountLabel).mockResolvedValue(); 145 + vi.mocked(createAccountReport).mockResolvedValue(); 146 + 147 + await checkStarterPackThreshold(testDid, testUri, testTimestamp); 148 + 149 + expect(starterPackThresholdMetCounter.inc).toHaveBeenCalledWith({ 150 + account_label: "starter-pack-spam", 151 + }); 152 + expect(createAccountLabel).toHaveBeenCalledWith( 153 + testDid, 154 + "starter-pack-spam", 155 + expect.stringContaining("Too many starter packs"), 156 + ); 157 + expect(createAccountReport).toHaveBeenCalled(); 158 + expect(starterPackLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 159 + account_label: "starter-pack-spam", 160 + action: "label", 161 + }); 162 + }); 163 + 164 + it("should not apply label when threshold not met", async () => { 165 + vi.mocked(trackStarterPackForAccount).mockResolvedValue(); 166 + vi.mocked(getStarterPackCountInWindow).mockResolvedValue(3); 167 + 168 + await checkStarterPackThreshold(testDid, testUri, testTimestamp); 169 + 170 + expect(starterPackThresholdMetCounter.inc).not.toHaveBeenCalled(); 171 + expect(createAccountLabel).not.toHaveBeenCalled(); 172 + }); 173 + 174 + it("should handle Redis errors", async () => { 175 + const redisError = new Error("Redis connection failed"); 176 + vi.mocked(trackStarterPackForAccount).mockRejectedValue(redisError); 177 + 178 + await expect( 179 + checkStarterPackThreshold(testDid, testUri, testTimestamp), 180 + ).rejects.toThrow("Redis connection failed"); 181 + 182 + expect(logger.error).toHaveBeenCalled(); 183 + }); 184 + 185 + it("should check all configs for each starter pack", async () => { 186 + vi.mocked(trackStarterPackForAccount).mockResolvedValue(); 187 + vi.mocked(getStarterPackCountInWindow) 188 + .mockResolvedValueOnce(5) 189 + .mockResolvedValueOnce(10); 190 + vi.mocked(createAccountLabel).mockResolvedValue(); 191 + vi.mocked(createAccountReport).mockResolvedValue(); 192 + vi.mocked(createAccountComment).mockResolvedValue(); 193 + 194 + await checkStarterPackThreshold(testDid, testUri, testTimestamp); 195 + 196 + expect(trackStarterPackForAccount).toHaveBeenCalledTimes(2); 197 + expect(getStarterPackCountInWindow).toHaveBeenCalledTimes(2); 198 + expect(createAccountLabel).toHaveBeenCalledTimes(2); 199 + }); 200 + }); 201 + });
+46 -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; 6 + unlabel?: boolean; 4 7 comment: string; 5 8 description?: boolean; 6 9 displayName?: boolean; ··· 8 11 commentAcct: boolean; 9 12 reportPost?: boolean; 10 13 toLabel: boolean; 14 + trackOnly?: boolean; 11 15 duration?: number; 12 16 check: RegExp; 13 17 whitelist?: RegExp; ··· 38 42 description?: string; 39 43 } 40 44 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 45 export interface List { 48 46 label: string; 49 47 rkey: string; 50 48 } 51 49 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 - } 50 + // Re-export facet types from @atproto/ozone for convenience 51 + export type Facet = AppBskyRichtextFacet.Main; 52 + export type FacetIndex = AppBskyRichtextFacet.ByteSlice; 53 + export type FacetMention = AppBskyRichtextFacet.Mention; 54 + export type LinkFeature = AppBskyRichtextFacet.Link; 55 + export type FacetTag = AppBskyRichtextFacet.Tag; 61 56 62 57 export interface AccountAgeCheck { 63 58 monitoredDIDs?: string[]; // DIDs to monitor for replies (optional if monitoredPostURIs is provided) ··· 68 63 comment: string; // Comment for the label 69 64 expires?: string; // Optional expiration date (ISO 8601) - check will be skipped after this date 70 65 } 66 + 67 + export type WindowUnit = "minutes" | "hours" | "days"; 68 + 69 + export interface AccountThresholdConfig { 70 + labels: string | string[]; // Single label or array for OR matching 71 + threshold: number; // Number of labeled posts required to trigger account action 72 + accountLabel: string; // Label to apply to the account 73 + accountComment: string; // Comment for the account action 74 + window: number; // Rolling window duration 75 + windowUnit: WindowUnit; // Unit for the rolling window 76 + reportAcct: boolean; // Whether to report the account 77 + commentAcct: boolean; // Whether to comment on the account 78 + toLabel?: boolean; // Whether to apply label (defaults to true) 79 + } 80 + 81 + export interface StarterPackThresholdConfig { 82 + threshold: number; 83 + window: number; 84 + windowUnit: WindowUnit; 85 + accountLabel: string; 86 + accountComment: string; 87 + toLabel?: boolean; 88 + reportAcct?: boolean; 89 + commentAcct?: boolean; 90 + allowlist?: string[]; 91 + } 92 + 93 + export interface ModerationError { 94 + action: "label" | "report" | "comment" | "unlabel"; 95 + error: unknown; 96 + } 97 + 98 + export interface ModerationResult { 99 + success: boolean; 100 + errors: ModerationError[]; 101 + }
+7 -3
src/utils/getFinalUrl.ts
··· 2 2 3 3 export async function getFinalUrl(url: string): Promise<string> { 4 4 const controller = new AbortController(); 5 - const timeoutId = setTimeout(() => controller.abort(), 15000); // 15-second timeout 5 + const timeoutId = setTimeout(() => { 6 + controller.abort(); 7 + }, 15000); // 15-second timeout 6 8 7 9 const headers = { 8 10 "User-Agent": ··· 19 21 }); 20 22 clearTimeout(timeoutId); 21 23 return response.url; 22 - } catch (headError) { 24 + } catch { 23 25 clearTimeout(timeoutId); 24 26 25 27 // Some services block HEAD requests, try GET as fallback 26 28 const getController = new AbortController(); 27 - const getTimeoutId = setTimeout(() => getController.abort(), 15000); 29 + const getTimeoutId = setTimeout(() => { 30 + getController.abort(); 31 + }, 15000); 28 32 29 33 try { 30 34 logger.debug(
-2
src/utils/homoglyphs.ts
··· 1 - /* eslint-disable no-misleading-character-class */ 2 - 3 1 export const homoglyphMap: Record<string, string> = { 4 2 // Confusables for 'a' 5 3 á: "a",
-1
src/utils/normalizeUnicode.ts
··· 1 - import { logger } from "../logger.js"; 2 1 import { homoglyphMap } from "./homoglyphs.js"; 3 2 4 3 /**