+14
-2
.claude/settings.local.json
+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
+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
+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
+2
-2
.gitignore
-77
CLAUDE.md
-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
+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
+250
-1
bun.lock
···
24
24
"pino": "^9.9.0",
25
25
"pino-pretty": "^13.1.1",
26
26
"prom-client": "^15.1.3",
27
+
"redis": "^4.7.0",
27
28
"undici": "^7.15.0",
28
29
},
29
30
"devDependencies": {
···
35
36
"@types/express": "^4.17.23",
36
37
"@types/node": "^22.18.0",
37
38
"@types/supertest": "^6.0.3",
39
+
"@vitest/coverage-v8": "^1.6.0",
38
40
"@vitest/ui": "^1.6.0",
39
41
"eslint": "^9.34.0",
40
42
"eslint-config-prettier": "^10.1.8",
43
+
"eslint-plugin-import": "^2.32.0",
41
44
"prettier": "^3.6.2",
42
45
"supertest": "^7.1.4",
43
46
"tsx": "^4.20.5",
···
51
54
"protobufjs",
52
55
],
53
56
"packages": {
57
+
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
58
+
54
59
"@atcute/atproto": ["@atcute/atproto@3.1.7", "", { "dependencies": { "@atcute/lexicons": "^1.2.2" } }, "sha512-3Ym8qaVZg2vf8qw0KO1aue39z/5oik5J+UDoSes1vr8ddw40UVLA5sV4bXSKmLnhzQHiLLgoVZXe4zaKfozPoQ=="],
55
60
56
61
"@atcute/bluesky": ["@atcute/bluesky@1.0.15", "", { "peerDependencies": { "@atcute/client": "^1.0.0 || ^2.0.0" } }, "sha512-+EFiybmKQ97aBAgtaD+cKRJER5AMn3cZMkEwEg/pDdWyzxYJ9m1UgemmLdTgI8VrxPufKqdXS2nl7uO7TY6BPA=="],
···
124
129
"@babel/traverse": ["@babel/traverse@7.23.2", "", { "dependencies": { "@babel/code-frame": "^7.22.13", "@babel/generator": "^7.23.0", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", "@babel/parser": "^7.23.0", "@babel/types": "^7.23.0", "debug": "^4.1.0", "globals": "^11.1.0" } }, "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw=="],
125
130
126
131
"@babel/types": ["@babel/types@7.17.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.16.7", "to-fast-properties": "^2.0.0" } }, "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw=="],
132
+
133
+
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
127
134
128
135
"@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="],
129
136
···
289
296
290
297
"@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA=="],
291
298
299
+
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="],
300
+
292
301
"@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="],
293
302
294
303
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
···
357
366
358
367
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
359
368
369
+
"@redis/bloom": ["@redis/bloom@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg=="],
370
+
371
+
"@redis/client": ["@redis/client@1.6.1", "", { "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", "yallist": "4.0.0" } }, "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw=="],
372
+
373
+
"@redis/graph": ["@redis/graph@1.1.1", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw=="],
374
+
375
+
"@redis/json": ["@redis/json@1.0.7", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ=="],
376
+
377
+
"@redis/search": ["@redis/search@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw=="],
378
+
379
+
"@redis/time-series": ["@redis/time-series@1.1.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g=="],
380
+
360
381
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="],
361
382
362
383
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="],
···
401
422
402
423
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="],
403
424
425
+
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
426
+
404
427
"@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
405
428
406
429
"@skyware/bot": ["@skyware/bot@0.3.12", "", { "dependencies": { "@atcute/bluesky": "^1.0.7", "@atcute/bluesky-richtext-builder": "^1.0.1", "@atcute/client": "^2.0.3", "@atcute/ozone": "^1.0.5", "quick-lru": "^7.0.0", "rate-limit-threshold": "^0.1.5" }, "optionalDependencies": { "@skyware/firehose": "^0.3.2", "@skyware/jetstream": "^0.2.2" } }, "sha512-5OqTtwItYsBFMh0nwrxfsqgXrvRaJzg1P+ghMV4rlRGwHhdRgBJcnYQYgUqqREFcB247yGo73LNyqq7kHEwV7Q=="],
···
443
466
444
467
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
445
468
469
+
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
470
+
446
471
"@types/methods": ["@types/methods@1.1.4", "", {}, "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ=="],
447
472
448
473
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
···
481
506
482
507
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.46.1", "", { "dependencies": { "@typescript-eslint/types": "8.46.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA=="],
483
508
509
+
"@vitest/coverage-v8": ["@vitest/coverage-v8@1.6.1", "", { "dependencies": { "@ampproject/remapping": "^2.2.1", "@bcoe/v8-coverage": "^0.2.3", "debug": "^4.3.4", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.4", "istanbul-reports": "^3.1.6", "magic-string": "^0.30.5", "magicast": "^0.3.3", "picocolors": "^1.0.0", "std-env": "^3.5.0", "strip-literal": "^2.0.0", "test-exclude": "^6.0.0" }, "peerDependencies": { "vitest": "1.6.1" } }, "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw=="],
510
+
484
511
"@vitest/expect": ["@vitest/expect@1.6.1", "", { "dependencies": { "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "chai": "^4.3.10" } }, "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog=="],
485
512
486
513
"@vitest/runner": ["@vitest/runner@1.6.1", "", { "dependencies": { "@vitest/utils": "1.6.1", "p-limit": "^5.0.0", "pathe": "^1.1.1" } }, "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA=="],
···
517
544
518
545
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
519
546
547
+
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
548
+
520
549
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
521
550
551
+
"array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="],
552
+
553
+
"array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="],
554
+
555
+
"array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="],
556
+
557
+
"array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="],
558
+
559
+
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
560
+
522
561
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
523
562
524
563
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
525
564
526
565
"assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="],
566
+
567
+
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
527
568
528
569
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
529
570
530
571
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
572
+
573
+
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
531
574
532
575
"avvio": ["avvio@8.4.0", "", { "dependencies": { "@fastify/error": "^3.3.0", "fastq": "^1.17.1" } }, "sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA=="],
533
576
···
560
603
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
561
604
562
605
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
606
+
607
+
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
563
608
564
609
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
565
610
···
627
672
628
673
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
629
674
675
+
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
676
+
677
+
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
678
+
679
+
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
680
+
630
681
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
631
682
632
683
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
···
634
685
"deep-eql": ["deep-eql@4.1.4", "", { "dependencies": { "type-detect": "^4.0.0" } }, "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg=="],
635
686
636
687
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
688
+
689
+
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
690
+
691
+
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
637
692
638
693
"delay": ["delay@5.0.0", "", {}, "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw=="],
639
694
···
651
706
652
707
"diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="],
653
708
709
+
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
710
+
654
711
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
655
712
656
713
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
···
671
728
672
729
"error-causes": ["error-causes@3.0.2", "", {}, "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="],
673
730
731
+
"es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="],
732
+
674
733
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
675
734
676
735
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
···
678
737
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
679
738
680
739
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
740
+
741
+
"es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="],
742
+
743
+
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
681
744
682
745
"esbuild": ["esbuild@0.25.11", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.11", "@esbuild/android-arm": "0.25.11", "@esbuild/android-arm64": "0.25.11", "@esbuild/android-x64": "0.25.11", "@esbuild/darwin-arm64": "0.25.11", "@esbuild/darwin-x64": "0.25.11", "@esbuild/freebsd-arm64": "0.25.11", "@esbuild/freebsd-x64": "0.25.11", "@esbuild/linux-arm": "0.25.11", "@esbuild/linux-arm64": "0.25.11", "@esbuild/linux-ia32": "0.25.11", "@esbuild/linux-loong64": "0.25.11", "@esbuild/linux-mips64el": "0.25.11", "@esbuild/linux-ppc64": "0.25.11", "@esbuild/linux-riscv64": "0.25.11", "@esbuild/linux-s390x": "0.25.11", "@esbuild/linux-x64": "0.25.11", "@esbuild/netbsd-arm64": "0.25.11", "@esbuild/netbsd-x64": "0.25.11", "@esbuild/openbsd-arm64": "0.25.11", "@esbuild/openbsd-x64": "0.25.11", "@esbuild/openharmony-arm64": "0.25.11", "@esbuild/sunos-x64": "0.25.11", "@esbuild/win32-arm64": "0.25.11", "@esbuild/win32-ia32": "0.25.11", "@esbuild/win32-x64": "0.25.11" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="],
683
746
···
691
754
692
755
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
693
756
757
+
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="],
758
+
759
+
"eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="],
760
+
761
+
"eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="],
762
+
694
763
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
695
764
696
765
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
···
773
842
774
843
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
775
844
845
+
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
846
+
776
847
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
777
848
778
849
"formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="],
···
782
853
"franc": ["franc@6.2.0", "", { "dependencies": { "trigram-utils": "^2.0.0" } }, "sha512-rcAewP7PSHvjq7Kgd7dhj82zE071kX5B4W1M4ewYMf/P+i6YsDQmj62Xz3VQm9zyUzUXwhIde/wHLGCMrM+yGg=="],
783
854
784
855
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
856
+
857
+
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
785
858
786
859
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
787
860
788
861
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
789
862
863
+
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
864
+
865
+
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
866
+
867
+
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
868
+
869
+
"generic-pool": ["generic-pool@3.9.0", "", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="],
870
+
790
871
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
791
872
792
873
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
···
799
880
800
881
"get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
801
882
883
+
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
884
+
802
885
"get-tsconfig": ["get-tsconfig@4.12.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw=="],
886
+
887
+
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
803
888
804
889
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
805
890
806
891
"globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
807
892
893
+
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
894
+
808
895
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
809
896
810
897
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
898
+
899
+
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
811
900
812
901
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
813
902
903
+
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
904
+
905
+
"has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="],
906
+
814
907
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
815
908
816
909
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
···
822
915
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
823
916
824
917
"hmac-drbg": ["hmac-drbg@1.0.1", "", { "dependencies": { "hash.js": "^1.0.3", "minimalistic-assert": "^1.0.0", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg=="],
918
+
919
+
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
825
920
826
921
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
827
922
···
841
936
842
937
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
843
938
939
+
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
940
+
844
941
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
942
+
943
+
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
845
944
846
945
"ioredis": ["ioredis@5.8.1", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-Qho8TgIamqEPdgiMadJwzRMW3TudIg6vpg4YONokGDudy4eqRIJtDbVX72pfLBcWxvbn3qm/40TyGUObdW4tLQ=="],
847
946
···
849
948
850
949
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
851
950
951
+
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
952
+
852
953
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
853
954
955
+
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
956
+
957
+
"is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
958
+
959
+
"is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
960
+
961
+
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
962
+
963
+
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
964
+
965
+
"is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="],
966
+
967
+
"is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
968
+
854
969
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
855
970
971
+
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
972
+
856
973
"is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
974
+
975
+
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
857
976
858
977
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
859
978
979
+
"is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
980
+
981
+
"is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
982
+
860
983
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
861
984
985
+
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
986
+
987
+
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
988
+
989
+
"is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
990
+
991
+
"is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
992
+
862
993
"is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
863
994
995
+
"is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
996
+
997
+
"is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
998
+
999
+
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
1000
+
1001
+
"is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
1002
+
1003
+
"is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
1004
+
1005
+
"is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
1006
+
1007
+
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
1008
+
864
1009
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
865
1010
866
1011
"iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="],
1012
+
1013
+
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
1014
+
1015
+
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
1016
+
1017
+
"istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="],
1018
+
1019
+
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
867
1020
868
1021
"javascript-natural-sort": ["javascript-natural-sort@0.7.1", "", {}, "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw=="],
869
1022
···
884
1037
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
885
1038
886
1039
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
1040
+
1041
+
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
887
1042
888
1043
"key-encoder": ["key-encoder@2.0.3", "", { "dependencies": { "@types/elliptic": "^6.4.9", "asn1.js": "^5.0.1", "bn.js": "^4.11.8", "elliptic": "^6.4.1" } }, "sha512-fgBtpAGIr/Fy5/+ZLQZIPPhsZEcbSlYu/Wu96tNDFNSjSACw5lEIOFeaVdQ/iwrb8oxjlWi6wmWdH76hV6GZjg=="],
889
1044
···
929
1084
930
1085
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
931
1086
1087
+
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
1088
+
1089
+
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
1090
+
932
1091
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
933
1092
934
1093
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
···
991
1150
992
1151
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
993
1152
1153
+
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
1154
+
1155
+
"object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
1156
+
1157
+
"object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="],
1158
+
1159
+
"object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="],
1160
+
1161
+
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
1162
+
994
1163
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
995
1164
996
1165
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
···
1004
1173
"onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="],
1005
1174
1006
1175
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
1176
+
1177
+
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
1007
1178
1008
1179
"p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="],
1009
1180
···
1027
1198
1028
1199
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
1029
1200
1201
+
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
1202
+
1030
1203
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
1031
1204
1205
+
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
1206
+
1032
1207
"path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
1033
1208
1034
1209
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
···
1068
1243
"pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="],
1069
1244
1070
1245
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
1246
+
1247
+
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
1071
1248
1072
1249
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
1073
1250
···
1125
1302
1126
1303
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
1127
1304
1305
+
"redis": ["redis@4.7.1", "", { "dependencies": { "@redis/bloom": "1.2.0", "@redis/client": "1.6.1", "@redis/graph": "1.1.1", "@redis/json": "1.0.7", "@redis/search": "1.2.0", "@redis/time-series": "1.1.0" } }, "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ=="],
1306
+
1128
1307
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
1129
1308
1130
1309
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
1131
1310
1311
+
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
1312
+
1313
+
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
1314
+
1132
1315
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
1133
1316
1134
1317
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
1318
+
1319
+
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
1135
1320
1136
1321
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
1137
1322
···
1153
1338
1154
1339
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
1155
1340
1341
+
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
1342
+
1156
1343
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
1157
1344
1345
+
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
1346
+
1347
+
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
1348
+
1158
1349
"safe-regex2": ["safe-regex2@3.1.0", "", { "dependencies": { "ret": "~0.4.0" } }, "sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug=="],
1159
1350
1160
1351
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
···
1163
1354
1164
1355
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
1165
1356
1166
-
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
1357
+
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
1167
1358
1168
1359
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
1169
1360
···
1172
1363
"serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="],
1173
1364
1174
1365
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
1366
+
1367
+
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
1368
+
1369
+
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
1370
+
1371
+
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
1175
1372
1176
1373
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
1177
1374
···
1219
1416
1220
1417
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
1221
1418
1419
+
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
1420
+
1222
1421
"stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="],
1223
1422
1224
1423
"string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="],
1225
1424
1226
1425
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
1227
1426
1427
+
"string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="],
1428
+
1429
+
"string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="],
1430
+
1431
+
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
1432
+
1228
1433
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
1229
1434
1230
1435
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
1436
+
1437
+
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
1231
1438
1232
1439
"strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="],
1233
1440
···
1243
1450
1244
1451
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
1245
1452
1453
+
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
1454
+
1246
1455
"tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="],
1456
+
1457
+
"test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="],
1247
1458
1248
1459
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
1249
1460
···
1275
1486
1276
1487
"ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
1277
1488
1489
+
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
1490
+
1278
1491
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
1279
1492
1280
1493
"tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="],
···
1287
1500
1288
1501
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
1289
1502
1503
+
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
1504
+
1505
+
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
1506
+
1507
+
"typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="],
1508
+
1509
+
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
1510
+
1290
1511
"typed-emitter": ["typed-emitter@2.1.0", "", { "optionalDependencies": { "rxjs": "*" } }, "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA=="],
1291
1512
1292
1513
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
···
1298
1519
"ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
1299
1520
1300
1521
"uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="],
1522
+
1523
+
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
1301
1524
1302
1525
"undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="],
1303
1526
···
1329
1552
1330
1553
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
1331
1554
1555
+
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
1556
+
1557
+
"which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="],
1558
+
1559
+
"which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
1560
+
1561
+
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
1562
+
1332
1563
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
1333
1564
1334
1565
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
···
1342
1573
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
1343
1574
1344
1575
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
1576
+
1577
+
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
1345
1578
1346
1579
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
1347
1580
···
1423
1656
1424
1657
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
1425
1658
1659
+
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
1660
+
1426
1661
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
1427
1662
1428
1663
"ajv-formats/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
···
1439
1674
1440
1675
"duplexify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
1441
1676
1677
+
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
1678
+
1679
+
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
1680
+
1681
+
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
1682
+
1442
1683
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
1443
1684
1444
1685
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
···
1451
1692
1452
1693
"fastify/secure-json-parse": ["secure-json-parse@2.7.0", "", {}, "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw=="],
1453
1694
1695
+
"fastify/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
1696
+
1454
1697
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
1455
1698
1456
1699
"libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
···
1462
1705
"listr2/eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
1463
1706
1464
1707
"log-update/slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
1708
+
1709
+
"magicast/@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="],
1710
+
1711
+
"make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
1465
1712
1466
1713
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
1467
1714
···
1488
1735
"send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
1489
1736
1490
1737
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
1738
+
1739
+
"sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
1491
1740
1492
1741
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
1493
1742
+1
bun.lockb
+1
bun.lockb
···
1
+
+12
compose.dev.yaml
+12
compose.dev.yaml
···
1
+
# Development override for docker-compose
2
+
# Usage: docker compose -f compose.yaml -f compose.dev.yaml up
3
+
#
4
+
# This configuration:
5
+
# - Runs the app in watch mode (auto-reloads on file changes)
6
+
# - Mounts source code so changes are picked up without rebuild
7
+
8
+
services:
9
+
automod:
10
+
command: ["bun", "run", "dev"]
11
+
volumes:
12
+
- ./src:/app/src
+56
-1
compose.yaml
+56
-1
compose.yaml
···
9
9
version: "3.8"
10
10
11
11
services:
12
+
redis:
13
+
image: redis:7-alpine
14
+
container_name: skywatch-automod-redis
15
+
restart: unless-stopped
16
+
command: redis-server --appendonly yes --appendfsync everysec
17
+
volumes:
18
+
- redis-data:/data
19
+
networks:
20
+
- skywatch-network
21
+
healthcheck:
22
+
test: ["CMD", "redis-cli", "ping"]
23
+
interval: 10s
24
+
timeout: 3s
25
+
retries: 3
26
+
12
27
automod:
13
28
# Build the Docker image from the Dockerfile in the current directory.
14
29
build: .
···
19
34
20
35
# Expose the metrics server port to the host machine.
21
36
ports:
22
-
- "4100:4101"
37
+
- "4101:4101"
23
38
24
39
# Load environment variables from a .env file in the same directory.
25
40
# This is where you should put your BSKY_HANDLE, BSKY_PASSWORD, etc.
26
41
env_file:
27
42
- .env
28
43
44
+
# Wait for Redis to be healthy before starting
45
+
depends_on:
46
+
redis:
47
+
condition: service_healthy
48
+
49
+
networks:
50
+
- skywatch-network
51
+
29
52
# Mount a volume to persist the firehose cursor.
30
53
# This links the `cursor.txt` file from your host into the container at `/app/cursor.txt`.
31
54
# Persisting this file allows the automod to resume from where it left off
32
55
# after a restart, preventing it from reprocessing old events or skipping new ones.
33
56
volumes:
34
57
- ./cursor.txt:/app/cursor.txt
58
+
- ./.session:/app/.session
59
+
- ./rules:/app/rules
60
+
61
+
environment:
62
+
- NODE_ENV=production
63
+
- REDIS_URL=redis://redis:6379
64
+
65
+
prometheus:
66
+
image: prom/prometheus:latest
67
+
container_name: skywatch-prometheus
68
+
restart: unless-stopped
69
+
ports:
70
+
- "9090:9090"
71
+
volumes:
72
+
- ./prometheus.yml:/etc/prometheus/prometheus.yml
73
+
- prometheus-data:/prometheus
74
+
command:
75
+
- "--config.file=/etc/prometheus/prometheus.yml"
76
+
- "--storage.tsdb.path=/prometheus"
77
+
networks:
78
+
- skywatch-network
79
+
depends_on:
80
+
- automod
81
+
82
+
volumes:
83
+
redis-data:
84
+
prometheus-data:
85
+
86
+
networks:
87
+
skywatch-network:
88
+
driver: bridge
89
+
name: skywatch-network
+79
docs/automod/main.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+10
prometheus.yml
+43
rules/accountAge.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
-4
src/constants.example.ts
-25
src/developing_checks.md
-25
src/developing_checks.md
···
1
-
# How to build checks for skywatch-automod
2
-
3
-
## Introduction
4
-
5
-
Constants.ts defines three types of types of checks: `HANDLE_CHECKS`, `POST_CHECKS`, and `PROFILE_CHECKS`.
6
-
7
-
For each check, users need to define a set of regular expressions that will be used to match against the content of the post, handle, or profile. A maximal example of a check is as follows:
8
-
9
-
```typescript
10
-
export const HANDLE_CHECKS: Checks[] = [
11
-
{
12
-
label: "example",
13
-
comment: "Example found in handle",
14
-
description: true, // Optional, only used in handle checks
15
-
displayName: true, // Optional, only used in handle checks
16
-
reportOnly: false, // it true, the check will only report the content against the account, not label.
17
-
commentOnly: false, // Poorly named, if true, will generate an account level comment from flagged posts, rather than a report. Intended for use when reportOnly is false, and on posts only where the flag may generate a high volume of reports..
18
-
check: new RegExp("example", "i"), // Regular expression to match against the content
19
-
whitelist: new RegExp("example.com", "i"), // Optional, regular expression to whitelist content
20
-
ignoredDIDs: ["did:plc:example"], // Optional, array of DIDs to ignore if they match the check. Useful for folks who reclaim words.
21
-
},
22
-
];
23
-
```
24
-
25
-
In the above example, any handle that contains the word "example" will be labeled with the label "example" unless the handle is `example.com` or the handle belongs to the user with the DID `did:plc:example`.
+115
-8
src/limits.ts
+115
-8
src/limits.ts
···
1
1
import { pRateLimit } from "p-ratelimit";
2
+
import { Counter, Gauge, Histogram } from "prom-client";
3
+
import { logger } from "./logger.js";
2
4
3
-
// TypeScript
5
+
interface RateLimitState {
6
+
limit: number;
7
+
remaining: number;
8
+
reset: number; // Unix timestamp in seconds
9
+
policy?: string;
10
+
}
11
+
12
+
// Conservative defaults based on previous static configuration
13
+
// Will be replaced with dynamic values from ATP response headers
14
+
let rateLimitState: RateLimitState = {
15
+
limit: 280,
16
+
remaining: 280,
17
+
reset: Math.floor(Date.now() / 1000) + 30,
18
+
};
4
19
5
-
// create a rate limiter that allows up to 30 API calls per second,
6
-
// with max concurrency of 10
20
+
const SAFETY_BUFFER = 5; // Keep this many requests in reserve (reduced from 20)
21
+
const CONCURRENCY = 24; // Reduced from 48 to prevent rapid depletion
7
22
8
-
export const limit = pRateLimit({
9
-
interval: 30000, // 1000 ms == 1 second
10
-
rate: 280, // 30 API calls per interval
11
-
concurrency: 48, // no more than 10 running at once
12
-
maxDelay: 0, // an API call delayed > 30 sec is rejected
23
+
// Metrics
24
+
const rateLimitWaitsTotal = new Counter({
25
+
name: "rate_limit_waits_total",
26
+
help: "Total number of times rate limit wait was triggered",
13
27
});
28
+
29
+
const rateLimitWaitDuration = new Histogram({
30
+
name: "rate_limit_wait_duration_seconds",
31
+
help: "Duration of rate limit waits in seconds",
32
+
buckets: [0.1, 0.5, 1, 5, 10, 30, 60],
33
+
});
34
+
35
+
const rateLimitRemaining = new Gauge({
36
+
name: "rate_limit_remaining",
37
+
help: "Current remaining rate limit",
38
+
});
39
+
40
+
const rateLimitTotal = new Gauge({
41
+
name: "rate_limit_total",
42
+
help: "Total rate limit from headers",
43
+
});
44
+
45
+
const concurrentRequestsGauge = new Gauge({
46
+
name: "concurrent_requests",
47
+
help: "Current number of concurrent requests",
48
+
});
49
+
50
+
// Use p-ratelimit purely for concurrency management
51
+
const concurrencyLimiter = pRateLimit({
52
+
interval: 1000,
53
+
rate: 10000, // Very high rate, we manage rate limiting separately
54
+
concurrency: CONCURRENCY,
55
+
maxDelay: 0,
56
+
});
57
+
58
+
export function getRateLimitState(): RateLimitState {
59
+
return { ...rateLimitState };
60
+
}
61
+
62
+
export function updateRateLimitState(state: Partial<RateLimitState>): void {
63
+
rateLimitState = { ...rateLimitState, ...state };
64
+
65
+
// Update Prometheus metrics
66
+
if (state.remaining !== undefined) {
67
+
rateLimitRemaining.set(state.remaining);
68
+
}
69
+
if (state.limit !== undefined) {
70
+
rateLimitTotal.set(state.limit);
71
+
}
72
+
73
+
logger.debug(
74
+
{
75
+
limit: rateLimitState.limit,
76
+
remaining: rateLimitState.remaining,
77
+
resetIn: rateLimitState.reset - Math.floor(Date.now() / 1000),
78
+
},
79
+
"Rate limit state updated",
80
+
);
81
+
}
82
+
83
+
async function awaitRateLimit(): Promise<void> {
84
+
const state = getRateLimitState();
85
+
const now = Math.floor(Date.now() / 1000);
86
+
87
+
// Only wait if we're critically low
88
+
if (state.remaining <= SAFETY_BUFFER) {
89
+
rateLimitWaitsTotal.inc();
90
+
91
+
const delaySeconds = Math.max(0, state.reset - now);
92
+
const delayMs = delaySeconds * 1000;
93
+
94
+
if (delayMs > 0) {
95
+
logger.warn(
96
+
`Rate limit critical (${state.remaining.toString()}/${state.limit.toString()} remaining). Waiting ${delaySeconds.toString()}s until reset...`,
97
+
);
98
+
99
+
const waitStart = Date.now();
100
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
101
+
const waitDuration = (Date.now() - waitStart) / 1000;
102
+
rateLimitWaitDuration.observe(waitDuration);
103
+
104
+
// Don't manually reset state - let the next API response update it
105
+
logger.info("Rate limit wait complete, resuming requests");
106
+
}
107
+
}
108
+
}
109
+
110
+
export async function limit<T>(fn: () => Promise<T>): Promise<T> {
111
+
return concurrencyLimiter(async () => {
112
+
concurrentRequestsGauge.inc();
113
+
try {
114
+
await awaitRateLimit();
115
+
return await fn();
116
+
} finally {
117
+
concurrentRequestsGauge.dec();
118
+
}
119
+
});
120
+
}
+1
-1
src/logger.ts
+1
-1
src/logger.ts
+120
-94
src/main.ts
+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
+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
+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
+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
+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
-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
+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
+10
-6
src/rules/account/tests/age.test.ts
···
1
1
import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
import { ACCOUNT_AGE_CHECKS } from "../../../../rules/accountAge.js";
3
+
import { GLOBAL_ALLOW } from "../../../../rules/constants.js";
4
+
import {
5
+
checkAccountLabels,
6
+
createAccountLabel,
7
+
} from "../../../accountModeration.js";
2
8
import { agent } from "../../../agent.js";
3
-
import { GLOBAL_ALLOW } from "../../../constants.js";
9
+
import { PLC_URL } from "../../../config.js";
4
10
import { logger } from "../../../logger.js";
5
-
import { checkAccountLabels, createAccountLabel } from "../../../moderation.js";
6
11
import {
7
12
calculateAccountAge,
8
13
checkAccountAge,
9
14
getAccountCreationDate,
10
15
} from "../age.js";
11
-
import { ACCOUNT_AGE_CHECKS } from "../ageConstants.js";
12
16
13
17
// Mock dependencies
14
18
vi.mock("../../../agent.js", () => ({
···
27
31
},
28
32
}));
29
33
30
-
vi.mock("../../../moderation.js", () => ({
34
+
vi.mock("../../../accountModeration.js", () => ({
31
35
createAccountLabel: vi.fn(),
32
36
checkAccountLabels: vi.fn(),
33
37
}));
34
38
35
-
vi.mock("../../../constants.js", () => ({
39
+
vi.mock("../../../../rules/constants.js", () => ({
36
40
GLOBAL_ALLOW: [],
37
41
}));
38
42
···
97
101
const result = await getAccountCreationDate("did:plc:test123");
98
102
99
103
expect(global.fetch).toHaveBeenCalledWith(
100
-
"https://plc.directory/did:plc:test123/log/audit",
104
+
`https://${PLC_URL}/did:plc:test123/log/audit`,
101
105
);
102
106
expect(result).toEqual(new Date("2025-01-10T12:00:00.000Z"));
103
107
});
+2
-2
src/rules/account/tests/countStarterPacks.test.ts
+2
-2
src/rules/account/tests/countStarterPacks.test.ts
···
1
1
import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
import { createAccountLabel } from "../../../accountModeration.js";
2
3
import { agent } from "../../../agent.js";
3
4
import { limit } from "../../../limits.js";
4
5
import { logger } from "../../../logger.js";
5
-
import { createAccountLabel } from "../../../moderation.js";
6
6
import { countStarterPacks } from "../countStarterPacks.js";
7
7
8
8
// Mock dependencies
···
28
28
},
29
29
}));
30
30
31
-
vi.mock("../../../moderation.js", () => ({
31
+
vi.mock("../../../accountModeration.js", () => ({
32
32
createAccountLabel: vi.fn(),
33
33
}));
34
34
+15
-8
src/rules/facets/facets.ts
+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
+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
+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
+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
-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
+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
-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
+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
+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
-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
+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
+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
+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
+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
+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
+17
-4
src/tests/agent.test.ts
···
13
13
OZONE_PDS: "pds.test.com",
14
14
}));
15
15
16
+
// Mock session
17
+
const mockSession = {
18
+
did: "did:plc:test123",
19
+
handle: "test.bsky.social",
20
+
accessJwt: "test-access-jwt",
21
+
refreshJwt: "test-refresh-jwt",
22
+
};
23
+
16
24
// Mock the AtpAgent
17
-
const mockLogin = vi.fn(() => Promise.resolve());
25
+
const mockLogin = vi.fn(() =>
26
+
Promise.resolve({ success: true, data: mockSession }),
27
+
);
18
28
const mockConstructor = vi.fn();
19
29
vi.doMock("@atproto/api", () => ({
20
30
AtpAgent: class {
21
31
login = mockLogin;
22
32
service: URL;
33
+
session = mockSession;
23
34
constructor(options: { service: string }) {
24
35
mockConstructor(options);
25
36
this.service = new URL(options.service);
···
30
41
const { agent, login } = await import("../agent.js");
31
42
32
43
// Check that the agent was created with the correct service URL
33
-
expect(mockConstructor).toHaveBeenCalledWith({
34
-
service: "https://pds.test.com",
35
-
});
44
+
expect(mockConstructor).toHaveBeenCalledWith(
45
+
expect.objectContaining({
46
+
service: "https://pds.test.com",
47
+
}),
48
+
);
36
49
expect(agent.service.toString()).toBe("https://pds.test.com/");
37
50
38
51
// Check that the login function calls the mockLogin function
+3
-3
src/tests/metrics.test.ts
+3
-3
src/tests/metrics.test.ts
···
1
-
import { Server } from "http";
1
+
import type { Server } from "http";
2
2
import request from "supertest";
3
-
import { describe, expect, it } from "vitest";
3
+
import { afterEach, describe, expect, it } from "vitest";
4
4
import { startMetricsServer } from "../metrics.js";
5
5
6
6
describe("Metrics Server", () => {
7
-
let server: Server;
7
+
let server: Server | undefined;
8
8
9
9
afterEach(() => {
10
10
if (server) {
+92
-90
src/tests/moderation.test.ts
+92
-90
src/tests/moderation.test.ts
···
1
1
import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
// --- Imports Second ---
3
+
import { checkAccountLabels } from "../accountModeration.js";
2
4
import { agent } from "../agent.js";
3
-
import { logger } from "../logger.js";
4
-
import { checkAccountLabels } from "../moderation.js";
5
+
import { createPostLabel } from "../moderation.js";
6
+
import { tryClaimPostLabel } from "../redis.js";
5
7
6
-
// Mock dependencies
8
+
// --- Mocks First ---
9
+
7
10
vi.mock("../agent.js", () => ({
8
11
agent: {
9
12
tools: {
10
13
ozone: {
11
14
moderation: {
12
15
getRepo: vi.fn(),
16
+
getRecord: vi.fn(),
17
+
emitEvent: vi.fn(),
13
18
},
14
19
},
15
20
},
···
17
22
isLoggedIn: Promise.resolve(true),
18
23
}));
19
24
25
+
vi.mock("../redis.js", () => ({
26
+
tryClaimPostLabel: vi.fn(),
27
+
tryClaimAccountLabel: vi.fn(),
28
+
}));
29
+
20
30
vi.mock("../logger.js", () => ({
21
31
logger: {
22
32
info: vi.fn(),
···
34
44
limit: vi.fn((fn) => fn()),
35
45
}));
36
46
37
-
describe("checkAccountLabels", () => {
47
+
describe("Moderation Logic", () => {
38
48
beforeEach(() => {
39
49
vi.clearAllMocks();
40
50
});
41
51
42
-
it("should return true if label exists on account", async () => {
43
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
44
-
data: {
45
-
labels: [
46
-
{ val: "spam" },
47
-
{ val: "harassment" },
48
-
{ val: "window-reply" },
49
-
],
50
-
},
51
-
});
52
-
53
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
54
-
55
-
expect(result).toBe(true);
56
-
expect(agent.tools.ozone.moderation.getRepo).toHaveBeenCalledWith(
57
-
{ did: "did:plc:test123" },
58
-
{
59
-
headers: {
60
-
"atproto-proxy": "did:plc:moderator123#atproto_labeler",
61
-
"atproto-accept-labelers": "did:plc:ar7c4by46qjdydhdevvrndac;redact",
52
+
describe("checkAccountLabels", () => {
53
+
it("should return true if label exists on account", async () => {
54
+
vi.mocked(agent.tools.ozone.moderation.getRepo).mockResolvedValueOnce({
55
+
data: {
56
+
labels: [
57
+
{
58
+
val: "spam",
59
+
src: "did:plc:test",
60
+
uri: "at://test",
61
+
cts: "2024-01-01T00:00:00Z",
62
+
},
63
+
{
64
+
val: "window-reply",
65
+
src: "did:plc:test",
66
+
uri: "at://test",
67
+
cts: "2024-01-01T00:00:00Z",
68
+
},
69
+
],
62
70
},
63
-
},
64
-
);
65
-
});
66
-
67
-
it("should return false if label does not exist on account", async () => {
68
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
69
-
data: {
70
-
labels: [{ val: "spam" }, { val: "harassment" }],
71
-
},
71
+
} as any);
72
+
const result = await checkAccountLabels(
73
+
"did:plc:test123",
74
+
"window-reply",
75
+
);
76
+
expect(result).toBe(true);
72
77
});
73
-
74
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
75
-
76
-
expect(result).toBe(false);
77
78
});
78
79
79
-
it("should return false if account has no labels", async () => {
80
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
81
-
data: {
82
-
labels: [],
83
-
},
84
-
});
80
+
describe("createPostLabel with Caching", () => {
81
+
const URI = "at://did:plc:test/app.bsky.feed.post/123";
82
+
const CID = "bafybeig6xv5nwph5j7grrlp3pdeolqptpep5nfljmdkmtcf2l4wisa2mfa";
83
+
const LABEL = "test-label";
84
+
const COMMENT = "test comment";
85
85
86
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
86
+
it("should skip if claim fails (already claimed)", async () => {
87
+
vi.mocked(tryClaimPostLabel).mockResolvedValue(false);
87
88
88
-
expect(result).toBe(false);
89
-
});
89
+
await createPostLabel(URI, CID, LABEL, COMMENT, undefined);
90
90
91
-
it("should return false if labels property is undefined", async () => {
92
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
93
-
data: {},
91
+
expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL);
92
+
expect(
93
+
vi.mocked(agent.tools.ozone.moderation.getRecord),
94
+
).not.toHaveBeenCalled();
95
+
expect(
96
+
vi.mocked(agent.tools.ozone.moderation.emitEvent),
97
+
).not.toHaveBeenCalled();
94
98
});
95
99
96
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
100
+
it("should skip event if claimed but already labeled via API", async () => {
101
+
vi.mocked(tryClaimPostLabel).mockResolvedValue(true);
102
+
vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({
103
+
data: {
104
+
labels: [
105
+
{
106
+
val: LABEL,
107
+
src: "did:plc:test",
108
+
uri: URI,
109
+
cts: "2024-01-01T00:00:00Z",
110
+
},
111
+
],
112
+
},
113
+
} as any);
97
114
98
-
expect(result).toBe(false);
99
-
});
115
+
await createPostLabel(URI, CID, LABEL, COMMENT, undefined);
100
116
101
-
it("should handle API errors gracefully", async () => {
102
-
(agent.tools.ozone.moderation.getRepo as any).mockRejectedValueOnce(
103
-
new Error("API Error"),
104
-
);
117
+
expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL);
118
+
expect(
119
+
vi.mocked(agent.tools.ozone.moderation.getRecord),
120
+
).toHaveBeenCalledWith({ uri: URI }, expect.any(Object));
121
+
expect(
122
+
vi.mocked(agent.tools.ozone.moderation.emitEvent),
123
+
).not.toHaveBeenCalled();
124
+
});
105
125
106
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
126
+
it("should emit event if claimed and not labeled anywhere", async () => {
127
+
vi.mocked(tryClaimPostLabel).mockResolvedValue(true);
128
+
vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({
129
+
data: { labels: [] },
130
+
} as any);
131
+
vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({
132
+
success: true,
133
+
} as any);
107
134
108
-
expect(result).toBe(false);
109
-
expect(logger.error).toHaveBeenCalledWith(
110
-
{
111
-
process: "MODERATION",
112
-
did: "did:plc:test123",
113
-
error: expect.any(Error),
114
-
},
115
-
"Failed to check account labels",
116
-
);
117
-
});
135
+
await createPostLabel(URI, CID, LABEL, COMMENT, undefined);
118
136
119
-
it("should perform case-sensitive label matching", async () => {
120
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
121
-
data: {
122
-
labels: [{ val: "window-reply" }],
123
-
},
137
+
expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL);
138
+
expect(
139
+
vi.mocked(agent.tools.ozone.moderation.getRecord),
140
+
).toHaveBeenCalledWith({ uri: URI }, expect.any(Object));
141
+
expect(
142
+
vi.mocked(agent.tools.ozone.moderation.emitEvent),
143
+
).toHaveBeenCalled();
124
144
});
125
-
126
-
const resultLower = await checkAccountLabels(
127
-
"did:plc:test123",
128
-
"window-reply",
129
-
);
130
-
expect(resultLower).toBe(true);
131
-
132
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
133
-
data: {
134
-
labels: [{ val: "window-reply" }],
135
-
},
136
-
});
137
-
138
-
const resultUpper = await checkAccountLabels(
139
-
"did:plc:test123",
140
-
"Window-Reply",
141
-
);
142
-
expect(resultUpper).toBe(false);
143
145
});
144
146
});
+333
src/tests/redis.test.ts
+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
+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
+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
+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
+7
-3
src/utils/getFinalUrl.ts
···
2
2
3
3
export async function getFinalUrl(url: string): Promise<string> {
4
4
const controller = new AbortController();
5
-
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15-second timeout
5
+
const timeoutId = setTimeout(() => {
6
+
controller.abort();
7
+
}, 15000); // 15-second timeout
6
8
7
9
const headers = {
8
10
"User-Agent":
···
19
21
});
20
22
clearTimeout(timeoutId);
21
23
return response.url;
22
-
} catch (headError) {
24
+
} catch {
23
25
clearTimeout(timeoutId);
24
26
25
27
// Some services block HEAD requests, try GET as fallback
26
28
const getController = new AbortController();
27
-
const getTimeoutId = setTimeout(() => getController.abort(), 15000);
29
+
const getTimeoutId = setTimeout(() => {
30
+
getController.abort();
31
+
}, 15000);
28
32
29
33
try {
30
34
logger.debug(
-2
src/utils/homoglyphs.ts
-2
src/utils/homoglyphs.ts
-1
src/utils/normalizeUnicode.ts
-1
src/utils/normalizeUnicode.ts