+19
bun.lock
+19
bun.lock
···
24
24
"pino": "^9.9.0",
25
25
"pino-pretty": "^13.1.1",
26
26
"prom-client": "^15.1.3",
27
+
"redis": "^4.7.0",
27
28
"undici": "^7.15.0",
28
29
},
29
30
"devDependencies": {
···
364
365
365
366
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
366
367
368
+
"@redis/bloom": ["@redis/bloom@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg=="],
369
+
370
+
"@redis/client": ["@redis/client@1.6.1", "", { "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", "yallist": "4.0.0" } }, "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw=="],
371
+
372
+
"@redis/graph": ["@redis/graph@1.1.1", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw=="],
373
+
374
+
"@redis/json": ["@redis/json@1.0.7", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ=="],
375
+
376
+
"@redis/search": ["@redis/search@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw=="],
377
+
378
+
"@redis/time-series": ["@redis/time-series@1.1.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g=="],
379
+
367
380
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="],
368
381
369
382
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="],
···
797
810
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
798
811
799
812
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
813
+
814
+
"generic-pool": ["generic-pool@3.9.0", "", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="],
800
815
801
816
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
802
817
···
1156
1171
1157
1172
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
1158
1173
1174
+
"redis": ["redis@4.7.1", "", { "dependencies": { "@redis/bloom": "1.2.0", "@redis/client": "1.6.1", "@redis/graph": "1.1.1", "@redis/json": "1.0.7", "@redis/search": "1.2.0", "@redis/time-series": "1.1.0" } }, "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ=="],
1175
+
1159
1176
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
1160
1177
1161
1178
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
···
1375
1392
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
1376
1393
1377
1394
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
1395
+
1396
+
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
1378
1397
1379
1398
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
1380
1399
+33
compose.yaml
+33
compose.yaml
···
9
9
version: "3.8"
10
10
11
11
services:
12
+
redis:
13
+
image: redis:7-alpine
14
+
container_name: skywatch-automod-redis
15
+
restart: unless-stopped
16
+
volumes:
17
+
- redis-data:/data
18
+
networks:
19
+
- skywatch-network
20
+
healthcheck:
21
+
test: ["CMD", "redis-cli", "ping"]
22
+
interval: 10s
23
+
timeout: 3s
24
+
retries: 3
25
+
12
26
automod:
13
27
# Build the Docker image from the Dockerfile in the current directory.
14
28
build: .
···
26
40
env_file:
27
41
- .env
28
42
43
+
# Wait for Redis to be healthy before starting
44
+
depends_on:
45
+
redis:
46
+
condition: service_healthy
47
+
48
+
networks:
49
+
- skywatch-network
50
+
29
51
# Mount a volume to persist the firehose cursor.
30
52
# This links the `cursor.txt` file from your host into the container at `/app/cursor.txt`.
31
53
# Persisting this file allows the automod to resume from where it left off
32
54
# after a restart, preventing it from reprocessing old events or skipping new ones.
33
55
volumes:
34
56
- ./cursor.txt:/app/cursor.txt
57
+
58
+
environment:
59
+
- NODE_ENV=production
60
+
- REDIS_URL=redis://redis:6379
61
+
62
+
volumes:
63
+
redis-data:
64
+
65
+
networks:
66
+
skywatch-network:
67
+
driver: bridge
+1
package.json
+1
package.json
+1
src/config.ts
+1
src/config.ts
+6
-1
src/main.ts
+6
-1
src/main.ts
···
13
13
} from "./config.js";
14
14
import { logger } from "./logger.js";
15
15
import { startMetricsServer } from "./metrics.js";
16
+
import { connectRedis, disconnectRedis } from "./redis.js";
16
17
import { checkAccountAge } from "./rules/account/age.js";
17
18
import { checkFacetSpam } from "./rules/facets/facets.js";
18
19
import { checkHandle } from "./rules/handles/checkHandles.js";
···
321
322
}
322
323
});*/
323
324
325
+
logger.info({ process: "MAIN" }, "Connecting to Redis");
326
+
await connectRedis();
327
+
324
328
jetstream.start();
325
329
326
-
function shutdown() {
330
+
async function shutdown() {
327
331
try {
328
332
logger.info({ process: "MAIN" }, "Shutting down gracefully");
329
333
fs.writeFileSync("cursor.txt", jetstream.cursor!.toString(), "utf8");
330
334
jetstream.close();
331
335
metricsServer.close();
336
+
await disconnectRedis();
332
337
} catch (error) {
333
338
logger.error({ process: "MAIN", error }, "Error shutting down gracefully");
334
339
process.exit(1);
+49
-2
src/moderation.ts
+49
-2
src/moderation.ts
···
2
2
import { MOD_DID } from "./config.js";
3
3
import { limit } from "./limits.js";
4
4
import { logger } from "./logger.js";
5
+
import {
6
+
tryClaimAccountComment,
7
+
tryClaimAccountLabel,
8
+
tryClaimPostLabel,
9
+
} from "./redis.js";
5
10
6
11
const doesLabelExist = (
7
12
labels: { val: string }[] | undefined,
···
19
24
label: string,
20
25
comment: string,
21
26
duration: number | undefined,
27
+
did?: string,
22
28
) => {
23
29
await isLoggedIn;
24
30
31
+
const claimed = await tryClaimPostLabel(uri, label);
32
+
if (!claimed) {
33
+
logger.debug(
34
+
{ process: "MODERATION", uri, label },
35
+
"Post label already claimed in Redis, skipping",
36
+
);
37
+
return;
38
+
}
39
+
25
40
const hasLabel = await checkRecordLabels(uri, label);
26
41
if (hasLabel) {
27
42
logger.debug(
···
30
45
);
31
46
return;
32
47
}
48
+
49
+
logger.info(
50
+
{ process: "MODERATION", label, did, atURI: uri },
51
+
"Labeling post",
52
+
);
33
53
34
54
await limit(async () => {
35
55
try {
···
50
70
event.durationInHours = duration;
51
71
}
52
72
53
-
return agent.tools.ozone.moderation.emitEvent(
73
+
await agent.tools.ozone.moderation.emitEvent(
54
74
{
55
75
event: event,
56
76
// specify the labeled post by strongRef
···
91
111
) => {
92
112
await isLoggedIn;
93
113
114
+
const claimed = await tryClaimAccountLabel(did, label);
115
+
if (!claimed) {
116
+
logger.debug(
117
+
{ process: "MODERATION", did, label },
118
+
"Account label already claimed in Redis, skipping",
119
+
);
120
+
return;
121
+
}
122
+
94
123
const hasLabel = await checkAccountLabels(did, label);
95
124
if (hasLabel) {
96
125
logger.debug(
···
99
128
);
100
129
return;
101
130
}
131
+
132
+
logger.info({ process: "MODERATION", did, label }, "Labeling account");
102
133
103
134
await limit(async () => {
104
135
try {
···
186
217
});
187
218
};
188
219
189
-
export const createAccountComment = async (did: string, comment: string) => {
220
+
export const createAccountComment = async (
221
+
did: string,
222
+
comment: string,
223
+
atURI: string,
224
+
) => {
190
225
await isLoggedIn;
226
+
227
+
const claimed = await tryClaimAccountComment(did, atURI);
228
+
if (!claimed) {
229
+
logger.debug(
230
+
{ process: "MODERATION", did, atURI },
231
+
"Account comment already claimed in Redis, skipping",
232
+
);
233
+
return;
234
+
}
235
+
236
+
logger.info({ process: "MODERATION", did, atURI }, "Commenting on account");
237
+
191
238
await limit(async () => {
192
239
try {
193
240
await agent.tools.ozone.moderation.emitEvent(
+109
src/redis.ts
+109
src/redis.ts
···
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,
7
+
});
8
+
9
+
redisClient.on("error", (err: Error) => {
10
+
logger.error({ err }, "Redis client error");
11
+
});
12
+
13
+
redisClient.on("connect", () => {
14
+
logger.info("Redis client connected");
15
+
});
16
+
17
+
redisClient.on("ready", () => {
18
+
logger.info("Redis client ready");
19
+
});
20
+
21
+
redisClient.on("reconnecting", () => {
22
+
logger.warn("Redis client reconnecting");
23
+
});
24
+
25
+
export async function connectRedis(): Promise<void> {
26
+
try {
27
+
await redisClient.connect();
28
+
} catch (err) {
29
+
logger.error({ err }, "Failed to connect to Redis");
30
+
throw err;
31
+
}
32
+
}
33
+
34
+
export async function disconnectRedis(): Promise<void> {
35
+
try {
36
+
await redisClient.quit();
37
+
logger.info("Redis client disconnected");
38
+
} catch (err) {
39
+
logger.error({ err }, "Error disconnecting Redis");
40
+
}
41
+
}
42
+
43
+
function getPostLabelCacheKey(atURI: string, label: string): string {
44
+
return `post-label:${atURI}:${label}`;
45
+
}
46
+
47
+
function getAccountLabelCacheKey(did: string, label: string): string {
48
+
return `account-label:${did}:${label}`;
49
+
}
50
+
51
+
export async function tryClaimPostLabel(
52
+
atURI: string,
53
+
label: string,
54
+
): Promise<boolean> {
55
+
try {
56
+
const key = getPostLabelCacheKey(atURI, label);
57
+
const result = await redisClient.set(key, "1", {
58
+
NX: true,
59
+
EX: 60 * 60 * 24 * 7,
60
+
});
61
+
return result === "OK";
62
+
} catch (err) {
63
+
logger.warn(
64
+
{ err, atURI, label },
65
+
"Error claiming post label in Redis, allowing through",
66
+
);
67
+
return true;
68
+
}
69
+
}
70
+
71
+
export async function tryClaimAccountLabel(
72
+
did: string,
73
+
label: string,
74
+
): Promise<boolean> {
75
+
try {
76
+
const key = getAccountLabelCacheKey(did, label);
77
+
const result = await redisClient.set(key, "1", {
78
+
NX: true,
79
+
EX: 60 * 60 * 24 * 7,
80
+
});
81
+
return result === "OK";
82
+
} catch (err) {
83
+
logger.warn(
84
+
{ err, did, label },
85
+
"Error claiming account label in Redis, allowing through",
86
+
);
87
+
return true;
88
+
}
89
+
}
90
+
91
+
export async function tryClaimAccountComment(
92
+
did: string,
93
+
atURI: string,
94
+
): Promise<boolean> {
95
+
try {
96
+
const key = `account-comment:${did}:${atURI}`;
97
+
const result = await redisClient.set(key, "1", {
98
+
NX: true,
99
+
EX: 60 * 60 * 24 * 7,
100
+
});
101
+
return result === "OK";
102
+
} catch (err) {
103
+
logger.warn(
104
+
{ err, did, atURI },
105
+
"Error claiming account comment in Redis, allowing through",
106
+
);
107
+
return true;
108
+
}
109
+
}
+38
src/rules/account/ageConstants.ts
+38
src/rules/account/ageConstants.ts
···
12
12
* - Detect brigading on specific controversial posts
13
13
*/
14
14
export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [
15
+
{
16
+
monitoredDIDs: [
17
+
"did:plc:b2ecyhl2z2tro25ltrcyiytd", // DHS
18
+
"did:plc:iw2wxg46hm4ezguswhwej6t6", // actual whitehouse
19
+
"did:plc:fhnl65q3us5evynqc4f2qak6", // HHS
20
+
"did:plc:wrz4athzuf2u5js2ltrktiqk", // DOL
21
+
"did:plc:3mqcgvyu4exg3pkx4bkfppih", // VA
22
+
"did:plc:pqn2sfkx5klnytms4uwqt5wo", // Treasurer
23
+
"did:plc:v4kvjftk6kr5ci3zqmfawwpb", // State
24
+
"did:plc:rlymk4d5qmq5udjdznojmvel", // Interior
25
+
"did:plc:f7a5etif42x56oyrbzuek6so", // USDA
26
+
"did:plc:7kusimwlnf4v5jo757jvkeaj", // DOE
27
+
"did:plc:jgq3vko3g6zg72457bda2snd", // SBA
28
+
"did:plc:h2iujdjlry6fpniofjtiqqmb", // DoD
29
+
"did:plc:jwncvpznkwe4luzvdroes45b", // CBP
30
+
"did:plc:azfxx5mdxcuoc2bkuqizs4kd",
31
+
"did:plc:vostkism5vbzjqfcmllmd6gz",
32
+
"did:plc:etthv4ychwti4b6i2hhe76c2",
33
+
"did:plc:swf7zddjselkcpbn6iw323gy",
34
+
"did:plc:h3zq65wioggctyxpovfpi6ec",
35
+
"did:plc:nofnc2xpdihktxkufkq7tn3w",
36
+
"did:plc:quezcqejcqw6g5t3om7wldns",
37
+
"did:plc:vlvqht2v3nsc4k7xaho6bjaf",
38
+
"did:plc:syyfuvqiabipi5mf3x632qij",
39
+
"did:plc:6vpxzm6mxjzcfvccnuw2pyd7",
40
+
"did:plc:yxqdgravj27gtxkpqhrnzhlx",
41
+
"did:plc:nrhrdxqa2v7hfxw2jnuy7rk7",
42
+
"did:plc:pr27argcmniiwxp7d7facqwy",
43
+
"did:plc:azfxx5mdxcuoc2bkuqizs4kd",
44
+
"did:plc:y42muzveli3sjyr3tufaq765",
45
+
"did:plc:22wazjq4e4yjafxlew2c6kov",
46
+
"did:plc:iw64z65wzkmqvftssb2nldj5",
47
+
],
48
+
anchorDate: "2025-10-17", // Date when harassment campaign started
49
+
maxAgeDays: 7, // Flag accounts less than 7 days old
50
+
label: "suspect-inauthentic",
51
+
comment: "New account replying to monitored user during campaign",
52
+
},
15
53
// Example: Monitor replies to specific accounts
16
54
// {
17
55
// monitoredDIDs: [
+3
src/rules/handles/checkHandles.test.ts
+3
src/rules/handles/checkHandles.test.ts
···
140
140
expect(createAccountComment).toHaveBeenCalledWith(
141
141
"did:plc:user1",
142
142
`${time}: Scam detected - scam-account`,
143
+
"handle:did:plc:user1:scam-account",
143
144
);
144
145
});
145
146
});
···
181
182
expect(createAccountComment).toHaveBeenCalledWith(
182
183
"did:plc:user1",
183
184
`${time}: Scam detected - scam-user`,
185
+
"handle:did:plc:user1:scam-user",
184
186
);
185
187
});
186
188
···
206
208
expect(createAccountComment).toHaveBeenCalledWith(
207
209
"did:plc:user1",
208
210
`${time}: Multi-action triggered - dangerous-account`,
211
+
"handle:did:plc:user1:dangerous-account",
209
212
);
210
213
expect(createAccountLabel).toHaveBeenCalledWith(
211
214
"did:plc:user1",
+8
-14
src/rules/handles/checkHandles.ts
+8
-14
src/rules/handles/checkHandles.ts
···
46
46
}
47
47
48
48
if (checkList.toLabel === true) {
49
-
logger.info(
50
-
{ process: "CHECKHANDLE", did, handle, time, label: checkList.label },
51
-
"Labeling account",
49
+
createAccountLabel(
50
+
did,
51
+
`${checkList.label}`,
52
+
`${time}: ${checkList.comment} - ${handle}`,
52
53
);
53
-
{
54
-
createAccountLabel(
55
-
did,
56
-
`${checkList.label}`,
57
-
`${time}: ${checkList.comment} - ${handle}`,
58
-
);
59
-
}
60
54
}
61
55
62
56
if (checkList.reportAcct === true) {
···
68
62
}
69
63
70
64
if (checkList.commentAcct === true) {
71
-
logger.info(
72
-
{ process: "CHECKHANDLE", did, handle, time, label: checkList.label },
73
-
"Commenting on account",
65
+
createAccountComment(
66
+
did,
67
+
`${time}: ${checkList.comment} - ${handle}`,
68
+
`handle:${did}:${handle}`,
74
69
);
75
-
createAccountComment(did, `${time}: ${checkList.comment} - ${handle}`);
76
70
}
77
71
}
78
72
});
+2
-18
src/rules/posts/checkPosts.ts
+2
-18
src/rules/posts/checkPosts.ts
···
106
106
countStarterPacks(post[0].did, post[0].time);
107
107
108
108
if (checkPost.toLabel === true) {
109
-
logger.info(
110
-
{
111
-
process: "CHECKPOSTS",
112
-
label: checkPost.label,
113
-
did: post[0].did,
114
-
atURI: post[0].atURI,
115
-
},
116
-
"Labeling post",
117
-
);
118
109
createPostLabel(
119
110
post[0].atURI,
120
111
post[0].cid,
121
112
`${checkPost.label}`,
122
113
`${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
123
114
checkPost.duration,
115
+
post[0].did,
124
116
);
125
117
}
126
118
···
158
150
}
159
151
160
152
if (checkPost.commentAcct === true) {
161
-
logger.info(
162
-
{
163
-
process: "CHECKPOSTS",
164
-
label: checkPost.label,
165
-
did: post[0].did,
166
-
atURI: post[0].atURI,
167
-
},
168
-
"Commenting on account",
169
-
);
170
153
createAccountComment(
171
154
post[0].did,
172
155
`${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
156
+
post[0].atURI,
173
157
);
174
158
}
175
159
}
+6
-32
src/rules/posts/tests/checkPosts.test.ts
+6
-32
src/rules/posts/tests/checkPosts.test.ts
···
244
244
245
245
await checkPosts(post);
246
246
247
-
expect(logger.info).toHaveBeenCalledWith(
248
-
{
249
-
process: "CHECKPOSTS",
250
-
label: "test-label",
251
-
did: post[0].did,
252
-
atURI: post[0].atURI,
253
-
},
254
-
"Labeling post",
255
-
);
256
247
expect(createPostLabel).toHaveBeenCalledWith(
257
248
post[0].atURI,
258
249
post[0].cid,
259
250
"test-label",
260
251
expect.stringContaining("Test comment"),
261
252
undefined,
253
+
post[0].did,
262
254
);
263
255
});
264
256
···
292
284
"language-specific",
293
285
expect.any(String),
294
286
undefined,
287
+
post[0].did,
295
288
);
296
289
});
297
290
···
345
338
"whitelisted-test",
346
339
expect.any(String),
347
340
undefined,
341
+
post[0].did,
348
342
);
349
343
});
350
344
});
···
389
383
"ignored-did",
390
384
expect.any(String),
391
385
undefined,
386
+
"did:plc:notignored",
392
387
);
393
388
});
394
389
});
···
405
400
"all-actions",
406
401
expect.any(String),
407
402
undefined,
403
+
post[0].did,
408
404
);
409
405
expect(createPostReport).toHaveBeenCalledWith(
410
406
post[0].atURI,
···
418
414
expect(createAccountComment).toHaveBeenCalledWith(
419
415
post[0].did,
420
416
expect.any(String),
421
-
);
422
-
});
423
-
424
-
it("should log all moderation actions", async () => {
425
-
const post = createMockPost({ text: "report this" });
426
-
427
-
await checkPosts(post);
428
-
429
-
expect(logger.info).toHaveBeenCalledWith(
430
-
expect.objectContaining({ label: "all-actions" }),
431
-
"Labeling post",
432
-
);
433
-
expect(logger.info).toHaveBeenCalledWith(
434
-
expect.objectContaining({ label: "all-actions" }),
435
-
"Reporting post",
436
-
);
437
-
expect(logger.info).toHaveBeenCalledWith(
438
-
expect.objectContaining({ label: "all-actions" }),
439
-
"Reporting account",
440
-
);
441
-
expect(logger.info).toHaveBeenCalledWith(
442
-
expect.objectContaining({ label: "all-actions" }),
443
-
"Commenting on account",
417
+
expect.any(String),
444
418
);
445
419
});
446
420
});
+2
-44
src/rules/profiles/checkProfiles.ts
+2
-44
src/rules/profiles/checkProfiles.ts
···
70
70
`${checkProfiles.label}`,
71
71
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
72
72
);
73
-
logger.info(
74
-
{
75
-
process: "CHECKDESCRIPTION",
76
-
did,
77
-
time,
78
-
displayName,
79
-
description,
80
-
label: checkProfiles.label,
81
-
},
82
-
"Labeling account",
83
-
);
84
73
}
85
74
86
75
if (checkProfiles.reportAcct === true) {
···
105
94
createAccountComment(
106
95
did,
107
96
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
108
-
);
109
-
logger.info(
110
-
{
111
-
process: "CHECKDESCRIPTION",
112
-
did,
113
-
time,
114
-
displayName,
115
-
description,
116
-
label: checkProfiles.label,
117
-
},
118
-
"Commenting on account",
97
+
`profile:${did}:${time}`,
119
98
);
120
99
}
121
100
}
···
186
165
`${checkProfiles.label}`,
187
166
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
188
167
);
189
-
logger.info(
190
-
{
191
-
process: "CHECKDISPLAYNAME",
192
-
did,
193
-
time,
194
-
displayName,
195
-
description,
196
-
label: checkProfiles.label,
197
-
},
198
-
"Labeling account",
199
-
);
200
168
}
201
169
202
170
if (checkProfiles.reportAcct === true) {
···
221
189
createAccountComment(
222
190
did,
223
191
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
224
-
);
225
-
logger.info(
226
-
{
227
-
process: "CHECKDISPLAYNAME",
228
-
did,
229
-
time,
230
-
displayName,
231
-
description,
232
-
label: checkProfiles.label,
233
-
},
234
-
"Commenting on account",
192
+
`profile:${did}:${time}`,
235
193
);
236
194
}
237
195
}
+1
-43
src/rules/profiles/tests/checkProfiles.test.ts
+1
-43
src/rules/profiles/tests/checkProfiles.test.ts
···
167
167
"This is spam content",
168
168
);
169
169
170
-
expect(logger.info).toHaveBeenCalledWith(
171
-
{
172
-
process: "CHECKDESCRIPTION",
173
-
did: mockDid,
174
-
time: mockTime,
175
-
displayName: mockDisplayName,
176
-
description: "This is spam content",
177
-
label: "test-description",
178
-
},
179
-
"Labeling account",
180
-
);
181
170
expect(createAccountLabel).toHaveBeenCalledWith(
182
171
mockDid,
183
172
"test-description",
···
365
354
expect(createAccountComment).toHaveBeenCalledWith(
366
355
mockDid,
367
356
expect.any(String),
357
+
expect.any(String),
368
358
);
369
359
});
370
360
371
-
it("should log all moderation actions", async () => {
372
-
await checkDescription(
373
-
mockDid,
374
-
mockTime,
375
-
mockDisplayName,
376
-
"report this",
377
-
);
378
-
379
-
expect(logger.info).toHaveBeenCalledWith(
380
-
expect.objectContaining({ label: "all-actions" }),
381
-
"Labeling account",
382
-
);
383
-
expect(logger.info).toHaveBeenCalledWith(
384
-
expect.objectContaining({ label: "all-actions" }),
385
-
"Reporting account",
386
-
);
387
-
expect(logger.info).toHaveBeenCalledWith(
388
-
expect.objectContaining({ label: "all-actions" }),
389
-
"Commenting on account",
390
-
);
391
-
});
392
361
});
393
362
});
394
363
···
434
403
mockDescription,
435
404
);
436
405
437
-
expect(logger.info).toHaveBeenCalledWith(
438
-
{
439
-
process: "CHECKDISPLAYNAME",
440
-
did: mockDid,
441
-
time: mockTime,
442
-
displayName: "fake account",
443
-
description: mockDescription,
444
-
label: "test-displayname",
445
-
},
446
-
"Labeling account",
447
-
);
448
406
expect(createAccountLabel).toHaveBeenCalledWith(
449
407
mockDid,
450
408
"test-displayname",
+66
-92
src/tests/moderation.test.ts
+66
-92
src/tests/moderation.test.ts
···
1
1
import { beforeEach, describe, expect, it, vi } from "vitest";
2
-
import { agent } from "../agent.js";
3
-
import { logger } from "../logger.js";
4
-
import { checkAccountLabels } from "../moderation.js";
5
2
6
-
// Mock dependencies
3
+
// --- Mocks First ---
4
+
7
5
vi.mock("../agent.js", () => ({
8
6
agent: {
9
7
tools: {
10
8
ozone: {
11
9
moderation: {
12
10
getRepo: vi.fn(),
11
+
getRecord: vi.fn(),
12
+
emitEvent: vi.fn(),
13
13
},
14
14
},
15
15
},
···
17
17
isLoggedIn: Promise.resolve(true),
18
18
}));
19
19
20
+
vi.mock("../redis.js", () => ({
21
+
tryClaimPostLabel: vi.fn(),
22
+
tryClaimAccountLabel: vi.fn(),
23
+
}));
24
+
20
25
vi.mock("../logger.js", () => ({
21
26
logger: {
22
27
info: vi.fn(),
···
34
39
limit: vi.fn((fn) => fn()),
35
40
}));
36
41
37
-
describe("checkAccountLabels", () => {
42
+
// --- Imports Second ---
43
+
44
+
import { agent } from "../agent.js";
45
+
import { checkAccountLabels, createPostLabel } from "../moderation.js";
46
+
import { tryClaimPostLabel } from "../redis.js";
47
+
import { logger } from "../logger.js";
48
+
49
+
describe("Moderation Logic", () => {
38
50
beforeEach(() => {
39
51
vi.clearAllMocks();
40
52
});
41
53
42
-
it("should return true if label exists on account", async () => {
43
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
44
-
data: {
45
-
labels: [
46
-
{ val: "spam" },
47
-
{ val: "harassment" },
48
-
{ val: "window-reply" },
49
-
],
50
-
},
51
-
});
52
-
53
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
54
-
55
-
expect(result).toBe(true);
56
-
expect(agent.tools.ozone.moderation.getRepo).toHaveBeenCalledWith(
57
-
{ did: "did:plc:test123" },
58
-
{
59
-
headers: {
60
-
"atproto-proxy": "did:plc:moderator123#atproto_labeler",
61
-
"atproto-accept-labelers": "did:plc:ar7c4by46qjdydhdevvrndac;redact",
54
+
describe("checkAccountLabels", () => {
55
+
it("should return true if label exists on account", async () => {
56
+
vi.mocked(agent.tools.ozone.moderation.getRepo).mockResolvedValueOnce({
57
+
data: {
58
+
labels: [
59
+
{ val: "spam", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" },
60
+
{ val: "window-reply", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" }
61
+
]
62
62
},
63
-
},
64
-
);
65
-
});
66
-
67
-
it("should return false if label does not exist on account", async () => {
68
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
69
-
data: {
70
-
labels: [{ val: "spam" }, { val: "harassment" }],
71
-
},
63
+
} as any);
64
+
const result = await checkAccountLabels("did:plc:test123", "window-reply");
65
+
expect(result).toBe(true);
72
66
});
73
-
74
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
75
-
76
-
expect(result).toBe(false);
77
67
});
78
68
79
-
it("should return false if account has no labels", async () => {
80
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
81
-
data: {
82
-
labels: [],
83
-
},
84
-
});
69
+
describe("createPostLabel with Caching", () => {
70
+
const URI = "at://did:plc:test/app.bsky.feed.post/123";
71
+
const CID = "bafybeig6xv5nwph5j7grrlp3pdeolqptpep5nfljmdkmtcf2l4wisa2mfa";
72
+
const LABEL = "test-label";
73
+
const COMMENT = "test comment";
85
74
86
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
75
+
it("should skip if claim fails (already claimed)", async () => {
76
+
vi.mocked(tryClaimPostLabel).mockResolvedValue(false);
87
77
88
-
expect(result).toBe(false);
89
-
});
78
+
await createPostLabel(URI, CID, LABEL, COMMENT, undefined);
90
79
91
-
it("should return false if labels property is undefined", async () => {
92
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
93
-
data: {},
80
+
expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL);
81
+
expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).not.toHaveBeenCalled();
82
+
expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled();
94
83
});
95
84
96
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
85
+
it("should skip event if claimed but already labeled via API", async () => {
86
+
vi.mocked(tryClaimPostLabel).mockResolvedValue(true);
87
+
vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({
88
+
data: { labels: [{ val: LABEL, src: "did:plc:test", uri: URI, cts: "2024-01-01T00:00:00Z" }] },
89
+
} as any);
97
90
98
-
expect(result).toBe(false);
99
-
});
91
+
await createPostLabel(URI, CID, LABEL, COMMENT, undefined);
100
92
101
-
it("should handle API errors gracefully", async () => {
102
-
(agent.tools.ozone.moderation.getRepo as any).mockRejectedValueOnce(
103
-
new Error("API Error"),
104
-
);
93
+
expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL);
94
+
expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith(
95
+
{ uri: URI },
96
+
expect.any(Object),
97
+
);
98
+
expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled();
99
+
});
105
100
106
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
101
+
it("should emit event if claimed and not labeled anywhere", async () => {
102
+
vi.mocked(tryClaimPostLabel).mockResolvedValue(true);
103
+
vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({
104
+
data: { labels: [] },
105
+
} as any);
106
+
vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({ success: true } as any);
107
107
108
-
expect(result).toBe(false);
109
-
expect(logger.error).toHaveBeenCalledWith(
110
-
{
111
-
process: "MODERATION",
112
-
did: "did:plc:test123",
113
-
error: expect.any(Error),
114
-
},
115
-
"Failed to check account labels",
116
-
);
117
-
});
108
+
await createPostLabel(URI, CID, LABEL, COMMENT, undefined);
118
109
119
-
it("should perform case-sensitive label matching", async () => {
120
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
121
-
data: {
122
-
labels: [{ val: "window-reply" }],
123
-
},
110
+
expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL);
111
+
expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith(
112
+
{ uri: URI },
113
+
expect.any(Object),
114
+
);
115
+
expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).toHaveBeenCalled();
124
116
});
125
-
126
-
const resultLower = await checkAccountLabels(
127
-
"did:plc:test123",
128
-
"window-reply",
129
-
);
130
-
expect(resultLower).toBe(true);
131
-
132
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
133
-
data: {
134
-
labels: [{ val: "window-reply" }],
135
-
},
136
-
});
137
-
138
-
const resultUpper = await checkAccountLabels(
139
-
"did:plc:test123",
140
-
"Window-Reply",
141
-
);
142
-
expect(resultUpper).toBe(false);
143
117
});
144
-
});
118
+
});
+106
src/tests/redis.test.ts
+106
src/tests/redis.test.ts
···
1
+
import { afterEach, describe, expect, it, vi } from 'vitest';
2
+
3
+
// Mock the 'redis' module in a way that avoids hoisting issues.
4
+
// The mock implementation is self-contained.
5
+
vi.mock('redis', () => {
6
+
const mockClient = {
7
+
on: vi.fn(),
8
+
connect: vi.fn(),
9
+
quit: vi.fn(),
10
+
exists: vi.fn(),
11
+
set: vi.fn(),
12
+
};
13
+
return {
14
+
createClient: vi.fn(() => mockClient),
15
+
};
16
+
});
17
+
18
+
// Import the mocked redis first to get a reference to the mock client
19
+
import { createClient } from 'redis';
20
+
const mockRedisClient = createClient();
21
+
22
+
// Import the modules to be tested
23
+
import {
24
+
tryClaimPostLabel,
25
+
tryClaimAccountLabel,
26
+
connectRedis,
27
+
disconnectRedis,
28
+
} from '../redis.js';
29
+
import { logger } from '../logger.js';
30
+
31
+
// Suppress logger output during tests
32
+
vi.mock('../logger.js', () => ({
33
+
logger: {
34
+
info: vi.fn(),
35
+
warn: vi.fn(),
36
+
error: vi.fn(),
37
+
debug: vi.fn(),
38
+
},
39
+
}));
40
+
41
+
describe('Redis Cache Logic', () => {
42
+
afterEach(() => {
43
+
vi.clearAllMocks();
44
+
});
45
+
46
+
describe('Connection', () => {
47
+
it('should call redisClient.connect on connectRedis', async () => {
48
+
await connectRedis();
49
+
expect(mockRedisClient.connect).toHaveBeenCalled();
50
+
});
51
+
52
+
it('should call redisClient.quit on disconnectRedis', async () => {
53
+
await disconnectRedis();
54
+
expect(mockRedisClient.quit).toHaveBeenCalled();
55
+
});
56
+
});
57
+
58
+
describe('tryClaimPostLabel', () => {
59
+
it('should return true and set key if key does not exist', async () => {
60
+
vi.mocked(mockRedisClient.set).mockResolvedValue('OK');
61
+
const result = await tryClaimPostLabel('at://uri', 'test-label');
62
+
expect(result).toBe(true);
63
+
expect(mockRedisClient.set).toHaveBeenCalledWith(
64
+
'post-label:at://uri:test-label',
65
+
'1',
66
+
{ NX: true, EX: 60 * 60 * 24 * 7 }
67
+
);
68
+
});
69
+
70
+
it('should return false if key already exists', async () => {
71
+
vi.mocked(mockRedisClient.set).mockResolvedValue(null);
72
+
const result = await tryClaimPostLabel('at://uri', 'test-label');
73
+
expect(result).toBe(false);
74
+
});
75
+
76
+
it('should return true and log warning on Redis error', async () => {
77
+
const redisError = new Error('Redis down');
78
+
vi.mocked(mockRedisClient.set).mockRejectedValue(redisError);
79
+
const result = await tryClaimPostLabel('at://uri', 'test-label');
80
+
expect(result).toBe(true);
81
+
expect(logger.warn).toHaveBeenCalledWith(
82
+
{ err: redisError, atURI: 'at://uri', label: 'test-label' },
83
+
'Error claiming post label in Redis, allowing through'
84
+
);
85
+
});
86
+
});
87
+
88
+
describe('tryClaimAccountLabel', () => {
89
+
it('should return true and set key if key does not exist', async () => {
90
+
vi.mocked(mockRedisClient.set).mockResolvedValue('OK');
91
+
const result = await tryClaimAccountLabel('did:plc:123', 'test-label');
92
+
expect(result).toBe(true);
93
+
expect(mockRedisClient.set).toHaveBeenCalledWith(
94
+
'account-label:did:plc:123:test-label',
95
+
'1',
96
+
{ NX: true, EX: 60 * 60 * 24 * 7 }
97
+
);
98
+
});
99
+
100
+
it('should return false if key already exists', async () => {
101
+
vi.mocked(mockRedisClient.set).mockResolvedValue(null);
102
+
const result = await tryClaimAccountLabel('did:plc:123', 'test-label');
103
+
expect(result).toBe(false);
104
+
});
105
+
});
106
+
});