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.

+12 -2
.claude/settings.local.json
··· 12 12 "mcp__git-mcp-server__git_set_working_dir", 13 13 "Bash(npm run test:run:*)", 14 14 "Bash(bunx eslint:*)", 15 - "Bash(bun test:run:*)" 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:*)" 16 24 ], 17 25 "deny": [], 18 26 "ask": [] 19 27 }, 20 28 "enableAllProjectMcpServers": true, 21 - "enabledMcpjsonServers": ["git-mcp-server"] 29 + "enabledMcpjsonServers": [ 30 + "git-mcp-server" 31 + ] 22 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
+1
.gitignore
··· 5 5 labels.db* 6 6 .DS_Store 7 7 coverage/ 8 + .session
-10
.session
··· 1 - { 2 - "accessJwt": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NksifQ.eyJzY29wZSI6ImNvbS5hdHByb3RvLmFwcFBhc3MiLCJzdWIiOiJkaWQ6cGxjOmpzamxoajM1NzRvZHRmcWF6cXhuNG9uZCIsImlhdCI6MTc2MTA3OTUzMSwiZXhwIjoxNzYxMDg2NzMxLCJhdWQiOiJkaWQ6d2ViOm95c3RlcmxpbmcudXMtd2VzdC5ob3N0LmJza3kubmV0d29yayJ9.2EPsA8yDLvngSPzOu-DHy-2SQCjgzk4wFxgsOL7BXq1gwmRkoJy_Poykjb8m9JeYt9_s08-VCM_h1C43FOVosg", 3 - "refreshJwt": "eyJ0eXAiOiJyZWZyZXNoK2p3dCIsImFsZyI6IkVTMjU2SyJ9.eyJzY29wZSI6ImNvbS5hdHByb3RvLnJlZnJlc2giLCJzdWIiOiJkaWQ6cGxjOmpzamxoajM1NzRvZHRmcWF6cXhuNG9uZCIsImF1ZCI6ImRpZDp3ZWI6YnNreS5zb2NpYWwiLCJqdGkiOiJpOWVKcTVHa0VQeS9ISVV0YWtUb0dqMW55Mllzb25PK0VGMHUySGRoNFNFIiwiaWF0IjoxNzYxMDc5NTMxLCJleHAiOjE3Njg4NTU1MzF9.5YawggT0amOGgZryO5h2kJ11ePimtc0YMqs8W-ZzxPkU8aymD0m29w4_wZXeyoK4vclU-YvlUc9iDr5SgrqJ2w", 4 - "handle": "automod.skywatch.blue", 5 - "did": "did:plc:jsjlhj3574odtfqazqxn4ond", 6 - "email": "bsky.duration409@passmail.net", 7 - "emailConfirmed": true, 8 - "emailAuthFactor": true, 9 - "active": true 10 - }
-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
+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` 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 `๏ฌ` 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`
+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",
+33 -7
rules/accountAge.ts
··· 3 3 /** 4 4 * Account age monitoring configurations 5 5 * 6 - * This file contains example values. Copy to accountAge.ts and configure with your checks. 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. 7 9 */ 8 10 export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [ 9 - // Example configuration: 11 + // Example - monitor replies to specific accounts: 10 12 // { 11 - // monitoredDIDs: ["did:plc:example123"], 12 - // anchorDate: "2025-01-15", 13 - // maxAgeDays: 7, 14 - // label: "new-account", 15 - // comment: "Account created within monitored window", 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", 16 42 // }, 17 43 ];
+2 -1
rules/accountThreshold.ts
··· 12 12 // threshold: 3, 13 13 // accountLabel: "repeat-offender", 14 14 // accountComment: "Account exceeded threshold", 15 - // windowDays: 7, 15 + // window: 5, 16 + // windowUnit: "hours", 16 17 // reportAcct: false, 17 18 // commentAcct: false, 18 19 // toLabel: true,
+22 -3
rules/constants.ts
··· 1 1 /** 2 2 * Global allowlist for accounts that should bypass all checks 3 3 * 4 - * This file contains example values. Copy to constants.ts and configure with your DIDs. 4 + * Add DIDs here to exempt them from all moderation checks. 5 5 */ 6 6 export const GLOBAL_ALLOW: string[] = [ 7 - // Example: "did:plc:example123", 7 + // Example: "did:plc:trusted-account", 8 8 ]; 9 9 10 - export const LINK_SHORTENER = new RegExp("", "i"); 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 + ```
+20 -6
rules/handles.ts
··· 3 3 /** 4 4 * Handle-based moderation checks 5 5 * 6 - * This file contains example values. Copy to handles.ts and configure with your checks. 6 + * Monitors user handles (usernames) for pattern matches. 7 + * Configure your checks below. 7 8 */ 8 9 export const HANDLE_CHECKS: Checks[] = [ 9 - // Example check: 10 + // Basic example - flag potential impersonation: 10 11 // { 11 - // label: "example-label", 12 - // comment: "Example check found in handle", 13 - // reportAcct: false, 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, 14 25 // commentAcct: false, 15 26 // toLabel: true, 16 - // check: new RegExp("example-pattern", "i"), 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"], 17 31 // }, 18 32 ];
+25 -5
rules/posts.ts
··· 3 3 /** 4 4 * Post content moderation checks 5 5 * 6 - * This file contains example values. Copy to posts.ts and configure with your checks. 6 + * Monitors post text and embedded URLs for pattern matches. 7 + * Configure your checks below. 7 8 */ 8 9 export const POST_CHECKS: Checks[] = [ 9 - // Example check: 10 + // Basic example - label posts matching a pattern: 10 11 // { 11 - // label: "example-label", 12 - // comment: "Example content found in post", 12 + // label: "spam", 13 + // comment: "Spam content detected in post", 13 14 // reportAcct: false, 14 15 // commentAcct: false, 15 16 // toLabel: true, 16 - // check: new RegExp("example-pattern", "i"), 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 17 37 // }, 18 38 ];
+23 -7
rules/profiles.ts
··· 3 3 /** 4 4 * Profile-based moderation checks 5 5 * 6 - * This file contains example values. Copy to profiles.ts and configure with your checks. 6 + * Monitors profile display names and descriptions for pattern matches. 7 + * Configure your checks below. 7 8 */ 8 9 export const PROFILE_CHECKS: Checks[] = [ 9 - // Example check: 10 + // Basic example - check both displayName and description: 10 11 // { 11 - // label: "example-label", 12 - // comment: "Example content found in profile", 13 - // description: true, 14 - // displayName: true, 12 + // label: "spam-profile", 13 + // comment: "Spam content in profile", 14 + // displayName: true, // Check display name 15 + // description: true, // Check description 15 16 // reportAcct: false, 16 17 // commentAcct: false, 17 18 // toLabel: true, 18 - // check: new RegExp("example-pattern", "i"), 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"], 19 35 // }, 20 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 + ];
+116 -2
src/accountModeration.ts
··· 2 2 import { MOD_DID } from "./config.js"; 3 3 import { limit } from "./limits.js"; 4 4 import { logger } from "./logger.js"; 5 - import { labelsAppliedCounter, labelsCachedCounter } from "./metrics.js"; 6 - import { tryClaimAccountComment, tryClaimAccountLabel } from "./redis.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"; 7 15 8 16 const doesLabelExist = ( 9 17 labels: { val: string }[] | undefined, ··· 73 81 createdAt: new Date().toISOString(), 74 82 modTool: { 75 83 name: "skywatch/skywatch-automod", 84 + meta: { 85 + time: new Date().toISOString(), 86 + externalUrl: `https://pdsls.dev/at://${did}`, 87 + }, 76 88 }, 77 89 }, 78 90 { ··· 89 101 { process: "MODERATION", error: e }, 90 102 "Failed to create account label", 91 103 ); 104 + throw e; 92 105 } 93 106 }); 94 107 }; ··· 129 142 createdAt: new Date().toISOString(), 130 143 modTool: { 131 144 name: "skywatch/skywatch-automod", 145 + meta: { 146 + time: new Date().toISOString(), 147 + externalUrl: `https://pdsls.dev/at://${did}`, 148 + }, 132 149 }, 133 150 }, 134 151 { ··· 145 162 { process: "MODERATION", error: e }, 146 163 "Failed to create account comment", 147 164 ); 165 + throw e; 148 166 } 149 167 }); 150 168 }; ··· 170 188 createdAt: new Date().toISOString(), 171 189 modTool: { 172 190 name: "skywatch/skywatch-automod", 191 + meta: { 192 + time: new Date().toISOString(), 193 + externalUrl: `https://pdsls.dev/at://${did}`, 194 + }, 173 195 }, 174 196 }, 175 197 { ··· 186 208 { process: "MODERATION", error: e }, 187 209 "Failed to create account report", 188 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; 189 277 } 190 278 }); 191 279 }; ··· 218 306 } 219 307 }); 220 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 + };
+14 -12
src/accountThreshold.ts
··· 41 41 `Invalid account threshold config: threshold must be positive`, 42 42 ); 43 43 } 44 - if (config.windowDays <= 0) { 44 + if (config.window <= 0) { 45 45 throw new Error( 46 - `Invalid account threshold config: windowDays must be positive`, 46 + `Invalid account threshold config: window must be positive`, 47 47 ); 48 48 } 49 49 } ··· 65 65 66 66 export async function checkAccountThreshold( 67 67 did: string, 68 + uri: string, 68 69 postLabel: string, 69 70 timestamp: number, 70 71 ): Promise<void> { ··· 93 94 did, 94 95 postLabel, 95 96 timestamp, 96 - config.windowDays, 97 + config.window, 98 + config.windowUnit, 97 99 ); 98 100 99 101 const count = await getPostLabelCountInWindow( 100 102 did, 101 103 labels, 102 - config.windowDays, 104 + config.window, 105 + config.windowUnit, 103 106 timestamp, 104 107 ); 105 108 ··· 110 113 labels, 111 114 count, 112 115 threshold: config.threshold, 113 - windowDays: config.windowDays, 116 + window: config.window, 117 + windowUnit: config.windowUnit, 114 118 }, 115 119 "Checked account threshold", 116 120 ); ··· 132 136 133 137 const shouldLabel = config.toLabel !== false; 134 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 + 135 141 if (shouldLabel) { 136 - await createAccountLabel( 137 - did, 138 - config.accountLabel, 139 - config.accountComment, 140 - ); 142 + await createAccountLabel(did, config.accountLabel, formattedComment); 141 143 accountLabelsThresholdAppliedCounter.inc({ 142 144 account_label: config.accountLabel, 143 145 action: "label", ··· 145 147 } 146 148 147 149 if (config.reportAcct) { 148 - await createAccountReport(did, config.accountComment); 150 + await createAccountReport(did, formattedComment); 149 151 accountLabelsThresholdAppliedCounter.inc({ 150 152 account_label: config.accountLabel, 151 153 action: "report", ··· 154 156 155 157 if (config.commentAcct) { 156 158 const atURI = `threshold-comment:${config.accountLabel}:${timestamp.toString()}`; 157 - await createAccountComment(did, config.accountComment, atURI); 159 + await createAccountComment(did, formattedComment, atURI); 158 160 accountLabelsThresholdAppliedCounter.inc({ 159 161 account_label: config.accountLabel, 160 162 action: "comment",
+67 -4
src/agent.ts
··· 99 99 } 100 100 } 101 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 + 102 111 async function authenticate(): Promise<boolean> { 103 112 const savedSession = loadSession(); 104 113 ··· 121 130 return performLogin(); 122 131 } 123 132 124 - export const login = authenticate; 125 - export const isLoggedIn = authenticate() 126 - .then((success) => success) 127 - .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 + };
+1
src/config.ts
··· 16 16 "app.bsky.feed.post", 17 17 "app.bsky.actor.defs", 18 18 "app.bsky.actor.profile", 19 + "app.bsky.graph.starterpack", 19 20 ]; 20 21 export const CURSOR_UPDATE_INTERVAL = process.env.CURSOR_UPDATE_INTERVAL 21 22 ? Number(process.env.CURSOR_UPDATE_INTERVAL)
+18 -18
src/main.ts
··· 5 5 IdentityEvent, 6 6 } from "@skyware/jetstream"; 7 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"; 22 + import { checkProfile } from "./rules/profiles/checkProfiles.js"; 23 + import { checkStarterPackThreshold } from "./starterPackThreshold.js"; 25 24 import type { Post } from "./types.js"; 26 25 27 26 let cursor = 0; ··· 281 280 async (event: CommitUpdateEvent<"app.bsky.actor.profile">) => { 282 281 try { 283 282 if (event.commit.record.displayName || event.commit.record.description) { 284 - void checkDescription( 285 - event.did, 286 - event.time_us, 287 - event.commit.record.displayName as string, 288 - event.commit.record.description as string, 289 - ); 290 - void checkDisplayName( 283 + void checkProfile( 291 284 event.did, 292 285 event.time_us, 293 286 event.commit.record.displayName as string, ··· 308 301 async (event: CommitCreateEvent<"app.bsky.actor.profile">) => { 309 302 try { 310 303 if (event.commit.record.displayName || event.commit.record.description) { 311 - void checkDescription( 312 - event.did, 313 - event.time_us, 314 - event.commit.record.displayName as string, 315 - event.commit.record.description as string, 316 - ); 317 - void checkDisplayName( 304 + void checkProfile( 318 305 event.did, 319 306 event.time_us, 320 307 event.commit.record.displayName as string, ··· 339 326 }, 340 327 ); 341 328 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 + ); 337 + 342 338 const metricsServer = startMetricsServer(METRICS_PORT); 343 339 344 340 logger.info({ process: "MAIN" }, "Connecting to Redis"); 345 341 await connectRedis(); 342 + 343 + logger.info({ process: "MAIN" }, "Authenticating with Bluesky"); 344 + await login(); 345 + logger.info({ process: "MAIN" }, "Authentication complete, starting Jetstream"); 346 346 347 347 jetstream.start(); 348 348
+34
src/metrics.ts
··· 20 20 registers: [register], 21 21 }); 22 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 + 23 30 export const accountLabelsThresholdAppliedCounter = new Counter({ 24 31 name: "skywatch_account_labels_threshold_applied_total", 25 32 help: "Total number of account actions applied due to threshold", ··· 38 45 name: "skywatch_account_threshold_met_total", 39 46 help: "Total number of times account thresholds were met", 40 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"], 41 75 registers: [register], 42 76 }); 43 77
+12 -2
src/moderation.ts
··· 93 93 createdAt: new Date().toISOString(), 94 94 modTool: { 95 95 name: "skywatch/skywatch-automod", 96 + meta: { 97 + time: new Date().toISOString(), 98 + externalUrl: `https://pdsls.dev/${uri}`, 99 + }, 96 100 }, 97 101 }, 98 102 { ··· 113 117 const { checkAccountThreshold } = await import( 114 118 "./accountThreshold.js" 115 119 ); 116 - await checkAccountThreshold(did, label, time); 120 + await checkAccountThreshold(did, uri, label, time); 117 121 } catch (error) { 118 122 logger.error( 119 123 { process: "ACCOUNT_THRESHOLD", did, label, error }, ··· 126 130 { process: "MODERATION", error: e }, 127 131 "Failed to create post label", 128 132 ); 133 + throw e; 129 134 } 130 135 }); 131 136 }; ··· 156 161 createdAt: new Date().toISOString(), 157 162 modTool: { 158 163 name: "skywatch/skywatch-automod", 164 + meta: { 165 + time: new Date().toISOString(), 166 + externalUrl: `https://pdsls.dev/${uri}`, 167 + }, 159 168 }, 160 169 }, 161 170 { ··· 170 179 } catch (e) { 171 180 logger.error( 172 181 { process: "MODERATION", error: e }, 173 - "Failed to create post label", 182 + "Failed to create post report", 174 183 ); 184 + throw e; 175 185 } 176 186 }); 177 187 };
+122 -13
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, ··· 108 128 } 109 129 } 110 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 + 111 149 function getPostLabelTrackingKey( 112 150 did: string, 113 151 label: string, 114 - windowDays: number, 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, 115 162 ): string { 116 - return `account-post-labels:${did}:${label}:${windowDays.toString()}`; 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 + } 117 224 } 118 225 119 226 export async function trackPostLabelForAccount( 120 227 did: string, 121 228 label: string, 122 229 timestamp: number, 123 - windowDays: number, 230 + window: number, 231 + windowUnit: WindowUnit, 124 232 ): Promise<void> { 125 233 try { 126 - const key = getPostLabelTrackingKey(did, label, windowDays); 127 - const windowStartTime = timestamp - windowDays * 24 * 60 * 60 * 1000000; 234 + const key = getPostLabelTrackingKey(did, label, window, windowUnit); 235 + const windowStartTime = timestamp - windowToMicroseconds(window, windowUnit); 128 236 129 237 await redisClient.zRemRangeByScore(key, "-inf", windowStartTime); 130 238 ··· 133 241 value: timestamp.toString(), 134 242 }); 135 243 136 - const ttlSeconds = (windowDays + 1) * 24 * 60 * 60; 244 + const ttlSeconds = windowToSeconds(window, windowUnit) + 60 * 60; 137 245 await redisClient.expire(key, ttlSeconds); 138 246 139 247 logger.debug( 140 - { did, label, timestamp, windowDays }, 248 + { did, label, timestamp, window, windowUnit }, 141 249 "Tracked post label for account", 142 250 ); 143 251 } catch (err) { 144 252 logger.error( 145 - { err, did, label, timestamp, windowDays }, 253 + { err, did, label, timestamp, window, windowUnit }, 146 254 "Error tracking post label in Redis", 147 255 ); 148 256 throw err; ··· 152 260 export async function getPostLabelCountInWindow( 153 261 did: string, 154 262 labels: string[], 155 - windowDays: number, 263 + window: number, 264 + windowUnit: WindowUnit, 156 265 currentTime: number, 157 266 ): Promise<number> { 158 267 try { 159 - const windowStartTime = currentTime - windowDays * 24 * 60 * 60 * 1000000; 268 + const windowStartTime = currentTime - windowToMicroseconds(window, windowUnit); 160 269 let totalCount = 0; 161 270 162 271 for (const label of labels) { 163 - const key = getPostLabelTrackingKey(did, label, windowDays); 272 + const key = getPostLabelTrackingKey(did, label, window, windowUnit); 164 273 const count = await redisClient.zCount(key, windowStartTime, "+inf"); 165 274 totalCount += count; 166 275 } 167 276 168 277 logger.debug( 169 - { did, labels, windowDays, totalCount }, 278 + { did, labels, window, windowUnit, totalCount }, 170 279 "Retrieved post label count in window", 171 280 ); 172 281 173 282 return totalCount; 174 283 } catch (err) { 175 284 logger.error( 176 - { err, did, labels, windowDays }, 285 + { err, did, labels, window, windowUnit }, 177 286 "Error getting post label count from Redis", 178 287 ); 179 288 throw err;
+1 -1
src/rules/account/age.ts
··· 240 240 await createAccountLabel( 241 241 context.actorDid, 242 242 check.label, 243 - `${context.time.toString()}: ${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}`, 244 244 ); 245 245 246 246 // Only apply one label per interaction
+18 -6
src/rules/account/countStarterPacks.ts
··· 2 2 import { agent, isLoggedIn } from "../../agent.js"; 3 3 import { limit } from "../../limits.js"; 4 4 import { logger } from "../../logger.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 - void createAccountLabel( 36 - did, 37 - "follow-farming", 38 - `${time.toString()}: Account has ${starterPacks.toString()} 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 =
+3 -3
src/rules/facets/facets.ts
··· 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 /** ··· 80 80 await createAccountLabel( 81 81 did, 82 82 FACET_SPAM_LABEL, 83 - `${time.toString()}: ${FACET_SPAM_COMMENT} - ${uniqueCount.toString()} 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}`, 84 84 ); 85 85 86 86 // Only label once per post even if multiple positions are suspicious
+2 -2
src/rules/facets/tests/facets.test.ts
··· 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 );
+12 -12
src/rules/handles/checkHandles.test.ts
··· 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 }); ··· 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
+5 -10
src/rules/handles/checkHandles.ts
··· 45 45 } 46 46 } 47 47 48 + const formattedComment = `${time.toString()}: ${checkList.comment}\n\nHandle: ${handle}`; 49 + 48 50 if (checkList.toLabel) { 49 - void createAccountLabel( 50 - did, 51 - checkList.label, 52 - `${time.toString()}: ${checkList.comment} - ${handle}`, 53 - ); 51 + void createAccountLabel(did, checkList.label, formattedComment); 54 52 } 55 53 56 54 if (checkList.reportAcct) { ··· 58 56 { process: "CHECKHANDLE", did, handle, time, label: checkList.label }, 59 57 "Reporting account", 60 58 ); 61 - void createAccountReport( 62 - did, 63 - `${time.toString()}: ${checkList.comment} - ${handle}`, 64 - ); 59 + void createAccountReport(did, formattedComment); 65 60 } 66 61 67 62 if (checkList.commentAcct) { 68 63 void createAccountComment( 69 64 did, 70 - `${time.toString()}: ${checkList.comment} - ${handle}`, 65 + formattedComment, 71 66 `handle:${did}:${handle}`, 72 67 ); 73 68 }
+91 -30
src/rules/posts/checkPosts.ts
··· 4 4 createAccountComment, 5 5 createAccountReport, 6 6 } from "../../accountModeration.js"; 7 + import { checkAccountThreshold } from "../../accountThreshold.js"; 7 8 import { logger } from "../../logger.js"; 9 + import { moderationActionsFailedCounter } from "../../metrics.js"; 8 10 import { createPostLabel, createPostReport } from "../../moderation.js"; 9 - import type { Post } from "../../types.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"; ··· 73 75 const lang = await getLanguage(post[0].text); 74 76 75 77 // iterate through the checks 76 - POST_CHECKS.forEach((checkPost) => { 78 + for (const checkPost of POST_CHECKS) { 77 79 if (checkPost.language) { 78 80 if (!checkPost.language.includes(lang)) { 79 - return; 81 + continue; 80 82 } 81 83 } 82 84 ··· 86 88 { process: "CHECKPOSTS", did: post[0].did, atURI: post[0].atURI }, 87 89 "Whitelisted DID", 88 90 ); 89 - return; 91 + continue; 90 92 } 91 93 } 92 94 ··· 98 100 { process: "CHECKPOSTS", did: post[0].did, atURI: post[0].atURI }, 99 101 "Whitelisted phrase found", 100 102 ); 101 - return; 103 + continue; 102 104 } 103 105 } 104 106 105 - void countStarterPacks(post[0].did, post[0].time); 107 + await countStarterPacks(post[0].did, post[0].time); 108 + 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: [] }; 106 113 107 114 if (checkPost.toLabel) { 108 - void createPostLabel( 109 - post[0].atURI, 110 - post[0].cid, 111 - checkPost.label, 112 - `${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 113 - checkPost.duration, 114 - post[0].did, 115 - post[0].time, 116 - ); 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 - void createPostReport( 130 - post[0].atURI, 131 - post[0].cid, 132 - `${post[0].time.toString()}: ${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 170 if (checkPost.reportAcct) { ··· 143 177 }, 144 178 "Reporting account", 145 179 ); 146 - void createAccountReport( 147 - post[0].did, 148 - `${post[0].time.toString()}: ${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 188 if (checkPost.commentAcct) { 153 - void createAccountComment( 154 - post[0].did, 155 - `${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 156 - post[0].atURI, 157 - ); 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 };
+40
src/rules/posts/tests/checkPosts.test.ts
··· 3 3 createAccountComment, 4 4 createAccountReport, 5 5 } from "../../../accountModeration.js"; 6 + import { checkAccountThreshold } from "../../../accountThreshold.js"; 6 7 import { logger } from "../../../logger.js"; 7 8 import { createPostLabel, createPostReport } from "../../../moderation.js"; 8 9 import type { Post } from "../../../types.js"; ··· 67 68 reportAcct: true, 68 69 commentAcct: true, 69 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 + }, 70 81 ], 71 82 })); 72 83 ··· 86 97 vi.mock("../../../accountModeration.js", () => ({ 87 98 createAccountReport: vi.fn(), 88 99 createAccountComment: vi.fn(), 100 + })); 101 + 102 + vi.mock("../../../accountThreshold.js", () => ({ 103 + checkAccountThreshold: vi.fn(), 89 104 })); 90 105 91 106 vi.mock("../../../moderation.js", () => ({ ··· 422 437 post[0].did, 423 438 expect.any(String), 424 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, 425 465 ); 426 466 }); 427 467 });
+233 -143
src/rules/profiles/checkProfiles.ts
··· 4 4 createAccountComment, 5 5 createAccountLabel, 6 6 createAccountReport, 7 + negateAccountLabel, 7 8 } from "../../accountModeration.js"; 8 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) { 68 - void createAccountLabel( 69 - did, 70 - checkProfiles.label, 71 - `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 72 - ); 73 - } 74 - 75 - if (checkProfiles.reportAcct) { 76 - void createAccountReport( 77 - did, 78 - `${time.toString()}: ${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) { 94 - void createAccountComment( 95 - did, 96 - `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 97 - `profile:${did}:${time.toString()}`, 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) { 163 - void createAccountLabel( 164 - did, 165 - checkProfiles.label, 166 - `${time.toString()}: ${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) { 171 - void createAccountReport( 172 - did, 173 - `${time.toString()}: ${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) { 189 - void createAccountComment( 190 - did, 191 - `${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`, 192 - `profile:${did}:${time.toString()}`, 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 };
+1 -2
src/rules/profiles/tests/checkProfiles.test.ts
··· 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 );
+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 + });
+35 -17
src/tests/accountThreshold.test.ts
··· 35 35 threshold: 3, 36 36 accountLabel: "test-account-label", 37 37 accountComment: "Test comment", 38 - windowDays: 5, 38 + window: 5, 39 + windowUnit: "days", 39 40 reportAcct: false, 40 41 commentAcct: false, 41 42 toLabel: true, ··· 45 46 threshold: 5, 46 47 accountLabel: "multi-label-account", 47 48 accountComment: "Multi label comment", 48 - windowDays: 7, 49 + window: 7, 50 + windowUnit: "days", 49 51 reportAcct: true, 50 52 commentAcct: true, 51 53 toLabel: true, ··· 55 57 threshold: 2, 56 58 accountLabel: "monitored", 57 59 accountComment: "Monitoring comment", 58 - windowDays: 3, 60 + window: 3, 61 + windowUnit: "days", 59 62 reportAcct: true, 60 63 commentAcct: false, 61 64 toLabel: false, ··· 65 68 threshold: 2, 66 69 accountLabel: "shared-config", 67 70 accountComment: "Shared config comment", 68 - windowDays: 4, 71 + window: 4, 72 + windowUnit: "days", 69 73 reportAcct: false, 70 74 commentAcct: false, 71 75 toLabel: true, ··· 118 122 119 123 describe("checkAccountThreshold", () => { 120 124 const testDid = "did:plc:test123"; 125 + const testUri = "at://did:plc:test123/app.bsky.feed.post/abc123"; 121 126 const testTimestamp = 1640000000000000; 122 127 123 128 it("should not check threshold for non-matching labels", async () => { 124 129 vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 125 130 vi.mocked(getPostLabelCountInWindow).mockResolvedValue(0); 126 131 127 - await checkAccountThreshold(testDid, "non-matching-label", testTimestamp); 132 + await checkAccountThreshold( 133 + testDid, 134 + testUri, 135 + "non-matching-label", 136 + testTimestamp, 137 + ); 128 138 129 139 expect(trackPostLabelForAccount).not.toHaveBeenCalled(); 130 140 expect(getPostLabelCountInWindow).not.toHaveBeenCalled(); ··· 134 144 vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 135 145 vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2); 136 146 137 - await checkAccountThreshold(testDid, "test-label", testTimestamp); 147 + await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp); 138 148 139 149 expect(accountThresholdChecksCounter.inc).toHaveBeenCalledWith({ 140 150 post_label: "test-label", ··· 144 154 "test-label", 145 155 testTimestamp, 146 156 5, 157 + "days", 147 158 ); 148 159 expect(getPostLabelCountInWindow).toHaveBeenCalledWith( 149 160 testDid, 150 161 ["test-label"], 151 162 5, 163 + "days", 152 164 testTimestamp, 153 165 ); 154 166 }); ··· 158 170 vi.mocked(getPostLabelCountInWindow).mockResolvedValue(3); 159 171 vi.mocked(createAccountLabel).mockResolvedValue(); 160 172 161 - await checkAccountThreshold(testDid, "test-label", testTimestamp); 173 + await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp); 162 174 163 175 expect(accountThresholdMetCounter.inc).toHaveBeenCalledWith({ 164 176 account_label: "test-account-label", ··· 166 178 expect(createAccountLabel).toHaveBeenCalledWith( 167 179 testDid, 168 180 "test-account-label", 169 - "Test comment", 181 + `Test comment\n\nThreshold: 3/3 in 5 days\n\nPost: ${testUri}\n\nPost Label: test-label`, 170 182 ); 171 183 expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 172 184 account_label: "test-account-label", ··· 178 190 vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 179 191 vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2); 180 192 181 - await checkAccountThreshold(testDid, "test-label", testTimestamp); 193 + await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp); 182 194 183 195 expect(accountThresholdMetCounter.inc).not.toHaveBeenCalled(); 184 196 expect(createAccountLabel).not.toHaveBeenCalled(); ··· 191 203 vi.mocked(createAccountReport).mockResolvedValue(); 192 204 vi.mocked(createAccountComment).mockResolvedValue(); 193 205 194 - await checkAccountThreshold(testDid, "label-2", testTimestamp); 206 + await checkAccountThreshold(testDid, testUri, "label-2", testTimestamp); 195 207 196 208 expect(getPostLabelCountInWindow).toHaveBeenCalledWith( 197 209 testDid, 198 210 ["label-1", "label-2", "label-3"], 199 211 7, 212 + "days", 200 213 testTimestamp, 201 214 ); 202 215 expect(createAccountLabel).toHaveBeenCalledWith( 203 216 testDid, 204 217 "multi-label-account", 205 - "Multi label comment", 218 + `Multi label comment\n\nThreshold: 5/5 in 7 days\n\nPost: ${testUri}\n\nPost Label: label-2`, 206 219 ); 207 220 expect(createAccountReport).toHaveBeenCalledWith( 208 221 testDid, 209 - "Multi label comment", 222 + `Multi label comment\n\nThreshold: 5/5 in 7 days\n\nPost: ${testUri}\n\nPost Label: label-2`, 210 223 ); 211 224 expect(createAccountComment).toHaveBeenCalled(); 212 225 }); ··· 216 229 vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2); 217 230 vi.mocked(createAccountReport).mockResolvedValue(); 218 231 219 - await checkAccountThreshold(testDid, "monitor-only-label", testTimestamp); 232 + await checkAccountThreshold( 233 + testDid, 234 + testUri, 235 + "monitor-only-label", 236 + testTimestamp, 237 + ); 220 238 221 239 expect(trackPostLabelForAccount).toHaveBeenCalled(); 222 240 expect(getPostLabelCountInWindow).toHaveBeenCalled(); ··· 237 255 vi.mocked(createAccountReport).mockResolvedValue(); 238 256 vi.mocked(createAccountComment).mockResolvedValue(); 239 257 240 - await checkAccountThreshold(testDid, "label-1", testTimestamp); 258 + await checkAccountThreshold(testDid, testUri, "label-1", testTimestamp); 241 259 242 260 expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledTimes(3); 243 261 expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ ··· 259 277 vi.mocked(trackPostLabelForAccount).mockRejectedValue(redisError); 260 278 261 279 await expect( 262 - checkAccountThreshold(testDid, "test-label", testTimestamp), 280 + checkAccountThreshold(testDid, testUri, "test-label", testTimestamp), 263 281 ).rejects.toThrow("Redis connection failed"); 264 282 265 283 expect(logger.error).toHaveBeenCalled(); ··· 271 289 vi.mocked(getPostLabelCountInWindow).mockRejectedValue(redisError); 272 290 273 291 await expect( 274 - checkAccountThreshold(testDid, "test-label", testTimestamp), 292 + checkAccountThreshold(testDid, testUri, "test-label", testTimestamp), 275 293 ).rejects.toThrow("Redis query failed"); 276 294 277 295 expect(logger.error).toHaveBeenCalled(); ··· 286 304 vi.mocked(createAccountReport).mockResolvedValue(); 287 305 vi.mocked(createAccountComment).mockResolvedValue(); 288 306 289 - await checkAccountThreshold(testDid, "label-1", testTimestamp); 307 + await checkAccountThreshold(testDid, testUri, "label-1", testTimestamp); 290 308 291 309 expect(trackPostLabelForAccount).toHaveBeenCalledTimes(2); 292 310 expect(getPostLabelCountInWindow).toHaveBeenCalledTimes(2);
+12 -1
src/tests/agent.test.ts
··· 13 13 OZONE_PDS: "pds.test.com", 14 14 })); 15 15 16 + // Mock session 17 + const mockSession = { 18 + did: "did:plc:test123", 19 + handle: "test.bsky.social", 20 + accessJwt: "test-access-jwt", 21 + refreshJwt: "test-refresh-jwt", 22 + }; 23 + 16 24 // Mock the AtpAgent 17 - const mockLogin = vi.fn(() => Promise.resolve()); 25 + const mockLogin = vi.fn(() => 26 + Promise.resolve({ success: true, data: mockSession }), 27 + ); 18 28 const mockConstructor = vi.fn(); 19 29 vi.doMock("@atproto/api", () => ({ 20 30 AtpAgent: class { 21 31 login = mockLogin; 22 32 service: URL; 33 + session = mockSession; 23 34 constructor(options: { service: string }) { 24 35 mockConstructor(options); 25 36 this.service = new URL(options.service);
+111 -11
src/tests/redis.test.ts
··· 7 7 connectRedis, 8 8 disconnectRedis, 9 9 getPostLabelCountInWindow, 10 + getStarterPackCountInWindow, 10 11 trackPostLabelForAccount, 12 + trackStarterPackForAccount, 11 13 tryClaimAccountLabel, 12 14 tryClaimPostLabel, 13 15 } from "../redis.js"; ··· 116 118 vi.mocked(mockRedisClient.expire).mockResolvedValue(true); 117 119 118 120 const timestamp = 1640000000000000; // microseconds 119 - const windowDays = 5; 121 + const window = 5; 122 + const windowUnit = "days" as const; 120 123 121 124 await trackPostLabelForAccount( 122 125 "did:plc:123", 123 126 "test-label", 124 127 timestamp, 125 - windowDays, 128 + window, 129 + windowUnit, 126 130 ); 127 131 128 - const expectedKey = "account-post-labels:did:plc:123:test-label:5"; 129 - const windowStartTime = timestamp - windowDays * 24 * 60 * 60 * 1000000; 132 + const expectedKey = "account-post-labels:did:plc:123:test-label:5days"; 133 + const windowStartTime = timestamp - window * 24 * 60 * 60 * 1000000; 130 134 131 135 expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith( 132 136 expectedKey, ··· 139 143 }); 140 144 expect(mockRedisClient.expire).toHaveBeenCalledWith( 141 145 expectedKey, 142 - (windowDays + 1) * 24 * 60 * 60, 146 + window * 24 * 60 * 60 + 60 * 60, 143 147 ); 144 148 }); 145 149 ··· 153 157 "test-label", 154 158 1640000000000000, 155 159 5, 160 + "days", 156 161 ), 157 162 ).rejects.toThrow("Redis down"); 158 163 ··· 160 165 }); 161 166 }); 162 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 + 163 257 describe("getPostLabelCountInWindow", () => { 164 258 it("should count posts for single label", async () => { 165 259 vi.mocked(mockRedisClient.zCount).mockResolvedValue(3); 166 260 167 261 const currentTime = 1640000000000000; 168 - const windowDays = 5; 262 + const window = 5; 263 + const windowUnit = "days" as const; 169 264 const count = await getPostLabelCountInWindow( 170 265 "did:plc:123", 171 266 ["test-label"], 172 - windowDays, 267 + window, 268 + windowUnit, 173 269 currentTime, 174 270 ); 175 271 176 272 expect(count).toBe(3); 177 - const windowStartTime = currentTime - windowDays * 24 * 60 * 60 * 1000000; 273 + const windowStartTime = currentTime - window * 24 * 60 * 60 * 1000000; 178 274 expect(mockRedisClient.zCount).toHaveBeenCalledWith( 179 - "account-post-labels:did:plc:123:test-label:5", 275 + "account-post-labels:did:plc:123:test-label:5days", 180 276 windowStartTime, 181 277 "+inf", 182 278 ); ··· 189 285 .mockResolvedValueOnce(1); 190 286 191 287 const currentTime = 1640000000000000; 192 - const windowDays = 5; 288 + const window = 5; 289 + const windowUnit = "days" as const; 193 290 const count = await getPostLabelCountInWindow( 194 291 "did:plc:123", 195 292 ["label-1", "label-2", "label-3"], 196 - windowDays, 293 + window, 294 + windowUnit, 197 295 currentTime, 198 296 ); 199 297 ··· 208 306 "did:plc:123", 209 307 ["test-label"], 210 308 5, 309 + "days", 211 310 1640000000000000, 212 311 ); 213 312 ··· 223 322 "did:plc:123", 224 323 ["test-label"], 225 324 5, 325 + "days", 226 326 1640000000000000, 227 327 ), 228 328 ).rejects.toThrow("Redis down");
+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 + });
+28 -1
src/types.ts
··· 3 3 export interface Checks { 4 4 language?: string[]; 5 5 label: string; 6 + unlabel?: boolean; 6 7 comment: string; 7 8 description?: boolean; 8 9 displayName?: boolean; ··· 10 11 commentAcct: boolean; 11 12 reportPost?: boolean; 12 13 toLabel: boolean; 14 + trackOnly?: boolean; 13 15 duration?: number; 14 16 check: RegExp; 15 17 whitelist?: RegExp; ··· 62 64 expires?: string; // Optional expiration date (ISO 8601) - check will be skipped after this date 63 65 } 64 66 67 + export type WindowUnit = "minutes" | "hours" | "days"; 68 + 65 69 export interface AccountThresholdConfig { 66 70 labels: string | string[]; // Single label or array for OR matching 67 71 threshold: number; // Number of labeled posts required to trigger account action 68 72 accountLabel: string; // Label to apply to the account 69 73 accountComment: string; // Comment for the account action 70 - windowDays: number; // Rolling window in days 74 + window: number; // Rolling window duration 75 + windowUnit: WindowUnit; // Unit for the rolling window 71 76 reportAcct: boolean; // Whether to report the account 72 77 commentAcct: boolean; // Whether to comment on the account 73 78 toLabel?: boolean; // Whether to apply label (defaults to true) 74 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 + }