+11
-2
.claude/settings.local.json
+11
-2
.claude/settings.local.json
···
13
13
"Bash(npm run test:run:*)",
14
14
"Bash(bunx eslint:*)",
15
15
"Bash(bun test:run:*)",
16
-
"Bash(bun run type-check:*)"
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:*)"
17
24
],
18
25
"deny": [],
19
26
"ask": []
20
27
},
21
28
"enableAllProjectMcpServers": true,
22
-
"enabledMcpjsonServers": ["git-mcp-server"]
29
+
"enabledMcpjsonServers": [
30
+
"git-mcp-server"
31
+
]
23
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
-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).
+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`
16
+
- And many other "confusable" characters.
17
+
18
+
## `normalizeUnicode.ts`
19
+
20
+
This file provides the function that performs the normalization using the `homoglyphMap`.
21
+
22
+
### Key Function
23
+
24
+
#### `normalizeUnicode(text: string): string`
25
+
26
+
This function takes a string and returns a "flattened" version of it in a predictable, normalized form.
27
+
28
+
**Parameters:**
29
+
30
+
- `text`: The input string to be normalized.
31
+
32
+
**Returns:**
33
+
34
+
- A `string` that has been normalized.
35
+
36
+
**Normalization Process:**
37
+
38
+
The function applies a multi-step process to the input string:
39
+
40
+
1. **Lowercase**: The entire string is converted to lowercase. This ensures that checks are case-insensitive.
41
+
2. **Homoglyph Replacement**: It iterates through each character of the lowercased string and replaces it with its ASCII equivalent from the `homoglyphMap` if a mapping exists. This is done *before* Unicode decomposition to catch pre-composed characters that are also used as homoglyphs.
42
+
3. **Decomposition (NFD)**: It applies Unicode Normalization Form D (`.normalize("NFD")`). This separates base characters from their combining marks. For example, `รฉ` becomes `e` + `ยด` (combining acute accent).
43
+
4. **Diacritic Removal**: It uses a regular expression (`/[\u0300-\u036f]/g`) to strip out all Unicode combining diacritical marks, leaving only the base characters.
44
+
5. **Compatibility Normalization (NFKC)**: As a final step, it applies Unicode Normalization Form KC (`.normalize("NFKC")`). This handles a broader range of compatibility characters (e.g., converting ligatures like `๏ฌ` into `fi`) to ensure the string is in its simplest, most comparable form.
45
+
46
+
This robust process ensures that a string like `"P@sswะพrd"` (with a capital P, an @ symbol, and a Cyrillic 'o') is converted to `"password"`, which can then be easily matched against a rule.
+57
docs/common/accountModeration.md
+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`
+2
-2
package.json
+2
-2
package.json
+33
-7
rules/accountAge.ts
+33
-7
rules/accountAge.ts
···
3
3
/**
4
4
* Account age monitoring configurations
5
5
*
6
-
* This file contains example values. Copy to accountAge.ts and configure with your checks.
6
+
* Labels new accounts that interact with monitored DIDs or posts.
7
+
* Useful for protecting high-profile accounts from coordinated harassment.
8
+
* Configure your checks below.
7
9
*/
8
10
export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [
9
-
// Example configuration:
11
+
// Example - monitor replies to specific accounts:
10
12
// {
11
-
// monitoredDIDs: ["did:plc:example123"],
12
-
// anchorDate: "2025-01-15",
13
-
// maxAgeDays: 7,
14
-
// label: "new-account",
15
-
// comment: "Account created within monitored window",
13
+
// monitoredDIDs: ["did:plc:example123", "did:plc:example456"],
14
+
// anchorDate: "2025-01-15", // Only check accounts created after this date
15
+
// maxAgeDays: 7, // Flag accounts younger than 7 days
16
+
// label: "new-account-reply",
17
+
// comment: "New account replying to monitored user",
18
+
// expires: "2025-02-15", // Stop checking after this date
19
+
// },
20
+
21
+
// Example - monitor replies/quotes to specific posts:
22
+
// {
23
+
// monitoredPostURIs: [
24
+
// "at://did:plc:xyz/app.bsky.feed.post/abc123",
25
+
// "at://did:plc:xyz/app.bsky.feed.post/def456",
26
+
// ],
27
+
// anchorDate: "2025-01-20",
28
+
// maxAgeDays: 3,
29
+
// label: "new-account-quote",
30
+
// comment: "New account quoting monitored post",
31
+
// },
32
+
33
+
// Example - combine both DID and post monitoring:
34
+
// {
35
+
// monitoredDIDs: ["did:plc:high-profile"],
36
+
// monitoredPostURIs: ["at://did:plc:high-profile/app.bsky.feed.post/viral"],
37
+
// anchorDate: "2025-01-01",
38
+
// maxAgeDays: 14,
39
+
// label: "new-account-interaction",
40
+
// comment: "New account interacting with high-profile content",
41
+
// expires: "2025-03-01",
16
42
// },
17
43
];
+2
-1
rules/accountThreshold.ts
+2
-1
rules/accountThreshold.ts
+22
-3
rules/constants.ts
+22
-3
rules/constants.ts
···
1
1
/**
2
2
* Global allowlist for accounts that should bypass all checks
3
3
*
4
-
* This file contains example values. Copy to constants.ts and configure with your DIDs.
4
+
* Add DIDs here to exempt them from all moderation checks.
5
5
*/
6
6
export const GLOBAL_ALLOW: string[] = [
7
-
// Example: "did:plc:example123",
7
+
// Example: "did:plc:trusted-account",
8
8
];
9
9
10
-
export const LINK_SHORTENER = new RegExp("", "i");
10
+
/**
11
+
* URL shortener detection pattern
12
+
*
13
+
* Matched URLs are resolved to their final destination before checking.
14
+
* Add domains for URL shorteners you want to expand.
15
+
*/
16
+
export const LINK_SHORTENER = new RegExp(
17
+
[
18
+
"bit\\.ly",
19
+
"tinyurl\\.com",
20
+
"t\\.co",
21
+
"goo\\.gl",
22
+
"ow\\.ly",
23
+
"is\\.gd",
24
+
"buff\\.ly",
25
+
"rebrand\\.ly",
26
+
"short\\.io",
27
+
].join("|"),
28
+
"i",
29
+
);
+327
rules/developing_checks.md
+327
rules/developing_checks.md
···
1
+
# Developing Moderation Checks
2
+
3
+
This guide explains how to configure moderation rules for skywatch-automod.
4
+
5
+
## Overview
6
+
7
+
Moderation checks are defined in TypeScript files in the `rules/` directory. Each check uses regular expressions to match content and specifies what action to take when a match is found.
8
+
9
+
## Check Types
10
+
11
+
### Post Content Checks
12
+
13
+
File: `rules/posts.ts`
14
+
15
+
Monitors post text and embedded URLs for matches.
16
+
17
+
```typescript
18
+
import type { Checks } from "../src/types.js";
19
+
20
+
export const POST_CHECKS: Checks[] = [
21
+
{
22
+
label: "spam",
23
+
comment: "Spam content detected in post",
24
+
reportAcct: false,
25
+
commentAcct: false,
26
+
toLabel: true,
27
+
check: new RegExp("buy.*followers", "i"),
28
+
},
29
+
];
30
+
```
31
+
32
+
### Handle Checks
33
+
34
+
File: `rules/handles.ts`
35
+
36
+
Monitors user handles for pattern matches.
37
+
38
+
```typescript
39
+
export const HANDLE_CHECKS: Checks[] = [
40
+
{
41
+
label: "impersonation",
42
+
comment: "Potential impersonation detected",
43
+
reportAcct: true,
44
+
commentAcct: false,
45
+
toLabel: false,
46
+
check: new RegExp("official.*support", "i"),
47
+
},
48
+
];
49
+
```
50
+
51
+
### Profile Checks
52
+
53
+
File: `rules/profiles.ts`
54
+
55
+
Monitors profile display names and descriptions.
56
+
57
+
```typescript
58
+
export const PROFILE_CHECKS: Checks[] = [
59
+
{
60
+
label: "spam-profile",
61
+
comment: "Spam content in profile",
62
+
reportAcct: false,
63
+
commentAcct: false,
64
+
toLabel: true,
65
+
displayName: true, // Check display name
66
+
description: true, // Check description
67
+
check: new RegExp("follow.*back", "i"),
68
+
},
69
+
];
70
+
```
71
+
72
+
### Account Age Checks
73
+
74
+
File: `rules/accountAge.ts`
75
+
76
+
Labels accounts created after a specific date when they interact with monitored content.
77
+
78
+
```typescript
79
+
import type { AccountAgeCheck } from "../src/types.js";
80
+
81
+
export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [
82
+
{
83
+
monitoredDIDs: ["did:plc:abc123"],
84
+
anchorDate: "2025-01-15",
85
+
maxAgeDays: 7,
86
+
label: "new-account-spam",
87
+
comment: "New account replying to monitored user",
88
+
expires: "2025-02-15", // Optional expiration
89
+
},
90
+
];
91
+
```
92
+
93
+
### Account Threshold Checks
94
+
95
+
File: `rules/accountThreshold.ts`
96
+
97
+
Applies account-level labels when an account accumulates multiple post-level violations within a time window.
98
+
99
+
```typescript
100
+
import type { AccountThresholdConfig } from "../src/types.js";
101
+
102
+
export const ACCOUNT_THRESHOLD_CONFIGS: AccountThresholdConfig[] = [
103
+
{
104
+
labels: ["spam", "scam"], // Trigger on either label
105
+
threshold: 3,
106
+
accountLabel: "repeat-offender",
107
+
accountComment: "Account exceeded spam threshold",
108
+
window: 7,
109
+
windowUnit: "days", // Options: "minutes", "hours", "days"
110
+
reportAcct: true,
111
+
commentAcct: false,
112
+
toLabel: true,
113
+
},
114
+
];
115
+
```
116
+
117
+
### Starter Pack Threshold Checks
118
+
119
+
File: `rules/starterPackThreshold.ts`
120
+
121
+
Applies account-level labels when an account creates too many starter packs within a time window. Useful for detecting follow-farming and coordinated campaign behaviour.
122
+
123
+
```typescript
124
+
import type { StarterPackThresholdConfig } from "../src/types.js";
125
+
126
+
export const STARTER_PACK_THRESHOLD_CONFIGS: StarterPackThresholdConfig[] = [
127
+
{
128
+
threshold: 10, // Account action triggered after 10 starter packs
129
+
window: 7, // Within this duration
130
+
windowUnit: "days", // Options: "minutes", "hours", "days"
131
+
accountLabel: "follow-farming",
132
+
accountComment: "Account created multiple starter packs in short period",
133
+
toLabel: true, // Whether to apply the label (default: true)
134
+
reportAcct: true, // Whether to report the account
135
+
commentAcct: false, // Whether to comment on the account
136
+
allowlist: [], // DIDs to exempt from this check
137
+
},
138
+
];
139
+
```
140
+
141
+
## Check Configuration Fields
142
+
143
+
### Basic Fields (Required)
144
+
145
+
- `label` - Label to apply (string)
146
+
- `comment` - Comment for the moderation action (string)
147
+
- `reportAcct` - Create account report (boolean)
148
+
- `commentAcct` - Add comment to account (boolean)
149
+
- `toLabel` - Apply the label (boolean)
150
+
- `check` - Regular expression pattern (RegExp)
151
+
152
+
### Optional Fields
153
+
154
+
- `language` - Language codes to restrict check to (string[])
155
+
- `description` - Check profile descriptions (boolean)
156
+
- `displayName` - Check profile display names (boolean)
157
+
- `reportPost` - Create post report instead of just labeling (boolean)
158
+
- `duration` - Label duration in hours (number)
159
+
- `whitelist` - RegExp to exclude from matching (RegExp)
160
+
- `ignoredDIDs` - DIDs to skip checking (string[])
161
+
- `starterPacks` - Filter by starter pack membership (string[])
162
+
- `knownVectors` - Known attack vectors for tracking (string[])
163
+
- `trackOnly` - Track without applying label (boolean)
164
+
- `unlabel` - Remove existing label if content no longer matches (boolean)
165
+
166
+
### Threshold Configuration Fields
167
+
168
+
#### Account Threshold
169
+
170
+
- `labels` - Single label or array of labels to aggregate (string | string[])
171
+
- `threshold` - Number of labeled posts required to trigger account action (number)
172
+
- `window` - Rolling window duration (number)
173
+
- `windowUnit` - Unit for the rolling window: "minutes", "hours", or "days" (WindowUnit)
174
+
- `accountLabel` - Label to apply to the account (string)
175
+
- `accountComment` - Comment for the account action (string)
176
+
- `toLabel` - Whether to apply the label, defaults to true (boolean)
177
+
- `reportAcct` - Whether to report the account (boolean)
178
+
- `commentAcct` - Whether to comment on the account (boolean)
179
+
180
+
#### Starter Pack Threshold
181
+
182
+
- `threshold` - Number of starter packs required to trigger account action (number)
183
+
- `window` - Rolling window duration (number)
184
+
- `windowUnit` - Unit for the rolling window: "minutes", "hours", or "days" (WindowUnit)
185
+
- `accountLabel` - Label to apply to the account (string)
186
+
- `accountComment` - Comment for the account action (string)
187
+
- `toLabel` - Whether to apply the label, defaults to true (boolean)
188
+
- `reportAcct` - Whether to report the account (boolean)
189
+
- `commentAcct` - Whether to comment on the account (boolean)
190
+
- `allowlist` - DIDs to exempt from this check (string[])
191
+
192
+
## Examples
193
+
194
+
### Language-Specific Check
195
+
196
+
```typescript
197
+
{
198
+
language: ["spa"],
199
+
label: "spam-es",
200
+
comment: "Spanish spam detected",
201
+
reportAcct: false,
202
+
commentAcct: false,
203
+
toLabel: true,
204
+
check: new RegExp("comprar seguidores", "i"),
205
+
}
206
+
```
207
+
208
+
### Temporary Label
209
+
210
+
```typescript
211
+
{
212
+
label: "review-needed",
213
+
comment: "Content flagged for review",
214
+
reportAcct: true,
215
+
commentAcct: false,
216
+
toLabel: false,
217
+
duration: 24, // Label expires after 24 hours
218
+
check: new RegExp("suspicious.*pattern", "i"),
219
+
}
220
+
```
221
+
222
+
### Whitelist Exception
223
+
224
+
```typescript
225
+
{
226
+
label: "blocked-term",
227
+
comment: "Blocked term used",
228
+
reportAcct: false,
229
+
commentAcct: false,
230
+
toLabel: true,
231
+
check: new RegExp("\\bterm\\b", "i"),
232
+
whitelist: new RegExp("legitimate.*context", "i"),
233
+
}
234
+
```
235
+
236
+
### Ignored DIDs
237
+
238
+
```typescript
239
+
{
240
+
label: "blocked-term",
241
+
comment: "Blocked term used",
242
+
reportAcct: false,
243
+
commentAcct: false,
244
+
toLabel: true,
245
+
check: new RegExp("\\bterm\\b", "i"),
246
+
ignoredDIDs: [
247
+
"did:plc:trusted123",
248
+
"did:plc:verified456",
249
+
],
250
+
}
251
+
```
252
+
253
+
## Global Configuration
254
+
255
+
### Allowlist
256
+
257
+
File: `rules/constants.ts`
258
+
259
+
DIDs in the global allowlist bypass all checks.
260
+
261
+
```typescript
262
+
export const GLOBAL_ALLOW: string[] = [
263
+
"did:plc:trusted123",
264
+
"did:plc:verified456",
265
+
];
266
+
```
267
+
268
+
### Link Shorteners
269
+
270
+
Pattern to match URL shorteners for special handling.
271
+
272
+
```typescript
273
+
export const LINK_SHORTENER = new RegExp(
274
+
"bit\\.ly|tinyurl\\.com|goo\\.gl",
275
+
"i"
276
+
);
277
+
```
278
+
279
+
## Best Practices
280
+
281
+
### Regular Expressions
282
+
283
+
- Use word boundaries (`\\b`) to avoid partial matches
284
+
- Test patterns thoroughly to minimize false positives
285
+
- Use case-insensitive matching (`i` flag) when appropriate
286
+
- Escape special regex characters
287
+
288
+
### Action Selection
289
+
290
+
- `toLabel: true` - Apply label immediately (use for clear violations)
291
+
- `reportAcct: true` - Create report for manual review (use for ambiguous cases)
292
+
- `commentAcct: true` - Create comment on account (probably can be depreciated)
293
+
294
+
### Performance
295
+
296
+
- Keep regex patterns simple and efficient
297
+
- Use language filters to reduce unnecessary checks
298
+
- Leverage whitelists instead of complex negative lookaheads
299
+
300
+
### Testing
301
+
302
+
After modifying rules:
303
+
304
+
```bash
305
+
bun test:run
306
+
```
307
+
308
+
Test specific rule modules:
309
+
310
+
```bash
311
+
bun test src/rules/posts/tests/
312
+
```
313
+
314
+
## Deployment
315
+
316
+
Rules are mounted as a volume in docker compose:
317
+
318
+
```yaml
319
+
volumes:
320
+
- ./rules:/app/rules
321
+
```
322
+
323
+
Changes require automod rebuild:
324
+
325
+
```bash
326
+
docker compose up -d --build automod
327
+
```
+20
-6
rules/handles.ts
+20
-6
rules/handles.ts
···
3
3
/**
4
4
* Handle-based moderation checks
5
5
*
6
-
* This file contains example values. Copy to handles.ts and configure with your checks.
6
+
* Monitors user handles (usernames) for pattern matches.
7
+
* Configure your checks below.
7
8
*/
8
9
export const HANDLE_CHECKS: Checks[] = [
9
-
// Example check:
10
+
// Basic example - flag potential impersonation:
10
11
// {
11
-
// label: "example-label",
12
-
// comment: "Example check found in handle",
13
-
// reportAcct: false,
12
+
// label: "impersonation",
13
+
// comment: "Potential impersonation detected in handle",
14
+
// reportAcct: true,
15
+
// commentAcct: false,
16
+
// toLabel: false,
17
+
// check: new RegExp("official.*support", "i"),
18
+
// },
19
+
20
+
// Advanced example with optional fields:
21
+
// {
22
+
// label: "suspicious-handle",
23
+
// comment: "Handle matches known spam pattern",
24
+
// reportAcct: true,
14
25
// commentAcct: false,
15
26
// toLabel: true,
16
-
// check: new RegExp("example-pattern", "i"),
27
+
// unlabel: true, // Remove label if handle changes
28
+
// check: new RegExp("crypto.*airdrop", "i"),
29
+
// whitelist: new RegExp("cryptography", "i"), // Don't match legitimate use
30
+
// ignoredDIDs: ["did:plc:verified123"],
17
31
// },
18
32
];
+25
-5
rules/posts.ts
+25
-5
rules/posts.ts
···
3
3
/**
4
4
* Post content moderation checks
5
5
*
6
-
* This file contains example values. Copy to posts.ts and configure with your checks.
6
+
* Monitors post text and embedded URLs for pattern matches.
7
+
* Configure your checks below.
7
8
*/
8
9
export const POST_CHECKS: Checks[] = [
9
-
// Example check:
10
+
// Basic example - label posts matching a pattern:
10
11
// {
11
-
// label: "example-label",
12
-
// comment: "Example content found in post",
12
+
// label: "spam",
13
+
// comment: "Spam content detected in post",
13
14
// reportAcct: false,
14
15
// commentAcct: false,
15
16
// toLabel: true,
16
-
// check: new RegExp("example-pattern", "i"),
17
+
// check: new RegExp("buy.*followers", "i"),
18
+
// },
19
+
20
+
// Advanced example - all optional fields:
21
+
// {
22
+
// label: "scam-link",
23
+
// comment: "Suspicious link detected",
24
+
// language: ["eng", "spa"], // Only check posts in these languages
25
+
// reportAcct: true, // Create account report
26
+
// reportPost: true, // Create post report
27
+
// commentAcct: false, // Add comment to account record
28
+
// toLabel: true, // Apply the label
29
+
// trackOnly: false, // If true, track but don't take action
30
+
// unlabel: false, // If true, remove label when no longer matching
31
+
// duration: 24, // Label expires after 24 hours
32
+
// check: new RegExp("crypto.*giveaway", "i"),
33
+
// whitelist: new RegExp("legitimate-site\\.com", "i"), // Skip if this matches
34
+
// ignoredDIDs: ["did:plc:trusted123"], // Skip these accounts
35
+
// starterPacks: ["at://did:plc:xyz/app.bsky.graph.starterpack/abc"], // Only check members
36
+
// knownVectors: ["telegram-scam", "discord-spam"], // Tracking tags
17
37
// },
18
38
];
+23
-7
rules/profiles.ts
+23
-7
rules/profiles.ts
···
3
3
/**
4
4
* Profile-based moderation checks
5
5
*
6
-
* This file contains example values. Copy to profiles.ts and configure with your checks.
6
+
* Monitors profile display names and descriptions for pattern matches.
7
+
* Configure your checks below.
7
8
*/
8
9
export const PROFILE_CHECKS: Checks[] = [
9
-
// Example check:
10
+
// Basic example - check both displayName and description:
10
11
// {
11
-
// label: "example-label",
12
-
// comment: "Example content found in profile",
13
-
// description: true,
14
-
// displayName: true,
12
+
// label: "spam-profile",
13
+
// comment: "Spam content in profile",
14
+
// displayName: true, // Check display name
15
+
// description: true, // Check description
15
16
// reportAcct: false,
16
17
// commentAcct: false,
17
18
// toLabel: true,
18
-
// check: new RegExp("example-pattern", "i"),
19
+
// check: new RegExp("follow.*back.*guaranteed", "i"),
20
+
// },
21
+
22
+
// Advanced example - displayName only with unlabel:
23
+
// {
24
+
// label: "impersonation-profile",
25
+
// comment: "Profile impersonating official account",
26
+
// displayName: true,
27
+
// description: false, // Only check display name
28
+
// reportAcct: true,
29
+
// commentAcct: false,
30
+
// toLabel: true,
31
+
// unlabel: true, // Remove label if profile changes
32
+
// check: new RegExp("official.*bluesky.*team", "i"),
33
+
// whitelist: new RegExp("parody|fan", "i"),
34
+
// ignoredDIDs: ["did:plc:actual-team-member"],
19
35
// },
20
36
];
+35
rules/starterPackThreshold.ts
+35
rules/starterPackThreshold.ts
···
1
+
import type { StarterPackThresholdConfig } from "../src/types.js";
2
+
3
+
/**
4
+
* Starter pack threshold configurations
5
+
*
6
+
* Labels accounts that create too many starter packs within a time window.
7
+
* Useful for detecting follow-farming and coordinated campaign behaviour.
8
+
* Configure your checks below.
9
+
*/
10
+
export const STARTER_PACK_THRESHOLD_CONFIGS: StarterPackThresholdConfig[] = [
11
+
// Example - detect follow-farming:
12
+
// {
13
+
// threshold: 10, // Trigger after 10 starter packs
14
+
// window: 7, // Within this duration
15
+
// windowUnit: "days", // Options: "minutes", "hours", "days"
16
+
// accountLabel: "follow-farming",
17
+
// accountComment: "Account created multiple starter packs in short period",
18
+
// toLabel: true, // Apply the label (default: true)
19
+
// reportAcct: true, // Create account report
20
+
// commentAcct: false, // Add comment to account record
21
+
// allowlist: ["did:plc:trusted123"], // DIDs to exempt from this check
22
+
// },
23
+
24
+
// Example - stricter threshold for rapid creation:
25
+
// {
26
+
// threshold: 5,
27
+
// window: 1,
28
+
// windowUnit: "hours",
29
+
// accountLabel: "spam-starterpack",
30
+
// accountComment: "Rapid starter pack creation detected",
31
+
// toLabel: false,
32
+
// reportAcct: true,
33
+
// commentAcct: true,
34
+
// },
35
+
];
+116
-2
src/accountModeration.ts
+116
-2
src/accountModeration.ts
···
2
2
import { MOD_DID } from "./config.js";
3
3
import { limit } from "./limits.js";
4
4
import { logger } from "./logger.js";
5
-
import { labelsAppliedCounter, labelsCachedCounter } from "./metrics.js";
6
-
import { tryClaimAccountComment, tryClaimAccountLabel } from "./redis.js";
5
+
import {
6
+
labelsAppliedCounter,
7
+
labelsCachedCounter,
8
+
unlabelsRemovedCounter,
9
+
} from "./metrics.js";
10
+
import {
11
+
deleteAccountLabelClaim,
12
+
tryClaimAccountComment,
13
+
tryClaimAccountLabel,
14
+
} from "./redis.js";
7
15
8
16
const doesLabelExist = (
9
17
labels: { val: string }[] | undefined,
···
73
81
createdAt: new Date().toISOString(),
74
82
modTool: {
75
83
name: "skywatch/skywatch-automod",
84
+
meta: {
85
+
time: new Date().toISOString(),
86
+
externalUrl: `https://pdsls.dev/at://${did}`,
87
+
},
76
88
},
77
89
},
78
90
{
···
89
101
{ process: "MODERATION", error: e },
90
102
"Failed to create account label",
91
103
);
104
+
throw e;
92
105
}
93
106
});
94
107
};
···
129
142
createdAt: new Date().toISOString(),
130
143
modTool: {
131
144
name: "skywatch/skywatch-automod",
145
+
meta: {
146
+
time: new Date().toISOString(),
147
+
externalUrl: `https://pdsls.dev/at://${did}`,
148
+
},
132
149
},
133
150
},
134
151
{
···
145
162
{ process: "MODERATION", error: e },
146
163
"Failed to create account comment",
147
164
);
165
+
throw e;
148
166
}
149
167
});
150
168
};
···
170
188
createdAt: new Date().toISOString(),
171
189
modTool: {
172
190
name: "skywatch/skywatch-automod",
191
+
meta: {
192
+
time: new Date().toISOString(),
193
+
externalUrl: `https://pdsls.dev/at://${did}`,
194
+
},
173
195
},
174
196
},
175
197
{
···
186
208
{ process: "MODERATION", error: e },
187
209
"Failed to create account report",
188
210
);
211
+
throw e;
212
+
}
213
+
});
214
+
};
215
+
216
+
export const negateAccountLabel = async (
217
+
did: string,
218
+
label: string,
219
+
comment: string,
220
+
) => {
221
+
await isLoggedIn;
222
+
223
+
const hasLabel = await checkAccountLabels(did, label);
224
+
if (!hasLabel) {
225
+
logger.debug(
226
+
{ process: "MODERATION", did, label },
227
+
"Account does not have label, skipping",
228
+
);
229
+
return;
230
+
}
231
+
232
+
logger.info({ process: "MODERATION", did, label }, "Unlabeling account");
233
+
unlabelsRemovedCounter.inc({ label_type: label, target_type: "account" });
234
+
235
+
await limit(async () => {
236
+
try {
237
+
await agent.tools.ozone.moderation.emitEvent(
238
+
{
239
+
event: {
240
+
$type: "tools.ozone.moderation.defs#modEventLabel",
241
+
comment,
242
+
createLabelVals: [],
243
+
negateLabelVals: [label],
244
+
},
245
+
// specify the labeled post by strongRef
246
+
subject: {
247
+
$type: "com.atproto.admin.defs#repoRef",
248
+
did,
249
+
},
250
+
// put in the rest of the metadata
251
+
createdBy: agent.did ?? "",
252
+
createdAt: new Date().toISOString(),
253
+
modTool: {
254
+
name: "skywatch/skywatch-automod",
255
+
meta: {
256
+
time: new Date().toISOString(),
257
+
externalUrl: `https://pdsls.dev/at://${did}`,
258
+
},
259
+
},
260
+
},
261
+
{
262
+
encoding: "application/json",
263
+
headers: {
264
+
"atproto-proxy": `${MOD_DID}#atproto_labeler`,
265
+
"atproto-accept-labelers":
266
+
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
267
+
},
268
+
},
269
+
);
270
+
await deleteAccountLabelClaim(did, label);
271
+
} catch (e) {
272
+
logger.error(
273
+
{ process: "MODERATION", error: e },
274
+
"Failed to negate account label",
275
+
);
276
+
throw e;
189
277
}
190
278
});
191
279
};
···
218
306
}
219
307
});
220
308
};
309
+
310
+
export const getAllAccountLabels = async (did: string): Promise<string[]> => {
311
+
await isLoggedIn;
312
+
return await limit(async () => {
313
+
try {
314
+
const response = await agent.tools.ozone.moderation.getRepo(
315
+
{ did },
316
+
{
317
+
headers: {
318
+
"atproto-proxy": `${MOD_DID}#atproto_labeler`,
319
+
"atproto-accept-labelers":
320
+
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
321
+
},
322
+
},
323
+
);
324
+
325
+
return (response.data.labels ?? []).map((label) => label.val);
326
+
} catch (e) {
327
+
logger.error(
328
+
{ process: "MODERATION", did, error: e },
329
+
"Failed to get account labels",
330
+
);
331
+
return [];
332
+
}
333
+
});
334
+
};
+14
-12
src/accountThreshold.ts
+14
-12
src/accountThreshold.ts
···
41
41
`Invalid account threshold config: threshold must be positive`,
42
42
);
43
43
}
44
-
if (config.windowDays <= 0) {
44
+
if (config.window <= 0) {
45
45
throw new Error(
46
-
`Invalid account threshold config: windowDays must be positive`,
46
+
`Invalid account threshold config: window must be positive`,
47
47
);
48
48
}
49
49
}
···
65
65
66
66
export async function checkAccountThreshold(
67
67
did: string,
68
+
uri: string,
68
69
postLabel: string,
69
70
timestamp: number,
70
71
): Promise<void> {
···
93
94
did,
94
95
postLabel,
95
96
timestamp,
96
-
config.windowDays,
97
+
config.window,
98
+
config.windowUnit,
97
99
);
98
100
99
101
const count = await getPostLabelCountInWindow(
100
102
did,
101
103
labels,
102
-
config.windowDays,
104
+
config.window,
105
+
config.windowUnit,
103
106
timestamp,
104
107
);
105
108
···
110
113
labels,
111
114
count,
112
115
threshold: config.threshold,
113
-
windowDays: config.windowDays,
116
+
window: config.window,
117
+
windowUnit: config.windowUnit,
114
118
},
115
119
"Checked account threshold",
116
120
);
···
132
136
133
137
const shouldLabel = config.toLabel !== false;
134
138
139
+
const formattedComment = `${config.accountComment}\n\nThreshold: ${count.toString()}/${config.threshold.toString()} in ${config.window.toString()} ${config.windowUnit}\n\nPost: ${uri}\n\nPost Label: ${postLabel}`;
140
+
135
141
if (shouldLabel) {
136
-
await createAccountLabel(
137
-
did,
138
-
config.accountLabel,
139
-
config.accountComment,
140
-
);
142
+
await createAccountLabel(did, config.accountLabel, formattedComment);
141
143
accountLabelsThresholdAppliedCounter.inc({
142
144
account_label: config.accountLabel,
143
145
action: "label",
···
145
147
}
146
148
147
149
if (config.reportAcct) {
148
-
await createAccountReport(did, config.accountComment);
150
+
await createAccountReport(did, formattedComment);
149
151
accountLabelsThresholdAppliedCounter.inc({
150
152
account_label: config.accountLabel,
151
153
action: "report",
···
154
156
155
157
if (config.commentAcct) {
156
158
const atURI = `threshold-comment:${config.accountLabel}:${timestamp.toString()}`;
157
-
await createAccountComment(did, config.accountComment, atURI);
159
+
await createAccountComment(did, formattedComment, atURI);
158
160
accountLabelsThresholdAppliedCounter.inc({
159
161
account_label: config.accountLabel,
160
162
action: "comment",
+18
-1
src/agent.ts
+18
-1
src/agent.ts
···
170
170
}
171
171
172
172
export const login = authenticateWithRetry;
173
-
export const isLoggedIn = authenticateWithRetry().then(() => true);
173
+
174
+
// Lazy getter for isLoggedIn - authentication only starts when first accessed
175
+
let _isLoggedIn: Promise<boolean> | null = null;
176
+
177
+
export function getIsLoggedIn(): Promise<boolean> {
178
+
if (!_isLoggedIn) {
179
+
_isLoggedIn = authenticateWithRetry().then(() => true);
180
+
}
181
+
return _isLoggedIn;
182
+
}
183
+
184
+
// For backward compatibility - callers can still use `await isLoggedIn`
185
+
// but authentication is now lazy instead of eager
186
+
export const isLoggedIn = {
187
+
then<T>(onFulfilled: (value: boolean) => T | PromiseLike<T>): Promise<T> {
188
+
return getIsLoggedIn().then(onFulfilled);
189
+
},
190
+
};
+1
src/config.ts
+1
src/config.ts
+13
-18
src/main.ts
+13
-18
src/main.ts
···
19
19
import { checkFacetSpam } from "./rules/facets/facets.js";
20
20
import { checkHandle } from "./rules/handles/checkHandles.js";
21
21
import { checkPosts } from "./rules/posts/checkPosts.js";
22
-
import {
23
-
checkDescription,
24
-
checkDisplayName,
25
-
} from "./rules/profiles/checkProfiles.js";
22
+
import { checkProfile } from "./rules/profiles/checkProfiles.js";
23
+
import { checkStarterPackThreshold } from "./starterPackThreshold.js";
26
24
import type { Post } from "./types.js";
27
25
28
26
let cursor = 0;
···
282
280
async (event: CommitUpdateEvent<"app.bsky.actor.profile">) => {
283
281
try {
284
282
if (event.commit.record.displayName || event.commit.record.description) {
285
-
void checkDescription(
286
-
event.did,
287
-
event.time_us,
288
-
event.commit.record.displayName as string,
289
-
event.commit.record.description as string,
290
-
);
291
-
void checkDisplayName(
283
+
void checkProfile(
292
284
event.did,
293
285
event.time_us,
294
286
event.commit.record.displayName as string,
···
309
301
async (event: CommitCreateEvent<"app.bsky.actor.profile">) => {
310
302
try {
311
303
if (event.commit.record.displayName || event.commit.record.description) {
312
-
void checkDescription(
313
-
event.did,
314
-
event.time_us,
315
-
event.commit.record.displayName as string,
316
-
event.commit.record.description as string,
317
-
);
318
-
void checkDisplayName(
304
+
void checkProfile(
319
305
event.did,
320
306
event.time_us,
321
307
event.commit.record.displayName as string,
···
337
323
// checkHandle is sync but calls async functions with void
338
324
checkHandle(event.identity.did, event.identity.handle, event.time_us);
339
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);
340
335
},
341
336
);
342
337
+34
src/metrics.ts
+34
src/metrics.ts
···
20
20
registers: [register],
21
21
});
22
22
23
+
export const unlabelsRemovedCounter: Counter = new Counter({
24
+
name: "skywatch_labels_removed_total",
25
+
help: "Total number of labels removed due to criteria no longer matching",
26
+
labelNames: ["label_type", "target_type"],
27
+
registers: [register],
28
+
});
29
+
23
30
export const accountLabelsThresholdAppliedCounter = new Counter({
24
31
name: "skywatch_account_labels_threshold_applied_total",
25
32
help: "Total number of account actions applied due to threshold",
···
38
45
name: "skywatch_account_threshold_met_total",
39
46
help: "Total number of times account thresholds were met",
40
47
labelNames: ["account_label"],
48
+
registers: [register],
49
+
});
50
+
51
+
export const starterPackThresholdChecksCounter = new Counter({
52
+
name: "skywatch_starter_pack_threshold_checks_total",
53
+
help: "Total number of starter pack threshold checks performed",
54
+
registers: [register],
55
+
});
56
+
57
+
export const starterPackThresholdMetCounter = new Counter({
58
+
name: "skywatch_starter_pack_threshold_met_total",
59
+
help: "Total number of times starter pack thresholds were met",
60
+
labelNames: ["account_label"],
61
+
registers: [register],
62
+
});
63
+
64
+
export const starterPackLabelsThresholdAppliedCounter = new Counter({
65
+
name: "skywatch_starter_pack_labels_threshold_applied_total",
66
+
help: "Total number of account actions applied due to starter pack threshold",
67
+
labelNames: ["account_label", "action"],
68
+
registers: [register],
69
+
});
70
+
71
+
export const moderationActionsFailedCounter = new Counter({
72
+
name: "skywatch_moderation_actions_failed_total",
73
+
help: "Total number of moderation actions that failed",
74
+
labelNames: ["action", "target_type"],
41
75
registers: [register],
42
76
});
43
77
+12
-2
src/moderation.ts
+12
-2
src/moderation.ts
···
93
93
createdAt: new Date().toISOString(),
94
94
modTool: {
95
95
name: "skywatch/skywatch-automod",
96
+
meta: {
97
+
time: new Date().toISOString(),
98
+
externalUrl: `https://pdsls.dev/${uri}`,
99
+
},
96
100
},
97
101
},
98
102
{
···
113
117
const { checkAccountThreshold } = await import(
114
118
"./accountThreshold.js"
115
119
);
116
-
await checkAccountThreshold(did, label, time);
120
+
await checkAccountThreshold(did, uri, label, time);
117
121
} catch (error) {
118
122
logger.error(
119
123
{ process: "ACCOUNT_THRESHOLD", did, label, error },
···
126
130
{ process: "MODERATION", error: e },
127
131
"Failed to create post label",
128
132
);
133
+
throw e;
129
134
}
130
135
});
131
136
};
···
156
161
createdAt: new Date().toISOString(),
157
162
modTool: {
158
163
name: "skywatch/skywatch-automod",
164
+
meta: {
165
+
time: new Date().toISOString(),
166
+
externalUrl: `https://pdsls.dev/${uri}`,
167
+
},
159
168
},
160
169
},
161
170
{
···
170
179
} catch (e) {
171
180
logger.error(
172
181
{ process: "MODERATION", error: e },
173
-
"Failed to create post label",
182
+
"Failed to create post report",
174
183
);
184
+
throw e;
175
185
}
176
186
});
177
187
};
+122
-13
src/redis.ts
+122
-13
src/redis.ts
···
1
1
import { createClient } from "redis";
2
2
import { REDIS_URL } from "./config.js";
3
3
import { logger } from "./logger.js";
4
+
import type { WindowUnit } from "./types.js";
4
5
5
6
export const redisClient = createClient({
6
7
url: REDIS_URL,
···
88
89
}
89
90
}
90
91
92
+
export async function deleteAccountLabelClaim(
93
+
did: string,
94
+
label: string,
95
+
): Promise<void> {
96
+
try {
97
+
const key = getAccountLabelCacheKey(did, label);
98
+
await redisClient.del(key);
99
+
logger.debug(
100
+
{ did, label },
101
+
"Deleted account label claim from Redis cache",
102
+
);
103
+
} catch (err) {
104
+
logger.warn(
105
+
{ err, did, label },
106
+
"Error deleting account label claim from Redis",
107
+
);
108
+
}
109
+
}
110
+
91
111
export async function tryClaimAccountComment(
92
112
did: string,
93
113
atURI: string,
···
108
128
}
109
129
}
110
130
131
+
function windowToMicroseconds(window: number, unit: WindowUnit): number {
132
+
const multipliers: Record<WindowUnit, number> = {
133
+
minutes: 60 * 1000000,
134
+
hours: 60 * 60 * 1000000,
135
+
days: 24 * 60 * 60 * 1000000,
136
+
};
137
+
return window * multipliers[unit];
138
+
}
139
+
140
+
function windowToSeconds(window: number, unit: WindowUnit): number {
141
+
const multipliers: Record<WindowUnit, number> = {
142
+
minutes: 60,
143
+
hours: 60 * 60,
144
+
days: 24 * 60 * 60,
145
+
};
146
+
return window * multipliers[unit];
147
+
}
148
+
111
149
function getPostLabelTrackingKey(
112
150
did: string,
113
151
label: string,
114
-
windowDays: number,
152
+
window: number,
153
+
unit: WindowUnit,
154
+
): string {
155
+
return `account-post-labels:${did}:${label}:${window.toString()}${unit}`;
156
+
}
157
+
158
+
function getStarterPackTrackingKey(
159
+
did: string,
160
+
window: number,
161
+
unit: WindowUnit,
115
162
): string {
116
-
return `account-post-labels:${did}:${label}:${windowDays.toString()}`;
163
+
return `starterpack:threshold:${did}:${window.toString()}${unit}`;
164
+
}
165
+
166
+
export async function trackStarterPackForAccount(
167
+
did: string,
168
+
starterPackUri: string,
169
+
timestamp: number,
170
+
window: number,
171
+
windowUnit: WindowUnit,
172
+
): Promise<void> {
173
+
try {
174
+
const key = getStarterPackTrackingKey(did, window, windowUnit);
175
+
const windowStartTime = timestamp - windowToMicroseconds(window, windowUnit);
176
+
177
+
await redisClient.zRemRangeByScore(key, "-inf", windowStartTime);
178
+
179
+
await redisClient.zAdd(key, {
180
+
score: timestamp,
181
+
value: starterPackUri,
182
+
});
183
+
184
+
const ttlSeconds = windowToSeconds(window, windowUnit) + 60 * 60;
185
+
await redisClient.expire(key, ttlSeconds);
186
+
187
+
logger.debug(
188
+
{ did, starterPackUri, timestamp, window, windowUnit },
189
+
"Tracked starter pack for account",
190
+
);
191
+
} catch (err) {
192
+
logger.error(
193
+
{ err, did, starterPackUri, timestamp, window, windowUnit },
194
+
"Error tracking starter pack in Redis",
195
+
);
196
+
throw err;
197
+
}
198
+
}
199
+
200
+
export async function getStarterPackCountInWindow(
201
+
did: string,
202
+
window: number,
203
+
windowUnit: WindowUnit,
204
+
currentTime: number,
205
+
): Promise<number> {
206
+
try {
207
+
const key = getStarterPackTrackingKey(did, window, windowUnit);
208
+
const windowStartTime = currentTime - windowToMicroseconds(window, windowUnit);
209
+
const count = await redisClient.zCount(key, windowStartTime, "+inf");
210
+
211
+
logger.debug(
212
+
{ did, window, windowUnit, count },
213
+
"Retrieved starter pack count in window",
214
+
);
215
+
216
+
return count;
217
+
} catch (err) {
218
+
logger.error(
219
+
{ err, did, window, windowUnit },
220
+
"Error getting starter pack count from Redis",
221
+
);
222
+
throw err;
223
+
}
117
224
}
118
225
119
226
export async function trackPostLabelForAccount(
120
227
did: string,
121
228
label: string,
122
229
timestamp: number,
123
-
windowDays: number,
230
+
window: number,
231
+
windowUnit: WindowUnit,
124
232
): Promise<void> {
125
233
try {
126
-
const key = getPostLabelTrackingKey(did, label, windowDays);
127
-
const windowStartTime = timestamp - windowDays * 24 * 60 * 60 * 1000000;
234
+
const key = getPostLabelTrackingKey(did, label, window, windowUnit);
235
+
const windowStartTime = timestamp - windowToMicroseconds(window, windowUnit);
128
236
129
237
await redisClient.zRemRangeByScore(key, "-inf", windowStartTime);
130
238
···
133
241
value: timestamp.toString(),
134
242
});
135
243
136
-
const ttlSeconds = (windowDays + 1) * 24 * 60 * 60;
244
+
const ttlSeconds = windowToSeconds(window, windowUnit) + 60 * 60;
137
245
await redisClient.expire(key, ttlSeconds);
138
246
139
247
logger.debug(
140
-
{ did, label, timestamp, windowDays },
248
+
{ did, label, timestamp, window, windowUnit },
141
249
"Tracked post label for account",
142
250
);
143
251
} catch (err) {
144
252
logger.error(
145
-
{ err, did, label, timestamp, windowDays },
253
+
{ err, did, label, timestamp, window, windowUnit },
146
254
"Error tracking post label in Redis",
147
255
);
148
256
throw err;
···
152
260
export async function getPostLabelCountInWindow(
153
261
did: string,
154
262
labels: string[],
155
-
windowDays: number,
263
+
window: number,
264
+
windowUnit: WindowUnit,
156
265
currentTime: number,
157
266
): Promise<number> {
158
267
try {
159
-
const windowStartTime = currentTime - windowDays * 24 * 60 * 60 * 1000000;
268
+
const windowStartTime = currentTime - windowToMicroseconds(window, windowUnit);
160
269
let totalCount = 0;
161
270
162
271
for (const label of labels) {
163
-
const key = getPostLabelTrackingKey(did, label, windowDays);
272
+
const key = getPostLabelTrackingKey(did, label, window, windowUnit);
164
273
const count = await redisClient.zCount(key, windowStartTime, "+inf");
165
274
totalCount += count;
166
275
}
167
276
168
277
logger.debug(
169
-
{ did, labels, windowDays, totalCount },
278
+
{ did, labels, window, windowUnit, totalCount },
170
279
"Retrieved post label count in window",
171
280
);
172
281
173
282
return totalCount;
174
283
} catch (err) {
175
284
logger.error(
176
-
{ err, did, labels, windowDays },
285
+
{ err, did, labels, window, windowUnit },
177
286
"Error getting post label count from Redis",
178
287
);
179
288
throw err;
+1
-1
src/rules/account/age.ts
+1
-1
src/rules/account/age.ts
···
240
240
await createAccountLabel(
241
241
context.actorDid,
242
242
check.label,
243
-
`${context.time.toString()}: ${check.comment} - Account created within monitored range - Interaction: ${context.atURI}`,
243
+
`${context.time.toString()}: ${check.comment} \n\nAccount created within monitored range. \n\nInteraction: ${context.atURI}`,
244
244
);
245
245
246
246
// Only apply one label per interaction
+18
-6
src/rules/account/countStarterPacks.ts
+18
-6
src/rules/account/countStarterPacks.ts
···
2
2
import { agent, isLoggedIn } from "../../agent.js";
3
3
import { limit } from "../../limits.js";
4
4
import { logger } from "../../logger.js";
5
+
import { moderationActionsFailedCounter } from "../../metrics.js";
5
6
6
-
const ALLOWED_DIDS = ["did:plc:gpunjjgvlyb4racypz3yfiq4"];
7
+
const ALLOWED_DIDS = ["did:plc:example"];
7
8
8
9
export const countStarterPacks = async (did: string, time: number) => {
9
10
await isLoggedIn;
···
32
33
"Labeling account with excessive starter packs",
33
34
);
34
35
35
-
void createAccountLabel(
36
-
did,
37
-
"follow-farming",
38
-
`${time.toString()}: Account has ${starterPacks.toString()} starter packs`,
39
-
);
36
+
try {
37
+
await createAccountLabel(
38
+
did,
39
+
"follow-farming",
40
+
`${time.toString()}: Account has ${starterPacks.toString()} starter packs`,
41
+
);
42
+
} catch (labelError) {
43
+
logger.error(
44
+
{ process: "COUNTSTARTERPACKS", did, time, error: labelError },
45
+
"Failed to apply follow-farming label",
46
+
);
47
+
moderationActionsFailedCounter.inc({
48
+
action: "label",
49
+
target_type: "account",
50
+
});
51
+
}
40
52
}
41
53
} catch (error) {
42
54
const errorInfo =
+3
-3
src/rules/facets/facets.ts
+3
-3
src/rules/facets/facets.ts
···
6
6
export const FACET_SPAM_THRESHOLD = 1;
7
7
8
8
// Label configuration
9
-
export const FACET_SPAM_LABEL = "suspect-inauthentic";
9
+
export const FACET_SPAM_LABEL = "platform-manipulation";
10
10
export const FACET_SPAM_COMMENT =
11
11
"Abusive facet usage detected (hidden mentions)";
12
12
13
13
// Allowlist for DIDs with legitimate duplicate facet use cases
14
14
export const FACET_SPAM_ALLOWLIST: string[] = [
15
-
// Add DIDs here that should be exempt from facet spam detection
15
+
"did:plc:ei7hqam5oasdpw5cdihdphcv",
16
16
];
17
17
18
18
/**
···
80
80
await createAccountLabel(
81
81
did,
82
82
FACET_SPAM_LABEL,
83
-
`${time.toString()}: ${FACET_SPAM_COMMENT} - ${uniqueCount.toString()} unique mentions at position ${position} in ${atURI}`,
83
+
`${time.toString()}: ${FACET_SPAM_COMMENT} \n\n${uniqueCount.toString()} unique mentions at position ${position}. \n\nPost: ${atURI}`,
84
84
);
85
85
86
86
// Only label once per post even if multiple positions are suspicious
+2
-2
src/rules/facets/tests/facets.test.ts
+2
-2
src/rules/facets/tests/facets.test.ts
···
251
251
expect(createAccountLabel).toHaveBeenCalledWith(
252
252
TEST_DID,
253
253
FACET_SPAM_LABEL,
254
-
`${TEST_TIME}: ${FACET_SPAM_COMMENT} - 2 unique mentions at position 0:1 in ${TEST_URI}`,
254
+
`${TEST_TIME}: ${FACET_SPAM_COMMENT} \n\n2 unique mentions at position 0:1. \n\nPost: ${TEST_URI}`,
255
255
);
256
256
});
257
257
···
355
355
});
356
356
357
357
it("should use correct label and comment constants", () => {
358
-
expect(FACET_SPAM_LABEL).toBe("suspect-inauthentic");
358
+
expect(FACET_SPAM_LABEL).toBe("platform-manipulation");
359
359
expect(FACET_SPAM_COMMENT).toBe(
360
360
"Abusive facet usage detected (hidden mentions)",
361
361
);
+12
-12
src/rules/handles/checkHandles.test.ts
+12
-12
src/rules/handles/checkHandles.test.ts
···
103
103
104
104
expect(createAccountReport).toHaveBeenCalledWith(
105
105
"did:plc:user1",
106
-
`${time}: Spam detected - spam-account`,
106
+
`${time}: Spam detected\n\nHandle: spam-account`,
107
107
);
108
108
});
109
109
···
121
121
122
122
expect(createAccountReport).toHaveBeenCalledWith(
123
123
"did:plc:user1",
124
-
`${time}: Spam detected - SPAM-ACCOUNT`,
124
+
`${time}: Spam detected\n\nHandle: SPAM-ACCOUNT`,
125
125
);
126
126
});
127
127
});
···
139
139
140
140
expect(createAccountComment).toHaveBeenCalledWith(
141
141
"did:plc:user1",
142
-
`${time}: Scam detected - scam-account`,
142
+
`${time}: Scam detected\n\nHandle: scam-account`,
143
143
"handle:did:plc:user1:scam-account",
144
144
);
145
145
});
···
159
159
expect(createAccountLabel).toHaveBeenCalledWith(
160
160
"did:plc:normaluser",
161
161
"bot",
162
-
`${time}: Bot detected - bot-456`,
162
+
`${time}: Bot detected\n\nHandle: bot-456`,
163
163
);
164
164
});
165
165
});
···
171
171
172
172
expect(createAccountReport).toHaveBeenCalledWith(
173
173
"did:plc:user1",
174
-
`${time}: Spam detected - spam-user`,
174
+
`${time}: Spam detected\n\nHandle: spam-user`,
175
175
);
176
176
});
177
177
···
181
181
182
182
expect(createAccountComment).toHaveBeenCalledWith(
183
183
"did:plc:user1",
184
-
`${time}: Scam detected - scam-user`,
184
+
`${time}: Scam detected\n\nHandle: scam-user`,
185
185
"handle:did:plc:user1:scam-user",
186
186
);
187
187
});
···
193
193
expect(createAccountLabel).toHaveBeenCalledWith(
194
194
"did:plc:user1",
195
195
"bot",
196
-
`${time}: Bot detected - bot-789`,
196
+
`${time}: Bot detected\n\nHandle: bot-789`,
197
197
);
198
198
});
199
199
···
203
203
204
204
expect(createAccountReport).toHaveBeenCalledWith(
205
205
"did:plc:user1",
206
-
`${time}: Multi-action triggered - dangerous-account`,
206
+
`${time}: Multi-action triggered\n\nHandle: dangerous-account`,
207
207
);
208
208
expect(createAccountComment).toHaveBeenCalledWith(
209
209
"did:plc:user1",
210
-
`${time}: Multi-action triggered - dangerous-account`,
210
+
`${time}: Multi-action triggered\n\nHandle: dangerous-account`,
211
211
"handle:did:plc:user1:dangerous-account",
212
212
);
213
213
expect(createAccountLabel).toHaveBeenCalledWith(
214
214
"did:plc:user1",
215
215
"multi-action",
216
-
`${time}: Multi-action triggered - dangerous-account`,
216
+
`${time}: Multi-action triggered\n\nHandle: dangerous-account`,
217
217
);
218
218
});
219
219
});
···
276
276
277
277
expect(createAccountReport).toHaveBeenCalledWith(
278
278
"did:plc:user1",
279
-
`${time}: Spam detected - ${longHandle}`,
279
+
`${time}: Spam detected\n\nHandle: ${longHandle}`,
280
280
);
281
281
});
282
282
···
294
294
295
295
expect(createAccountReport).toHaveBeenCalledWith(
296
296
"did:plc:user1",
297
-
"1234567890: Spam detected - spam-account",
297
+
"1234567890: Spam detected\n\nHandle: spam-account",
298
298
);
299
299
});
300
300
+5
-10
src/rules/handles/checkHandles.ts
+5
-10
src/rules/handles/checkHandles.ts
···
45
45
}
46
46
}
47
47
48
+
const formattedComment = `${time.toString()}: ${checkList.comment}\n\nHandle: ${handle}`;
49
+
48
50
if (checkList.toLabel) {
49
-
void createAccountLabel(
50
-
did,
51
-
checkList.label,
52
-
`${time.toString()}: ${checkList.comment} - ${handle}`,
53
-
);
51
+
void createAccountLabel(did, checkList.label, formattedComment);
54
52
}
55
53
56
54
if (checkList.reportAcct) {
···
58
56
{ process: "CHECKHANDLE", did, handle, time, label: checkList.label },
59
57
"Reporting account",
60
58
);
61
-
void createAccountReport(
62
-
did,
63
-
`${time.toString()}: ${checkList.comment} - ${handle}`,
64
-
);
59
+
void createAccountReport(did, formattedComment);
65
60
}
66
61
67
62
if (checkList.commentAcct) {
68
63
void createAccountComment(
69
64
did,
70
-
`${time.toString()}: ${checkList.comment} - ${handle}`,
65
+
formattedComment,
71
66
`handle:${did}:${handle}`,
72
67
);
73
68
}
+91
-30
src/rules/posts/checkPosts.ts
+91
-30
src/rules/posts/checkPosts.ts
···
4
4
createAccountComment,
5
5
createAccountReport,
6
6
} from "../../accountModeration.js";
7
+
import { checkAccountThreshold } from "../../accountThreshold.js";
7
8
import { logger } from "../../logger.js";
9
+
import { moderationActionsFailedCounter } from "../../metrics.js";
8
10
import { createPostLabel, createPostReport } from "../../moderation.js";
9
-
import type { Post } from "../../types.js";
11
+
import type { ModerationResult, Post } from "../../types.js";
10
12
import { getFinalUrl } from "../../utils/getFinalUrl.js";
11
13
import { getLanguage } from "../../utils/getLanguage.js";
12
14
import { countStarterPacks } from "../account/countStarterPacks.js";
···
73
75
const lang = await getLanguage(post[0].text);
74
76
75
77
// iterate through the checks
76
-
POST_CHECKS.forEach((checkPost) => {
78
+
for (const checkPost of POST_CHECKS) {
77
79
if (checkPost.language) {
78
80
if (!checkPost.language.includes(lang)) {
79
-
return;
81
+
continue;
80
82
}
81
83
}
82
84
···
86
88
{ process: "CHECKPOSTS", did: post[0].did, atURI: post[0].atURI },
87
89
"Whitelisted DID",
88
90
);
89
-
return;
91
+
continue;
90
92
}
91
93
}
92
94
···
98
100
{ process: "CHECKPOSTS", did: post[0].did, atURI: post[0].atURI },
99
101
"Whitelisted phrase found",
100
102
);
101
-
return;
103
+
continue;
102
104
}
103
105
}
104
106
105
-
void countStarterPacks(post[0].did, post[0].time);
107
+
await countStarterPacks(post[0].did, post[0].time);
108
+
109
+
const postURL = `https://pdsls.dev/${post[0].atURI}`;
110
+
const formattedComment = `${checkPost.comment}\n\nPost: ${postURL}\n\nText: "${post[0].text}"`;
111
+
112
+
const results: ModerationResult = { success: true, errors: [] };
106
113
107
114
if (checkPost.toLabel) {
108
-
void createPostLabel(
109
-
post[0].atURI,
110
-
post[0].cid,
111
-
checkPost.label,
112
-
`${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
113
-
checkPost.duration,
114
-
post[0].did,
115
-
post[0].time,
116
-
);
115
+
try {
116
+
await createPostLabel(
117
+
post[0].atURI,
118
+
post[0].cid,
119
+
checkPost.label,
120
+
formattedComment,
121
+
checkPost.duration,
122
+
post[0].did,
123
+
post[0].time,
124
+
);
125
+
} catch (error) {
126
+
results.success = false;
127
+
results.errors.push({ action: "label", error });
128
+
}
129
+
} else if (checkPost.trackOnly) {
130
+
try {
131
+
await checkAccountThreshold(
132
+
post[0].did,
133
+
post[0].atURI,
134
+
checkPost.label,
135
+
post[0].time,
136
+
);
137
+
} catch (error) {
138
+
// Threshold check failures are logged but don't add to results.errors
139
+
// since it's not a direct moderation action
140
+
logger.error(
141
+
{
142
+
process: "CHECKPOSTS",
143
+
did: post[0].did,
144
+
atURI: post[0].atURI,
145
+
error,
146
+
},
147
+
"Account threshold check failed",
148
+
);
149
+
}
117
150
}
118
151
119
152
if (checkPost.reportPost === true) {
···
126
159
},
127
160
"Reporting post",
128
161
);
129
-
void createPostReport(
130
-
post[0].atURI,
131
-
post[0].cid,
132
-
`${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
133
-
);
162
+
try {
163
+
await createPostReport(post[0].atURI, post[0].cid, formattedComment);
164
+
} catch (error) {
165
+
results.success = false;
166
+
results.errors.push({ action: "report", error });
167
+
}
134
168
}
135
169
136
170
if (checkPost.reportAcct) {
···
143
177
},
144
178
"Reporting account",
145
179
);
146
-
void createAccountReport(
147
-
post[0].did,
148
-
`${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
149
-
);
180
+
try {
181
+
await createAccountReport(post[0].did, formattedComment);
182
+
} catch (error) {
183
+
results.success = false;
184
+
results.errors.push({ action: "report", error });
185
+
}
150
186
}
151
187
152
188
if (checkPost.commentAcct) {
153
-
void createAccountComment(
154
-
post[0].did,
155
-
`${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
156
-
post[0].atURI,
157
-
);
189
+
try {
190
+
await createAccountComment(
191
+
post[0].did,
192
+
formattedComment,
193
+
post[0].atURI,
194
+
);
195
+
} catch (error) {
196
+
results.success = false;
197
+
results.errors.push({ action: "comment", error });
198
+
}
199
+
}
200
+
201
+
// Log and track any failures
202
+
if (!results.success) {
203
+
for (const error of results.errors) {
204
+
logger.error(
205
+
{
206
+
process: "CHECKPOSTS",
207
+
did: post[0].did,
208
+
atURI: post[0].atURI,
209
+
action: error.action,
210
+
error: error.error,
211
+
},
212
+
"Moderation action failed",
213
+
);
214
+
moderationActionsFailedCounter.inc({
215
+
action: error.action,
216
+
target_type: "post",
217
+
});
218
+
}
158
219
}
159
220
}
160
-
});
221
+
}
161
222
};
+40
src/rules/posts/tests/checkPosts.test.ts
+40
src/rules/posts/tests/checkPosts.test.ts
···
3
3
createAccountComment,
4
4
createAccountReport,
5
5
} from "../../../accountModeration.js";
6
+
import { checkAccountThreshold } from "../../../accountThreshold.js";
6
7
import { logger } from "../../../logger.js";
7
8
import { createPostLabel, createPostReport } from "../../../moderation.js";
8
9
import type { Post } from "../../../types.js";
···
67
68
reportAcct: true,
68
69
commentAcct: true,
69
70
},
71
+
{
72
+
label: "track-only-label",
73
+
comment: "Track only test",
74
+
check: /shopping/i,
75
+
toLabel: false,
76
+
trackOnly: true,
77
+
reportPost: false,
78
+
reportAcct: false,
79
+
commentAcct: false,
80
+
},
70
81
],
71
82
}));
72
83
···
86
97
vi.mock("../../../accountModeration.js", () => ({
87
98
createAccountReport: vi.fn(),
88
99
createAccountComment: vi.fn(),
100
+
}));
101
+
102
+
vi.mock("../../../accountThreshold.js", () => ({
103
+
checkAccountThreshold: vi.fn(),
89
104
}));
90
105
91
106
vi.mock("../../../moderation.js", () => ({
···
422
437
post[0].did,
423
438
expect.any(String),
424
439
expect.any(String),
440
+
);
441
+
});
442
+
});
443
+
444
+
describe("trackOnly behavior", () => {
445
+
it("should track for account threshold without emitting post label when trackOnly is true", async () => {
446
+
const post = createMockPost({ text: "check out this shopping link" });
447
+
448
+
await checkPosts(post);
449
+
450
+
expect(createPostLabel).not.toHaveBeenCalledWith(
451
+
expect.any(String),
452
+
expect.any(String),
453
+
"track-only-label",
454
+
expect.any(String),
455
+
expect.any(Number),
456
+
expect.any(String),
457
+
expect.any(Number),
458
+
);
459
+
460
+
expect(checkAccountThreshold).toHaveBeenCalledWith(
461
+
post[0].did,
462
+
post[0].atURI,
463
+
"track-only-label",
464
+
post[0].time,
425
465
);
426
466
});
427
467
});
+233
-143
src/rules/profiles/checkProfiles.ts
+233
-143
src/rules/profiles/checkProfiles.ts
···
4
4
createAccountComment,
5
5
createAccountLabel,
6
6
createAccountReport,
7
+
negateAccountLabel,
7
8
} from "../../accountModeration.js";
8
9
import { logger } from "../../logger.js";
10
+
import { moderationActionsFailedCounter } from "../../metrics.js";
11
+
import type { Checks, ModerationResult } from "../../types.js";
9
12
import { getLanguage } from "../../utils/getLanguage.js";
10
13
14
+
export class ProfileChecker {
15
+
private check: Checks;
16
+
private did: string;
17
+
private time: number;
18
+
19
+
constructor(check: Checks, did: string, time: number) {
20
+
this.check = check;
21
+
this.did = did;
22
+
this.time = time;
23
+
}
24
+
25
+
async checkDescription(description: string): Promise<void> {
26
+
if (!description) return;
27
+
await this.performActions(description, "CHECKDESCRIPTION");
28
+
}
29
+
30
+
async checkDisplayName(displayName: string): Promise<void> {
31
+
if (!displayName) return;
32
+
await this.performActions(displayName, "CHECKDISPLAYNAME");
33
+
}
34
+
35
+
async checkBoth(displayName: string, description: string): Promise<void> {
36
+
const profile = `${displayName} ${description}`;
37
+
if (!profile) return;
38
+
await this.performActions(profile, "CHECKPROFILE");
39
+
}
40
+
41
+
private async performActions(
42
+
content: string,
43
+
processType: "CHECKPROFILE" | "CHECKDESCRIPTION" | "CHECKDISPLAYNAME",
44
+
): Promise<void> {
45
+
const matched = this.check.check.test(content);
46
+
47
+
if (matched) {
48
+
if (this.check.whitelist?.test(content)) {
49
+
logger.debug(
50
+
{ process: processType, did: this.did, time: this.time, content },
51
+
"Whitelisted phrase found",
52
+
);
53
+
return;
54
+
}
55
+
56
+
const result = await this.applyActions(content, processType);
57
+
if (!result.success) {
58
+
for (const error of result.errors) {
59
+
logger.error(
60
+
{
61
+
process: processType,
62
+
did: this.did,
63
+
action: error.action,
64
+
error: error.error,
65
+
},
66
+
"Moderation action failed",
67
+
);
68
+
moderationActionsFailedCounter.inc({
69
+
action: error.action,
70
+
target_type: "account",
71
+
});
72
+
}
73
+
}
74
+
} else {
75
+
if (this.check.unlabel) {
76
+
const result = await this.removeLabel(content, processType);
77
+
if (!result.success) {
78
+
for (const error of result.errors) {
79
+
logger.error(
80
+
{
81
+
process: processType,
82
+
did: this.did,
83
+
action: error.action,
84
+
error: error.error,
85
+
},
86
+
"Moderation action failed",
87
+
);
88
+
moderationActionsFailedCounter.inc({
89
+
action: error.action,
90
+
target_type: "account",
91
+
});
92
+
}
93
+
}
94
+
}
95
+
}
96
+
}
97
+
98
+
private async applyActions(
99
+
content: string,
100
+
processType: string,
101
+
): Promise<ModerationResult> {
102
+
const results: ModerationResult = { success: true, errors: [] };
103
+
const formattedComment = `${this.time.toString()}: ${this.check.comment}\n\nContent: ${content}`;
104
+
105
+
if (this.check.toLabel) {
106
+
try {
107
+
await createAccountLabel(this.did, this.check.label, formattedComment);
108
+
} catch (error) {
109
+
results.success = false;
110
+
results.errors.push({ action: "label", error });
111
+
}
112
+
}
113
+
114
+
if (this.check.reportAcct) {
115
+
try {
116
+
await createAccountReport(this.did, formattedComment);
117
+
logger.info(
118
+
{
119
+
process: processType,
120
+
did: this.did,
121
+
time: this.time,
122
+
label: this.check.label,
123
+
},
124
+
"Reporting account",
125
+
);
126
+
} catch (error) {
127
+
results.success = false;
128
+
results.errors.push({ action: "report", error });
129
+
}
130
+
}
131
+
132
+
if (this.check.commentAcct) {
133
+
try {
134
+
await createAccountComment(
135
+
this.did,
136
+
formattedComment,
137
+
`profile:${this.did}`,
138
+
);
139
+
} catch (error) {
140
+
results.success = false;
141
+
results.errors.push({ action: "comment", error });
142
+
}
143
+
}
144
+
145
+
return results;
146
+
}
147
+
148
+
private async removeLabel(
149
+
content: string,
150
+
_processType: string,
151
+
): Promise<ModerationResult> {
152
+
const results: ModerationResult = { success: true, errors: [] };
153
+
const formattedComment = `${this.check.comment}\n\nContent: ${content}`;
154
+
try {
155
+
await negateAccountLabel(this.did, this.check.label, formattedComment);
156
+
} catch (error) {
157
+
results.success = false;
158
+
results.errors.push({ action: "unlabel", error });
159
+
}
160
+
return results;
161
+
}
162
+
}
163
+
11
164
export const checkDescription = async (
12
165
did: string,
13
166
time: number,
14
167
displayName: string,
15
168
description: string,
16
-
) => {
17
-
const lang = await getLanguage(description);
169
+
): Promise<void> => {
170
+
if (!description) return;
18
171
19
-
// Check if DID is whitelisted
20
172
if (GLOBAL_ALLOW.includes(did)) {
21
173
logger.warn(
22
174
{ process: "CHECKDESCRIPTION", did, time, displayName, description },
···
25
177
return;
26
178
}
27
179
28
-
// iterate through the checks
29
-
PROFILE_CHECKS.forEach((checkProfiles) => {
30
-
if (checkProfiles.language) {
31
-
if (!checkProfiles.language.includes(lang)) {
32
-
return;
180
+
for (const checkRule of PROFILE_CHECKS) {
181
+
if (checkRule.language) {
182
+
const lang = await getLanguage(description);
183
+
if (!checkRule.language.includes(lang)) {
184
+
continue;
33
185
}
34
186
}
35
187
36
-
// Check if DID is whitelisted
37
-
if (checkProfiles.ignoredDIDs) {
38
-
if (checkProfiles.ignoredDIDs.includes(did)) {
39
-
logger.debug(
40
-
{ process: "CHECKDESCRIPTION", did, time, displayName, description },
41
-
"Whitelisted DID",
42
-
);
43
-
return;
44
-
}
188
+
if (checkRule.ignoredDIDs?.includes(did)) {
189
+
logger.debug(
190
+
{ process: "CHECKDESCRIPTION", did, time, displayName, description },
191
+
"Whitelisted DID",
192
+
);
193
+
continue;
45
194
}
46
195
47
-
if (description) {
48
-
if (checkProfiles.description === true) {
49
-
if (checkProfiles.check.test(description)) {
50
-
// Check if description is whitelisted
51
-
if (checkProfiles.whitelist) {
52
-
if (checkProfiles.whitelist.test(description)) {
53
-
logger.debug(
54
-
{
55
-
process: "CHECKDESCRIPTION",
56
-
did,
57
-
time,
58
-
displayName,
59
-
description,
60
-
},
61
-
"Whitelisted phrase found",
62
-
);
63
-
return;
64
-
}
65
-
}
66
-
67
-
if (checkProfiles.toLabel) {
68
-
void createAccountLabel(
69
-
did,
70
-
checkProfiles.label,
71
-
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
72
-
);
73
-
}
74
-
75
-
if (checkProfiles.reportAcct) {
76
-
void createAccountReport(
77
-
did,
78
-
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
79
-
);
80
-
logger.info(
81
-
{
82
-
process: "CHECKDESCRIPTION",
83
-
did,
84
-
time,
85
-
displayName,
86
-
description,
87
-
label: checkProfiles.label,
88
-
},
89
-
"Reporting account",
90
-
);
91
-
}
92
-
93
-
if (checkProfiles.commentAcct) {
94
-
void createAccountComment(
95
-
did,
96
-
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
97
-
`profile:${did}:${time.toString()}`,
98
-
);
99
-
}
100
-
}
101
-
}
196
+
if (checkRule.description === true) {
197
+
const checker = new ProfileChecker(checkRule, did, time);
198
+
await checker.checkDescription(description);
102
199
}
103
-
});
200
+
}
104
201
};
105
202
106
203
export const checkDisplayName = async (
···
108
205
time: number,
109
206
displayName: string,
110
207
description: string,
111
-
) => {
112
-
// Check if DID is whitelisted
208
+
): Promise<void> => {
209
+
if (!displayName) return;
210
+
113
211
if (GLOBAL_ALLOW.includes(did)) {
114
212
logger.warn(
115
213
{ process: "CHECKDISPLAYNAME", did, time, displayName, description },
···
118
216
return;
119
217
}
120
218
121
-
const lang = await getLanguage(description);
122
-
123
-
// iterate through the checks
124
-
PROFILE_CHECKS.forEach((checkProfiles) => {
125
-
if (checkProfiles.language) {
126
-
if (!checkProfiles.language.includes(lang)) {
127
-
return;
219
+
for (const checkRule of PROFILE_CHECKS) {
220
+
if (checkRule.language) {
221
+
const lang = await getLanguage(displayName);
222
+
if (!checkRule.language.includes(lang)) {
223
+
continue;
128
224
}
129
225
}
130
226
131
-
// Check if DID is whitelisted
132
-
if (checkProfiles.ignoredDIDs) {
133
-
if (checkProfiles.ignoredDIDs.includes(did)) {
134
-
logger.debug(
135
-
{ process: "CHECKDISPLAYNAME", did, time, displayName, description },
136
-
"Whitelisted DID",
137
-
);
138
-
return;
139
-
}
227
+
if (checkRule.ignoredDIDs?.includes(did)) {
228
+
logger.debug(
229
+
{ process: "CHECKDISPLAYNAME", did, time, displayName, description },
230
+
"Whitelisted DID",
231
+
);
232
+
continue;
140
233
}
141
234
142
-
if (displayName) {
143
-
if (checkProfiles.displayName === true) {
144
-
if (checkProfiles.check.test(displayName)) {
145
-
// Check if displayName is whitelisted
146
-
if (checkProfiles.whitelist) {
147
-
if (checkProfiles.whitelist.test(displayName)) {
148
-
logger.debug(
149
-
{
150
-
process: "CHECKDISPLAYNAME",
151
-
did,
152
-
time,
153
-
displayName,
154
-
description,
155
-
},
156
-
"Whitelisted phrase found",
157
-
);
158
-
return;
159
-
}
160
-
}
235
+
if (checkRule.displayName === true) {
236
+
const checker = new ProfileChecker(checkRule, did, time);
237
+
await checker.checkDisplayName(displayName);
238
+
}
239
+
}
240
+
};
161
241
162
-
if (checkProfiles.toLabel) {
163
-
void createAccountLabel(
164
-
did,
165
-
checkProfiles.label,
166
-
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
167
-
);
168
-
}
242
+
export const checkProfile = async (
243
+
did: string,
244
+
time: number,
245
+
displayName: string,
246
+
description: string,
247
+
) => {
248
+
const profile = `${displayName} ${description}`;
249
+
const lang = await getLanguage(profile);
169
250
170
-
if (checkProfiles.reportAcct) {
171
-
void createAccountReport(
172
-
did,
173
-
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
174
-
);
175
-
logger.info(
176
-
{
177
-
process: "CHECKDISPLAYNAME",
178
-
did,
179
-
time,
180
-
displayName,
181
-
description,
182
-
label: checkProfiles.label,
183
-
},
184
-
"Reporting account",
185
-
);
186
-
}
251
+
// Check if DID is whitelisted at global level
252
+
if (GLOBAL_ALLOW.includes(did)) {
253
+
logger.warn(
254
+
{ process: "CHECKPROFILE", did, time, profile },
255
+
"Global AllowListed DID",
256
+
);
257
+
return;
258
+
}
187
259
188
-
if (checkProfiles.commentAcct) {
189
-
void createAccountComment(
190
-
did,
191
-
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
192
-
`profile:${did}:${time.toString()}`,
193
-
);
194
-
}
195
-
}
260
+
// Iterate through checks and delegate to ProfileChecker
261
+
for (const checkRule of PROFILE_CHECKS) {
262
+
// Language filter (same for all branches)
263
+
if (checkRule.language) {
264
+
if (!checkRule.language.includes(lang)) {
265
+
continue;
196
266
}
197
267
}
198
-
});
268
+
269
+
// DID whitelist (same for all branches)
270
+
if (checkRule.ignoredDIDs?.includes(did)) {
271
+
logger.debug(
272
+
{ process: "CHECKPROFILE", did, time, displayName, description },
273
+
"Whitelisted DID",
274
+
);
275
+
continue;
276
+
}
277
+
278
+
// Dispatch to correct method based on check configuration
279
+
const checker = new ProfileChecker(checkRule, did, time);
280
+
281
+
if (checkRule.description === true && checkRule.displayName === true) {
282
+
await checker.checkBoth(displayName, description);
283
+
} else if (checkRule.description === true) {
284
+
await checker.checkDescription(description);
285
+
} else if (checkRule.displayName === true) {
286
+
await checker.checkDisplayName(displayName);
287
+
}
288
+
}
199
289
};
+1
-2
src/rules/profiles/tests/checkProfiles.test.ts
+1
-2
src/rules/profiles/tests/checkProfiles.test.ts
+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
+
});
+35
-17
src/tests/accountThreshold.test.ts
+35
-17
src/tests/accountThreshold.test.ts
···
35
35
threshold: 3,
36
36
accountLabel: "test-account-label",
37
37
accountComment: "Test comment",
38
-
windowDays: 5,
38
+
window: 5,
39
+
windowUnit: "days",
39
40
reportAcct: false,
40
41
commentAcct: false,
41
42
toLabel: true,
···
45
46
threshold: 5,
46
47
accountLabel: "multi-label-account",
47
48
accountComment: "Multi label comment",
48
-
windowDays: 7,
49
+
window: 7,
50
+
windowUnit: "days",
49
51
reportAcct: true,
50
52
commentAcct: true,
51
53
toLabel: true,
···
55
57
threshold: 2,
56
58
accountLabel: "monitored",
57
59
accountComment: "Monitoring comment",
58
-
windowDays: 3,
60
+
window: 3,
61
+
windowUnit: "days",
59
62
reportAcct: true,
60
63
commentAcct: false,
61
64
toLabel: false,
···
65
68
threshold: 2,
66
69
accountLabel: "shared-config",
67
70
accountComment: "Shared config comment",
68
-
windowDays: 4,
71
+
window: 4,
72
+
windowUnit: "days",
69
73
reportAcct: false,
70
74
commentAcct: false,
71
75
toLabel: true,
···
118
122
119
123
describe("checkAccountThreshold", () => {
120
124
const testDid = "did:plc:test123";
125
+
const testUri = "at://did:plc:test123/app.bsky.feed.post/abc123";
121
126
const testTimestamp = 1640000000000000;
122
127
123
128
it("should not check threshold for non-matching labels", async () => {
124
129
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
125
130
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(0);
126
131
127
-
await checkAccountThreshold(testDid, "non-matching-label", testTimestamp);
132
+
await checkAccountThreshold(
133
+
testDid,
134
+
testUri,
135
+
"non-matching-label",
136
+
testTimestamp,
137
+
);
128
138
129
139
expect(trackPostLabelForAccount).not.toHaveBeenCalled();
130
140
expect(getPostLabelCountInWindow).not.toHaveBeenCalled();
···
134
144
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
135
145
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
136
146
137
-
await checkAccountThreshold(testDid, "test-label", testTimestamp);
147
+
await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp);
138
148
139
149
expect(accountThresholdChecksCounter.inc).toHaveBeenCalledWith({
140
150
post_label: "test-label",
···
144
154
"test-label",
145
155
testTimestamp,
146
156
5,
157
+
"days",
147
158
);
148
159
expect(getPostLabelCountInWindow).toHaveBeenCalledWith(
149
160
testDid,
150
161
["test-label"],
151
162
5,
163
+
"days",
152
164
testTimestamp,
153
165
);
154
166
});
···
158
170
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(3);
159
171
vi.mocked(createAccountLabel).mockResolvedValue();
160
172
161
-
await checkAccountThreshold(testDid, "test-label", testTimestamp);
173
+
await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp);
162
174
163
175
expect(accountThresholdMetCounter.inc).toHaveBeenCalledWith({
164
176
account_label: "test-account-label",
···
166
178
expect(createAccountLabel).toHaveBeenCalledWith(
167
179
testDid,
168
180
"test-account-label",
169
-
"Test comment",
181
+
`Test comment\n\nThreshold: 3/3 in 5 days\n\nPost: ${testUri}\n\nPost Label: test-label`,
170
182
);
171
183
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
172
184
account_label: "test-account-label",
···
178
190
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
179
191
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
180
192
181
-
await checkAccountThreshold(testDid, "test-label", testTimestamp);
193
+
await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp);
182
194
183
195
expect(accountThresholdMetCounter.inc).not.toHaveBeenCalled();
184
196
expect(createAccountLabel).not.toHaveBeenCalled();
···
191
203
vi.mocked(createAccountReport).mockResolvedValue();
192
204
vi.mocked(createAccountComment).mockResolvedValue();
193
205
194
-
await checkAccountThreshold(testDid, "label-2", testTimestamp);
206
+
await checkAccountThreshold(testDid, testUri, "label-2", testTimestamp);
195
207
196
208
expect(getPostLabelCountInWindow).toHaveBeenCalledWith(
197
209
testDid,
198
210
["label-1", "label-2", "label-3"],
199
211
7,
212
+
"days",
200
213
testTimestamp,
201
214
);
202
215
expect(createAccountLabel).toHaveBeenCalledWith(
203
216
testDid,
204
217
"multi-label-account",
205
-
"Multi label comment",
218
+
`Multi label comment\n\nThreshold: 5/5 in 7 days\n\nPost: ${testUri}\n\nPost Label: label-2`,
206
219
);
207
220
expect(createAccountReport).toHaveBeenCalledWith(
208
221
testDid,
209
-
"Multi label comment",
222
+
`Multi label comment\n\nThreshold: 5/5 in 7 days\n\nPost: ${testUri}\n\nPost Label: label-2`,
210
223
);
211
224
expect(createAccountComment).toHaveBeenCalled();
212
225
});
···
216
229
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
217
230
vi.mocked(createAccountReport).mockResolvedValue();
218
231
219
-
await checkAccountThreshold(testDid, "monitor-only-label", testTimestamp);
232
+
await checkAccountThreshold(
233
+
testDid,
234
+
testUri,
235
+
"monitor-only-label",
236
+
testTimestamp,
237
+
);
220
238
221
239
expect(trackPostLabelForAccount).toHaveBeenCalled();
222
240
expect(getPostLabelCountInWindow).toHaveBeenCalled();
···
237
255
vi.mocked(createAccountReport).mockResolvedValue();
238
256
vi.mocked(createAccountComment).mockResolvedValue();
239
257
240
-
await checkAccountThreshold(testDid, "label-1", testTimestamp);
258
+
await checkAccountThreshold(testDid, testUri, "label-1", testTimestamp);
241
259
242
260
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledTimes(3);
243
261
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
···
259
277
vi.mocked(trackPostLabelForAccount).mockRejectedValue(redisError);
260
278
261
279
await expect(
262
-
checkAccountThreshold(testDid, "test-label", testTimestamp),
280
+
checkAccountThreshold(testDid, testUri, "test-label", testTimestamp),
263
281
).rejects.toThrow("Redis connection failed");
264
282
265
283
expect(logger.error).toHaveBeenCalled();
···
271
289
vi.mocked(getPostLabelCountInWindow).mockRejectedValue(redisError);
272
290
273
291
await expect(
274
-
checkAccountThreshold(testDid, "test-label", testTimestamp),
292
+
checkAccountThreshold(testDid, testUri, "test-label", testTimestamp),
275
293
).rejects.toThrow("Redis query failed");
276
294
277
295
expect(logger.error).toHaveBeenCalled();
···
286
304
vi.mocked(createAccountReport).mockResolvedValue();
287
305
vi.mocked(createAccountComment).mockResolvedValue();
288
306
289
-
await checkAccountThreshold(testDid, "label-1", testTimestamp);
307
+
await checkAccountThreshold(testDid, testUri, "label-1", testTimestamp);
290
308
291
309
expect(trackPostLabelForAccount).toHaveBeenCalledTimes(2);
292
310
expect(getPostLabelCountInWindow).toHaveBeenCalledTimes(2);
+111
-11
src/tests/redis.test.ts
+111
-11
src/tests/redis.test.ts
···
7
7
connectRedis,
8
8
disconnectRedis,
9
9
getPostLabelCountInWindow,
10
+
getStarterPackCountInWindow,
10
11
trackPostLabelForAccount,
12
+
trackStarterPackForAccount,
11
13
tryClaimAccountLabel,
12
14
tryClaimPostLabel,
13
15
} from "../redis.js";
···
116
118
vi.mocked(mockRedisClient.expire).mockResolvedValue(true);
117
119
118
120
const timestamp = 1640000000000000; // microseconds
119
-
const windowDays = 5;
121
+
const window = 5;
122
+
const windowUnit = "days" as const;
120
123
121
124
await trackPostLabelForAccount(
122
125
"did:plc:123",
123
126
"test-label",
124
127
timestamp,
125
-
windowDays,
128
+
window,
129
+
windowUnit,
126
130
);
127
131
128
-
const expectedKey = "account-post-labels:did:plc:123:test-label:5";
129
-
const windowStartTime = timestamp - windowDays * 24 * 60 * 60 * 1000000;
132
+
const expectedKey = "account-post-labels:did:plc:123:test-label:5days";
133
+
const windowStartTime = timestamp - window * 24 * 60 * 60 * 1000000;
130
134
131
135
expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith(
132
136
expectedKey,
···
139
143
});
140
144
expect(mockRedisClient.expire).toHaveBeenCalledWith(
141
145
expectedKey,
142
-
(windowDays + 1) * 24 * 60 * 60,
146
+
window * 24 * 60 * 60 + 60 * 60,
143
147
);
144
148
});
145
149
···
153
157
"test-label",
154
158
1640000000000000,
155
159
5,
160
+
"days",
156
161
),
157
162
).rejects.toThrow("Redis down");
158
163
···
160
165
});
161
166
});
162
167
168
+
describe("trackStarterPackForAccount", () => {
169
+
it("should track starter pack with correct timestamp and TTL", async () => {
170
+
vi.mocked(mockRedisClient.zRemRangeByScore).mockResolvedValue(0);
171
+
vi.mocked(mockRedisClient.zAdd).mockResolvedValue(1);
172
+
vi.mocked(mockRedisClient.expire).mockResolvedValue(true);
173
+
174
+
const timestamp = 1640000000000000;
175
+
const window = 24;
176
+
const windowUnit = "hours" as const;
177
+
178
+
await trackStarterPackForAccount(
179
+
"did:plc:123",
180
+
"at://did:plc:123/app.bsky.graph.starterpack/abc",
181
+
timestamp,
182
+
window,
183
+
windowUnit,
184
+
);
185
+
186
+
const expectedKey = "starterpack:threshold:did:plc:123:24hours";
187
+
const windowStartTime = timestamp - window * 60 * 60 * 1000000;
188
+
189
+
expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith(
190
+
expectedKey,
191
+
"-inf",
192
+
windowStartTime,
193
+
);
194
+
expect(mockRedisClient.zAdd).toHaveBeenCalledWith(expectedKey, {
195
+
score: timestamp,
196
+
value: "at://did:plc:123/app.bsky.graph.starterpack/abc",
197
+
});
198
+
expect(mockRedisClient.expire).toHaveBeenCalledWith(
199
+
expectedKey,
200
+
window * 60 * 60 + 60 * 60,
201
+
);
202
+
});
203
+
204
+
it("should throw error on Redis failure", async () => {
205
+
const redisError = new Error("Redis down");
206
+
vi.mocked(mockRedisClient.zRemRangeByScore).mockRejectedValue(redisError);
207
+
208
+
await expect(
209
+
trackStarterPackForAccount(
210
+
"did:plc:123",
211
+
"at://did:plc:123/app.bsky.graph.starterpack/abc",
212
+
1640000000000000,
213
+
24,
214
+
"hours",
215
+
),
216
+
).rejects.toThrow("Redis down");
217
+
218
+
expect(logger.error).toHaveBeenCalled();
219
+
});
220
+
});
221
+
222
+
describe("getStarterPackCountInWindow", () => {
223
+
it("should count starter packs in window", async () => {
224
+
vi.mocked(mockRedisClient.zCount).mockResolvedValue(3);
225
+
226
+
const currentTime = 1640000000000000;
227
+
const window = 24;
228
+
const windowUnit = "hours" as const;
229
+
const count = await getStarterPackCountInWindow(
230
+
"did:plc:123",
231
+
window,
232
+
windowUnit,
233
+
currentTime,
234
+
);
235
+
236
+
expect(count).toBe(3);
237
+
const windowStartTime = currentTime - window * 60 * 60 * 1000000;
238
+
expect(mockRedisClient.zCount).toHaveBeenCalledWith(
239
+
"starterpack:threshold:did:plc:123:24hours",
240
+
windowStartTime,
241
+
"+inf",
242
+
);
243
+
});
244
+
245
+
it("should throw error on Redis failure", async () => {
246
+
const redisError = new Error("Redis down");
247
+
vi.mocked(mockRedisClient.zCount).mockRejectedValue(redisError);
248
+
249
+
await expect(
250
+
getStarterPackCountInWindow("did:plc:123", 24, "hours", 1640000000000000),
251
+
).rejects.toThrow("Redis down");
252
+
253
+
expect(logger.error).toHaveBeenCalled();
254
+
});
255
+
});
256
+
163
257
describe("getPostLabelCountInWindow", () => {
164
258
it("should count posts for single label", async () => {
165
259
vi.mocked(mockRedisClient.zCount).mockResolvedValue(3);
166
260
167
261
const currentTime = 1640000000000000;
168
-
const windowDays = 5;
262
+
const window = 5;
263
+
const windowUnit = "days" as const;
169
264
const count = await getPostLabelCountInWindow(
170
265
"did:plc:123",
171
266
["test-label"],
172
-
windowDays,
267
+
window,
268
+
windowUnit,
173
269
currentTime,
174
270
);
175
271
176
272
expect(count).toBe(3);
177
-
const windowStartTime = currentTime - windowDays * 24 * 60 * 60 * 1000000;
273
+
const windowStartTime = currentTime - window * 24 * 60 * 60 * 1000000;
178
274
expect(mockRedisClient.zCount).toHaveBeenCalledWith(
179
-
"account-post-labels:did:plc:123:test-label:5",
275
+
"account-post-labels:did:plc:123:test-label:5days",
180
276
windowStartTime,
181
277
"+inf",
182
278
);
···
189
285
.mockResolvedValueOnce(1);
190
286
191
287
const currentTime = 1640000000000000;
192
-
const windowDays = 5;
288
+
const window = 5;
289
+
const windowUnit = "days" as const;
193
290
const count = await getPostLabelCountInWindow(
194
291
"did:plc:123",
195
292
["label-1", "label-2", "label-3"],
196
-
windowDays,
293
+
window,
294
+
windowUnit,
197
295
currentTime,
198
296
);
199
297
···
208
306
"did:plc:123",
209
307
["test-label"],
210
308
5,
309
+
"days",
211
310
1640000000000000,
212
311
);
213
312
···
223
322
"did:plc:123",
224
323
["test-label"],
225
324
5,
325
+
"days",
226
326
1640000000000000,
227
327
),
228
328
).rejects.toThrow("Redis down");
+201
src/tests/starterPackThreshold.test.ts
+201
src/tests/starterPackThreshold.test.ts
···
1
+
import { afterEach, describe, expect, it, vi } from "vitest";
2
+
import {
3
+
createAccountComment,
4
+
createAccountLabel,
5
+
createAccountReport,
6
+
} from "../accountModeration.js";
7
+
import { logger } from "../logger.js";
8
+
import {
9
+
starterPackLabelsThresholdAppliedCounter,
10
+
starterPackThresholdChecksCounter,
11
+
starterPackThresholdMetCounter,
12
+
} from "../metrics.js";
13
+
import {
14
+
getStarterPackCountInWindow,
15
+
trackStarterPackForAccount,
16
+
} from "../redis.js";
17
+
import {
18
+
checkStarterPackThreshold,
19
+
loadStarterPackThresholdConfigs,
20
+
} from "../starterPackThreshold.js";
21
+
22
+
vi.mock("../logger.js", () => ({
23
+
logger: {
24
+
info: vi.fn(),
25
+
warn: vi.fn(),
26
+
error: vi.fn(),
27
+
debug: vi.fn(),
28
+
},
29
+
}));
30
+
31
+
vi.mock("../../rules/starterPackThreshold.js", () => ({
32
+
STARTER_PACK_THRESHOLD_CONFIGS: [
33
+
{
34
+
threshold: 5,
35
+
window: 24,
36
+
windowUnit: "hours",
37
+
accountLabel: "starter-pack-spam",
38
+
accountComment: "Too many starter packs",
39
+
toLabel: true,
40
+
reportAcct: true,
41
+
commentAcct: false,
42
+
allowlist: ["did:plc:allowed123"],
43
+
},
44
+
{
45
+
threshold: 10,
46
+
window: 7,
47
+
windowUnit: "days",
48
+
accountLabel: "starter-pack-abuse",
49
+
accountComment: "Excessive starter pack creation",
50
+
toLabel: true,
51
+
reportAcct: false,
52
+
commentAcct: true,
53
+
allowlist: [],
54
+
},
55
+
],
56
+
}));
57
+
58
+
vi.mock("../redis.js", () => ({
59
+
trackStarterPackForAccount: vi.fn(),
60
+
getStarterPackCountInWindow: vi.fn(),
61
+
}));
62
+
63
+
vi.mock("../accountModeration.js", () => ({
64
+
createAccountLabel: vi.fn(),
65
+
createAccountReport: vi.fn(),
66
+
createAccountComment: vi.fn(),
67
+
}));
68
+
69
+
vi.mock("../metrics.js", () => ({
70
+
starterPackLabelsThresholdAppliedCounter: {
71
+
inc: vi.fn(),
72
+
},
73
+
starterPackThresholdChecksCounter: {
74
+
inc: vi.fn(),
75
+
},
76
+
starterPackThresholdMetCounter: {
77
+
inc: vi.fn(),
78
+
},
79
+
}));
80
+
81
+
describe("Starter Pack Threshold Logic", () => {
82
+
afterEach(() => {
83
+
vi.clearAllMocks();
84
+
});
85
+
86
+
describe("loadStarterPackThresholdConfigs", () => {
87
+
it("should load and cache configs successfully", () => {
88
+
const configs = loadStarterPackThresholdConfigs();
89
+
expect(configs).toHaveLength(2);
90
+
expect(configs[0].threshold).toBe(5);
91
+
expect(configs[1].threshold).toBe(10);
92
+
});
93
+
});
94
+
95
+
describe("checkStarterPackThreshold", () => {
96
+
const testDid = "did:plc:test123";
97
+
const testUri = "at://did:plc:test123/app.bsky.graph.starterpack/abc";
98
+
const testTimestamp = 1640000000000000;
99
+
100
+
it("should skip threshold check for allowlisted accounts", async () => {
101
+
vi.mocked(trackStarterPackForAccount).mockResolvedValue();
102
+
vi.mocked(getStarterPackCountInWindow).mockResolvedValue(0);
103
+
104
+
await checkStarterPackThreshold(
105
+
"did:plc:allowed123",
106
+
testUri,
107
+
testTimestamp,
108
+
);
109
+
110
+
expect(starterPackThresholdChecksCounter.inc).toHaveBeenCalled();
111
+
// Should skip first config (allowlist), but process second config
112
+
expect(trackStarterPackForAccount).toHaveBeenCalledTimes(1);
113
+
expect(logger.debug).toHaveBeenCalledWith(
114
+
expect.objectContaining({ did: "did:plc:allowed123" }),
115
+
"Account is in allowlist, skipping threshold check",
116
+
);
117
+
});
118
+
119
+
it("should track and check threshold for non-allowlisted accounts", async () => {
120
+
vi.mocked(trackStarterPackForAccount).mockResolvedValue();
121
+
vi.mocked(getStarterPackCountInWindow).mockResolvedValue(3);
122
+
123
+
await checkStarterPackThreshold(testDid, testUri, testTimestamp);
124
+
125
+
expect(starterPackThresholdChecksCounter.inc).toHaveBeenCalled();
126
+
expect(trackStarterPackForAccount).toHaveBeenCalledWith(
127
+
testDid,
128
+
testUri,
129
+
testTimestamp,
130
+
24,
131
+
"hours",
132
+
);
133
+
expect(getStarterPackCountInWindow).toHaveBeenCalledWith(
134
+
testDid,
135
+
24,
136
+
"hours",
137
+
testTimestamp,
138
+
);
139
+
});
140
+
141
+
it("should apply account label when threshold is met", async () => {
142
+
vi.mocked(trackStarterPackForAccount).mockResolvedValue();
143
+
vi.mocked(getStarterPackCountInWindow).mockResolvedValue(5);
144
+
vi.mocked(createAccountLabel).mockResolvedValue();
145
+
vi.mocked(createAccountReport).mockResolvedValue();
146
+
147
+
await checkStarterPackThreshold(testDid, testUri, testTimestamp);
148
+
149
+
expect(starterPackThresholdMetCounter.inc).toHaveBeenCalledWith({
150
+
account_label: "starter-pack-spam",
151
+
});
152
+
expect(createAccountLabel).toHaveBeenCalledWith(
153
+
testDid,
154
+
"starter-pack-spam",
155
+
expect.stringContaining("Too many starter packs"),
156
+
);
157
+
expect(createAccountReport).toHaveBeenCalled();
158
+
expect(starterPackLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
159
+
account_label: "starter-pack-spam",
160
+
action: "label",
161
+
});
162
+
});
163
+
164
+
it("should not apply label when threshold not met", async () => {
165
+
vi.mocked(trackStarterPackForAccount).mockResolvedValue();
166
+
vi.mocked(getStarterPackCountInWindow).mockResolvedValue(3);
167
+
168
+
await checkStarterPackThreshold(testDid, testUri, testTimestamp);
169
+
170
+
expect(starterPackThresholdMetCounter.inc).not.toHaveBeenCalled();
171
+
expect(createAccountLabel).not.toHaveBeenCalled();
172
+
});
173
+
174
+
it("should handle Redis errors", async () => {
175
+
const redisError = new Error("Redis connection failed");
176
+
vi.mocked(trackStarterPackForAccount).mockRejectedValue(redisError);
177
+
178
+
await expect(
179
+
checkStarterPackThreshold(testDid, testUri, testTimestamp),
180
+
).rejects.toThrow("Redis connection failed");
181
+
182
+
expect(logger.error).toHaveBeenCalled();
183
+
});
184
+
185
+
it("should check all configs for each starter pack", async () => {
186
+
vi.mocked(trackStarterPackForAccount).mockResolvedValue();
187
+
vi.mocked(getStarterPackCountInWindow)
188
+
.mockResolvedValueOnce(5)
189
+
.mockResolvedValueOnce(10);
190
+
vi.mocked(createAccountLabel).mockResolvedValue();
191
+
vi.mocked(createAccountReport).mockResolvedValue();
192
+
vi.mocked(createAccountComment).mockResolvedValue();
193
+
194
+
await checkStarterPackThreshold(testDid, testUri, testTimestamp);
195
+
196
+
expect(trackStarterPackForAccount).toHaveBeenCalledTimes(2);
197
+
expect(getStarterPackCountInWindow).toHaveBeenCalledTimes(2);
198
+
expect(createAccountLabel).toHaveBeenCalledTimes(2);
199
+
});
200
+
});
201
+
});
+28
-1
src/types.ts
+28
-1
src/types.ts
···
3
3
export interface Checks {
4
4
language?: string[];
5
5
label: string;
6
+
unlabel?: boolean;
6
7
comment: string;
7
8
description?: boolean;
8
9
displayName?: boolean;
···
10
11
commentAcct: boolean;
11
12
reportPost?: boolean;
12
13
toLabel: boolean;
14
+
trackOnly?: boolean;
13
15
duration?: number;
14
16
check: RegExp;
15
17
whitelist?: RegExp;
···
62
64
expires?: string; // Optional expiration date (ISO 8601) - check will be skipped after this date
63
65
}
64
66
67
+
export type WindowUnit = "minutes" | "hours" | "days";
68
+
65
69
export interface AccountThresholdConfig {
66
70
labels: string | string[]; // Single label or array for OR matching
67
71
threshold: number; // Number of labeled posts required to trigger account action
68
72
accountLabel: string; // Label to apply to the account
69
73
accountComment: string; // Comment for the account action
70
-
windowDays: number; // Rolling window in days
74
+
window: number; // Rolling window duration
75
+
windowUnit: WindowUnit; // Unit for the rolling window
71
76
reportAcct: boolean; // Whether to report the account
72
77
commentAcct: boolean; // Whether to comment on the account
73
78
toLabel?: boolean; // Whether to apply label (defaults to true)
74
79
}
80
+
81
+
export interface StarterPackThresholdConfig {
82
+
threshold: number;
83
+
window: number;
84
+
windowUnit: WindowUnit;
85
+
accountLabel: string;
86
+
accountComment: string;
87
+
toLabel?: boolean;
88
+
reportAcct?: boolean;
89
+
commentAcct?: boolean;
90
+
allowlist?: string[];
91
+
}
92
+
93
+
export interface ModerationError {
94
+
action: "label" | "report" | "comment" | "unlabel";
95
+
error: unknown;
96
+
}
97
+
98
+
export interface ModerationResult {
99
+
success: boolean;
100
+
errors: ModerationError[];
101
+
}