tangled
alpha
login
or
join now
skywatch.blue
/
skywatch-automod
7
fork
atom
A tool for parsing traffic on the jetstream and applying a moderation workstream based on regexp based rules
7
fork
atom
overview
issues
pulls
pipelines
Resolved merge conflict
Skywatch
4 months ago
d2ec70a2
78e42d35
+184
-178
5 changed files
expand all
collapse all
unified
split
.claude
settings.local.json
src
rules
handles
constants.example.ts
posts
constants.example.ts
profiles
constants.example.ts
tests
session.test.ts
+1
-3
.claude/settings.local.json
···
18
18
"ask": []
19
19
},
20
20
"enableAllProjectMcpServers": true,
21
21
-
"enabledMcpjsonServers": [
22
22
-
"git-mcp-server"
23
23
-
]
21
21
+
"enabledMcpjsonServers": ["git-mcp-server"]
24
22
}
-89
src/rules/handles/constants.example.ts
···
1
1
-
import type { Checks } from "../../types.js";
2
2
-
3
3
-
/**
4
4
-
* Example handle check configurations
5
5
-
*
6
6
-
* This file demonstrates how to configure handle-based moderation rules.
7
7
-
* Copy this file to constants.ts and customize for your labeler's needs.
8
8
-
*
9
9
-
* Each check can match against handles, display names, and/or descriptions
10
10
-
* based on the flags you set (description: true, displayName: true).
11
11
-
*/
12
12
-
13
13
-
export const HANDLE_CHECKS: Checks[] = [
14
14
-
// Example 1: Simple pattern matching with whitelist
15
15
-
{
16
16
-
label: "spam-indicator",
17
17
-
comment: "Handle matches common spam patterns",
18
18
-
reportAcct: false,
19
19
-
commentAcct: false,
20
20
-
toLabel: true,
21
21
-
check: new RegExp(
22
22
-
"follow.*?back|gain.*?followers|crypto.*?giveaway|free.*?money",
23
23
-
"i",
24
24
-
),
25
25
-
whitelist: new RegExp("legitimate.*?business", "i"),
26
26
-
},
27
27
-
28
28
-
// Example 2: Check specific domain patterns
29
29
-
{
30
30
-
label: "suspicious-domain",
31
31
-
comment: "Handle uses suspicious domain pattern",
32
32
-
reportAcct: false,
33
33
-
commentAcct: false,
34
34
-
toLabel: true,
35
35
-
check: new RegExp("(?:suspicious-site\\.example)", "i"),
36
36
-
},
37
37
-
38
38
-
// Example 3: Check with display name and description matching
39
39
-
{
40
40
-
label: "potential-impersonator",
41
41
-
comment: "Account may be impersonating verified entities",
42
42
-
description: true,
43
43
-
displayName: true,
44
44
-
reportAcct: false,
45
45
-
commentAcct: false,
46
46
-
toLabel: true,
47
47
-
check: new RegExp(
48
48
-
"official.*?support|customer.*?service.*?rep|verified.*?account",
49
49
-
"i",
50
50
-
),
51
51
-
// Exclude accounts that are actually legitimate
52
52
-
ignoredDIDs: [
53
53
-
"did:plc:example123", // Real customer support account
54
54
-
"did:plc:example456", // Verified business account
55
55
-
],
56
56
-
},
57
57
-
58
58
-
// Example 4: Pattern with specific character variations
59
59
-
{
60
60
-
label: "suspicious-pattern",
61
61
-
comment: "Handle contains suspicious character patterns",
62
62
-
reportAcct: false,
63
63
-
commentAcct: false,
64
64
-
toLabel: true,
65
65
-
check: new RegExp("[a-z]{2,}[0-9]{6,}|random.*?numbers.*?[0-9]{4,}", "i"),
66
66
-
whitelist: new RegExp("year[0-9]{4}", "i"),
67
67
-
ignoredDIDs: [
68
68
-
"did:plc:example789", // Legitimate account with number pattern
69
69
-
],
70
70
-
},
71
71
-
72
72
-
// Example 5: Brand protection
73
73
-
{
74
74
-
label: "brand-impersonation",
75
75
-
comment: "Potential brand impersonation detected",
76
76
-
reportAcct: false,
77
77
-
commentAcct: false,
78
78
-
toLabel: true,
79
79
-
check: new RegExp("example-?brand|cool-?company|awesome-?corp", "i"),
80
80
-
whitelist: new RegExp(
81
81
-
"anti-example-brand|not-cool-company|parody.*awesome-corp",
82
82
-
"i",
83
83
-
),
84
84
-
ignoredDIDs: [
85
85
-
"did:plc:exampleabc", // Official brand account
86
86
-
"did:plc:exampledef", // Authorized partner
87
87
-
],
88
88
-
},
89
89
-
];
-31
src/rules/posts/constants.example.ts
···
1
1
-
import type { Checks } from "../../types.js";
2
2
-
3
3
-
export const LINK_SHORTENER = /bit\.ly|tinyurl\.com|ow\.ly/i;
4
4
-
5
5
-
export const POST_CHECKS: Checks[] = [
6
6
-
// Example 1: Spam detection
7
7
-
{
8
8
-
label: "spam",
9
9
-
comment: "Post contains spam indicators",
10
10
-
reportPost: true,
11
11
-
reportAcct: false,
12
12
-
commentAcct: false,
13
13
-
toLabel: true,
14
14
-
check: new RegExp(
15
15
-
"click.*?here|limited.*?time.*?offer|act.*?now|100%.*?free",
16
16
-
"i",
17
17
-
),
18
18
-
whitelist: new RegExp("legitimate.*?offer", "i"),
19
19
-
},
20
20
-
21
21
-
// Example 2: Promotional content
22
22
-
{
23
23
-
label: "promotional",
24
24
-
comment: "Promotional content detected",
25
25
-
reportPost: false,
26
26
-
reportAcct: false,
27
27
-
commentAcct: false,
28
28
-
toLabel: true,
29
29
-
check: new RegExp("buy.*?now|discount.*?code|promo.*?link", "i"),
30
30
-
},
31
31
-
];
-55
src/rules/profiles/constants.example.ts
···
1
1
-
import type { Checks } from "../../types.js";
2
2
-
3
3
-
/**
4
4
-
* Example profile check configurations
5
5
-
*
6
6
-
* This file demonstrates how to configure profile moderation rules.
7
7
-
* Copy this file to constants.ts and customize for your labeler's needs.
8
8
-
*
9
9
-
* Profile checks can match against display names and/or descriptions.
10
10
-
*/
11
11
-
12
12
-
export const PROFILE_CHECKS: Checks[] = [
13
13
-
// Example 1: Suspicious bio patterns
14
14
-
{
15
15
-
label: "suspicious-bio",
16
16
-
comment: "Profile contains suspicious patterns",
17
17
-
description: true,
18
18
-
displayName: false,
19
19
-
reportAcct: false,
20
20
-
commentAcct: false,
21
21
-
toLabel: true,
22
22
-
check: new RegExp(
23
23
-
"dm.*?for.*?promo|follow.*?for.*?follow|gain.*?followers",
24
24
-
"i",
25
25
-
),
26
26
-
},
27
27
-
28
28
-
// Example 2: Display name checks
29
29
-
{
30
30
-
label: "impersonation-risk",
31
31
-
comment: "Display name may indicate impersonation",
32
32
-
description: false,
33
33
-
displayName: true,
34
34
-
reportAcct: false,
35
35
-
commentAcct: false,
36
36
-
toLabel: true,
37
37
-
check: new RegExp("official|verified|admin|support", "i"),
38
38
-
whitelist: new RegExp("unofficial|parody|fan", "i"),
39
39
-
ignoredDIDs: [
40
40
-
"did:plc:example123", // Actual official account
41
41
-
],
42
42
-
},
43
43
-
44
44
-
// Example 3: Both display name and description
45
45
-
{
46
46
-
label: "crypto-spam",
47
47
-
comment: "Profile suggests crypto spam activity",
48
48
-
description: true,
49
49
-
displayName: true,
50
50
-
reportAcct: false,
51
51
-
commentAcct: false,
52
52
-
toLabel: true,
53
53
-
check: new RegExp("crypto.*?giveaway|nft.*?drop|airdrop", "i"),
54
54
-
},
55
55
-
];
+183
src/tests/session.test.ts
···
1
1
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
+
import {
3
3
+
existsSync,
4
4
+
mkdirSync,
5
5
+
rmSync,
6
6
+
writeFileSync,
7
7
+
readFileSync,
8
8
+
unlinkSync,
9
9
+
chmodSync,
10
10
+
} from "node:fs";
11
11
+
import { join } from "node:path";
12
12
+
import type { SessionData } from "../session.js";
13
13
+
14
14
+
const TEST_DIR = join(process.cwd(), ".test-session");
15
15
+
const TEST_SESSION_PATH = join(TEST_DIR, ".session");
16
16
+
17
17
+
// Helper functions that mimic session.ts but use TEST_SESSION_PATH
18
18
+
function testLoadSession(): SessionData | null {
19
19
+
try {
20
20
+
if (!existsSync(TEST_SESSION_PATH)) {
21
21
+
return null;
22
22
+
}
23
23
+
24
24
+
const data = readFileSync(TEST_SESSION_PATH, "utf-8");
25
25
+
const session = JSON.parse(data) as SessionData;
26
26
+
27
27
+
if (!session.accessJwt || !session.refreshJwt || !session.did) {
28
28
+
return null;
29
29
+
}
30
30
+
31
31
+
return session;
32
32
+
} catch (error) {
33
33
+
return null;
34
34
+
}
35
35
+
}
36
36
+
37
37
+
function testSaveSession(session: SessionData): void {
38
38
+
try {
39
39
+
const data = JSON.stringify(session, null, 2);
40
40
+
writeFileSync(TEST_SESSION_PATH, data, "utf-8");
41
41
+
chmodSync(TEST_SESSION_PATH, 0o600);
42
42
+
} catch (error) {
43
43
+
// Ignore errors for test
44
44
+
}
45
45
+
}
46
46
+
47
47
+
function testClearSession(): void {
48
48
+
try {
49
49
+
if (existsSync(TEST_SESSION_PATH)) {
50
50
+
unlinkSync(TEST_SESSION_PATH);
51
51
+
}
52
52
+
} catch (error) {
53
53
+
// Ignore errors for test
54
54
+
}
55
55
+
}
56
56
+
57
57
+
describe("session", () => {
58
58
+
beforeEach(() => {
59
59
+
// Create test directory
60
60
+
if (!existsSync(TEST_DIR)) {
61
61
+
mkdirSync(TEST_DIR, { recursive: true });
62
62
+
}
63
63
+
});
64
64
+
65
65
+
afterEach(() => {
66
66
+
// Clean up test directory
67
67
+
if (existsSync(TEST_DIR)) {
68
68
+
rmSync(TEST_DIR, { recursive: true, force: true });
69
69
+
}
70
70
+
});
71
71
+
72
72
+
describe("saveSession", () => {
73
73
+
it("should save session to file with proper permissions", () => {
74
74
+
const session: SessionData = {
75
75
+
accessJwt: "access-token",
76
76
+
refreshJwt: "refresh-token",
77
77
+
did: "did:plc:test123",
78
78
+
handle: "test.bsky.social",
79
79
+
active: true,
80
80
+
};
81
81
+
82
82
+
testSaveSession(session);
83
83
+
84
84
+
expect(existsSync(TEST_SESSION_PATH)).toBe(true);
85
85
+
});
86
86
+
87
87
+
it("should save all session fields correctly", () => {
88
88
+
const session: SessionData = {
89
89
+
accessJwt: "access-token",
90
90
+
refreshJwt: "refresh-token",
91
91
+
did: "did:plc:test123",
92
92
+
handle: "test.bsky.social",
93
93
+
email: "test@example.com",
94
94
+
emailConfirmed: true,
95
95
+
emailAuthFactor: false,
96
96
+
active: true,
97
97
+
status: "active",
98
98
+
};
99
99
+
100
100
+
testSaveSession(session);
101
101
+
102
102
+
const loaded = testLoadSession();
103
103
+
expect(loaded).toEqual(session);
104
104
+
});
105
105
+
});
106
106
+
107
107
+
describe("loadSession", () => {
108
108
+
it("should return null if session file does not exist", () => {
109
109
+
const session = testLoadSession();
110
110
+
expect(session).toBeNull();
111
111
+
});
112
112
+
113
113
+
it("should load valid session from file", () => {
114
114
+
const session: SessionData = {
115
115
+
accessJwt: "access-token",
116
116
+
refreshJwt: "refresh-token",
117
117
+
did: "did:plc:test123",
118
118
+
handle: "test.bsky.social",
119
119
+
active: true,
120
120
+
};
121
121
+
122
122
+
testSaveSession(session);
123
123
+
const loaded = testLoadSession();
124
124
+
125
125
+
expect(loaded).toEqual(session);
126
126
+
});
127
127
+
128
128
+
it("should return null for corrupted session file", () => {
129
129
+
writeFileSync(TEST_SESSION_PATH, "{ invalid json", "utf-8");
130
130
+
131
131
+
const session = testLoadSession();
132
132
+
expect(session).toBeNull();
133
133
+
});
134
134
+
135
135
+
it("should return null for session missing required fields", () => {
136
136
+
writeFileSync(
137
137
+
TEST_SESSION_PATH,
138
138
+
JSON.stringify({ accessJwt: "token" }),
139
139
+
"utf-8"
140
140
+
);
141
141
+
142
142
+
const session = testLoadSession();
143
143
+
expect(session).toBeNull();
144
144
+
});
145
145
+
146
146
+
it("should return null for session missing did", () => {
147
147
+
writeFileSync(
148
148
+
TEST_SESSION_PATH,
149
149
+
JSON.stringify({
150
150
+
accessJwt: "access",
151
151
+
refreshJwt: "refresh",
152
152
+
handle: "test.bsky.social",
153
153
+
}),
154
154
+
"utf-8"
155
155
+
);
156
156
+
157
157
+
const session = testLoadSession();
158
158
+
expect(session).toBeNull();
159
159
+
});
160
160
+
});
161
161
+
162
162
+
describe("clearSession", () => {
163
163
+
it("should remove session file if it exists", () => {
164
164
+
const session: SessionData = {
165
165
+
accessJwt: "access-token",
166
166
+
refreshJwt: "refresh-token",
167
167
+
did: "did:plc:test123",
168
168
+
handle: "test.bsky.social",
169
169
+
active: true,
170
170
+
};
171
171
+
172
172
+
testSaveSession(session);
173
173
+
expect(existsSync(TEST_SESSION_PATH)).toBe(true);
174
174
+
175
175
+
testClearSession();
176
176
+
expect(existsSync(TEST_SESSION_PATH)).toBe(false);
177
177
+
});
178
178
+
179
179
+
it("should not throw if session file does not exist", () => {
180
180
+
expect(() => testClearSession()).not.toThrow();
181
181
+
});
182
182
+
});
183
183
+
});