+2
-1
.claude/settings.local.json
+2
-1
.claude/settings.local.json
+5
-5
.env.example
+5
-5
.env.example
···
3
3
OZONE_PDS=
4
4
BSKY_HANDLE=
5
5
BSKY_PASSWORD=
6
-
HOST=0.0.0.0
7
-
METRICS_PORT=4101
8
-
FIREHOSE_URL=wss://jetstream1.us-east.fire.hose.cam/subscribe
6
+
HOST=127.0.0.1
7
+
PORT=4000
8
+
METRICS_PORT=4001
9
+
FIREHOSE_URL=
10
+
PLC_URL=plc.wtf
9
11
CURSOR_UPDATE_INTERVAL=10000
10
12
LABEL_LIMIT=2900 * 1000
11
13
LABEL_LIMIT_WAIT=300 * 1000
12
-
LOG_LEVEL=info
13
-
PLC_URL=plc.wtf
+14
-41
README.md
+14
-41
README.md
···
1
-
# skywatch-automod
1
+
# skywatch-tools
2
2
3
-
Automated moderation tooling for the Bluesky independent labeler skywatch.blue. Monitors the Bluesky firehose and applies labels based on configured moderation rules.
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
4
5
-
## Setup
5
+
## Installation and Setup
6
6
7
-
Configure environment:
7
+
To install dependencies:
8
8
9
9
```bash
10
-
cp .env.example .env
11
-
# Edit .env with your credentials and configuration
10
+
bun i
12
11
```
13
12
14
-
Required environment variables:
15
-
- `BSKY_HANDLE` - Bluesky account handle
16
-
- `BSKY_PASSWORD` - Account password
17
-
- `MOD_DID` - Moderator DID
18
-
- `OZONE_PDS` - Ozone PDS URL
19
-
- `FIREHOSE_URL` - Jetstream firehose URL
20
-
21
-
Create cursor file (optional but recommended):
13
+
Modify .env.example with your own values and rename it to .env
22
14
23
15
```bash
24
-
touch cursor.txt
16
+
bun run start
25
17
```
26
18
27
-
## Running
28
-
29
-
Production:
19
+
To run in docker:
30
20
31
21
```bash
32
-
docker compose up -d
33
-
```
34
-
35
-
Development mode with auto-reload:
36
-
37
-
```bash
38
-
docker compose -f compose.yaml -f compose.dev.yaml up
22
+
docker build -pull -t skywatch-tools .
23
+
docker run -d -p 4101:4101 skywatch-autolabeler
39
24
```
40
25
41
-
The service runs on port 4101 (metrics endpoint). Redis and Prometheus are included in the compose stack.
26
+
## Brief overview
42
27
43
-
## Authentication
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.
44
29
45
-
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).
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.
46
31
47
-
## Testing
48
-
49
-
```bash
50
-
bun test # Watch mode
51
-
bun test:run # Single run
52
-
bun test:coverage # With coverage
53
-
```
54
-
55
-
## How It Works
56
-
57
-
Monitors the Bluesky firehose via Jetstream and analyzes posts, profiles, and handles against configured moderation rules. When criteria are met, applies appropriate labels or creates moderation reports.
58
-
59
-
For developing custom checks, see [developing_checks.md](./rules/developing_checks.md).
32
+
For information on how to set-up your own checks, please see the [developing_checks.md](./src/developing_checks.md) file.
+2
-2
package.json
+2
-2
package.json
-274
rules/developing_checks.md
-274
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
-
windowDays: 7,
109
-
reportAcct: true,
110
-
commentAcct: false,
111
-
toLabel: true,
112
-
},
113
-
];
114
-
```
115
-
116
-
## Check Configuration Fields
117
-
118
-
### Basic Fields (Required)
119
-
120
-
- `label` - Label to apply (string)
121
-
- `comment` - Comment for the moderation action (string)
122
-
- `reportAcct` - Create account report (boolean)
123
-
- `commentAcct` - Add comment to account (boolean)
124
-
- `toLabel` - Apply the label (boolean)
125
-
- `check` - Regular expression pattern (RegExp)
126
-
127
-
### Optional Fields
128
-
129
-
- `language` - Language codes to restrict check to (string[])
130
-
- `description` - Check profile descriptions (boolean)
131
-
- `displayName` - Check profile display names (boolean)
132
-
- `reportPost` - Create post report instead of just labeling (boolean)
133
-
- `duration` - Label duration in hours (number)
134
-
- `whitelist` - RegExp to exclude from matching (RegExp)
135
-
- `ignoredDIDs` - DIDs to skip checking (string[])
136
-
- `starterPacks` - Filter by starter pack membership (string[])
137
-
- `knownVectors` - Known attack vectors for tracking (string[])
138
-
139
-
## Examples
140
-
141
-
### Language-Specific Check
142
-
143
-
```typescript
144
-
{
145
-
language: ["spa"],
146
-
label: "spam-es",
147
-
comment: "Spanish spam detected",
148
-
reportAcct: false,
149
-
commentAcct: false,
150
-
toLabel: true,
151
-
check: new RegExp("comprar seguidores", "i"),
152
-
}
153
-
```
154
-
155
-
### Temporary Label
156
-
157
-
```typescript
158
-
{
159
-
label: "review-needed",
160
-
comment: "Content flagged for review",
161
-
reportAcct: true,
162
-
commentAcct: false,
163
-
toLabel: false,
164
-
duration: 24, // Label expires after 24 hours
165
-
check: new RegExp("suspicious.*pattern", "i"),
166
-
}
167
-
```
168
-
169
-
### Whitelist Exception
170
-
171
-
```typescript
172
-
{
173
-
label: "blocked-term",
174
-
comment: "Blocked term used",
175
-
reportAcct: false,
176
-
commentAcct: false,
177
-
toLabel: true,
178
-
check: new RegExp("\\bterm\\b", "i"),
179
-
whitelist: new RegExp("legitimate.*context", "i"),
180
-
}
181
-
```
182
-
183
-
### Ignored DIDs
184
-
185
-
```typescript
186
-
{
187
-
label: "blocked-term",
188
-
comment: "Blocked term used",
189
-
reportAcct: false,
190
-
commentAcct: false,
191
-
toLabel: true,
192
-
check: new RegExp("\\bterm\\b", "i"),
193
-
ignoredDIDs: [
194
-
"did:plc:trusted123",
195
-
"did:plc:verified456",
196
-
],
197
-
}
198
-
```
199
-
200
-
## Global Configuration
201
-
202
-
### Allowlist
203
-
204
-
File: `rules/constants.ts`
205
-
206
-
DIDs in the global allowlist bypass all checks.
207
-
208
-
```typescript
209
-
export const GLOBAL_ALLOW: string[] = [
210
-
"did:plc:trusted123",
211
-
"did:plc:verified456",
212
-
];
213
-
```
214
-
215
-
### Link Shorteners
216
-
217
-
Pattern to match URL shorteners for special handling.
218
-
219
-
```typescript
220
-
export const LINK_SHORTENER = new RegExp(
221
-
"bit\\.ly|tinyurl\\.com|goo\\.gl",
222
-
"i"
223
-
);
224
-
```
225
-
226
-
## Best Practices
227
-
228
-
### Regular Expressions
229
-
230
-
- Use word boundaries (`\\b`) to avoid partial matches
231
-
- Test patterns thoroughly to minimize false positives
232
-
- Use case-insensitive matching (`i` flag) when appropriate
233
-
- Escape special regex characters
234
-
235
-
### Action Selection
236
-
237
-
- `toLabel: true` - Apply label immediately (use for clear violations)
238
-
- `reportAcct: true` - Create report for manual review (use for ambiguous cases)
239
-
- `commentAcct: true` - Create comment on account (probably can be depreciated)
240
-
241
-
### Performance
242
-
243
-
- Keep regex patterns simple and efficient
244
-
- Use language filters to reduce unnecessary checks
245
-
- Leverage whitelists instead of complex negative lookaheads
246
-
247
-
### Testing
248
-
249
-
After modifying rules:
250
-
251
-
```bash
252
-
bun test:run
253
-
```
254
-
255
-
Test specific rule modules:
256
-
257
-
```bash
258
-
bun test src/rules/posts/tests/
259
-
```
260
-
261
-
## Deployment
262
-
263
-
Rules are mounted as a volume in docker compose:
264
-
265
-
```yaml
266
-
volumes:
267
-
- ./rules:/app/rules
268
-
```
269
-
270
-
Changes require automod rebuild:
271
-
272
-
```bash
273
-
docker compose up -d --build automod
274
-
```
+50
-4
src/agent.ts
+50
-4
src/agent.ts
···
99
99
}
100
100
}
101
101
102
+
const MAX_LOGIN_RETRIES = 3;
103
+
const RETRY_DELAY_MS = 2000;
104
+
105
+
let loginPromise: Promise<void> | null = null;
106
+
107
+
async function sleep(ms: number): Promise<void> {
108
+
return new Promise((resolve) => setTimeout(resolve, ms));
109
+
}
110
+
102
111
async function authenticate(): Promise<boolean> {
103
112
const savedSession = loadSession();
104
113
···
121
130
return performLogin();
122
131
}
123
132
124
-
export const login = authenticate;
125
-
export const isLoggedIn = authenticate()
126
-
.then((success) => success)
127
-
.catch(() => false);
133
+
async function authenticateWithRetry(): Promise<void> {
134
+
// Reuse existing login attempt if one is in progress
135
+
if (loginPromise) {
136
+
return loginPromise;
137
+
}
138
+
139
+
loginPromise = (async () => {
140
+
for (let attempt = 1; attempt <= MAX_LOGIN_RETRIES; attempt++) {
141
+
logger.info(
142
+
{ attempt, maxRetries: MAX_LOGIN_RETRIES },
143
+
"Attempting login",
144
+
);
145
+
146
+
const success = await authenticate();
147
+
148
+
if (success) {
149
+
logger.info("Authentication successful");
150
+
return;
151
+
}
152
+
153
+
if (attempt < MAX_LOGIN_RETRIES) {
154
+
logger.warn(
155
+
{ attempt, maxRetries: MAX_LOGIN_RETRIES, retryInMs: RETRY_DELAY_MS },
156
+
"Login failed, retrying",
157
+
);
158
+
await sleep(RETRY_DELAY_MS);
159
+
}
160
+
}
161
+
162
+
logger.error(
163
+
{ maxRetries: MAX_LOGIN_RETRIES },
164
+
"All login attempts failed, aborting",
165
+
);
166
+
process.exit(1);
167
+
})();
168
+
169
+
return loginPromise;
170
+
}
171
+
172
+
export const login = authenticateWithRetry;
173
+
export const isLoggedIn = authenticateWithRetry().then(() => true);
+5
src/main.ts
+5
src/main.ts
···
5
5
IdentityEvent,
6
6
} from "@skyware/jetstream";
7
7
import { Jetstream } from "@skyware/jetstream";
8
+
import { login } from "./agent.js";
8
9
import {
9
10
CURSOR_UPDATE_INTERVAL,
10
11
FIREHOSE_URL,
···
343
344
344
345
logger.info({ process: "MAIN" }, "Connecting to Redis");
345
346
await connectRedis();
347
+
348
+
logger.info({ process: "MAIN" }, "Authenticating with Bluesky");
349
+
await login();
350
+
logger.info({ process: "MAIN" }, "Authentication complete, starting Jetstream");
346
351
347
352
jetstream.start();
348
353
+12
-1
src/tests/agent.test.ts
+12
-1
src/tests/agent.test.ts
···
13
13
OZONE_PDS: "pds.test.com",
14
14
}));
15
15
16
+
// Mock session
17
+
const mockSession = {
18
+
did: "did:plc:test123",
19
+
handle: "test.bsky.social",
20
+
accessJwt: "test-access-jwt",
21
+
refreshJwt: "test-refresh-jwt",
22
+
};
23
+
16
24
// Mock the AtpAgent
17
-
const mockLogin = vi.fn(() => Promise.resolve());
25
+
const mockLogin = vi.fn(() =>
26
+
Promise.resolve({ success: true, data: mockSession }),
27
+
);
18
28
const mockConstructor = vi.fn();
19
29
vi.doMock("@atproto/api", () => ({
20
30
AtpAgent: class {
21
31
login = mockLogin;
22
32
service: URL;
33
+
session = mockSession;
23
34
constructor(options: { service: string }) {
24
35
mockConstructor(options);
25
36
this.service = new URL(options.service);