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

Compare changes

Choose any two refs to compare.

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