+2
-11
.claude/settings.local.json
+2
-11
.claude/settings.local.json
···
13
"Bash(npm run test:run:*)",
14
"Bash(bunx eslint:*)",
15
"Bash(bun test:run:*)",
16
-
"Bash(wc:*)",
17
-
"Bash(grep:*)",
18
-
"Bash(npm test:*)",
19
-
"Bash(npx vitest:*)",
20
-
"Bash(npm install:*)",
21
-
"Bash(git stash:*)",
22
-
"Bash(gh pr view:*)",
23
-
"Bash(gh pr edit:*)"
24
],
25
"deny": [],
26
"ask": []
27
},
28
"enableAllProjectMcpServers": true,
29
-
"enabledMcpjsonServers": [
30
-
"git-mcp-server"
31
-
]
32
}
+5
-5
.env.example
+5
-5
.env.example
+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
+14
-66
README.md
+14
-66
README.md
···
1
-
# skywatch-automod
2
-
3
-
Automated moderation tooling for the Bluesky independent labeler skywatch.blue. Monitors the Bluesky firehose and applies labels based on configured moderation rules.
4
-
5
-
## Setup
6
-
7
-
Configure environment:
8
-
9
-
```bash
10
-
cp .env.example .env
11
-
# Edit .env with your credentials and configuration
12
-
```
13
-
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):
32
33
-
```bash
34
-
touch cursor.txt
35
-
```
36
37
-
## Running
38
39
-
Production:
40
41
```bash
42
-
docker compose up -d
43
```
44
45
-
Development mode with auto-reload:
46
47
```bash
48
-
docker compose -f compose.yaml -f compose.dev.yaml up
49
```
50
51
-
The service runs on port 4101 (metrics endpoint). Redis and Prometheus are included in the compose stack.
52
-
53
-
## Authentication
54
-
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).
···
1
+
# skywatch-tools
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
4
5
+
## Installation and Setup
6
7
+
To install dependencies:
8
9
```bash
10
+
bun i
11
```
12
13
+
Modify .env.example with your own values and rename it to .env
14
15
```bash
16
+
bun run start
17
```
18
19
+
To run in docker:
20
21
```bash
22
+
docker build -pull -t skywatch-tools .
23
+
docker run -d -p 4101:4101 skywatch-autolabeler
24
```
25
26
+
## Brief overview
27
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.
29
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.
31
32
+
For information on how to set-up your own checks, please see the [developing_checks.md](./src/developing_checks.md) file.
-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
+7
-33
rules/accountAge.ts
+7
-33
rules/accountAge.ts
···
3
/**
4
* Account age monitoring configurations
5
*
6
-
* Labels new accounts that interact with monitored DIDs or posts.
7
-
* Useful for protecting high-profile accounts from coordinated harassment.
8
-
* Configure your checks below.
9
*/
10
export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [
11
-
// Example - monitor replies to specific accounts:
12
-
// {
13
-
// monitoredDIDs: ["did:plc:example123", "did:plc:example456"],
14
-
// anchorDate: "2025-01-15", // Only check accounts created after this date
15
-
// maxAgeDays: 7, // Flag accounts younger than 7 days
16
-
// label: "new-account-reply",
17
-
// comment: "New account replying to monitored user",
18
-
// expires: "2025-02-15", // Stop checking after this date
19
-
// },
20
-
21
-
// Example - monitor replies/quotes to specific posts:
22
-
// {
23
-
// monitoredPostURIs: [
24
-
// "at://did:plc:xyz/app.bsky.feed.post/abc123",
25
-
// "at://did:plc:xyz/app.bsky.feed.post/def456",
26
-
// ],
27
-
// anchorDate: "2025-01-20",
28
-
// maxAgeDays: 3,
29
-
// label: "new-account-quote",
30
-
// comment: "New account quoting monitored post",
31
-
// },
32
-
33
-
// Example - combine both DID and post monitoring:
34
// {
35
-
// monitoredDIDs: ["did:plc:high-profile"],
36
-
// monitoredPostURIs: ["at://did:plc:high-profile/app.bsky.feed.post/viral"],
37
-
// anchorDate: "2025-01-01",
38
-
// maxAgeDays: 14,
39
-
// label: "new-account-interaction",
40
-
// comment: "New account interacting with high-profile content",
41
-
// expires: "2025-03-01",
42
// },
43
];
···
3
/**
4
* Account age monitoring configurations
5
*
6
+
* This file contains example values. Copy to accountAge.ts and configure with your checks.
7
*/
8
export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [
9
+
// Example configuration:
10
// {
11
+
// monitoredDIDs: ["did:plc:example123"],
12
+
// anchorDate: "2025-01-15",
13
+
// maxAgeDays: 7,
14
+
// label: "new-account",
15
+
// comment: "Account created within monitored window",
16
// },
17
];
+1
-2
rules/accountThreshold.ts
+1
-2
rules/accountThreshold.ts
+3
-22
rules/constants.ts
+3
-22
rules/constants.ts
···
1
/**
2
* Global allowlist for accounts that should bypass all checks
3
*
4
-
* Add DIDs here to exempt them from all moderation checks.
5
*/
6
export const GLOBAL_ALLOW: string[] = [
7
-
// Example: "did:plc:trusted-account",
8
];
9
10
-
/**
11
-
* URL shortener detection pattern
12
-
*
13
-
* Matched URLs are resolved to their final destination before checking.
14
-
* Add domains for URL shorteners you want to expand.
15
-
*/
16
-
export const LINK_SHORTENER = new RegExp(
17
-
[
18
-
"bit\\.ly",
19
-
"tinyurl\\.com",
20
-
"t\\.co",
21
-
"goo\\.gl",
22
-
"ow\\.ly",
23
-
"is\\.gd",
24
-
"buff\\.ly",
25
-
"rebrand\\.ly",
26
-
"short\\.io",
27
-
].join("|"),
28
-
"i",
29
-
);
···
1
/**
2
* Global allowlist for accounts that should bypass all checks
3
*
4
+
* This file contains example values. Copy to constants.ts and configure with your DIDs.
5
*/
6
export const GLOBAL_ALLOW: string[] = [
7
+
// Example: "did:plc:example123",
8
];
9
10
+
export const LINK_SHORTENER = new RegExp("", "i");
-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
-
```
···
+6
-20
rules/handles.ts
+6
-20
rules/handles.ts
···
3
/**
4
* Handle-based moderation checks
5
*
6
-
* Monitors user handles (usernames) for pattern matches.
7
-
* Configure your checks below.
8
*/
9
export const HANDLE_CHECKS: Checks[] = [
10
-
// Basic example - flag potential impersonation:
11
-
// {
12
-
// label: "impersonation",
13
-
// comment: "Potential impersonation detected in handle",
14
-
// reportAcct: true,
15
-
// commentAcct: false,
16
-
// toLabel: false,
17
-
// check: new RegExp("official.*support", "i"),
18
-
// },
19
-
20
-
// Advanced example with optional fields:
21
// {
22
-
// label: "suspicious-handle",
23
-
// comment: "Handle matches known spam pattern",
24
-
// reportAcct: true,
25
// commentAcct: false,
26
// toLabel: true,
27
-
// unlabel: true, // Remove label if handle changes
28
-
// check: new RegExp("crypto.*airdrop", "i"),
29
-
// whitelist: new RegExp("cryptography", "i"), // Don't match legitimate use
30
-
// ignoredDIDs: ["did:plc:verified123"],
31
// },
32
];
···
3
/**
4
* Handle-based moderation checks
5
*
6
+
* This file contains example values. Copy to handles.ts and configure with your checks.
7
*/
8
export const HANDLE_CHECKS: Checks[] = [
9
+
// Example check:
10
// {
11
+
// label: "example-label",
12
+
// comment: "Example check found in handle",
13
+
// reportAcct: false,
14
// commentAcct: false,
15
// toLabel: true,
16
+
// check: new RegExp("example-pattern", "i"),
17
// },
18
];
+5
-25
rules/posts.ts
+5
-25
rules/posts.ts
···
3
/**
4
* Post content moderation checks
5
*
6
-
* Monitors post text and embedded URLs for pattern matches.
7
-
* Configure your checks below.
8
*/
9
export const POST_CHECKS: Checks[] = [
10
-
// Basic example - label posts matching a pattern:
11
// {
12
-
// label: "spam",
13
-
// comment: "Spam content detected in post",
14
// reportAcct: false,
15
// commentAcct: false,
16
// toLabel: true,
17
-
// check: new RegExp("buy.*followers", "i"),
18
-
// },
19
-
20
-
// Advanced example - all optional fields:
21
-
// {
22
-
// label: "scam-link",
23
-
// comment: "Suspicious link detected",
24
-
// language: ["eng", "spa"], // Only check posts in these languages
25
-
// reportAcct: true, // Create account report
26
-
// reportPost: true, // Create post report
27
-
// commentAcct: false, // Add comment to account record
28
-
// toLabel: true, // Apply the label
29
-
// trackOnly: false, // If true, track but don't take action
30
-
// unlabel: false, // If true, remove label when no longer matching
31
-
// duration: 24, // Label expires after 24 hours
32
-
// check: new RegExp("crypto.*giveaway", "i"),
33
-
// whitelist: new RegExp("legitimate-site\\.com", "i"), // Skip if this matches
34
-
// ignoredDIDs: ["did:plc:trusted123"], // Skip these accounts
35
-
// starterPacks: ["at://did:plc:xyz/app.bsky.graph.starterpack/abc"], // Only check members
36
-
// knownVectors: ["telegram-scam", "discord-spam"], // Tracking tags
37
// },
38
];
···
3
/**
4
* Post content moderation checks
5
*
6
+
* This file contains example values. Copy to posts.ts and configure with your checks.
7
*/
8
export const POST_CHECKS: Checks[] = [
9
+
// Example check:
10
// {
11
+
// label: "example-label",
12
+
// comment: "Example content found in post",
13
// reportAcct: false,
14
// commentAcct: false,
15
// toLabel: true,
16
+
// check: new RegExp("example-pattern", "i"),
17
// },
18
];
+7
-23
rules/profiles.ts
+7
-23
rules/profiles.ts
···
3
/**
4
* Profile-based moderation checks
5
*
6
-
* Monitors profile display names and descriptions for pattern matches.
7
-
* Configure your checks below.
8
*/
9
export const PROFILE_CHECKS: Checks[] = [
10
-
// Basic example - check both displayName and description:
11
-
// {
12
-
// label: "spam-profile",
13
-
// comment: "Spam content in profile",
14
-
// displayName: true, // Check display name
15
-
// description: true, // Check description
16
-
// reportAcct: false,
17
-
// commentAcct: false,
18
-
// toLabel: true,
19
-
// check: new RegExp("follow.*back.*guaranteed", "i"),
20
-
// },
21
-
22
-
// Advanced example - displayName only with unlabel:
23
// {
24
-
// label: "impersonation-profile",
25
-
// comment: "Profile impersonating official account",
26
// displayName: true,
27
-
// description: false, // Only check display name
28
-
// reportAcct: true,
29
// commentAcct: false,
30
// toLabel: true,
31
-
// unlabel: true, // Remove label if profile changes
32
-
// check: new RegExp("official.*bluesky.*team", "i"),
33
-
// whitelist: new RegExp("parody|fan", "i"),
34
-
// ignoredDIDs: ["did:plc:actual-team-member"],
35
// },
36
];
···
3
/**
4
* Profile-based moderation checks
5
*
6
+
* This file contains example values. Copy to profiles.ts and configure with your checks.
7
*/
8
export const PROFILE_CHECKS: Checks[] = [
9
+
// Example check:
10
// {
11
+
// label: "example-label",
12
+
// comment: "Example content found in profile",
13
+
// description: true,
14
// displayName: true,
15
+
// reportAcct: false,
16
// commentAcct: false,
17
// toLabel: true,
18
+
// check: new RegExp("example-pattern", "i"),
19
// },
20
];
-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
-
];
···
+2
-116
src/accountModeration.ts
+2
-116
src/accountModeration.ts
···
2
import { MOD_DID } from "./config.js";
3
import { limit } from "./limits.js";
4
import { logger } from "./logger.js";
5
-
import {
6
-
labelsAppliedCounter,
7
-
labelsCachedCounter,
8
-
unlabelsRemovedCounter,
9
-
} from "./metrics.js";
10
-
import {
11
-
deleteAccountLabelClaim,
12
-
tryClaimAccountComment,
13
-
tryClaimAccountLabel,
14
-
} from "./redis.js";
15
16
const doesLabelExist = (
17
labels: { val: string }[] | undefined,
···
81
createdAt: new Date().toISOString(),
82
modTool: {
83
name: "skywatch/skywatch-automod",
84
-
meta: {
85
-
time: new Date().toISOString(),
86
-
externalUrl: `https://pdsls.dev/at://${did}`,
87
-
},
88
},
89
},
90
{
···
101
{ process: "MODERATION", error: e },
102
"Failed to create account label",
103
);
104
-
throw e;
105
}
106
});
107
};
···
142
createdAt: new Date().toISOString(),
143
modTool: {
144
name: "skywatch/skywatch-automod",
145
-
meta: {
146
-
time: new Date().toISOString(),
147
-
externalUrl: `https://pdsls.dev/at://${did}`,
148
-
},
149
},
150
},
151
{
···
162
{ process: "MODERATION", error: e },
163
"Failed to create account comment",
164
);
165
-
throw e;
166
}
167
});
168
};
···
188
createdAt: new Date().toISOString(),
189
modTool: {
190
name: "skywatch/skywatch-automod",
191
-
meta: {
192
-
time: new Date().toISOString(),
193
-
externalUrl: `https://pdsls.dev/at://${did}`,
194
-
},
195
},
196
},
197
{
···
208
{ process: "MODERATION", error: e },
209
"Failed to create account report",
210
);
211
-
throw e;
212
-
}
213
-
});
214
-
};
215
-
216
-
export const negateAccountLabel = async (
217
-
did: string,
218
-
label: string,
219
-
comment: string,
220
-
) => {
221
-
await isLoggedIn;
222
-
223
-
const hasLabel = await checkAccountLabels(did, label);
224
-
if (!hasLabel) {
225
-
logger.debug(
226
-
{ process: "MODERATION", did, label },
227
-
"Account does not have label, skipping",
228
-
);
229
-
return;
230
-
}
231
-
232
-
logger.info({ process: "MODERATION", did, label }, "Unlabeling account");
233
-
unlabelsRemovedCounter.inc({ label_type: label, target_type: "account" });
234
-
235
-
await limit(async () => {
236
-
try {
237
-
await agent.tools.ozone.moderation.emitEvent(
238
-
{
239
-
event: {
240
-
$type: "tools.ozone.moderation.defs#modEventLabel",
241
-
comment,
242
-
createLabelVals: [],
243
-
negateLabelVals: [label],
244
-
},
245
-
// specify the labeled post by strongRef
246
-
subject: {
247
-
$type: "com.atproto.admin.defs#repoRef",
248
-
did,
249
-
},
250
-
// put in the rest of the metadata
251
-
createdBy: agent.did ?? "",
252
-
createdAt: new Date().toISOString(),
253
-
modTool: {
254
-
name: "skywatch/skywatch-automod",
255
-
meta: {
256
-
time: new Date().toISOString(),
257
-
externalUrl: `https://pdsls.dev/at://${did}`,
258
-
},
259
-
},
260
-
},
261
-
{
262
-
encoding: "application/json",
263
-
headers: {
264
-
"atproto-proxy": `${MOD_DID}#atproto_labeler`,
265
-
"atproto-accept-labelers":
266
-
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
267
-
},
268
-
},
269
-
);
270
-
await deleteAccountLabelClaim(did, label);
271
-
} catch (e) {
272
-
logger.error(
273
-
{ process: "MODERATION", error: e },
274
-
"Failed to negate account label",
275
-
);
276
-
throw e;
277
}
278
});
279
};
···
306
}
307
});
308
};
309
-
310
-
export const getAllAccountLabels = async (did: string): Promise<string[]> => {
311
-
await isLoggedIn;
312
-
return await limit(async () => {
313
-
try {
314
-
const response = await agent.tools.ozone.moderation.getRepo(
315
-
{ did },
316
-
{
317
-
headers: {
318
-
"atproto-proxy": `${MOD_DID}#atproto_labeler`,
319
-
"atproto-accept-labelers":
320
-
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
321
-
},
322
-
},
323
-
);
324
-
325
-
return (response.data.labels ?? []).map((label) => label.val);
326
-
} catch (e) {
327
-
logger.error(
328
-
{ process: "MODERATION", did, error: e },
329
-
"Failed to get account labels",
330
-
);
331
-
return [];
332
-
}
333
-
});
334
-
};
···
2
import { MOD_DID } from "./config.js";
3
import { limit } from "./limits.js";
4
import { logger } from "./logger.js";
5
+
import { labelsAppliedCounter, labelsCachedCounter } from "./metrics.js";
6
+
import { tryClaimAccountComment, tryClaimAccountLabel } from "./redis.js";
7
8
const doesLabelExist = (
9
labels: { val: string }[] | undefined,
···
73
createdAt: new Date().toISOString(),
74
modTool: {
75
name: "skywatch/skywatch-automod",
76
},
77
},
78
{
···
89
{ process: "MODERATION", error: e },
90
"Failed to create account label",
91
);
92
}
93
});
94
};
···
129
createdAt: new Date().toISOString(),
130
modTool: {
131
name: "skywatch/skywatch-automod",
132
},
133
},
134
{
···
145
{ process: "MODERATION", error: e },
146
"Failed to create account comment",
147
);
148
}
149
});
150
};
···
170
createdAt: new Date().toISOString(),
171
modTool: {
172
name: "skywatch/skywatch-automod",
173
},
174
},
175
{
···
186
{ process: "MODERATION", error: e },
187
"Failed to create account report",
188
);
189
}
190
});
191
};
···
218
}
219
});
220
};
+12
-14
src/accountThreshold.ts
+12
-14
src/accountThreshold.ts
···
41
`Invalid account threshold config: threshold must be positive`,
42
);
43
}
44
-
if (config.window <= 0) {
45
throw new Error(
46
-
`Invalid account threshold config: window must be positive`,
47
);
48
}
49
}
···
65
66
export async function checkAccountThreshold(
67
did: string,
68
-
uri: string,
69
postLabel: string,
70
timestamp: number,
71
): Promise<void> {
···
94
did,
95
postLabel,
96
timestamp,
97
-
config.window,
98
-
config.windowUnit,
99
);
100
101
const count = await getPostLabelCountInWindow(
102
did,
103
labels,
104
-
config.window,
105
-
config.windowUnit,
106
timestamp,
107
);
108
···
113
labels,
114
count,
115
threshold: config.threshold,
116
-
window: config.window,
117
-
windowUnit: config.windowUnit,
118
},
119
"Checked account threshold",
120
);
···
136
137
const shouldLabel = config.toLabel !== false;
138
139
-
const formattedComment = `${config.accountComment}\n\nThreshold: ${count.toString()}/${config.threshold.toString()} in ${config.window.toString()} ${config.windowUnit}\n\nPost: ${uri}\n\nPost Label: ${postLabel}`;
140
-
141
if (shouldLabel) {
142
-
await createAccountLabel(did, config.accountLabel, formattedComment);
143
accountLabelsThresholdAppliedCounter.inc({
144
account_label: config.accountLabel,
145
action: "label",
···
147
}
148
149
if (config.reportAcct) {
150
-
await createAccountReport(did, formattedComment);
151
accountLabelsThresholdAppliedCounter.inc({
152
account_label: config.accountLabel,
153
action: "report",
···
156
157
if (config.commentAcct) {
158
const atURI = `threshold-comment:${config.accountLabel}:${timestamp.toString()}`;
159
-
await createAccountComment(did, formattedComment, atURI);
160
accountLabelsThresholdAppliedCounter.inc({
161
account_label: config.accountLabel,
162
action: "comment",
···
41
`Invalid account threshold config: threshold must be positive`,
42
);
43
}
44
+
if (config.windowDays <= 0) {
45
throw new Error(
46
+
`Invalid account threshold config: windowDays must be positive`,
47
);
48
}
49
}
···
65
66
export async function checkAccountThreshold(
67
did: string,
68
postLabel: string,
69
timestamp: number,
70
): Promise<void> {
···
93
did,
94
postLabel,
95
timestamp,
96
+
config.windowDays,
97
);
98
99
const count = await getPostLabelCountInWindow(
100
did,
101
labels,
102
+
config.windowDays,
103
timestamp,
104
);
105
···
110
labels,
111
count,
112
threshold: config.threshold,
113
+
windowDays: config.windowDays,
114
},
115
"Checked account threshold",
116
);
···
132
133
const shouldLabel = config.toLabel !== false;
134
135
if (shouldLabel) {
136
+
await createAccountLabel(
137
+
did,
138
+
config.accountLabel,
139
+
config.accountComment,
140
+
);
141
accountLabelsThresholdAppliedCounter.inc({
142
account_label: config.accountLabel,
143
action: "label",
···
145
}
146
147
if (config.reportAcct) {
148
+
await createAccountReport(did, config.accountComment);
149
accountLabelsThresholdAppliedCounter.inc({
150
account_label: config.accountLabel,
151
action: "report",
···
154
155
if (config.commentAcct) {
156
const atURI = `threshold-comment:${config.accountLabel}:${timestamp.toString()}`;
157
+
await createAccountComment(did, config.accountComment, atURI);
158
accountLabelsThresholdAppliedCounter.inc({
159
account_label: config.accountLabel,
160
action: "comment",
+1
-18
src/agent.ts
+1
-18
src/agent.ts
···
170
}
171
172
export const login = authenticateWithRetry;
173
-
174
-
// Lazy getter for isLoggedIn - authentication only starts when first accessed
175
-
let _isLoggedIn: Promise<boolean> | null = null;
176
-
177
-
export function getIsLoggedIn(): Promise<boolean> {
178
-
if (!_isLoggedIn) {
179
-
_isLoggedIn = authenticateWithRetry().then(() => true);
180
-
}
181
-
return _isLoggedIn;
182
-
}
183
-
184
-
// For backward compatibility - callers can still use `await isLoggedIn`
185
-
// but authentication is now lazy instead of eager
186
-
export const isLoggedIn = {
187
-
then<T>(onFulfilled: (value: boolean) => T | PromiseLike<T>): Promise<T> {
188
-
return getIsLoggedIn().then(onFulfilled);
189
-
},
190
-
};
-1
src/config.ts
-1
src/config.ts
+18
-13
src/main.ts
+18
-13
src/main.ts
···
19
import { checkFacetSpam } from "./rules/facets/facets.js";
20
import { checkHandle } from "./rules/handles/checkHandles.js";
21
import { checkPosts } from "./rules/posts/checkPosts.js";
22
-
import { checkProfile } from "./rules/profiles/checkProfiles.js";
23
-
import { checkStarterPackThreshold } from "./starterPackThreshold.js";
24
import type { Post } from "./types.js";
25
26
let cursor = 0;
···
280
async (event: CommitUpdateEvent<"app.bsky.actor.profile">) => {
281
try {
282
if (event.commit.record.displayName || event.commit.record.description) {
283
-
void checkProfile(
284
event.did,
285
event.time_us,
286
event.commit.record.displayName as string,
···
301
async (event: CommitCreateEvent<"app.bsky.actor.profile">) => {
302
try {
303
if (event.commit.record.displayName || event.commit.record.description) {
304
-
void checkProfile(
305
event.did,
306
event.time_us,
307
event.commit.record.displayName as string,
···
323
// checkHandle is sync but calls async functions with void
324
checkHandle(event.identity.did, event.identity.handle, event.time_us);
325
}
326
-
},
327
-
);
328
-
329
-
// Check for starter pack creation
330
-
jetstream.onCreate(
331
-
"app.bsky.graph.starterpack",
332
-
(event: CommitCreateEvent<"app.bsky.graph.starterpack">) => {
333
-
const starterPackUri = `at://${event.did}/app.bsky.graph.starterpack/${event.commit.rkey}`;
334
-
void checkStarterPackThreshold(event.did, starterPackUri, event.time_us);
335
},
336
);
337
···
19
import { checkFacetSpam } from "./rules/facets/facets.js";
20
import { checkHandle } from "./rules/handles/checkHandles.js";
21
import { checkPosts } from "./rules/posts/checkPosts.js";
22
+
import {
23
+
checkDescription,
24
+
checkDisplayName,
25
+
} from "./rules/profiles/checkProfiles.js";
26
import type { Post } from "./types.js";
27
28
let cursor = 0;
···
282
async (event: CommitUpdateEvent<"app.bsky.actor.profile">) => {
283
try {
284
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(
292
event.did,
293
event.time_us,
294
event.commit.record.displayName as string,
···
309
async (event: CommitCreateEvent<"app.bsky.actor.profile">) => {
310
try {
311
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(
319
event.did,
320
event.time_us,
321
event.commit.record.displayName as string,
···
337
// checkHandle is sync but calls async functions with void
338
checkHandle(event.identity.did, event.identity.handle, event.time_us);
339
}
340
},
341
);
342
-34
src/metrics.ts
-34
src/metrics.ts
···
20
registers: [register],
21
});
22
23
-
export const unlabelsRemovedCounter: Counter = new Counter({
24
-
name: "skywatch_labels_removed_total",
25
-
help: "Total number of labels removed due to criteria no longer matching",
26
-
labelNames: ["label_type", "target_type"],
27
-
registers: [register],
28
-
});
29
-
30
export const accountLabelsThresholdAppliedCounter = new Counter({
31
name: "skywatch_account_labels_threshold_applied_total",
32
help: "Total number of account actions applied due to threshold",
···
45
name: "skywatch_account_threshold_met_total",
46
help: "Total number of times account thresholds were met",
47
labelNames: ["account_label"],
48
-
registers: [register],
49
-
});
50
-
51
-
export const starterPackThresholdChecksCounter = new Counter({
52
-
name: "skywatch_starter_pack_threshold_checks_total",
53
-
help: "Total number of starter pack threshold checks performed",
54
-
registers: [register],
55
-
});
56
-
57
-
export const starterPackThresholdMetCounter = new Counter({
58
-
name: "skywatch_starter_pack_threshold_met_total",
59
-
help: "Total number of times starter pack thresholds were met",
60
-
labelNames: ["account_label"],
61
-
registers: [register],
62
-
});
63
-
64
-
export const starterPackLabelsThresholdAppliedCounter = new Counter({
65
-
name: "skywatch_starter_pack_labels_threshold_applied_total",
66
-
help: "Total number of account actions applied due to starter pack threshold",
67
-
labelNames: ["account_label", "action"],
68
-
registers: [register],
69
-
});
70
-
71
-
export const moderationActionsFailedCounter = new Counter({
72
-
name: "skywatch_moderation_actions_failed_total",
73
-
help: "Total number of moderation actions that failed",
74
-
labelNames: ["action", "target_type"],
75
registers: [register],
76
});
77
···
20
registers: [register],
21
});
22
23
export const accountLabelsThresholdAppliedCounter = new Counter({
24
name: "skywatch_account_labels_threshold_applied_total",
25
help: "Total number of account actions applied due to threshold",
···
38
name: "skywatch_account_threshold_met_total",
39
help: "Total number of times account thresholds were met",
40
labelNames: ["account_label"],
41
registers: [register],
42
});
43
+2
-12
src/moderation.ts
+2
-12
src/moderation.ts
···
93
createdAt: new Date().toISOString(),
94
modTool: {
95
name: "skywatch/skywatch-automod",
96
-
meta: {
97
-
time: new Date().toISOString(),
98
-
externalUrl: `https://pdsls.dev/${uri}`,
99
-
},
100
},
101
},
102
{
···
117
const { checkAccountThreshold } = await import(
118
"./accountThreshold.js"
119
);
120
-
await checkAccountThreshold(did, uri, label, time);
121
} catch (error) {
122
logger.error(
123
{ process: "ACCOUNT_THRESHOLD", did, label, error },
···
130
{ process: "MODERATION", error: e },
131
"Failed to create post label",
132
);
133
-
throw e;
134
}
135
});
136
};
···
161
createdAt: new Date().toISOString(),
162
modTool: {
163
name: "skywatch/skywatch-automod",
164
-
meta: {
165
-
time: new Date().toISOString(),
166
-
externalUrl: `https://pdsls.dev/${uri}`,
167
-
},
168
},
169
},
170
{
···
179
} catch (e) {
180
logger.error(
181
{ process: "MODERATION", error: e },
182
-
"Failed to create post report",
183
);
184
-
throw e;
185
}
186
});
187
};
···
93
createdAt: new Date().toISOString(),
94
modTool: {
95
name: "skywatch/skywatch-automod",
96
},
97
},
98
{
···
113
const { checkAccountThreshold } = await import(
114
"./accountThreshold.js"
115
);
116
+
await checkAccountThreshold(did, label, time);
117
} catch (error) {
118
logger.error(
119
{ process: "ACCOUNT_THRESHOLD", did, label, error },
···
126
{ process: "MODERATION", error: e },
127
"Failed to create post label",
128
);
129
}
130
});
131
};
···
156
createdAt: new Date().toISOString(),
157
modTool: {
158
name: "skywatch/skywatch-automod",
159
},
160
},
161
{
···
170
} catch (e) {
171
logger.error(
172
{ process: "MODERATION", error: e },
173
+
"Failed to create post label",
174
);
175
}
176
});
177
};
+13
-122
src/redis.ts
+13
-122
src/redis.ts
···
1
import { createClient } from "redis";
2
import { REDIS_URL } from "./config.js";
3
import { logger } from "./logger.js";
4
-
import type { WindowUnit } from "./types.js";
5
6
export const redisClient = createClient({
7
url: REDIS_URL,
···
89
}
90
}
91
92
-
export async function deleteAccountLabelClaim(
93
-
did: string,
94
-
label: string,
95
-
): Promise<void> {
96
-
try {
97
-
const key = getAccountLabelCacheKey(did, label);
98
-
await redisClient.del(key);
99
-
logger.debug(
100
-
{ did, label },
101
-
"Deleted account label claim from Redis cache",
102
-
);
103
-
} catch (err) {
104
-
logger.warn(
105
-
{ err, did, label },
106
-
"Error deleting account label claim from Redis",
107
-
);
108
-
}
109
-
}
110
-
111
export async function tryClaimAccountComment(
112
did: string,
113
atURI: string,
···
128
}
129
}
130
131
-
function windowToMicroseconds(window: number, unit: WindowUnit): number {
132
-
const multipliers: Record<WindowUnit, number> = {
133
-
minutes: 60 * 1000000,
134
-
hours: 60 * 60 * 1000000,
135
-
days: 24 * 60 * 60 * 1000000,
136
-
};
137
-
return window * multipliers[unit];
138
-
}
139
-
140
-
function windowToSeconds(window: number, unit: WindowUnit): number {
141
-
const multipliers: Record<WindowUnit, number> = {
142
-
minutes: 60,
143
-
hours: 60 * 60,
144
-
days: 24 * 60 * 60,
145
-
};
146
-
return window * multipliers[unit];
147
-
}
148
-
149
function getPostLabelTrackingKey(
150
did: string,
151
label: string,
152
-
window: number,
153
-
unit: WindowUnit,
154
): string {
155
-
return `account-post-labels:${did}:${label}:${window.toString()}${unit}`;
156
-
}
157
-
158
-
function getStarterPackTrackingKey(
159
-
did: string,
160
-
window: number,
161
-
unit: WindowUnit,
162
-
): string {
163
-
return `starterpack:threshold:${did}:${window.toString()}${unit}`;
164
-
}
165
-
166
-
export async function trackStarterPackForAccount(
167
-
did: string,
168
-
starterPackUri: string,
169
-
timestamp: number,
170
-
window: number,
171
-
windowUnit: WindowUnit,
172
-
): Promise<void> {
173
-
try {
174
-
const key = getStarterPackTrackingKey(did, window, windowUnit);
175
-
const windowStartTime = timestamp - windowToMicroseconds(window, windowUnit);
176
-
177
-
await redisClient.zRemRangeByScore(key, "-inf", windowStartTime);
178
-
179
-
await redisClient.zAdd(key, {
180
-
score: timestamp,
181
-
value: starterPackUri,
182
-
});
183
-
184
-
const ttlSeconds = windowToSeconds(window, windowUnit) + 60 * 60;
185
-
await redisClient.expire(key, ttlSeconds);
186
-
187
-
logger.debug(
188
-
{ did, starterPackUri, timestamp, window, windowUnit },
189
-
"Tracked starter pack for account",
190
-
);
191
-
} catch (err) {
192
-
logger.error(
193
-
{ err, did, starterPackUri, timestamp, window, windowUnit },
194
-
"Error tracking starter pack in Redis",
195
-
);
196
-
throw err;
197
-
}
198
-
}
199
-
200
-
export async function getStarterPackCountInWindow(
201
-
did: string,
202
-
window: number,
203
-
windowUnit: WindowUnit,
204
-
currentTime: number,
205
-
): Promise<number> {
206
-
try {
207
-
const key = getStarterPackTrackingKey(did, window, windowUnit);
208
-
const windowStartTime = currentTime - windowToMicroseconds(window, windowUnit);
209
-
const count = await redisClient.zCount(key, windowStartTime, "+inf");
210
-
211
-
logger.debug(
212
-
{ did, window, windowUnit, count },
213
-
"Retrieved starter pack count in window",
214
-
);
215
-
216
-
return count;
217
-
} catch (err) {
218
-
logger.error(
219
-
{ err, did, window, windowUnit },
220
-
"Error getting starter pack count from Redis",
221
-
);
222
-
throw err;
223
-
}
224
}
225
226
export async function trackPostLabelForAccount(
227
did: string,
228
label: string,
229
timestamp: number,
230
-
window: number,
231
-
windowUnit: WindowUnit,
232
): Promise<void> {
233
try {
234
-
const key = getPostLabelTrackingKey(did, label, window, windowUnit);
235
-
const windowStartTime = timestamp - windowToMicroseconds(window, windowUnit);
236
237
await redisClient.zRemRangeByScore(key, "-inf", windowStartTime);
238
···
241
value: timestamp.toString(),
242
});
243
244
-
const ttlSeconds = windowToSeconds(window, windowUnit) + 60 * 60;
245
await redisClient.expire(key, ttlSeconds);
246
247
logger.debug(
248
-
{ did, label, timestamp, window, windowUnit },
249
"Tracked post label for account",
250
);
251
} catch (err) {
252
logger.error(
253
-
{ err, did, label, timestamp, window, windowUnit },
254
"Error tracking post label in Redis",
255
);
256
throw err;
···
260
export async function getPostLabelCountInWindow(
261
did: string,
262
labels: string[],
263
-
window: number,
264
-
windowUnit: WindowUnit,
265
currentTime: number,
266
): Promise<number> {
267
try {
268
-
const windowStartTime = currentTime - windowToMicroseconds(window, windowUnit);
269
let totalCount = 0;
270
271
for (const label of labels) {
272
-
const key = getPostLabelTrackingKey(did, label, window, windowUnit);
273
const count = await redisClient.zCount(key, windowStartTime, "+inf");
274
totalCount += count;
275
}
276
277
logger.debug(
278
-
{ did, labels, window, windowUnit, totalCount },
279
"Retrieved post label count in window",
280
);
281
282
return totalCount;
283
} catch (err) {
284
logger.error(
285
-
{ err, did, labels, window, windowUnit },
286
"Error getting post label count from Redis",
287
);
288
throw err;
···
1
import { createClient } from "redis";
2
import { REDIS_URL } from "./config.js";
3
import { logger } from "./logger.js";
4
5
export const redisClient = createClient({
6
url: REDIS_URL,
···
88
}
89
}
90
91
export async function tryClaimAccountComment(
92
did: string,
93
atURI: string,
···
108
}
109
}
110
111
function getPostLabelTrackingKey(
112
did: string,
113
label: string,
114
+
windowDays: number,
115
): string {
116
+
return `account-post-labels:${did}:${label}:${windowDays.toString()}`;
117
}
118
119
export async function trackPostLabelForAccount(
120
did: string,
121
label: string,
122
timestamp: number,
123
+
windowDays: number,
124
): Promise<void> {
125
try {
126
+
const key = getPostLabelTrackingKey(did, label, windowDays);
127
+
const windowStartTime = timestamp - windowDays * 24 * 60 * 60 * 1000000;
128
129
await redisClient.zRemRangeByScore(key, "-inf", windowStartTime);
130
···
133
value: timestamp.toString(),
134
});
135
136
+
const ttlSeconds = (windowDays + 1) * 24 * 60 * 60;
137
await redisClient.expire(key, ttlSeconds);
138
139
logger.debug(
140
+
{ did, label, timestamp, windowDays },
141
"Tracked post label for account",
142
);
143
} catch (err) {
144
logger.error(
145
+
{ err, did, label, timestamp, windowDays },
146
"Error tracking post label in Redis",
147
);
148
throw err;
···
152
export async function getPostLabelCountInWindow(
153
did: string,
154
labels: string[],
155
+
windowDays: number,
156
currentTime: number,
157
): Promise<number> {
158
try {
159
+
const windowStartTime = currentTime - windowDays * 24 * 60 * 60 * 1000000;
160
let totalCount = 0;
161
162
for (const label of labels) {
163
+
const key = getPostLabelTrackingKey(did, label, windowDays);
164
const count = await redisClient.zCount(key, windowStartTime, "+inf");
165
totalCount += count;
166
}
167
168
logger.debug(
169
+
{ did, labels, windowDays, totalCount },
170
"Retrieved post label count in window",
171
);
172
173
return totalCount;
174
} catch (err) {
175
logger.error(
176
+
{ err, did, labels, windowDays },
177
"Error getting post label count from Redis",
178
);
179
throw err;
+1
-1
src/rules/account/age.ts
+1
-1
src/rules/account/age.ts
+6
-18
src/rules/account/countStarterPacks.ts
+6
-18
src/rules/account/countStarterPacks.ts
···
2
import { agent, isLoggedIn } from "../../agent.js";
3
import { limit } from "../../limits.js";
4
import { logger } from "../../logger.js";
5
-
import { moderationActionsFailedCounter } from "../../metrics.js";
6
7
-
const ALLOWED_DIDS = ["did:plc:example"];
8
9
export const countStarterPacks = async (did: string, time: number) => {
10
await isLoggedIn;
···
33
"Labeling account with excessive starter packs",
34
);
35
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
-
}
52
}
53
} catch (error) {
54
const errorInfo =
···
2
import { agent, isLoggedIn } from "../../agent.js";
3
import { limit } from "../../limits.js";
4
import { logger } from "../../logger.js";
5
6
+
const ALLOWED_DIDS = ["did:plc:gpunjjgvlyb4racypz3yfiq4"];
7
8
export const countStarterPacks = async (did: string, time: number) => {
9
await isLoggedIn;
···
32
"Labeling account with excessive starter packs",
33
);
34
35
+
void createAccountLabel(
36
+
did,
37
+
"follow-farming",
38
+
`${time.toString()}: Account has ${starterPacks.toString()} starter packs`,
39
+
);
40
}
41
} catch (error) {
42
const errorInfo =
+3
-3
src/rules/facets/facets.ts
+3
-3
src/rules/facets/facets.ts
···
6
export const FACET_SPAM_THRESHOLD = 1;
7
8
// Label configuration
9
-
export const FACET_SPAM_LABEL = "platform-manipulation";
10
export const FACET_SPAM_COMMENT =
11
"Abusive facet usage detected (hidden mentions)";
12
13
// Allowlist for DIDs with legitimate duplicate facet use cases
14
export const FACET_SPAM_ALLOWLIST: string[] = [
15
-
"did:plc:ei7hqam5oasdpw5cdihdphcv",
16
];
17
18
/**
···
80
await createAccountLabel(
81
did,
82
FACET_SPAM_LABEL,
83
-
`${time.toString()}: ${FACET_SPAM_COMMENT} \n\n${uniqueCount.toString()} unique mentions at position ${position}. \n\nPost: ${atURI}`,
84
);
85
86
// Only label once per post even if multiple positions are suspicious
···
6
export const FACET_SPAM_THRESHOLD = 1;
7
8
// Label configuration
9
+
export const FACET_SPAM_LABEL = "suspect-inauthentic";
10
export const FACET_SPAM_COMMENT =
11
"Abusive facet usage detected (hidden mentions)";
12
13
// Allowlist for DIDs with legitimate duplicate facet use cases
14
export const FACET_SPAM_ALLOWLIST: string[] = [
15
+
// Add DIDs here that should be exempt from facet spam detection
16
];
17
18
/**
···
80
await createAccountLabel(
81
did,
82
FACET_SPAM_LABEL,
83
+
`${time.toString()}: ${FACET_SPAM_COMMENT} - ${uniqueCount.toString()} unique mentions at position ${position} in ${atURI}`,
84
);
85
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
expect(createAccountLabel).toHaveBeenCalledWith(
252
TEST_DID,
253
FACET_SPAM_LABEL,
254
-
`${TEST_TIME}: ${FACET_SPAM_COMMENT} \n\n2 unique mentions at position 0:1. \n\nPost: ${TEST_URI}`,
255
);
256
});
257
···
355
});
356
357
it("should use correct label and comment constants", () => {
358
-
expect(FACET_SPAM_LABEL).toBe("platform-manipulation");
359
expect(FACET_SPAM_COMMENT).toBe(
360
"Abusive facet usage detected (hidden mentions)",
361
);
···
251
expect(createAccountLabel).toHaveBeenCalledWith(
252
TEST_DID,
253
FACET_SPAM_LABEL,
254
+
`${TEST_TIME}: ${FACET_SPAM_COMMENT} - 2 unique mentions at position 0:1 in ${TEST_URI}`,
255
);
256
});
257
···
355
});
356
357
it("should use correct label and comment constants", () => {
358
+
expect(FACET_SPAM_LABEL).toBe("suspect-inauthentic");
359
expect(FACET_SPAM_COMMENT).toBe(
360
"Abusive facet usage detected (hidden mentions)",
361
);
+12
-12
src/rules/handles/checkHandles.test.ts
+12
-12
src/rules/handles/checkHandles.test.ts
···
103
104
expect(createAccountReport).toHaveBeenCalledWith(
105
"did:plc:user1",
106
-
`${time}: Spam detected\n\nHandle: spam-account`,
107
);
108
});
109
···
121
122
expect(createAccountReport).toHaveBeenCalledWith(
123
"did:plc:user1",
124
-
`${time}: Spam detected\n\nHandle: SPAM-ACCOUNT`,
125
);
126
});
127
});
···
139
140
expect(createAccountComment).toHaveBeenCalledWith(
141
"did:plc:user1",
142
-
`${time}: Scam detected\n\nHandle: scam-account`,
143
"handle:did:plc:user1:scam-account",
144
);
145
});
···
159
expect(createAccountLabel).toHaveBeenCalledWith(
160
"did:plc:normaluser",
161
"bot",
162
-
`${time}: Bot detected\n\nHandle: bot-456`,
163
);
164
});
165
});
···
171
172
expect(createAccountReport).toHaveBeenCalledWith(
173
"did:plc:user1",
174
-
`${time}: Spam detected\n\nHandle: spam-user`,
175
);
176
});
177
···
181
182
expect(createAccountComment).toHaveBeenCalledWith(
183
"did:plc:user1",
184
-
`${time}: Scam detected\n\nHandle: scam-user`,
185
"handle:did:plc:user1:scam-user",
186
);
187
});
···
193
expect(createAccountLabel).toHaveBeenCalledWith(
194
"did:plc:user1",
195
"bot",
196
-
`${time}: Bot detected\n\nHandle: bot-789`,
197
);
198
});
199
···
203
204
expect(createAccountReport).toHaveBeenCalledWith(
205
"did:plc:user1",
206
-
`${time}: Multi-action triggered\n\nHandle: dangerous-account`,
207
);
208
expect(createAccountComment).toHaveBeenCalledWith(
209
"did:plc:user1",
210
-
`${time}: Multi-action triggered\n\nHandle: dangerous-account`,
211
"handle:did:plc:user1:dangerous-account",
212
);
213
expect(createAccountLabel).toHaveBeenCalledWith(
214
"did:plc:user1",
215
"multi-action",
216
-
`${time}: Multi-action triggered\n\nHandle: dangerous-account`,
217
);
218
});
219
});
···
276
277
expect(createAccountReport).toHaveBeenCalledWith(
278
"did:plc:user1",
279
-
`${time}: Spam detected\n\nHandle: ${longHandle}`,
280
);
281
});
282
···
294
295
expect(createAccountReport).toHaveBeenCalledWith(
296
"did:plc:user1",
297
-
"1234567890: Spam detected\n\nHandle: spam-account",
298
);
299
});
300
···
103
104
expect(createAccountReport).toHaveBeenCalledWith(
105
"did:plc:user1",
106
+
`${time}: Spam detected - spam-account`,
107
);
108
});
109
···
121
122
expect(createAccountReport).toHaveBeenCalledWith(
123
"did:plc:user1",
124
+
`${time}: Spam detected - SPAM-ACCOUNT`,
125
);
126
});
127
});
···
139
140
expect(createAccountComment).toHaveBeenCalledWith(
141
"did:plc:user1",
142
+
`${time}: Scam detected - scam-account`,
143
"handle:did:plc:user1:scam-account",
144
);
145
});
···
159
expect(createAccountLabel).toHaveBeenCalledWith(
160
"did:plc:normaluser",
161
"bot",
162
+
`${time}: Bot detected - bot-456`,
163
);
164
});
165
});
···
171
172
expect(createAccountReport).toHaveBeenCalledWith(
173
"did:plc:user1",
174
+
`${time}: Spam detected - spam-user`,
175
);
176
});
177
···
181
182
expect(createAccountComment).toHaveBeenCalledWith(
183
"did:plc:user1",
184
+
`${time}: Scam detected - scam-user`,
185
"handle:did:plc:user1:scam-user",
186
);
187
});
···
193
expect(createAccountLabel).toHaveBeenCalledWith(
194
"did:plc:user1",
195
"bot",
196
+
`${time}: Bot detected - bot-789`,
197
);
198
});
199
···
203
204
expect(createAccountReport).toHaveBeenCalledWith(
205
"did:plc:user1",
206
+
`${time}: Multi-action triggered - dangerous-account`,
207
);
208
expect(createAccountComment).toHaveBeenCalledWith(
209
"did:plc:user1",
210
+
`${time}: Multi-action triggered - dangerous-account`,
211
"handle:did:plc:user1:dangerous-account",
212
);
213
expect(createAccountLabel).toHaveBeenCalledWith(
214
"did:plc:user1",
215
"multi-action",
216
+
`${time}: Multi-action triggered - dangerous-account`,
217
);
218
});
219
});
···
276
277
expect(createAccountReport).toHaveBeenCalledWith(
278
"did:plc:user1",
279
+
`${time}: Spam detected - ${longHandle}`,
280
);
281
});
282
···
294
295
expect(createAccountReport).toHaveBeenCalledWith(
296
"did:plc:user1",
297
+
"1234567890: Spam detected - spam-account",
298
);
299
});
300
+10
-5
src/rules/handles/checkHandles.ts
+10
-5
src/rules/handles/checkHandles.ts
···
45
}
46
}
47
48
-
const formattedComment = `${time.toString()}: ${checkList.comment}\n\nHandle: ${handle}`;
49
-
50
if (checkList.toLabel) {
51
-
void createAccountLabel(did, checkList.label, formattedComment);
52
}
53
54
if (checkList.reportAcct) {
···
56
{ process: "CHECKHANDLE", did, handle, time, label: checkList.label },
57
"Reporting account",
58
);
59
-
void createAccountReport(did, formattedComment);
60
}
61
62
if (checkList.commentAcct) {
63
void createAccountComment(
64
did,
65
-
formattedComment,
66
`handle:${did}:${handle}`,
67
);
68
}
···
45
}
46
}
47
48
if (checkList.toLabel) {
49
+
void createAccountLabel(
50
+
did,
51
+
checkList.label,
52
+
`${time.toString()}: ${checkList.comment} - ${handle}`,
53
+
);
54
}
55
56
if (checkList.reportAcct) {
···
58
{ process: "CHECKHANDLE", did, handle, time, label: checkList.label },
59
"Reporting account",
60
);
61
+
void createAccountReport(
62
+
did,
63
+
`${time.toString()}: ${checkList.comment} - ${handle}`,
64
+
);
65
}
66
67
if (checkList.commentAcct) {
68
void createAccountComment(
69
did,
70
+
`${time.toString()}: ${checkList.comment} - ${handle}`,
71
`handle:${did}:${handle}`,
72
);
73
}
+30
-91
src/rules/posts/checkPosts.ts
+30
-91
src/rules/posts/checkPosts.ts
···
4
createAccountComment,
5
createAccountReport,
6
} from "../../accountModeration.js";
7
-
import { checkAccountThreshold } from "../../accountThreshold.js";
8
import { logger } from "../../logger.js";
9
-
import { moderationActionsFailedCounter } from "../../metrics.js";
10
import { createPostLabel, createPostReport } from "../../moderation.js";
11
-
import type { ModerationResult, Post } from "../../types.js";
12
import { getFinalUrl } from "../../utils/getFinalUrl.js";
13
import { getLanguage } from "../../utils/getLanguage.js";
14
import { countStarterPacks } from "../account/countStarterPacks.js";
···
75
const lang = await getLanguage(post[0].text);
76
77
// iterate through the checks
78
-
for (const checkPost of POST_CHECKS) {
79
if (checkPost.language) {
80
if (!checkPost.language.includes(lang)) {
81
-
continue;
82
}
83
}
84
···
88
{ process: "CHECKPOSTS", did: post[0].did, atURI: post[0].atURI },
89
"Whitelisted DID",
90
);
91
-
continue;
92
}
93
}
94
···
100
{ process: "CHECKPOSTS", did: post[0].did, atURI: post[0].atURI },
101
"Whitelisted phrase found",
102
);
103
-
continue;
104
}
105
}
106
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: [] };
113
114
if (checkPost.toLabel) {
115
-
try {
116
-
await createPostLabel(
117
-
post[0].atURI,
118
-
post[0].cid,
119
-
checkPost.label,
120
-
formattedComment,
121
-
checkPost.duration,
122
-
post[0].did,
123
-
post[0].time,
124
-
);
125
-
} catch (error) {
126
-
results.success = false;
127
-
results.errors.push({ action: "label", error });
128
-
}
129
-
} else if (checkPost.trackOnly) {
130
-
try {
131
-
await checkAccountThreshold(
132
-
post[0].did,
133
-
post[0].atURI,
134
-
checkPost.label,
135
-
post[0].time,
136
-
);
137
-
} catch (error) {
138
-
// Threshold check failures are logged but don't add to results.errors
139
-
// since it's not a direct moderation action
140
-
logger.error(
141
-
{
142
-
process: "CHECKPOSTS",
143
-
did: post[0].did,
144
-
atURI: post[0].atURI,
145
-
error,
146
-
},
147
-
"Account threshold check failed",
148
-
);
149
-
}
150
}
151
152
if (checkPost.reportPost === true) {
···
159
},
160
"Reporting post",
161
);
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
-
}
168
}
169
170
if (checkPost.reportAcct) {
···
177
},
178
"Reporting account",
179
);
180
-
try {
181
-
await createAccountReport(post[0].did, formattedComment);
182
-
} catch (error) {
183
-
results.success = false;
184
-
results.errors.push({ action: "report", error });
185
-
}
186
}
187
188
if (checkPost.commentAcct) {
189
-
try {
190
-
await createAccountComment(
191
-
post[0].did,
192
-
formattedComment,
193
-
post[0].atURI,
194
-
);
195
-
} catch (error) {
196
-
results.success = false;
197
-
results.errors.push({ action: "comment", error });
198
-
}
199
-
}
200
-
201
-
// Log and track any failures
202
-
if (!results.success) {
203
-
for (const error of results.errors) {
204
-
logger.error(
205
-
{
206
-
process: "CHECKPOSTS",
207
-
did: post[0].did,
208
-
atURI: post[0].atURI,
209
-
action: error.action,
210
-
error: error.error,
211
-
},
212
-
"Moderation action failed",
213
-
);
214
-
moderationActionsFailedCounter.inc({
215
-
action: error.action,
216
-
target_type: "post",
217
-
});
218
-
}
219
}
220
}
221
-
}
222
};
···
4
createAccountComment,
5
createAccountReport,
6
} from "../../accountModeration.js";
7
import { logger } from "../../logger.js";
8
import { createPostLabel, createPostReport } from "../../moderation.js";
9
+
import type { Post } from "../../types.js";
10
import { getFinalUrl } from "../../utils/getFinalUrl.js";
11
import { getLanguage } from "../../utils/getLanguage.js";
12
import { countStarterPacks } from "../account/countStarterPacks.js";
···
73
const lang = await getLanguage(post[0].text);
74
75
// iterate through the checks
76
+
POST_CHECKS.forEach((checkPost) => {
77
if (checkPost.language) {
78
if (!checkPost.language.includes(lang)) {
79
+
return;
80
}
81
}
82
···
86
{ process: "CHECKPOSTS", did: post[0].did, atURI: post[0].atURI },
87
"Whitelisted DID",
88
);
89
+
return;
90
}
91
}
92
···
98
{ process: "CHECKPOSTS", did: post[0].did, atURI: post[0].atURI },
99
"Whitelisted phrase found",
100
);
101
+
return;
102
}
103
}
104
105
+
void countStarterPacks(post[0].did, post[0].time);
106
107
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
+
);
117
}
118
119
if (checkPost.reportPost === true) {
···
126
},
127
"Reporting post",
128
);
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
+
);
134
}
135
136
if (checkPost.reportAcct) {
···
143
},
144
"Reporting account",
145
);
146
+
void createAccountReport(
147
+
post[0].did,
148
+
`${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
149
+
);
150
}
151
152
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
+
);
158
}
159
}
160
+
});
161
};
-40
src/rules/posts/tests/checkPosts.test.ts
-40
src/rules/posts/tests/checkPosts.test.ts
···
3
createAccountComment,
4
createAccountReport,
5
} from "../../../accountModeration.js";
6
-
import { checkAccountThreshold } from "../../../accountThreshold.js";
7
import { logger } from "../../../logger.js";
8
import { createPostLabel, createPostReport } from "../../../moderation.js";
9
import type { Post } from "../../../types.js";
···
68
reportAcct: true,
69
commentAcct: true,
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
-
},
81
],
82
}));
83
···
97
vi.mock("../../../accountModeration.js", () => ({
98
createAccountReport: vi.fn(),
99
createAccountComment: vi.fn(),
100
-
}));
101
-
102
-
vi.mock("../../../accountThreshold.js", () => ({
103
-
checkAccountThreshold: vi.fn(),
104
}));
105
106
vi.mock("../../../moderation.js", () => ({
···
437
post[0].did,
438
expect.any(String),
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,
465
);
466
});
467
});
···
3
createAccountComment,
4
createAccountReport,
5
} from "../../../accountModeration.js";
6
import { logger } from "../../../logger.js";
7
import { createPostLabel, createPostReport } from "../../../moderation.js";
8
import type { Post } from "../../../types.js";
···
67
reportAcct: true,
68
commentAcct: true,
69
},
70
],
71
}));
72
···
86
vi.mock("../../../accountModeration.js", () => ({
87
createAccountReport: vi.fn(),
88
createAccountComment: vi.fn(),
89
}));
90
91
vi.mock("../../../moderation.js", () => ({
···
422
post[0].did,
423
expect.any(String),
424
expect.any(String),
425
);
426
});
427
});
+143
-233
src/rules/profiles/checkProfiles.ts
+143
-233
src/rules/profiles/checkProfiles.ts
···
4
createAccountComment,
5
createAccountLabel,
6
createAccountReport,
7
-
negateAccountLabel,
8
} from "../../accountModeration.js";
9
import { logger } from "../../logger.js";
10
-
import { moderationActionsFailedCounter } from "../../metrics.js";
11
-
import type { Checks, ModerationResult } from "../../types.js";
12
import { getLanguage } from "../../utils/getLanguage.js";
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
-
164
export const checkDescription = async (
165
did: string,
166
time: number,
167
displayName: string,
168
description: string,
169
-
): Promise<void> => {
170
-
if (!description) return;
171
172
if (GLOBAL_ALLOW.includes(did)) {
173
logger.warn(
174
{ process: "CHECKDESCRIPTION", did, time, displayName, description },
···
177
return;
178
}
179
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;
185
}
186
}
187
188
-
if (checkRule.ignoredDIDs?.includes(did)) {
189
-
logger.debug(
190
-
{ process: "CHECKDESCRIPTION", did, time, displayName, description },
191
-
"Whitelisted DID",
192
-
);
193
-
continue;
194
}
195
196
-
if (checkRule.description === true) {
197
-
const checker = new ProfileChecker(checkRule, did, time);
198
-
await checker.checkDescription(description);
199
-
}
200
-
}
201
-
};
202
203
-
export const checkDisplayName = async (
204
-
did: string,
205
-
time: number,
206
-
displayName: string,
207
-
description: string,
208
-
): Promise<void> => {
209
-
if (!displayName) return;
210
211
-
if (GLOBAL_ALLOW.includes(did)) {
212
-
logger.warn(
213
-
{ process: "CHECKDISPLAYNAME", did, time, displayName, description },
214
-
"Global AllowListed DID",
215
-
);
216
-
return;
217
-
}
218
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;
224
}
225
}
226
-
227
-
if (checkRule.ignoredDIDs?.includes(did)) {
228
-
logger.debug(
229
-
{ process: "CHECKDISPLAYNAME", did, time, displayName, description },
230
-
"Whitelisted DID",
231
-
);
232
-
continue;
233
-
}
234
-
235
-
if (checkRule.displayName === true) {
236
-
const checker = new ProfileChecker(checkRule, did, time);
237
-
await checker.checkDisplayName(displayName);
238
-
}
239
-
}
240
};
241
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);
250
-
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
}
259
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;
266
}
267
}
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
-
}
289
};
···
4
createAccountComment,
5
createAccountLabel,
6
createAccountReport,
7
} from "../../accountModeration.js";
8
import { logger } from "../../logger.js";
9
import { getLanguage } from "../../utils/getLanguage.js";
10
11
export const checkDescription = async (
12
did: string,
13
time: number,
14
displayName: string,
15
description: string,
16
+
) => {
17
+
const lang = await getLanguage(description);
18
19
+
// Check if DID is whitelisted
20
if (GLOBAL_ALLOW.includes(did)) {
21
logger.warn(
22
{ process: "CHECKDESCRIPTION", did, time, displayName, description },
···
25
return;
26
}
27
28
+
// iterate through the checks
29
+
PROFILE_CHECKS.forEach((checkProfiles) => {
30
+
if (checkProfiles.language) {
31
+
if (!checkProfiles.language.includes(lang)) {
32
+
return;
33
}
34
}
35
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
+
}
45
}
46
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
}
102
}
103
+
});
104
};
105
106
+
export const checkDisplayName = async (
107
did: string,
108
time: number,
109
displayName: string,
110
description: string,
111
) => {
112
+
// Check if DID is whitelisted
113
if (GLOBAL_ALLOW.includes(did)) {
114
logger.warn(
115
+
{ process: "CHECKDISPLAYNAME", did, time, displayName, description },
116
"Global AllowListed DID",
117
);
118
return;
119
}
120
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;
128
}
129
}
130
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
+
}
140
}
141
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
+
}
161
162
+
if (checkProfiles.toLabel) {
163
+
void createAccountLabel(
164
+
did,
165
+
checkProfiles.label,
166
+
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
167
+
);
168
+
}
169
+
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
+
}
187
+
188
+
if (checkProfiles.commentAcct) {
189
+
void createAccountComment(
190
+
did,
191
+
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
192
+
`profile:${did}:${time.toString()}`,
193
+
);
194
+
}
195
+
}
196
+
}
197
}
198
+
});
199
};
+2
-1
src/rules/profiles/tests/checkProfiles.test.ts
+2
-1
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
-
});
···
+17
-35
src/tests/accountThreshold.test.ts
+17
-35
src/tests/accountThreshold.test.ts
···
35
threshold: 3,
36
accountLabel: "test-account-label",
37
accountComment: "Test comment",
38
-
window: 5,
39
-
windowUnit: "days",
40
reportAcct: false,
41
commentAcct: false,
42
toLabel: true,
···
46
threshold: 5,
47
accountLabel: "multi-label-account",
48
accountComment: "Multi label comment",
49
-
window: 7,
50
-
windowUnit: "days",
51
reportAcct: true,
52
commentAcct: true,
53
toLabel: true,
···
57
threshold: 2,
58
accountLabel: "monitored",
59
accountComment: "Monitoring comment",
60
-
window: 3,
61
-
windowUnit: "days",
62
reportAcct: true,
63
commentAcct: false,
64
toLabel: false,
···
68
threshold: 2,
69
accountLabel: "shared-config",
70
accountComment: "Shared config comment",
71
-
window: 4,
72
-
windowUnit: "days",
73
reportAcct: false,
74
commentAcct: false,
75
toLabel: true,
···
122
123
describe("checkAccountThreshold", () => {
124
const testDid = "did:plc:test123";
125
-
const testUri = "at://did:plc:test123/app.bsky.feed.post/abc123";
126
const testTimestamp = 1640000000000000;
127
128
it("should not check threshold for non-matching labels", async () => {
129
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
130
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(0);
131
132
-
await checkAccountThreshold(
133
-
testDid,
134
-
testUri,
135
-
"non-matching-label",
136
-
testTimestamp,
137
-
);
138
139
expect(trackPostLabelForAccount).not.toHaveBeenCalled();
140
expect(getPostLabelCountInWindow).not.toHaveBeenCalled();
···
144
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
145
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
146
147
-
await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp);
148
149
expect(accountThresholdChecksCounter.inc).toHaveBeenCalledWith({
150
post_label: "test-label",
···
154
"test-label",
155
testTimestamp,
156
5,
157
-
"days",
158
);
159
expect(getPostLabelCountInWindow).toHaveBeenCalledWith(
160
testDid,
161
["test-label"],
162
5,
163
-
"days",
164
testTimestamp,
165
);
166
});
···
170
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(3);
171
vi.mocked(createAccountLabel).mockResolvedValue();
172
173
-
await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp);
174
175
expect(accountThresholdMetCounter.inc).toHaveBeenCalledWith({
176
account_label: "test-account-label",
···
178
expect(createAccountLabel).toHaveBeenCalledWith(
179
testDid,
180
"test-account-label",
181
-
`Test comment\n\nThreshold: 3/3 in 5 days\n\nPost: ${testUri}\n\nPost Label: test-label`,
182
);
183
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
184
account_label: "test-account-label",
···
190
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
191
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
192
193
-
await checkAccountThreshold(testDid, testUri, "test-label", testTimestamp);
194
195
expect(accountThresholdMetCounter.inc).not.toHaveBeenCalled();
196
expect(createAccountLabel).not.toHaveBeenCalled();
···
203
vi.mocked(createAccountReport).mockResolvedValue();
204
vi.mocked(createAccountComment).mockResolvedValue();
205
206
-
await checkAccountThreshold(testDid, testUri, "label-2", testTimestamp);
207
208
expect(getPostLabelCountInWindow).toHaveBeenCalledWith(
209
testDid,
210
["label-1", "label-2", "label-3"],
211
7,
212
-
"days",
213
testTimestamp,
214
);
215
expect(createAccountLabel).toHaveBeenCalledWith(
216
testDid,
217
"multi-label-account",
218
-
`Multi label comment\n\nThreshold: 5/5 in 7 days\n\nPost: ${testUri}\n\nPost Label: label-2`,
219
);
220
expect(createAccountReport).toHaveBeenCalledWith(
221
testDid,
222
-
`Multi label comment\n\nThreshold: 5/5 in 7 days\n\nPost: ${testUri}\n\nPost Label: label-2`,
223
);
224
expect(createAccountComment).toHaveBeenCalled();
225
});
···
229
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
230
vi.mocked(createAccountReport).mockResolvedValue();
231
232
-
await checkAccountThreshold(
233
-
testDid,
234
-
testUri,
235
-
"monitor-only-label",
236
-
testTimestamp,
237
-
);
238
239
expect(trackPostLabelForAccount).toHaveBeenCalled();
240
expect(getPostLabelCountInWindow).toHaveBeenCalled();
···
255
vi.mocked(createAccountReport).mockResolvedValue();
256
vi.mocked(createAccountComment).mockResolvedValue();
257
258
-
await checkAccountThreshold(testDid, testUri, "label-1", testTimestamp);
259
260
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledTimes(3);
261
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
···
277
vi.mocked(trackPostLabelForAccount).mockRejectedValue(redisError);
278
279
await expect(
280
-
checkAccountThreshold(testDid, testUri, "test-label", testTimestamp),
281
).rejects.toThrow("Redis connection failed");
282
283
expect(logger.error).toHaveBeenCalled();
···
289
vi.mocked(getPostLabelCountInWindow).mockRejectedValue(redisError);
290
291
await expect(
292
-
checkAccountThreshold(testDid, testUri, "test-label", testTimestamp),
293
).rejects.toThrow("Redis query failed");
294
295
expect(logger.error).toHaveBeenCalled();
···
304
vi.mocked(createAccountReport).mockResolvedValue();
305
vi.mocked(createAccountComment).mockResolvedValue();
306
307
-
await checkAccountThreshold(testDid, testUri, "label-1", testTimestamp);
308
309
expect(trackPostLabelForAccount).toHaveBeenCalledTimes(2);
310
expect(getPostLabelCountInWindow).toHaveBeenCalledTimes(2);
···
35
threshold: 3,
36
accountLabel: "test-account-label",
37
accountComment: "Test comment",
38
+
windowDays: 5,
39
reportAcct: false,
40
commentAcct: false,
41
toLabel: true,
···
45
threshold: 5,
46
accountLabel: "multi-label-account",
47
accountComment: "Multi label comment",
48
+
windowDays: 7,
49
reportAcct: true,
50
commentAcct: true,
51
toLabel: true,
···
55
threshold: 2,
56
accountLabel: "monitored",
57
accountComment: "Monitoring comment",
58
+
windowDays: 3,
59
reportAcct: true,
60
commentAcct: false,
61
toLabel: false,
···
65
threshold: 2,
66
accountLabel: "shared-config",
67
accountComment: "Shared config comment",
68
+
windowDays: 4,
69
reportAcct: false,
70
commentAcct: false,
71
toLabel: true,
···
118
119
describe("checkAccountThreshold", () => {
120
const testDid = "did:plc:test123";
121
const testTimestamp = 1640000000000000;
122
123
it("should not check threshold for non-matching labels", async () => {
124
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
125
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(0);
126
127
+
await checkAccountThreshold(testDid, "non-matching-label", testTimestamp);
128
129
expect(trackPostLabelForAccount).not.toHaveBeenCalled();
130
expect(getPostLabelCountInWindow).not.toHaveBeenCalled();
···
134
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
135
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
136
137
+
await checkAccountThreshold(testDid, "test-label", testTimestamp);
138
139
expect(accountThresholdChecksCounter.inc).toHaveBeenCalledWith({
140
post_label: "test-label",
···
144
"test-label",
145
testTimestamp,
146
5,
147
);
148
expect(getPostLabelCountInWindow).toHaveBeenCalledWith(
149
testDid,
150
["test-label"],
151
5,
152
testTimestamp,
153
);
154
});
···
158
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(3);
159
vi.mocked(createAccountLabel).mockResolvedValue();
160
161
+
await checkAccountThreshold(testDid, "test-label", testTimestamp);
162
163
expect(accountThresholdMetCounter.inc).toHaveBeenCalledWith({
164
account_label: "test-account-label",
···
166
expect(createAccountLabel).toHaveBeenCalledWith(
167
testDid,
168
"test-account-label",
169
+
"Test comment",
170
);
171
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
172
account_label: "test-account-label",
···
178
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
179
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
180
181
+
await checkAccountThreshold(testDid, "test-label", testTimestamp);
182
183
expect(accountThresholdMetCounter.inc).not.toHaveBeenCalled();
184
expect(createAccountLabel).not.toHaveBeenCalled();
···
191
vi.mocked(createAccountReport).mockResolvedValue();
192
vi.mocked(createAccountComment).mockResolvedValue();
193
194
+
await checkAccountThreshold(testDid, "label-2", testTimestamp);
195
196
expect(getPostLabelCountInWindow).toHaveBeenCalledWith(
197
testDid,
198
["label-1", "label-2", "label-3"],
199
7,
200
testTimestamp,
201
);
202
expect(createAccountLabel).toHaveBeenCalledWith(
203
testDid,
204
"multi-label-account",
205
+
"Multi label comment",
206
);
207
expect(createAccountReport).toHaveBeenCalledWith(
208
testDid,
209
+
"Multi label comment",
210
);
211
expect(createAccountComment).toHaveBeenCalled();
212
});
···
216
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
217
vi.mocked(createAccountReport).mockResolvedValue();
218
219
+
await checkAccountThreshold(testDid, "monitor-only-label", testTimestamp);
220
221
expect(trackPostLabelForAccount).toHaveBeenCalled();
222
expect(getPostLabelCountInWindow).toHaveBeenCalled();
···
237
vi.mocked(createAccountReport).mockResolvedValue();
238
vi.mocked(createAccountComment).mockResolvedValue();
239
240
+
await checkAccountThreshold(testDid, "label-1", testTimestamp);
241
242
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledTimes(3);
243
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
···
259
vi.mocked(trackPostLabelForAccount).mockRejectedValue(redisError);
260
261
await expect(
262
+
checkAccountThreshold(testDid, "test-label", testTimestamp),
263
).rejects.toThrow("Redis connection failed");
264
265
expect(logger.error).toHaveBeenCalled();
···
271
vi.mocked(getPostLabelCountInWindow).mockRejectedValue(redisError);
272
273
await expect(
274
+
checkAccountThreshold(testDid, "test-label", testTimestamp),
275
).rejects.toThrow("Redis query failed");
276
277
expect(logger.error).toHaveBeenCalled();
···
286
vi.mocked(createAccountReport).mockResolvedValue();
287
vi.mocked(createAccountComment).mockResolvedValue();
288
289
+
await checkAccountThreshold(testDid, "label-1", testTimestamp);
290
291
expect(trackPostLabelForAccount).toHaveBeenCalledTimes(2);
292
expect(getPostLabelCountInWindow).toHaveBeenCalledTimes(2);
+11
-111
src/tests/redis.test.ts
+11
-111
src/tests/redis.test.ts
···
7
connectRedis,
8
disconnectRedis,
9
getPostLabelCountInWindow,
10
-
getStarterPackCountInWindow,
11
trackPostLabelForAccount,
12
-
trackStarterPackForAccount,
13
tryClaimAccountLabel,
14
tryClaimPostLabel,
15
} from "../redis.js";
···
118
vi.mocked(mockRedisClient.expire).mockResolvedValue(true);
119
120
const timestamp = 1640000000000000; // microseconds
121
-
const window = 5;
122
-
const windowUnit = "days" as const;
123
124
await trackPostLabelForAccount(
125
"did:plc:123",
126
"test-label",
127
timestamp,
128
-
window,
129
-
windowUnit,
130
);
131
132
-
const expectedKey = "account-post-labels:did:plc:123:test-label:5days";
133
-
const windowStartTime = timestamp - window * 24 * 60 * 60 * 1000000;
134
135
expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith(
136
expectedKey,
···
143
});
144
expect(mockRedisClient.expire).toHaveBeenCalledWith(
145
expectedKey,
146
-
window * 24 * 60 * 60 + 60 * 60,
147
);
148
});
149
···
157
"test-label",
158
1640000000000000,
159
5,
160
-
"days",
161
),
162
).rejects.toThrow("Redis down");
163
···
165
});
166
});
167
168
-
describe("trackStarterPackForAccount", () => {
169
-
it("should track starter pack with correct timestamp and TTL", async () => {
170
-
vi.mocked(mockRedisClient.zRemRangeByScore).mockResolvedValue(0);
171
-
vi.mocked(mockRedisClient.zAdd).mockResolvedValue(1);
172
-
vi.mocked(mockRedisClient.expire).mockResolvedValue(true);
173
-
174
-
const timestamp = 1640000000000000;
175
-
const window = 24;
176
-
const windowUnit = "hours" as const;
177
-
178
-
await trackStarterPackForAccount(
179
-
"did:plc:123",
180
-
"at://did:plc:123/app.bsky.graph.starterpack/abc",
181
-
timestamp,
182
-
window,
183
-
windowUnit,
184
-
);
185
-
186
-
const expectedKey = "starterpack:threshold:did:plc:123:24hours";
187
-
const windowStartTime = timestamp - window * 60 * 60 * 1000000;
188
-
189
-
expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith(
190
-
expectedKey,
191
-
"-inf",
192
-
windowStartTime,
193
-
);
194
-
expect(mockRedisClient.zAdd).toHaveBeenCalledWith(expectedKey, {
195
-
score: timestamp,
196
-
value: "at://did:plc:123/app.bsky.graph.starterpack/abc",
197
-
});
198
-
expect(mockRedisClient.expire).toHaveBeenCalledWith(
199
-
expectedKey,
200
-
window * 60 * 60 + 60 * 60,
201
-
);
202
-
});
203
-
204
-
it("should throw error on Redis failure", async () => {
205
-
const redisError = new Error("Redis down");
206
-
vi.mocked(mockRedisClient.zRemRangeByScore).mockRejectedValue(redisError);
207
-
208
-
await expect(
209
-
trackStarterPackForAccount(
210
-
"did:plc:123",
211
-
"at://did:plc:123/app.bsky.graph.starterpack/abc",
212
-
1640000000000000,
213
-
24,
214
-
"hours",
215
-
),
216
-
).rejects.toThrow("Redis down");
217
-
218
-
expect(logger.error).toHaveBeenCalled();
219
-
});
220
-
});
221
-
222
-
describe("getStarterPackCountInWindow", () => {
223
-
it("should count starter packs in window", async () => {
224
-
vi.mocked(mockRedisClient.zCount).mockResolvedValue(3);
225
-
226
-
const currentTime = 1640000000000000;
227
-
const window = 24;
228
-
const windowUnit = "hours" as const;
229
-
const count = await getStarterPackCountInWindow(
230
-
"did:plc:123",
231
-
window,
232
-
windowUnit,
233
-
currentTime,
234
-
);
235
-
236
-
expect(count).toBe(3);
237
-
const windowStartTime = currentTime - window * 60 * 60 * 1000000;
238
-
expect(mockRedisClient.zCount).toHaveBeenCalledWith(
239
-
"starterpack:threshold:did:plc:123:24hours",
240
-
windowStartTime,
241
-
"+inf",
242
-
);
243
-
});
244
-
245
-
it("should throw error on Redis failure", async () => {
246
-
const redisError = new Error("Redis down");
247
-
vi.mocked(mockRedisClient.zCount).mockRejectedValue(redisError);
248
-
249
-
await expect(
250
-
getStarterPackCountInWindow("did:plc:123", 24, "hours", 1640000000000000),
251
-
).rejects.toThrow("Redis down");
252
-
253
-
expect(logger.error).toHaveBeenCalled();
254
-
});
255
-
});
256
-
257
describe("getPostLabelCountInWindow", () => {
258
it("should count posts for single label", async () => {
259
vi.mocked(mockRedisClient.zCount).mockResolvedValue(3);
260
261
const currentTime = 1640000000000000;
262
-
const window = 5;
263
-
const windowUnit = "days" as const;
264
const count = await getPostLabelCountInWindow(
265
"did:plc:123",
266
["test-label"],
267
-
window,
268
-
windowUnit,
269
currentTime,
270
);
271
272
expect(count).toBe(3);
273
-
const windowStartTime = currentTime - window * 24 * 60 * 60 * 1000000;
274
expect(mockRedisClient.zCount).toHaveBeenCalledWith(
275
-
"account-post-labels:did:plc:123:test-label:5days",
276
windowStartTime,
277
"+inf",
278
);
···
285
.mockResolvedValueOnce(1);
286
287
const currentTime = 1640000000000000;
288
-
const window = 5;
289
-
const windowUnit = "days" as const;
290
const count = await getPostLabelCountInWindow(
291
"did:plc:123",
292
["label-1", "label-2", "label-3"],
293
-
window,
294
-
windowUnit,
295
currentTime,
296
);
297
···
306
"did:plc:123",
307
["test-label"],
308
5,
309
-
"days",
310
1640000000000000,
311
);
312
···
322
"did:plc:123",
323
["test-label"],
324
5,
325
-
"days",
326
1640000000000000,
327
),
328
).rejects.toThrow("Redis down");
···
7
connectRedis,
8
disconnectRedis,
9
getPostLabelCountInWindow,
10
trackPostLabelForAccount,
11
tryClaimAccountLabel,
12
tryClaimPostLabel,
13
} from "../redis.js";
···
116
vi.mocked(mockRedisClient.expire).mockResolvedValue(true);
117
118
const timestamp = 1640000000000000; // microseconds
119
+
const windowDays = 5;
120
121
await trackPostLabelForAccount(
122
"did:plc:123",
123
"test-label",
124
timestamp,
125
+
windowDays,
126
);
127
128
+
const expectedKey = "account-post-labels:did:plc:123:test-label:5";
129
+
const windowStartTime = timestamp - windowDays * 24 * 60 * 60 * 1000000;
130
131
expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith(
132
expectedKey,
···
139
});
140
expect(mockRedisClient.expire).toHaveBeenCalledWith(
141
expectedKey,
142
+
(windowDays + 1) * 24 * 60 * 60,
143
);
144
});
145
···
153
"test-label",
154
1640000000000000,
155
5,
156
),
157
).rejects.toThrow("Redis down");
158
···
160
});
161
});
162
163
describe("getPostLabelCountInWindow", () => {
164
it("should count posts for single label", async () => {
165
vi.mocked(mockRedisClient.zCount).mockResolvedValue(3);
166
167
const currentTime = 1640000000000000;
168
+
const windowDays = 5;
169
const count = await getPostLabelCountInWindow(
170
"did:plc:123",
171
["test-label"],
172
+
windowDays,
173
currentTime,
174
);
175
176
expect(count).toBe(3);
177
+
const windowStartTime = currentTime - windowDays * 24 * 60 * 60 * 1000000;
178
expect(mockRedisClient.zCount).toHaveBeenCalledWith(
179
+
"account-post-labels:did:plc:123:test-label:5",
180
windowStartTime,
181
"+inf",
182
);
···
189
.mockResolvedValueOnce(1);
190
191
const currentTime = 1640000000000000;
192
+
const windowDays = 5;
193
const count = await getPostLabelCountInWindow(
194
"did:plc:123",
195
["label-1", "label-2", "label-3"],
196
+
windowDays,
197
currentTime,
198
);
199
···
208
"did:plc:123",
209
["test-label"],
210
5,
211
1640000000000000,
212
);
213
···
223
"did:plc:123",
224
["test-label"],
225
5,
226
1640000000000000,
227
),
228
).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
-
});
···
+1
-28
src/types.ts
+1
-28
src/types.ts
···
3
export interface Checks {
4
language?: string[];
5
label: string;
6
-
unlabel?: boolean;
7
comment: string;
8
description?: boolean;
9
displayName?: boolean;
···
11
commentAcct: boolean;
12
reportPost?: boolean;
13
toLabel: boolean;
14
-
trackOnly?: boolean;
15
duration?: number;
16
check: RegExp;
17
whitelist?: RegExp;
···
64
expires?: string; // Optional expiration date (ISO 8601) - check will be skipped after this date
65
}
66
67
-
export type WindowUnit = "minutes" | "hours" | "days";
68
-
69
export interface AccountThresholdConfig {
70
labels: string | string[]; // Single label or array for OR matching
71
threshold: number; // Number of labeled posts required to trigger account action
72
accountLabel: string; // Label to apply to the account
73
accountComment: string; // Comment for the account action
74
-
window: number; // Rolling window duration
75
-
windowUnit: WindowUnit; // Unit for the rolling window
76
reportAcct: boolean; // Whether to report the account
77
commentAcct: boolean; // Whether to comment on the account
78
toLabel?: boolean; // Whether to apply label (defaults to true)
79
}
80
-
81
-
export interface StarterPackThresholdConfig {
82
-
threshold: number;
83
-
window: number;
84
-
windowUnit: WindowUnit;
85
-
accountLabel: string;
86
-
accountComment: string;
87
-
toLabel?: boolean;
88
-
reportAcct?: boolean;
89
-
commentAcct?: boolean;
90
-
allowlist?: string[];
91
-
}
92
-
93
-
export interface ModerationError {
94
-
action: "label" | "report" | "comment" | "unlabel";
95
-
error: unknown;
96
-
}
97
-
98
-
export interface ModerationResult {
99
-
success: boolean;
100
-
errors: ModerationError[];
101
-
}
···
3
export interface Checks {
4
language?: string[];
5
label: string;
6
comment: string;
7
description?: boolean;
8
displayName?: boolean;
···
10
commentAcct: boolean;
11
reportPost?: boolean;
12
toLabel: boolean;
13
duration?: number;
14
check: RegExp;
15
whitelist?: RegExp;
···
62
expires?: string; // Optional expiration date (ISO 8601) - check will be skipped after this date
63
}
64
65
export interface AccountThresholdConfig {
66
labels: string | string[]; // Single label or array for OR matching
67
threshold: number; // Number of labeled posts required to trigger account action
68
accountLabel: string; // Label to apply to the account
69
accountComment: string; // Comment for the account action
70
+
windowDays: number; // Rolling window in days
71
reportAcct: boolean; // Whether to report the account
72
commentAcct: boolean; // Whether to comment on the account
73
toLabel?: boolean; // Whether to apply label (defaults to true)
74
}