+12
compose.dev.yaml
+12
compose.dev.yaml
···
1
+
# Development override for docker-compose
2
+
# Usage: docker compose -f compose.yaml -f compose.dev.yaml up
3
+
#
4
+
# This configuration:
5
+
# - Runs the app in watch mode (auto-reloads on file changes)
6
+
# - Mounts source code so changes are picked up without rebuild
7
+
8
+
services:
9
+
automod:
10
+
command: ["bun", "run", "dev"]
11
+
volumes:
12
+
- ./src:/app/src
+22
-1
compose.yaml
+22
-1
compose.yaml
···
13
13
image: redis:7-alpine
14
14
container_name: skywatch-automod-redis
15
15
restart: unless-stopped
16
+
command: redis-server --appendonly yes --appendfsync everysec
16
17
volumes:
17
18
- redis-data:/data
18
19
networks:
···
33
34
34
35
# Expose the metrics server port to the host machine.
35
36
ports:
36
-
- "4100:4101"
37
+
- "4101:4101"
37
38
38
39
# Load environment variables from a .env file in the same directory.
39
40
# This is where you should put your BSKY_HANDLE, BSKY_PASSWORD, etc.
···
55
56
volumes:
56
57
- ./cursor.txt:/app/cursor.txt
57
58
- ./.session:/app/.session
59
+
- ./rules:/app/rules
58
60
59
61
environment:
60
62
- NODE_ENV=production
61
63
- REDIS_URL=redis://redis:6379
62
64
65
+
prometheus:
66
+
image: prom/prometheus:latest
67
+
container_name: skywatch-prometheus
68
+
restart: unless-stopped
69
+
ports:
70
+
- "9090:9090"
71
+
volumes:
72
+
- ./prometheus.yml:/etc/prometheus/prometheus.yml
73
+
- prometheus-data:/prometheus
74
+
command:
75
+
- '--config.file=/etc/prometheus/prometheus.yml'
76
+
- '--storage.tsdb.path=/prometheus'
77
+
networks:
78
+
- skywatch-network
79
+
depends_on:
80
+
- automod
81
+
63
82
volumes:
64
83
redis-data:
84
+
prometheus-data:
65
85
66
86
networks:
67
87
skywatch-network:
68
88
driver: bridge
89
+
name: skywatch-network
+10
prometheus.yml
+10
prometheus.yml
+220
src/accountModeration.ts
+220
src/accountModeration.ts
···
1
+
import { agent, isLoggedIn } from "./agent.js";
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,
10
+
labelVal: string,
11
+
): boolean => {
12
+
if (!labels) {
13
+
return false;
14
+
}
15
+
return labels.some((label) => label.val === labelVal);
16
+
};
17
+
18
+
export const createAccountLabel = async (
19
+
did: string,
20
+
label: string,
21
+
comment: string,
22
+
) => {
23
+
await isLoggedIn;
24
+
25
+
const claimed = await tryClaimAccountLabel(did, label);
26
+
if (!claimed) {
27
+
logger.debug(
28
+
{ process: "MODERATION", did, label },
29
+
"Account label already claimed in Redis, skipping",
30
+
);
31
+
labelsCachedCounter.inc({
32
+
label_type: label,
33
+
target_type: "account",
34
+
reason: "redis_cache",
35
+
});
36
+
return;
37
+
}
38
+
39
+
const hasLabel = await checkAccountLabels(did, label);
40
+
if (hasLabel) {
41
+
logger.debug(
42
+
{ process: "MODERATION", did, label },
43
+
"Account already has label, skipping",
44
+
);
45
+
labelsCachedCounter.inc({
46
+
label_type: label,
47
+
target_type: "account",
48
+
reason: "existing_label",
49
+
});
50
+
return;
51
+
}
52
+
53
+
logger.info({ process: "MODERATION", did, label }, "Labeling account");
54
+
labelsAppliedCounter.inc({ label_type: label, target_type: "account" });
55
+
56
+
await limit(async () => {
57
+
try {
58
+
await agent.tools.ozone.moderation.emitEvent(
59
+
{
60
+
event: {
61
+
$type: "tools.ozone.moderation.defs#modEventLabel",
62
+
comment: comment,
63
+
createLabelVals: [label],
64
+
negateLabelVals: [],
65
+
},
66
+
// specify the labeled post by strongRef
67
+
subject: {
68
+
$type: "com.atproto.admin.defs#repoRef",
69
+
did: did,
70
+
},
71
+
// put in the rest of the metadata
72
+
createdBy: `${agent.did}`,
73
+
createdAt: new Date().toISOString(),
74
+
modTool: {
75
+
name: "skywatch/skywatch-automod",
76
+
},
77
+
},
78
+
{
79
+
encoding: "application/json",
80
+
headers: {
81
+
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
82
+
"atproto-accept-labelers":
83
+
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
84
+
},
85
+
},
86
+
);
87
+
} catch (e) {
88
+
logger.error(
89
+
{ process: "MODERATION", error: e },
90
+
"Failed to create account label",
91
+
);
92
+
}
93
+
});
94
+
};
95
+
96
+
export const createAccountComment = async (
97
+
did: string,
98
+
comment: string,
99
+
atURI: string,
100
+
) => {
101
+
await isLoggedIn;
102
+
103
+
const claimed = await tryClaimAccountComment(did, atURI);
104
+
if (!claimed) {
105
+
logger.debug(
106
+
{ process: "MODERATION", did, atURI },
107
+
"Account comment already claimed in Redis, skipping",
108
+
);
109
+
return;
110
+
}
111
+
112
+
logger.info({ process: "MODERATION", did, atURI }, "Commenting on account");
113
+
114
+
await limit(async () => {
115
+
try {
116
+
await agent.tools.ozone.moderation.emitEvent(
117
+
{
118
+
event: {
119
+
$type: "tools.ozone.moderation.defs#modEventComment",
120
+
comment: comment,
121
+
},
122
+
// specify the labeled post by strongRef
123
+
subject: {
124
+
$type: "com.atproto.admin.defs#repoRef",
125
+
did: did,
126
+
},
127
+
// put in the rest of the metadata
128
+
createdBy: `${agent.did}`,
129
+
createdAt: new Date().toISOString(),
130
+
modTool: {
131
+
name: "skywatch/skywatch-automod",
132
+
},
133
+
},
134
+
{
135
+
encoding: "application/json",
136
+
headers: {
137
+
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
138
+
"atproto-accept-labelers":
139
+
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
140
+
},
141
+
},
142
+
);
143
+
} catch (e) {
144
+
logger.error(
145
+
{ process: "MODERATION", error: e },
146
+
"Failed to create account comment",
147
+
);
148
+
}
149
+
});
150
+
};
151
+
152
+
export const createAccountReport = async (did: string, comment: string) => {
153
+
await isLoggedIn;
154
+
await limit(async () => {
155
+
try {
156
+
await agent.tools.ozone.moderation.emitEvent(
157
+
{
158
+
event: {
159
+
$type: "tools.ozone.moderation.defs#modEventReport",
160
+
comment: comment,
161
+
reportType: "com.atproto.moderation.defs#reasonOther",
162
+
},
163
+
// specify the labeled post by strongRef
164
+
subject: {
165
+
$type: "com.atproto.admin.defs#repoRef",
166
+
did: did,
167
+
},
168
+
// put in the rest of the metadata
169
+
createdBy: `${agent.did}`,
170
+
createdAt: new Date().toISOString(),
171
+
modTool: {
172
+
name: "skywatch/skywatch-automod",
173
+
},
174
+
},
175
+
{
176
+
encoding: "application/json",
177
+
headers: {
178
+
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
179
+
"atproto-accept-labelers":
180
+
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
181
+
},
182
+
},
183
+
);
184
+
} catch (e) {
185
+
logger.error(
186
+
{ process: "MODERATION", error: e },
187
+
"Failed to create account report",
188
+
);
189
+
}
190
+
});
191
+
};
192
+
193
+
export const checkAccountLabels = async (
194
+
did: string,
195
+
label: string,
196
+
): Promise<boolean> => {
197
+
await isLoggedIn;
198
+
return await limit(async () => {
199
+
try {
200
+
const response = await agent.tools.ozone.moderation.getRepo(
201
+
{ did },
202
+
{
203
+
headers: {
204
+
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
205
+
"atproto-accept-labelers":
206
+
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
207
+
},
208
+
},
209
+
);
210
+
211
+
return doesLabelExist(response.data.labels, label);
212
+
} catch (e) {
213
+
logger.error(
214
+
{ process: "MODERATION", did, error: e },
215
+
"Failed to check account labels",
216
+
);
217
+
return false;
218
+
}
219
+
});
220
+
};
+168
src/accountThreshold.ts
+168
src/accountThreshold.ts
···
1
+
import { ACCOUNT_THRESHOLD_CONFIGS } from "../rules/accountThreshold.js";
2
+
import {
3
+
createAccountComment,
4
+
createAccountLabel,
5
+
createAccountReport,
6
+
} from "./accountModeration.js";
7
+
import { logger } from "./logger.js";
8
+
import {
9
+
accountLabelsThresholdAppliedCounter,
10
+
accountThresholdChecksCounter,
11
+
accountThresholdMetCounter,
12
+
} from "./metrics.js";
13
+
import {
14
+
getPostLabelCountInWindow,
15
+
trackPostLabelForAccount,
16
+
} from "./redis.js";
17
+
import { AccountThresholdConfig } from "./types.js";
18
+
19
+
function normalizeLabels(labels: string | string[]): string[] {
20
+
return Array.isArray(labels) ? labels : [labels];
21
+
}
22
+
23
+
function validateAndLoadConfigs(): AccountThresholdConfig[] {
24
+
if (!ACCOUNT_THRESHOLD_CONFIGS || ACCOUNT_THRESHOLD_CONFIGS.length === 0) {
25
+
logger.warn(
26
+
{ process: "ACCOUNT_THRESHOLD" },
27
+
"No account threshold configs found",
28
+
);
29
+
return [];
30
+
}
31
+
32
+
for (const config of ACCOUNT_THRESHOLD_CONFIGS) {
33
+
const labels = normalizeLabels(config.labels);
34
+
if (labels.length === 0) {
35
+
throw new Error(
36
+
`Invalid account threshold config: labels cannot be empty`,
37
+
);
38
+
}
39
+
if (config.threshold <= 0) {
40
+
throw new Error(
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
+
}
50
+
51
+
logger.info(
52
+
{ process: "ACCOUNT_THRESHOLD", count: ACCOUNT_THRESHOLD_CONFIGS.length },
53
+
"Loaded account threshold configs",
54
+
);
55
+
56
+
return ACCOUNT_THRESHOLD_CONFIGS;
57
+
}
58
+
59
+
// Load and cache configs at module initialization
60
+
const cachedConfigs = validateAndLoadConfigs();
61
+
62
+
export function loadThresholdConfigs(): AccountThresholdConfig[] {
63
+
return cachedConfigs;
64
+
}
65
+
66
+
export async function checkAccountThreshold(
67
+
did: string,
68
+
postLabel: string,
69
+
timestamp: number,
70
+
): Promise<void> {
71
+
try {
72
+
const configs = loadThresholdConfigs();
73
+
74
+
const matchingConfigs = configs.filter((config) => {
75
+
const labels = normalizeLabels(config.labels);
76
+
return labels.includes(postLabel);
77
+
});
78
+
79
+
if (matchingConfigs.length === 0) {
80
+
logger.debug(
81
+
{ process: "ACCOUNT_THRESHOLD", did, postLabel },
82
+
"No matching threshold configs for post label",
83
+
);
84
+
return;
85
+
}
86
+
87
+
accountThresholdChecksCounter.inc({ post_label: postLabel });
88
+
89
+
for (const config of matchingConfigs) {
90
+
const labels = normalizeLabels(config.labels);
91
+
92
+
await trackPostLabelForAccount(
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
+
106
+
logger.debug(
107
+
{
108
+
process: "ACCOUNT_THRESHOLD",
109
+
did,
110
+
labels,
111
+
count,
112
+
threshold: config.threshold,
113
+
windowDays: config.windowDays,
114
+
},
115
+
"Checked account threshold",
116
+
);
117
+
118
+
if (count >= config.threshold) {
119
+
accountThresholdMetCounter.inc({ account_label: config.accountLabel });
120
+
121
+
logger.info(
122
+
{
123
+
process: "ACCOUNT_THRESHOLD",
124
+
did,
125
+
postLabel,
126
+
accountLabel: config.accountLabel,
127
+
count,
128
+
threshold: config.threshold,
129
+
},
130
+
"Account threshold met",
131
+
);
132
+
133
+
const shouldLabel = config.toLabel !== false;
134
+
135
+
if (shouldLabel) {
136
+
await createAccountLabel(did, config.accountLabel, config.accountComment);
137
+
accountLabelsThresholdAppliedCounter.inc({
138
+
account_label: config.accountLabel,
139
+
action: "label",
140
+
});
141
+
}
142
+
143
+
if (config.reportAcct) {
144
+
await createAccountReport(did, config.accountComment);
145
+
accountLabelsThresholdAppliedCounter.inc({
146
+
account_label: config.accountLabel,
147
+
action: "report",
148
+
});
149
+
}
150
+
151
+
if (config.commentAcct) {
152
+
const atURI = `threshold-comment:${config.accountLabel}:${timestamp}`;
153
+
await createAccountComment(did, config.accountComment, atURI);
154
+
accountLabelsThresholdAppliedCounter.inc({
155
+
account_label: config.accountLabel,
156
+
action: "comment",
157
+
});
158
+
}
159
+
}
160
+
}
161
+
} catch (error) {
162
+
logger.error(
163
+
{ process: "ACCOUNT_THRESHOLD", did, postLabel, error },
164
+
"Error checking account threshold",
165
+
);
166
+
throw error;
167
+
}
168
+
}
+7
-1
src/agent.ts
+7
-1
src/agent.ts
···
5
5
import { updateRateLimitState } from "./limits.js";
6
6
import { logger } from "./logger.js";
7
7
8
-
setGlobalDispatcher(new Agent({ connect: { timeout: 20_000 } }));
8
+
setGlobalDispatcher(
9
+
new Agent({
10
+
connect: { timeout: 20_000 },
11
+
keepAliveTimeout: 10_000,
12
+
keepAliveMaxTimeout: 20_000,
13
+
}),
14
+
);
9
15
10
16
const customFetch: typeof fetch = async (input, init) => {
11
17
const response = await fetch(input, init);
+1
-2
src/config.ts
+1
-2
src/config.ts
···
5
5
export const OZONE_PDS = process.env.OZONE_PDS ?? "";
6
6
export const BSKY_HANDLE = process.env.BSKY_HANDLE ?? "";
7
7
export const BSKY_PASSWORD = process.env.BSKY_PASSWORD ?? "";
8
-
export const HOST = process.env.HOST ?? "127.0.0.1";
9
-
export const PORT = process.env.PORT ? Number(process.env.PORT) : 4100;
8
+
export const HOST = process.env.HOST ?? "0.0.0.0";
10
9
export const METRICS_PORT = process.env.METRICS_PORT
11
10
? Number(process.env.METRICS_PORT)
12
11
: 4101; // Left this intact from the code I adapted this from
-8
src/main.ts
-8
src/main.ts
···
314
314
315
315
const metricsServer = startMetricsServer(METRICS_PORT);
316
316
317
-
/* labelerServer.app.listen({ port: PORT, host: HOST }, (error, address) => {
318
-
if (error) {
319
-
logger.error("Error starting server: %s", error);
320
-
} else {
321
-
logger.info(`Labeler server listening on ${address}`);
322
-
}
323
-
});*/
324
-
325
317
logger.info({ process: "MAIN" }, "Connecting to Redis");
326
318
await connectRedis();
327
319
+40
-4
src/metrics.ts
+40
-4
src/metrics.ts
···
1
1
import express from "express";
2
-
import { Registry, collectDefaultMetrics } from "prom-client";
2
+
import { Counter, Registry, collectDefaultMetrics } from "prom-client";
3
+
import { HOST } from "./config.js";
3
4
import { logger } from "./logger.js";
4
5
5
6
const register = new Registry();
6
7
collectDefaultMetrics({ register });
7
8
9
+
export const labelsAppliedCounter = new Counter({
10
+
name: "skywatch_labels_applied_total",
11
+
help: "Total number of labels applied by type",
12
+
labelNames: ["label_type", "target_type"],
13
+
registers: [register],
14
+
});
15
+
16
+
export const labelsCachedCounter = new Counter({
17
+
name: "skywatch_labels_cached_total",
18
+
help: "Total number of labels skipped due to cache/existing label",
19
+
labelNames: ["label_type", "target_type", "reason"],
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",
26
+
labelNames: ["account_label", "action"],
27
+
registers: [register],
28
+
});
29
+
30
+
export const accountThresholdChecksCounter = new Counter({
31
+
name: "skywatch_account_threshold_checks_total",
32
+
help: "Total number of account threshold checks performed",
33
+
labelNames: ["post_label"],
34
+
registers: [register],
35
+
});
36
+
37
+
export const accountThresholdMetCounter = new Counter({
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
+
8
44
const app = express();
9
45
10
46
app.get("/metrics", (req, res) => {
···
23
59
});
24
60
});
25
61
26
-
export const startMetricsServer = (port: number, host = "127.0.0.1") => {
27
-
return app.listen(port, host, () => {
62
+
export const startMetricsServer = (port: number) => {
63
+
return app.listen(port, HOST, () => {
28
64
logger.info(
29
-
{ process: "METRICS", host, port },
65
+
{ process: "METRICS", host: HOST, port },
30
66
"Metrics server is listening",
31
67
);
32
68
});
+31
-196
src/moderation.ts
+31
-196
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
+
import { labelsAppliedCounter, labelsCachedCounter } from "./metrics.js";
6
+
import { tryClaimPostLabel } from "./redis.js";
10
7
11
8
const doesLabelExist = (
12
9
labels: { val: string }[] | undefined,
···
25
22
comment: string,
26
23
duration: number | undefined,
27
24
did?: string,
25
+
time?: number,
28
26
) => {
29
27
await isLoggedIn;
30
28
···
34
32
{ process: "MODERATION", uri, label },
35
33
"Post label already claimed in Redis, skipping",
36
34
);
35
+
labelsCachedCounter.inc({
36
+
label_type: label,
37
+
target_type: "post",
38
+
reason: "redis_cache",
39
+
});
37
40
return;
38
41
}
39
42
···
43
46
{ process: "MODERATION", uri, label },
44
47
"Post already has label, skipping",
45
48
);
49
+
labelsCachedCounter.inc({
50
+
label_type: label,
51
+
target_type: "post",
52
+
reason: "existing_label",
53
+
});
46
54
return;
47
55
}
48
56
···
50
58
{ process: "MODERATION", label, did, atURI: uri },
51
59
"Labeling post",
52
60
);
61
+
labelsAppliedCounter.inc({ label_type: label, target_type: "post" });
53
62
54
63
await limit(async () => {
55
64
try {
···
95
104
},
96
105
},
97
106
);
107
+
108
+
if (did && time) {
109
+
try {
110
+
// Dynamic import to avoid circular dependency:
111
+
// accountThreshold imports from moderation (createAccountLabel, etc.)
112
+
// moderation imports from accountThreshold (checkAccountThreshold)
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 },
120
+
"Failed to check account threshold",
121
+
);
122
+
}
123
+
}
98
124
} catch (e) {
99
125
logger.error(
100
126
{ process: "MODERATION", error: e },
···
104
130
});
105
131
};
106
132
107
-
export const createAccountLabel = async (
108
-
did: string,
109
-
label: string,
110
-
comment: string,
111
-
) => {
112
-
await isLoggedIn;
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
-
123
-
const hasLabel = await checkAccountLabels(did, label);
124
-
if (hasLabel) {
125
-
logger.debug(
126
-
{ process: "MODERATION", did, label },
127
-
"Account already has label, skipping",
128
-
);
129
-
return;
130
-
}
131
-
132
-
logger.info({ process: "MODERATION", did, label }, "Labeling account");
133
-
134
-
await limit(async () => {
135
-
try {
136
-
await agent.tools.ozone.moderation.emitEvent(
137
-
{
138
-
event: {
139
-
$type: "tools.ozone.moderation.defs#modEventLabel",
140
-
comment: comment,
141
-
createLabelVals: [label],
142
-
negateLabelVals: [],
143
-
},
144
-
// specify the labeled post by strongRef
145
-
subject: {
146
-
$type: "com.atproto.admin.defs#repoRef",
147
-
did: did,
148
-
},
149
-
// put in the rest of the metadata
150
-
createdBy: `${agent.did}`,
151
-
createdAt: new Date().toISOString(),
152
-
modTool: {
153
-
name: "skywatch/skywatch-automod",
154
-
},
155
-
},
156
-
{
157
-
encoding: "application/json",
158
-
headers: {
159
-
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
160
-
"atproto-accept-labelers":
161
-
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
162
-
},
163
-
},
164
-
);
165
-
} catch (e) {
166
-
logger.error(
167
-
{ process: "MODERATION", error: e },
168
-
"Failed to create account label",
169
-
);
170
-
}
171
-
});
172
-
};
173
133
174
134
export const createPostReport = async (
175
135
uri: string,
···
217
177
});
218
178
};
219
179
220
-
export const createAccountComment = async (
221
-
did: string,
222
-
comment: string,
223
-
atURI: string,
224
-
) => {
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
-
238
-
await limit(async () => {
239
-
try {
240
-
await agent.tools.ozone.moderation.emitEvent(
241
-
{
242
-
event: {
243
-
$type: "tools.ozone.moderation.defs#modEventComment",
244
-
comment: comment,
245
-
},
246
-
// specify the labeled post by strongRef
247
-
subject: {
248
-
$type: "com.atproto.admin.defs#repoRef",
249
-
did: did,
250
-
},
251
-
// put in the rest of the metadata
252
-
createdBy: `${agent.did}`,
253
-
createdAt: new Date().toISOString(),
254
-
modTool: {
255
-
name: "skywatch/skywatch-automod",
256
-
},
257
-
},
258
-
{
259
-
encoding: "application/json",
260
-
headers: {
261
-
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
262
-
"atproto-accept-labelers":
263
-
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
264
-
},
265
-
},
266
-
);
267
-
} catch (e) {
268
-
logger.error(
269
-
{ process: "MODERATION", error: e },
270
-
"Failed to create account comment",
271
-
);
272
-
}
273
-
});
274
-
};
275
-
276
-
export const createAccountReport = async (did: string, comment: string) => {
277
-
await isLoggedIn;
278
-
await limit(async () => {
279
-
try {
280
-
await agent.tools.ozone.moderation.emitEvent(
281
-
{
282
-
event: {
283
-
$type: "tools.ozone.moderation.defs#modEventReport",
284
-
comment: comment,
285
-
reportType: "com.atproto.moderation.defs#reasonOther",
286
-
},
287
-
// specify the labeled post by strongRef
288
-
subject: {
289
-
$type: "com.atproto.admin.defs#repoRef",
290
-
did: did,
291
-
},
292
-
// put in the rest of the metadata
293
-
createdBy: `${agent.did}`,
294
-
createdAt: new Date().toISOString(),
295
-
modTool: {
296
-
name: "skywatch/skywatch-automod",
297
-
},
298
-
},
299
-
{
300
-
encoding: "application/json",
301
-
headers: {
302
-
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
303
-
"atproto-accept-labelers":
304
-
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
305
-
},
306
-
},
307
-
);
308
-
} catch (e) {
309
-
logger.error(
310
-
{ process: "MODERATION", error: e },
311
-
"Failed to create account report",
312
-
);
313
-
}
314
-
});
315
-
};
316
-
317
-
export const checkAccountLabels = async (
318
-
did: string,
319
-
label: string,
320
-
): Promise<boolean> => {
321
-
await isLoggedIn;
322
-
return await limit(async () => {
323
-
try {
324
-
const response = await agent.tools.ozone.moderation.getRepo(
325
-
{ did },
326
-
{
327
-
headers: {
328
-
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
329
-
"atproto-accept-labelers":
330
-
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
331
-
},
332
-
},
333
-
);
334
-
335
-
return doesLabelExist(response.data.labels, label);
336
-
} catch (e) {
337
-
logger.error(
338
-
{ process: "MODERATION", did, error: e },
339
-
"Failed to check account labels",
340
-
);
341
-
return false;
342
-
}
343
-
});
344
-
};
345
180
346
181
export const checkRecordLabels = async (
347
182
uri: string,
+72
src/redis.ts
+72
src/redis.ts
···
107
107
return true;
108
108
}
109
109
}
110
+
111
+
function getPostLabelTrackingKey(
112
+
did: string,
113
+
label: string,
114
+
windowDays: number,
115
+
): string {
116
+
return `account-post-labels:${did}:${label}:${windowDays}`;
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
+
131
+
await redisClient.zAdd(key, {
132
+
score: timestamp,
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;
149
+
}
150
+
}
151
+
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;
180
+
}
181
+
}
+3
-3
src/rules/account/age.ts
+3
-3
src/rules/account/age.ts
···
1
1
import { agent, isLoggedIn } from "../../agent.js";
2
2
import { PLC_URL } from "../../config.js";
3
-
import { GLOBAL_ALLOW } from "../../constants.js";
3
+
import { GLOBAL_ALLOW } from "../../../rules/constants.js";
4
4
import { logger } from "../../logger.js";
5
-
import { checkAccountLabels, createAccountLabel } from "../../moderation.js";
6
-
import { ACCOUNT_AGE_CHECKS } from "./ageConstants.js";
5
+
import { checkAccountLabels, createAccountLabel } from "../../accountModeration.js";
6
+
import { ACCOUNT_AGE_CHECKS } from "../../../rules/accountAge.js";
7
7
8
8
interface InteractionContext {
9
9
// For replies
-78
src/rules/account/ageConstants.ts
-78
src/rules/account/ageConstants.ts
···
1
-
import { AccountAgeCheck } from "../../types.js";
2
-
3
-
/**
4
-
* Account age monitoring configurations
5
-
*
6
-
* Each configuration monitors replies and/or quote posts to specified DIDs or posts
7
-
* and labels accounts that were created within a specific time window.
8
-
*
9
-
* Example use cases:
10
-
* - Monitor replies/quotes to high-profile accounts during harassment campaigns
11
-
* - Flag sock puppet accounts created to participate in coordinated harassment
12
-
* - Detect brigading on specific controversial posts
13
-
*/
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
-
},
53
-
// Example: Monitor replies to specific accounts
54
-
// {
55
-
// monitoredDIDs: [
56
-
// "did:plc:example123", // High-profile account 1
57
-
// "did:plc:example456", // High-profile account 2
58
-
// ],
59
-
// anchorDate: "2025-01-15", // Date when harassment campaign started
60
-
// maxAgeDays: 7, // Flag accounts less than 7 days old
61
-
// label: "new-account-reply",
62
-
// comment: "New account replying to monitored user during campaign",
63
-
// expires: "2025-02-15", // Optional: automatically stop this check after this date
64
-
// },
65
-
//
66
-
// Example: Monitor replies to specific posts
67
-
// {
68
-
// monitoredPostURIs: [
69
-
// "at://did:plc:example123/app.bsky.feed.post/abc123",
70
-
// "at://did:plc:example456/app.bsky.feed.post/def456",
71
-
// ],
72
-
// anchorDate: "2025-01-15",
73
-
// maxAgeDays: 7,
74
-
// label: "brigading-suspect",
75
-
// comment: "New account replying to specific targeted post",
76
-
// expires: "2025-02-15",
77
-
// },
78
-
];
+1
-1
src/rules/account/countStarterPacks.ts
+1
-1
src/rules/account/countStarterPacks.ts
···
1
1
import { agent, isLoggedIn } from "../../agent.js";
2
2
import { limit } from "../../limits.js";
3
3
import { logger } from "../../logger.js";
4
-
import { createAccountLabel } from "../../moderation.js";
4
+
import { createAccountLabel } from "../../accountModeration.js";
5
5
6
6
const ALLOWED_DIDS = ["did:plc:gpunjjgvlyb4racypz3yfiq4"];
7
7
+5
-5
src/rules/account/tests/age.test.ts
+5
-5
src/rules/account/tests/age.test.ts
···
1
1
import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
import { agent } from "../../../agent.js";
3
-
import { GLOBAL_ALLOW } from "../../../constants.js";
3
+
import { GLOBAL_ALLOW } from "../../../../rules/constants.js";
4
4
import { logger } from "../../../logger.js";
5
-
import { checkAccountLabels, createAccountLabel } from "../../../moderation.js";
5
+
import { checkAccountLabels, createAccountLabel } from "../../../accountModeration.js";
6
6
import {
7
7
calculateAccountAge,
8
8
checkAccountAge,
9
9
getAccountCreationDate,
10
10
} from "../age.js";
11
-
import { ACCOUNT_AGE_CHECKS } from "../ageConstants.js";
11
+
import { ACCOUNT_AGE_CHECKS } from "../../../../rules/accountAge.js";
12
12
13
13
// Mock dependencies
14
14
vi.mock("../../../agent.js", () => ({
···
27
27
},
28
28
}));
29
29
30
-
vi.mock("../../../moderation.js", () => ({
30
+
vi.mock("../../../accountModeration.js", () => ({
31
31
createAccountLabel: vi.fn(),
32
32
checkAccountLabels: vi.fn(),
33
33
}));
34
34
35
-
vi.mock("../../../constants.js", () => ({
35
+
vi.mock("../../../../rules/constants.js", () => ({
36
36
GLOBAL_ALLOW: [],
37
37
}));
38
38
+2
-2
src/rules/account/tests/countStarterPacks.test.ts
+2
-2
src/rules/account/tests/countStarterPacks.test.ts
···
2
2
import { agent } from "../../../agent.js";
3
3
import { limit } from "../../../limits.js";
4
4
import { logger } from "../../../logger.js";
5
-
import { createAccountLabel } from "../../../moderation.js";
5
+
import { createAccountLabel } from "../../../accountModeration.js";
6
6
import { countStarterPacks } from "../countStarterPacks.js";
7
7
8
8
// Mock dependencies
···
28
28
},
29
29
}));
30
30
31
-
vi.mock("../../../moderation.js", () => ({
31
+
vi.mock("../../../accountModeration.js", () => ({
32
32
createAccountLabel: vi.fn(),
33
33
}));
34
34
+1
-1
src/rules/facets/facets.ts
+1
-1
src/rules/facets/facets.ts
+2
-2
src/rules/facets/tests/facets.test.ts
+2
-2
src/rules/facets/tests/facets.test.ts
···
1
1
import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
import { logger } from "../../../logger.js";
3
-
import { createAccountLabel } from "../../../moderation.js";
3
+
import { createAccountLabel } from "../../../accountModeration.js";
4
4
import { Facet } from "../../../types.js";
5
5
import {
6
6
FACET_SPAM_ALLOWLIST,
···
11
11
} from "../facets.js";
12
12
13
13
// Mock dependencies
14
-
vi.mock("../../../moderation.js", () => ({
14
+
vi.mock("../../../accountModeration.js", () => ({
15
15
createAccountLabel: vi.fn(),
16
16
}));
17
17
+4
-4
src/rules/handles/checkHandles.test.ts
+4
-4
src/rules/handles/checkHandles.test.ts
···
3
3
createAccountComment,
4
4
createAccountLabel,
5
5
createAccountReport,
6
-
} from "../../moderation.js";
6
+
} from "../../accountModeration.js";
7
7
import { checkHandle } from "./checkHandles.js";
8
8
9
9
// Mock dependencies
10
-
vi.mock("../../moderation.js", () => ({
10
+
vi.mock("../../accountModeration.js", () => ({
11
11
createAccountReport: vi.fn(),
12
12
createAccountComment: vi.fn(),
13
13
createAccountLabel: vi.fn(),
···
21
21
},
22
22
}));
23
23
24
-
vi.mock("../../constants.js", () => ({
24
+
vi.mock("../../../rules/constants.js", () => ({
25
25
GLOBAL_ALLOW: ["did:plc:globalallow"],
26
26
}));
27
27
28
28
// Mock HANDLE_CHECKS with various test scenarios
29
-
vi.mock("./constants.js", () => ({
29
+
vi.mock("../../../rules/handles.js", () => ({
30
30
HANDLE_CHECKS: [
31
31
{
32
32
label: "spam",
+3
-3
src/rules/handles/checkHandles.ts
+3
-3
src/rules/handles/checkHandles.ts
···
1
-
import { GLOBAL_ALLOW } from "../../constants.js";
1
+
import { GLOBAL_ALLOW } from "../../../rules/constants.js";
2
2
import { logger } from "../../logger.js";
3
3
import {
4
4
createAccountComment,
5
5
createAccountLabel,
6
6
createAccountReport,
7
-
} from "../../moderation.js";
8
-
import { HANDLE_CHECKS } from "./constants.js";
7
+
} from "../../accountModeration.js";
8
+
import { HANDLE_CHECKS } from "../../../rules/handles.js";
9
9
10
10
export const checkHandle = async (
11
11
did: string,
+6
-6
src/rules/posts/checkPosts.ts
+6
-6
src/rules/posts/checkPosts.ts
···
1
-
import { GLOBAL_ALLOW } from "../../constants.js";
2
-
import { logger } from "../../logger.js";
1
+
import { GLOBAL_ALLOW, LINK_SHORTENER } from "../../../rules/constants.js";
2
+
import { POST_CHECKS } from "../../../rules/posts.js";
3
3
import {
4
4
createAccountComment,
5
5
createAccountReport,
6
-
createPostLabel,
7
-
createPostReport,
8
-
} from "../../moderation.js";
6
+
} from "../../accountModeration.js";
7
+
import { logger } from "../../logger.js";
8
+
import { createPostLabel, createPostReport } from "../../moderation.js";
9
9
import { Post } from "../../types.js";
10
10
import { getFinalUrl } from "../../utils/getFinalUrl.js";
11
11
import { getLanguage } from "../../utils/getLanguage.js";
12
12
import { countStarterPacks } from "../account/countStarterPacks.js";
13
-
import { LINK_SHORTENER, POST_CHECKS } from "./constants.js";
14
13
15
14
export const checkPosts = async (post: Post[]) => {
16
15
if (GLOBAL_ALLOW.includes(post[0].did)) {
···
113
112
`${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
114
113
checkPost.duration,
115
114
post[0].did,
115
+
post[0].time,
116
116
);
117
117
}
118
118
+18
-11
src/rules/posts/tests/checkPosts.test.ts
+18
-11
src/rules/posts/tests/checkPosts.test.ts
···
1
1
import { beforeEach, describe, expect, it, vi } from "vitest";
2
-
import { logger } from "../../../logger.js";
3
2
import {
4
3
createAccountComment,
5
4
createAccountReport,
6
-
createPostLabel,
7
-
createPostReport,
8
-
} from "../../../moderation.js";
5
+
} from "../../../accountModeration.js";
6
+
import { logger } from "../../../logger.js";
7
+
import { createPostLabel, createPostReport } from "../../../moderation.js";
9
8
import { Post } from "../../../types.js";
10
9
import { getFinalUrl } from "../../../utils/getFinalUrl.js";
11
10
import { getLanguage } from "../../../utils/getLanguage.js";
···
13
12
import { checkPosts } from "../checkPosts.js";
14
13
15
14
// Mock dependencies
16
-
vi.mock("../constants.js", () => ({
15
+
vi.mock("../../../../rules/constants.js", () => ({
16
+
GLOBAL_ALLOW: ["did:plc:globalallow"],
17
17
LINK_SHORTENER: /tinyurl\.com|bit\.ly/i,
18
+
}));
19
+
20
+
vi.mock("../../../../rules/posts.js", () => ({
18
21
POST_CHECKS: [
19
22
{
20
23
label: "test-label",
···
80
83
countStarterPacks: vi.fn(),
81
84
}));
82
85
86
+
vi.mock("../../../accountModeration.js", () => ({
87
+
createAccountReport: vi.fn(),
88
+
createAccountComment: vi.fn(),
89
+
}));
90
+
83
91
vi.mock("../../../moderation.js", () => ({
84
92
createPostLabel: vi.fn(),
85
-
createAccountReport: vi.fn(),
86
-
createAccountComment: vi.fn(),
87
93
createPostReport: vi.fn(),
88
94
}));
89
95
···
95
101
getFinalUrl: vi.fn(),
96
102
}));
97
103
98
-
vi.mock("../../../constants.js", () => ({
99
-
GLOBAL_ALLOW: ["did:plc:globalallow"],
100
-
}));
101
-
102
104
describe("checkPosts", () => {
103
105
beforeEach(() => {
104
106
vi.clearAllMocks();
···
251
253
expect.stringContaining("Test comment"),
252
254
undefined,
253
255
post[0].did,
256
+
post[0].time,
254
257
);
255
258
});
256
259
···
285
288
expect.any(String),
286
289
undefined,
287
290
post[0].did,
291
+
post[0].time,
288
292
);
289
293
});
290
294
···
339
343
expect.any(String),
340
344
undefined,
341
345
post[0].did,
346
+
post[0].time,
342
347
);
343
348
});
344
349
});
···
384
389
expect.any(String),
385
390
undefined,
386
391
"did:plc:notignored",
392
+
post[0].time,
387
393
);
388
394
});
389
395
});
···
401
407
expect.any(String),
402
408
undefined,
403
409
post[0].did,
410
+
post[0].time,
404
411
);
405
412
expect(createPostReport).toHaveBeenCalledWith(
406
413
post[0].atURI,
+3
-3
src/rules/profiles/checkProfiles.ts
+3
-3
src/rules/profiles/checkProfiles.ts
···
1
-
import { GLOBAL_ALLOW } from "../../constants.js";
1
+
import { GLOBAL_ALLOW } from "../../../rules/constants.js";
2
2
import { logger } from "../../logger.js";
3
3
import {
4
4
createAccountComment,
5
5
createAccountLabel,
6
6
createAccountReport,
7
-
} from "../../moderation.js";
8
-
import { PROFILE_CHECKS } from "../../rules/profiles/constants.js";
7
+
} from "../../accountModeration.js";
8
+
import { PROFILE_CHECKS } from "../../../rules/profiles.js";
9
9
import { getLanguage } from "../../utils/getLanguage.js";
10
10
11
11
export const checkDescription = async (
+4
-4
src/rules/profiles/tests/checkProfiles.test.ts
+4
-4
src/rules/profiles/tests/checkProfiles.test.ts
···
4
4
createAccountComment,
5
5
createAccountLabel,
6
6
createAccountReport,
7
-
} from "../../../moderation.js";
7
+
} from "../../../accountModeration.js";
8
8
import { getLanguage } from "../../../utils/getLanguage.js";
9
9
import { checkDescription, checkDisplayName } from "../checkProfiles.js";
10
10
11
11
// Mock dependencies
12
-
vi.mock("../constants.js", () => ({
12
+
vi.mock("../../../../rules/profiles.js", () => ({
13
13
PROFILE_CHECKS: [
14
14
{
15
15
label: "test-description",
···
96
96
},
97
97
}));
98
98
99
-
vi.mock("../../../moderation.js", () => ({
99
+
vi.mock("../../../accountModeration.js", () => ({
100
100
createAccountLabel: vi.fn(),
101
101
createAccountReport: vi.fn(),
102
102
createAccountComment: vi.fn(),
···
106
106
getLanguage: vi.fn().mockResolvedValue("eng"),
107
107
}));
108
108
109
-
vi.mock("../../../constants.js", () => ({
109
+
vi.mock("../../../../rules/constants.js", () => ({
110
110
GLOBAL_ALLOW: ["did:plc:globalallow"],
111
111
}));
112
112
+297
src/tests/accountThreshold.test.ts
+297
src/tests/accountThreshold.test.ts
···
1
+
import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+
vi.mock("../logger.js", () => ({
4
+
logger: {
5
+
info: vi.fn(),
6
+
warn: vi.fn(),
7
+
error: vi.fn(),
8
+
debug: vi.fn(),
9
+
},
10
+
}));
11
+
12
+
vi.mock("../../rules/accountThreshold.js", () => ({
13
+
ACCOUNT_THRESHOLD_CONFIGS: [
14
+
{
15
+
labels: ["test-label"],
16
+
threshold: 3,
17
+
accountLabel: "test-account-label",
18
+
accountComment: "Test comment",
19
+
windowDays: 5,
20
+
reportAcct: false,
21
+
commentAcct: false,
22
+
toLabel: true,
23
+
},
24
+
{
25
+
labels: ["label-1", "label-2", "label-3"],
26
+
threshold: 5,
27
+
accountLabel: "multi-label-account",
28
+
accountComment: "Multi label comment",
29
+
windowDays: 7,
30
+
reportAcct: true,
31
+
commentAcct: true,
32
+
toLabel: true,
33
+
},
34
+
{
35
+
labels: "monitor-only-label",
36
+
threshold: 2,
37
+
accountLabel: "monitored",
38
+
accountComment: "Monitoring comment",
39
+
windowDays: 3,
40
+
reportAcct: true,
41
+
commentAcct: false,
42
+
toLabel: false,
43
+
},
44
+
{
45
+
labels: ["label-1", "shared-label"],
46
+
threshold: 2,
47
+
accountLabel: "shared-config",
48
+
accountComment: "Shared config comment",
49
+
windowDays: 4,
50
+
reportAcct: false,
51
+
commentAcct: false,
52
+
toLabel: true,
53
+
},
54
+
],
55
+
}));
56
+
57
+
vi.mock("../redis.js", () => ({
58
+
trackPostLabelForAccount: vi.fn(),
59
+
getPostLabelCountInWindow: vi.fn(),
60
+
}));
61
+
62
+
vi.mock("../accountModeration.js", () => ({
63
+
createAccountLabel: vi.fn(),
64
+
createAccountReport: vi.fn(),
65
+
createAccountComment: vi.fn(),
66
+
}));
67
+
68
+
vi.mock("../metrics.js", () => ({
69
+
accountLabelsThresholdAppliedCounter: {
70
+
inc: vi.fn(),
71
+
},
72
+
accountThresholdChecksCounter: {
73
+
inc: vi.fn(),
74
+
},
75
+
accountThresholdMetCounter: {
76
+
inc: vi.fn(),
77
+
},
78
+
}));
79
+
80
+
import {
81
+
checkAccountThreshold,
82
+
loadThresholdConfigs,
83
+
} from "../accountThreshold.js";
84
+
import { logger } from "../logger.js";
85
+
import {
86
+
accountLabelsThresholdAppliedCounter,
87
+
accountThresholdChecksCounter,
88
+
accountThresholdMetCounter,
89
+
} from "../metrics.js";
90
+
import {
91
+
createAccountComment,
92
+
createAccountLabel,
93
+
createAccountReport,
94
+
} from "../accountModeration.js";
95
+
import {
96
+
getPostLabelCountInWindow,
97
+
trackPostLabelForAccount,
98
+
} from "../redis.js";
99
+
100
+
describe("Account Threshold Logic", () => {
101
+
afterEach(() => {
102
+
vi.clearAllMocks();
103
+
});
104
+
105
+
describe("loadThresholdConfigs", () => {
106
+
it("should load and cache configs successfully", () => {
107
+
const configs = loadThresholdConfigs();
108
+
expect(configs).toHaveLength(4);
109
+
expect(configs[0].labels).toEqual(["test-label"]);
110
+
expect(configs[1].labels).toEqual(["label-1", "label-2", "label-3"]);
111
+
});
112
+
113
+
it("should return cached configs on subsequent calls", () => {
114
+
const configs1 = loadThresholdConfigs();
115
+
const configs2 = loadThresholdConfigs();
116
+
expect(configs1).toBe(configs2);
117
+
});
118
+
});
119
+
120
+
describe("checkAccountThreshold", () => {
121
+
const testDid = "did:plc:test123";
122
+
const testTimestamp = 1640000000000000;
123
+
124
+
it("should not check threshold for non-matching labels", async () => {
125
+
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
126
+
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(0);
127
+
128
+
await checkAccountThreshold(testDid, "non-matching-label", testTimestamp);
129
+
130
+
expect(trackPostLabelForAccount).not.toHaveBeenCalled();
131
+
expect(getPostLabelCountInWindow).not.toHaveBeenCalled();
132
+
});
133
+
134
+
it("should track and check threshold for matching single label", async () => {
135
+
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
136
+
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
137
+
138
+
await checkAccountThreshold(testDid, "test-label", testTimestamp);
139
+
140
+
expect(accountThresholdChecksCounter.inc).toHaveBeenCalledWith({
141
+
post_label: "test-label",
142
+
});
143
+
expect(trackPostLabelForAccount).toHaveBeenCalledWith(
144
+
testDid,
145
+
"test-label",
146
+
testTimestamp,
147
+
5,
148
+
);
149
+
expect(getPostLabelCountInWindow).toHaveBeenCalledWith(
150
+
testDid,
151
+
["test-label"],
152
+
5,
153
+
testTimestamp,
154
+
);
155
+
});
156
+
157
+
it("should apply account label when threshold is met", async () => {
158
+
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
159
+
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(3);
160
+
vi.mocked(createAccountLabel).mockResolvedValue();
161
+
162
+
await checkAccountThreshold(testDid, "test-label", testTimestamp);
163
+
164
+
expect(accountThresholdMetCounter.inc).toHaveBeenCalledWith({
165
+
account_label: "test-account-label",
166
+
});
167
+
expect(createAccountLabel).toHaveBeenCalledWith(
168
+
testDid,
169
+
"test-account-label",
170
+
"Test comment",
171
+
);
172
+
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
173
+
account_label: "test-account-label",
174
+
action: "label",
175
+
});
176
+
});
177
+
178
+
it("should not apply label when threshold not met", async () => {
179
+
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
180
+
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
181
+
182
+
await checkAccountThreshold(testDid, "test-label", testTimestamp);
183
+
184
+
expect(accountThresholdMetCounter.inc).not.toHaveBeenCalled();
185
+
expect(createAccountLabel).not.toHaveBeenCalled();
186
+
});
187
+
188
+
it("should handle multi-label config with OR logic", async () => {
189
+
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
190
+
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(5);
191
+
vi.mocked(createAccountLabel).mockResolvedValue();
192
+
vi.mocked(createAccountReport).mockResolvedValue();
193
+
vi.mocked(createAccountComment).mockResolvedValue();
194
+
195
+
await checkAccountThreshold(testDid, "label-2", testTimestamp);
196
+
197
+
expect(getPostLabelCountInWindow).toHaveBeenCalledWith(
198
+
testDid,
199
+
["label-1", "label-2", "label-3"],
200
+
7,
201
+
testTimestamp,
202
+
);
203
+
expect(createAccountLabel).toHaveBeenCalledWith(
204
+
testDid,
205
+
"multi-label-account",
206
+
"Multi label comment",
207
+
);
208
+
expect(createAccountReport).toHaveBeenCalledWith(
209
+
testDid,
210
+
"Multi label comment",
211
+
);
212
+
expect(createAccountComment).toHaveBeenCalled();
213
+
});
214
+
215
+
it("should track but not label when toLabel is false", async () => {
216
+
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
217
+
vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2);
218
+
vi.mocked(createAccountReport).mockResolvedValue();
219
+
220
+
await checkAccountThreshold(testDid, "monitor-only-label", testTimestamp);
221
+
222
+
expect(trackPostLabelForAccount).toHaveBeenCalled();
223
+
expect(getPostLabelCountInWindow).toHaveBeenCalled();
224
+
expect(createAccountLabel).not.toHaveBeenCalled();
225
+
expect(createAccountReport).toHaveBeenCalled();
226
+
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
227
+
account_label: "monitored",
228
+
action: "report",
229
+
});
230
+
});
231
+
232
+
it("should increment all action metrics when threshold met", async () => {
233
+
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
234
+
vi.mocked(getPostLabelCountInWindow)
235
+
.mockResolvedValueOnce(5)
236
+
.mockResolvedValueOnce(1);
237
+
vi.mocked(createAccountLabel).mockResolvedValue();
238
+
vi.mocked(createAccountReport).mockResolvedValue();
239
+
vi.mocked(createAccountComment).mockResolvedValue();
240
+
241
+
await checkAccountThreshold(testDid, "label-1", testTimestamp);
242
+
243
+
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledTimes(3);
244
+
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
245
+
account_label: "multi-label-account",
246
+
action: "label",
247
+
});
248
+
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
249
+
account_label: "multi-label-account",
250
+
action: "report",
251
+
});
252
+
expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({
253
+
account_label: "multi-label-account",
254
+
action: "comment",
255
+
});
256
+
});
257
+
258
+
it("should handle Redis errors in trackPostLabelForAccount", async () => {
259
+
const redisError = new Error("Redis connection failed");
260
+
vi.mocked(trackPostLabelForAccount).mockRejectedValue(redisError);
261
+
262
+
await expect(
263
+
checkAccountThreshold(testDid, "test-label", testTimestamp),
264
+
).rejects.toThrow("Redis connection failed");
265
+
266
+
expect(logger.error).toHaveBeenCalled();
267
+
});
268
+
269
+
it("should handle Redis errors in getPostLabelCountInWindow", async () => {
270
+
const redisError = new Error("Redis query failed");
271
+
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
272
+
vi.mocked(getPostLabelCountInWindow).mockRejectedValue(redisError);
273
+
274
+
await expect(
275
+
checkAccountThreshold(testDid, "test-label", testTimestamp),
276
+
).rejects.toThrow("Redis query failed");
277
+
278
+
expect(logger.error).toHaveBeenCalled();
279
+
});
280
+
281
+
it("should handle multiple matching configs", async () => {
282
+
vi.mocked(trackPostLabelForAccount).mockResolvedValue();
283
+
vi.mocked(getPostLabelCountInWindow)
284
+
.mockResolvedValueOnce(5)
285
+
.mockResolvedValueOnce(3);
286
+
vi.mocked(createAccountLabel).mockResolvedValue();
287
+
vi.mocked(createAccountReport).mockResolvedValue();
288
+
vi.mocked(createAccountComment).mockResolvedValue();
289
+
290
+
await checkAccountThreshold(testDid, "label-1", testTimestamp);
291
+
292
+
expect(trackPostLabelForAccount).toHaveBeenCalledTimes(2);
293
+
expect(getPostLabelCountInWindow).toHaveBeenCalledTimes(2);
294
+
expect(createAccountLabel).toHaveBeenCalledTimes(2);
295
+
});
296
+
});
297
+
});
+18
-255
src/tests/agent.test.ts
+18
-255
src/tests/agent.test.ts
···
1
1
import { beforeEach, describe, expect, it, vi } from "vitest";
2
-
import type { SessionData } from "../session.js";
3
-
4
-
// TODO: Fix TypeScript mocking issues with AtpAgent
5
-
describe.skip("Agent", () => {
6
-
let mockLogin: any;
7
-
let mockResumeSession: any;
8
-
let mockGetProfile: any;
9
-
let loadSessionMock: any;
10
-
let saveSessionMock: any;
11
2
3
+
describe("Agent", () => {
12
4
beforeEach(() => {
13
-
vi.clearAllMocks();
5
+
vi.resetModules();
6
+
});
14
7
8
+
it("should create an agent and login", async () => {
15
9
// Mock the config variables
16
10
vi.doMock("../config.js", () => ({
17
11
BSKY_HANDLE: "test.bsky.social",
···
19
13
OZONE_PDS: "pds.test.com",
20
14
}));
21
15
22
-
// Create mock functions
23
-
mockLogin = vi.fn(() =>
24
-
Promise.resolve({
25
-
success: true,
26
-
data: {
27
-
accessJwt: "new-access-token",
28
-
refreshJwt: "new-refresh-token",
29
-
did: "did:plc:test123",
30
-
handle: "test.bsky.social",
31
-
},
32
-
})
33
-
);
34
-
mockResumeSession = vi.fn(() => Promise.resolve());
35
-
mockGetProfile = vi.fn(() =>
36
-
Promise.resolve({
37
-
success: true,
38
-
data: { did: "did:plc:test123", handle: "test.bsky.social" },
39
-
})
40
-
);
41
-
42
16
// Mock the AtpAgent
17
+
const mockLogin = vi.fn(() => Promise.resolve());
18
+
const mockConstructor = vi.fn();
43
19
vi.doMock("@atproto/api", () => ({
44
20
AtpAgent: class {
45
21
login = mockLogin;
46
-
resumeSession = mockResumeSession;
47
-
getProfile = mockGetProfile;
48
22
service: URL;
49
-
session: SessionData | null = null;
50
-
51
-
constructor(options: { service: string; fetch?: typeof fetch }) {
23
+
constructor(options: { service: string }) {
24
+
mockConstructor(options);
52
25
this.service = new URL(options.service);
53
-
// Store fetch function if provided for rate limit header testing
54
-
if (options.fetch) {
55
-
this.fetch = options.fetch;
56
-
}
57
26
}
58
-
59
-
fetch?: typeof fetch;
60
27
},
61
28
}));
62
29
63
-
// Mock session functions
64
-
loadSessionMock = vi.fn(() => null);
65
-
saveSessionMock = vi.fn();
30
+
const { agent, login } = await import("../agent.js");
66
31
67
-
vi.doMock("../session.js", () => ({
68
-
loadSession: loadSessionMock,
69
-
saveSession: saveSessionMock,
70
-
}));
71
-
72
-
// Mock updateRateLimitState
73
-
vi.doMock("../limits.js", () => ({
74
-
updateRateLimitState: vi.fn(),
75
-
}));
76
-
77
-
// Mock logger
78
-
vi.doMock("../logger.js", () => ({
79
-
logger: {
80
-
info: vi.fn(),
81
-
warn: vi.fn(),
82
-
error: vi.fn(),
83
-
debug: vi.fn(),
84
-
},
85
-
}));
86
-
});
87
-
88
-
describe("agent initialization", () => {
89
-
it("should create an agent with correct service URL", async () => {
90
-
const { agent } = await import("../agent.js");
91
-
expect(agent.service.toString()).toBe("https://pds.test.com/");
92
-
});
93
-
94
-
it("should provide custom fetch function for rate limit headers", async () => {
95
-
const { agent } = await import("../agent.js");
96
-
// @ts-expect-error - Testing custom fetch
97
-
expect(agent.fetch).toBeDefined();
98
-
});
99
-
});
100
-
101
-
describe("authentication with no saved session", () => {
102
-
it("should perform fresh login when no session exists", async () => {
103
-
loadSessionMock.mockReturnValue(null);
104
-
105
-
const { login } = await import("../agent.js");
106
-
const result = await login();
107
-
108
-
expect(loadSessionMock).toHaveBeenCalled();
109
-
expect(mockLogin).toHaveBeenCalledWith({
110
-
identifier: "test.bsky.social",
111
-
password: "password",
112
-
});
113
-
expect(result).toBe(true);
32
+
// Check that the agent was created with the correct service URL
33
+
expect(mockConstructor).toHaveBeenCalledWith({
34
+
service: "https://pds.test.com",
114
35
});
115
-
116
-
it("should save session after successful login", async () => {
117
-
loadSessionMock.mockReturnValue(null);
118
-
119
-
const mockSession: SessionData = {
120
-
accessJwt: "new-access-token",
121
-
refreshJwt: "new-refresh-token",
122
-
did: "did:plc:test123",
123
-
handle: "test.bsky.social",
124
-
active: true,
125
-
};
126
-
127
-
mockLogin.mockResolvedValue({
128
-
success: true,
129
-
data: mockSession,
130
-
});
131
-
132
-
// Need to manually set agent.session since we're mocking
133
-
const { login, agent } = await import("../agent.js");
134
-
// @ts-expect-error - Mocking session for tests
135
-
agent.session = mockSession;
36
+
expect(agent.service.toString()).toBe("https://pds.test.com/");
136
37
137
-
await login();
138
-
139
-
expect(saveSessionMock).toHaveBeenCalledWith(mockSession);
140
-
});
141
-
});
142
-
143
-
describe("authentication with saved session", () => {
144
-
it("should resume session when valid session exists", async () => {
145
-
const savedSession: SessionData = {
146
-
accessJwt: "saved-access-token",
147
-
refreshJwt: "saved-refresh-token",
148
-
did: "did:plc:test123",
149
-
handle: "test.bsky.social",
150
-
active: true,
151
-
};
152
-
153
-
loadSessionMock.mockReturnValue(savedSession);
154
-
155
-
const { login } = await import("../agent.js");
156
-
await login();
157
-
158
-
expect(loadSessionMock).toHaveBeenCalled();
159
-
expect(mockResumeSession).toHaveBeenCalledWith(savedSession);
160
-
expect(mockGetProfile).toHaveBeenCalledWith({ actor: savedSession.did });
161
-
});
162
-
163
-
it("should fallback to login when session resume fails", async () => {
164
-
const savedSession: SessionData = {
165
-
accessJwt: "invalid-token",
166
-
refreshJwt: "invalid-refresh",
167
-
did: "did:plc:test123",
168
-
handle: "test.bsky.social",
169
-
active: true,
170
-
};
171
-
172
-
loadSessionMock.mockReturnValue(savedSession);
173
-
mockResumeSession.mockRejectedValue(new Error("Invalid session"));
174
-
175
-
const { login } = await import("../agent.js");
176
-
await login();
177
-
178
-
expect(mockResumeSession).toHaveBeenCalled();
179
-
expect(mockLogin).toHaveBeenCalled();
180
-
});
181
-
182
-
it("should fallback to login when profile validation fails", async () => {
183
-
const savedSession: SessionData = {
184
-
accessJwt: "saved-token",
185
-
refreshJwt: "saved-refresh",
186
-
did: "did:plc:test123",
187
-
handle: "test.bsky.social",
188
-
active: true,
189
-
};
190
-
191
-
loadSessionMock.mockReturnValue(savedSession);
192
-
mockGetProfile.mockRejectedValue(new Error("Profile not found"));
193
-
194
-
const { login } = await import("../agent.js");
195
-
await login();
196
-
197
-
expect(mockResumeSession).toHaveBeenCalled();
198
-
expect(mockGetProfile).toHaveBeenCalled();
199
-
expect(mockLogin).toHaveBeenCalled();
200
-
});
201
-
});
202
-
203
-
describe("rate limit header extraction", () => {
204
-
it("should extract rate limit headers from responses", async () => {
205
-
const { updateRateLimitState } = await import("../limits.js");
206
-
const { agent } = await import("../agent.js");
207
-
208
-
// Simulate a response with rate limit headers
209
-
const mockResponse = new Response(JSON.stringify({ success: true }), {
210
-
headers: {
211
-
"ratelimit-limit": "3000",
212
-
"ratelimit-remaining": "2500",
213
-
"ratelimit-reset": "1760927355",
214
-
"ratelimit-policy": "3000;w=300",
215
-
},
216
-
});
217
-
218
-
// @ts-expect-error - Testing custom fetch
219
-
if (agent.fetch) {
220
-
// @ts-expect-error - Testing custom fetch
221
-
await agent.fetch("https://test.com", {});
222
-
}
223
-
224
-
// updateRateLimitState should have been called if headers are processed
225
-
// This is a basic check - actual implementation depends on fetch wrapper
226
-
});
227
-
});
228
-
229
-
describe("session refresh", () => {
230
-
it("should schedule session refresh after login", async () => {
231
-
vi.useFakeTimers();
232
-
233
-
loadSessionMock.mockReturnValue(null);
234
-
235
-
const mockSession: SessionData = {
236
-
accessJwt: "access-token",
237
-
refreshJwt: "refresh-token",
238
-
did: "did:plc:test123",
239
-
handle: "test.bsky.social",
240
-
active: true,
241
-
};
242
-
243
-
mockLogin.mockResolvedValue({
244
-
success: true,
245
-
data: mockSession,
246
-
});
247
-
248
-
const { login, agent } = await import("../agent.js");
249
-
// @ts-expect-error - Mocking session for tests
250
-
agent.session = mockSession;
251
-
252
-
await login();
253
-
254
-
// Fast-forward time to trigger refresh (2 hours * 0.8 = 96 minutes)
255
-
vi.advanceTimersByTime(96 * 60 * 1000);
256
-
257
-
vi.useRealTimers();
258
-
});
259
-
});
260
-
261
-
describe("error handling", () => {
262
-
it("should return false on login failure", async () => {
263
-
loadSessionMock.mockReturnValue(null);
264
-
mockLogin.mockResolvedValue({ success: false });
265
-
266
-
const { login } = await import("../agent.js");
267
-
const result = await login();
268
-
269
-
expect(result).toBe(false);
270
-
});
271
-
272
-
it("should return false when login throws error", async () => {
273
-
loadSessionMock.mockReturnValue(null);
274
-
mockLogin.mockRejectedValue(new Error("Network error"));
275
-
276
-
const { login } = await import("../agent.js");
277
-
const result = await login();
278
-
279
-
expect(result).toBe(false);
38
+
// Check that the login function calls the mockLogin function
39
+
await login();
40
+
expect(mockLogin).toHaveBeenCalledWith({
41
+
identifier: "test.bsky.social",
42
+
password: "password",
280
43
});
281
44
});
282
45
});
+21
-200
src/tests/limits.test.ts
+21
-200
src/tests/limits.test.ts
···
1
-
import { describe, expect, it, beforeEach, vi } from "vitest";
2
-
import { limit, getRateLimitState, updateRateLimitState } from "../limits.js";
1
+
import { describe, expect, it } from "vitest";
2
+
import { limit } from "../limits.js";
3
3
4
4
describe("Rate Limiter", () => {
5
-
beforeEach(() => {
6
-
// Reset rate limit state before each test
7
-
updateRateLimitState({
8
-
limit: 280,
9
-
remaining: 280,
10
-
reset: Math.floor(Date.now() / 1000) + 30,
11
-
});
12
-
});
13
-
14
-
describe("limit", () => {
15
-
it("should limit the rate of calls", async () => {
16
-
const calls = [];
17
-
for (let i = 0; i < 10; i++) {
18
-
calls.push(limit(() => Promise.resolve(Date.now())));
19
-
}
20
-
21
-
const start = Date.now();
22
-
const results = await Promise.all(calls);
23
-
const end = Date.now();
24
-
25
-
expect(results.length).toBe(10);
26
-
for (const result of results) {
27
-
expect(typeof result).toBe("number");
28
-
}
29
-
expect(end - start).toBeGreaterThanOrEqual(0);
30
-
}, 40000);
5
+
it("should limit the rate of calls", async () => {
6
+
const calls = [];
7
+
for (let i = 0; i < 10; i++) {
8
+
calls.push(limit(() => Promise.resolve(Date.now())));
9
+
}
31
10
32
-
it("should execute function and return result", async () => {
33
-
const result = await limit(() => Promise.resolve(42));
34
-
expect(result).toBe(42);
35
-
});
11
+
const start = Date.now();
12
+
const results = await Promise.all(calls);
13
+
const end = Date.now();
36
14
37
-
it("should handle errors from wrapped function", async () => {
38
-
await expect(
39
-
limit(() => Promise.reject(new Error("test error")))
40
-
).rejects.toThrow("test error");
41
-
});
42
-
43
-
it("should handle multiple concurrent requests", async () => {
44
-
const results = await Promise.all([
45
-
limit(() => Promise.resolve(1)),
46
-
limit(() => Promise.resolve(2)),
47
-
limit(() => Promise.resolve(3)),
48
-
]);
49
-
50
-
expect(results).toEqual([1, 2, 3]);
51
-
});
52
-
});
53
-
54
-
describe("getRateLimitState", () => {
55
-
it("should return current rate limit state", () => {
56
-
const state = getRateLimitState();
57
-
58
-
expect(state).toHaveProperty("limit");
59
-
expect(state).toHaveProperty("remaining");
60
-
expect(state).toHaveProperty("reset");
61
-
expect(typeof state.limit).toBe("number");
62
-
expect(typeof state.remaining).toBe("number");
63
-
expect(typeof state.reset).toBe("number");
64
-
});
65
-
66
-
it("should return a copy of state", () => {
67
-
const state1 = getRateLimitState();
68
-
const state2 = getRateLimitState();
69
-
70
-
expect(state1).toEqual(state2);
71
-
expect(state1).not.toBe(state2); // Different object references
72
-
});
73
-
});
74
-
75
-
describe("updateRateLimitState", () => {
76
-
it("should update limit", () => {
77
-
updateRateLimitState({ limit: 500 });
78
-
const state = getRateLimitState();
79
-
expect(state.limit).toBe(500);
80
-
});
81
-
82
-
it("should update remaining", () => {
83
-
updateRateLimitState({ remaining: 100 });
84
-
const state = getRateLimitState();
85
-
expect(state.remaining).toBe(100);
86
-
});
87
-
88
-
it("should update reset", () => {
89
-
const newReset = Math.floor(Date.now() / 1000) + 60;
90
-
updateRateLimitState({ reset: newReset });
91
-
const state = getRateLimitState();
92
-
expect(state.reset).toBe(newReset);
93
-
});
94
-
95
-
it("should update policy", () => {
96
-
updateRateLimitState({ policy: "3000;w=300" });
97
-
const state = getRateLimitState();
98
-
expect(state.policy).toBe("3000;w=300");
99
-
});
100
-
101
-
it("should update multiple fields at once", () => {
102
-
const updates = {
103
-
limit: 3000,
104
-
remaining: 2500,
105
-
reset: Math.floor(Date.now() / 1000) + 300,
106
-
policy: "3000;w=300",
107
-
};
108
-
109
-
updateRateLimitState(updates);
110
-
const state = getRateLimitState();
111
-
112
-
expect(state.limit).toBe(3000);
113
-
expect(state.remaining).toBe(2500);
114
-
expect(state.reset).toBe(updates.reset);
115
-
expect(state.policy).toBe("3000;w=300");
116
-
});
117
-
118
-
it("should preserve unspecified fields", () => {
119
-
updateRateLimitState({
120
-
limit: 3000,
121
-
remaining: 2500,
122
-
reset: Math.floor(Date.now() / 1000) + 300,
123
-
});
124
-
125
-
updateRateLimitState({ remaining: 2000 });
126
-
127
-
const state = getRateLimitState();
128
-
expect(state.limit).toBe(3000); // Preserved
129
-
expect(state.remaining).toBe(2000); // Updated
130
-
});
131
-
});
132
-
133
-
describe("awaitRateLimit", () => {
134
-
it("should not wait when remaining is above safety buffer", async () => {
135
-
updateRateLimitState({ remaining: 100 });
136
-
137
-
const start = Date.now();
138
-
await limit(() => Promise.resolve(1));
139
-
const elapsed = Date.now() - start;
140
-
141
-
// Should complete almost immediately (< 100ms)
142
-
expect(elapsed).toBeLessThan(100);
143
-
});
144
-
145
-
it("should wait when remaining is at safety buffer", async () => {
146
-
const now = Math.floor(Date.now() / 1000);
147
-
updateRateLimitState({
148
-
remaining: 5, // At safety buffer
149
-
reset: now + 1, // Reset in 1 second
150
-
});
151
-
152
-
const start = Date.now();
153
-
await limit(() => Promise.resolve(1));
154
-
const elapsed = Date.now() - start;
155
-
156
-
// Should wait approximately 1 second
157
-
expect(elapsed).toBeGreaterThanOrEqual(900);
158
-
expect(elapsed).toBeLessThan(1500);
159
-
}, 10000);
160
-
161
-
it("should wait when remaining is below safety buffer", async () => {
162
-
const now = Math.floor(Date.now() / 1000);
163
-
updateRateLimitState({
164
-
remaining: 2, // Below safety buffer
165
-
reset: now + 1, // Reset in 1 second
166
-
});
167
-
168
-
const start = Date.now();
169
-
await limit(() => Promise.resolve(1));
170
-
const elapsed = Date.now() - start;
171
-
172
-
// Should wait approximately 1 second
173
-
expect(elapsed).toBeGreaterThanOrEqual(900);
174
-
expect(elapsed).toBeLessThan(1500);
175
-
}, 10000);
176
-
177
-
it("should not wait if reset time has passed", async () => {
178
-
const now = Math.floor(Date.now() / 1000);
179
-
updateRateLimitState({
180
-
remaining: 2,
181
-
reset: now - 10, // Reset was 10 seconds ago
182
-
});
183
-
184
-
const start = Date.now();
185
-
await limit(() => Promise.resolve(1));
186
-
const elapsed = Date.now() - start;
187
-
188
-
// Should not wait
189
-
expect(elapsed).toBeLessThan(100);
190
-
});
191
-
});
192
-
193
-
describe("metrics", () => {
194
-
it("should track concurrent requests", async () => {
195
-
const delays = [100, 100, 100];
196
-
const promises = delays.map((delay) =>
197
-
limit(() => new Promise((resolve) => setTimeout(resolve, delay)))
198
-
);
199
-
200
-
await Promise.all(promises);
201
-
// If this completes without error, concurrent tracking works
202
-
expect(true).toBe(true);
203
-
});
204
-
});
15
+
// With a concurrency of 4, 10 calls should take at least 2 intervals.
16
+
// However, the interval is 30 seconds, so this test would be very slow.
17
+
// Instead, we'll just check that the calls were successful and returned a timestamp.
18
+
expect(results.length).toBe(10);
19
+
for (const result of results) {
20
+
expect(typeof result).toBe("number");
21
+
}
22
+
// A better test would be to mock the timer and advance it, but that's more complex.
23
+
// For now, we'll just check that the time taken is greater than 0.
24
+
expect(end - start).toBeGreaterThanOrEqual(0);
25
+
}, 40000); // Increase timeout for this test
205
26
});
+3
-2
src/tests/moderation.test.ts
+3
-2
src/tests/moderation.test.ts
···
41
41
42
42
// --- Imports Second ---
43
43
44
+
import { checkAccountLabels } from "../accountModeration.js";
44
45
import { agent } from "../agent.js";
45
-
import { checkAccountLabels, createPostLabel } from "../moderation.js";
46
+
import { logger } from "../logger.js";
47
+
import { createPostLabel } from "../moderation.js";
46
48
import { tryClaimPostLabel } from "../redis.js";
47
-
import { logger } from "../logger.js";
48
49
49
50
describe("Moderation Logic", () => {
50
51
beforeEach(() => {
+113
src/tests/redis.test.ts
+113
src/tests/redis.test.ts
···
9
9
quit: vi.fn(),
10
10
exists: vi.fn(),
11
11
set: vi.fn(),
12
+
zAdd: vi.fn(),
13
+
zRemRangeByScore: vi.fn(),
14
+
zCount: vi.fn(),
15
+
expire: vi.fn(),
12
16
};
13
17
return {
14
18
createClient: vi.fn(() => mockClient),
···
25
29
tryClaimAccountLabel,
26
30
connectRedis,
27
31
disconnectRedis,
32
+
trackPostLabelForAccount,
33
+
getPostLabelCountInWindow,
28
34
} from '../redis.js';
29
35
import { logger } from '../logger.js';
30
36
···
101
107
vi.mocked(mockRedisClient.set).mockResolvedValue(null);
102
108
const result = await tryClaimAccountLabel('did:plc:123', 'test-label');
103
109
expect(result).toBe(false);
110
+
});
111
+
});
112
+
113
+
describe('trackPostLabelForAccount', () => {
114
+
it('should track post label with correct timestamp and TTL', async () => {
115
+
vi.mocked(mockRedisClient.zRemRangeByScore).mockResolvedValue(0);
116
+
vi.mocked(mockRedisClient.zAdd).mockResolvedValue(1);
117
+
vi.mocked(mockRedisClient.expire).mockResolvedValue(true);
118
+
119
+
const timestamp = 1640000000000000; // microseconds
120
+
const windowDays = 5;
121
+
122
+
await trackPostLabelForAccount('did:plc:123', 'test-label', timestamp, windowDays);
123
+
124
+
const expectedKey = 'account-post-labels:did:plc:123:test-label:5';
125
+
const windowStartTime = timestamp - windowDays * 24 * 60 * 60 * 1000000;
126
+
127
+
expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith(
128
+
expectedKey,
129
+
'-inf',
130
+
windowStartTime
131
+
);
132
+
expect(mockRedisClient.zAdd).toHaveBeenCalledWith(expectedKey, {
133
+
score: timestamp,
134
+
value: timestamp.toString(),
135
+
});
136
+
expect(mockRedisClient.expire).toHaveBeenCalledWith(
137
+
expectedKey,
138
+
(windowDays + 1) * 24 * 60 * 60
139
+
);
140
+
});
141
+
142
+
it('should throw error on Redis failure', async () => {
143
+
const redisError = new Error('Redis down');
144
+
vi.mocked(mockRedisClient.zRemRangeByScore).mockRejectedValue(redisError);
145
+
146
+
await expect(
147
+
trackPostLabelForAccount('did:plc:123', 'test-label', 1640000000000000, 5)
148
+
).rejects.toThrow('Redis down');
149
+
150
+
expect(logger.error).toHaveBeenCalled();
151
+
});
152
+
});
153
+
154
+
describe('getPostLabelCountInWindow', () => {
155
+
it('should count posts for single label', async () => {
156
+
vi.mocked(mockRedisClient.zCount).mockResolvedValue(3);
157
+
158
+
const currentTime = 1640000000000000;
159
+
const windowDays = 5;
160
+
const count = await getPostLabelCountInWindow(
161
+
'did:plc:123',
162
+
['test-label'],
163
+
windowDays,
164
+
currentTime
165
+
);
166
+
167
+
expect(count).toBe(3);
168
+
const windowStartTime = currentTime - windowDays * 24 * 60 * 60 * 1000000;
169
+
expect(mockRedisClient.zCount).toHaveBeenCalledWith(
170
+
'account-post-labels:did:plc:123:test-label:5',
171
+
windowStartTime,
172
+
'+inf'
173
+
);
174
+
});
175
+
176
+
it('should sum counts for multiple labels (OR logic)', async () => {
177
+
vi.mocked(mockRedisClient.zCount)
178
+
.mockResolvedValueOnce(3)
179
+
.mockResolvedValueOnce(2)
180
+
.mockResolvedValueOnce(1);
181
+
182
+
const currentTime = 1640000000000000;
183
+
const windowDays = 5;
184
+
const count = await getPostLabelCountInWindow(
185
+
'did:plc:123',
186
+
['label-1', 'label-2', 'label-3'],
187
+
windowDays,
188
+
currentTime
189
+
);
190
+
191
+
expect(count).toBe(6);
192
+
expect(mockRedisClient.zCount).toHaveBeenCalledTimes(3);
193
+
});
194
+
195
+
it('should return 0 when no posts in window', async () => {
196
+
vi.mocked(mockRedisClient.zCount).mockResolvedValue(0);
197
+
198
+
const count = await getPostLabelCountInWindow(
199
+
'did:plc:123',
200
+
['test-label'],
201
+
5,
202
+
1640000000000000
203
+
);
204
+
205
+
expect(count).toBe(0);
206
+
});
207
+
208
+
it('should throw error on Redis failure', async () => {
209
+
const redisError = new Error('Redis down');
210
+
vi.mocked(mockRedisClient.zCount).mockRejectedValue(redisError);
211
+
212
+
await expect(
213
+
getPostLabelCountInWindow('did:plc:123', ['test-label'], 5, 1640000000000000)
214
+
).rejects.toThrow('Redis down');
215
+
216
+
expect(logger.error).toHaveBeenCalled();
104
217
});
105
218
});
106
219
});
-183
src/tests/session.test.ts
-183
src/tests/session.test.ts
···
1
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
-
import {
3
-
existsSync,
4
-
mkdirSync,
5
-
rmSync,
6
-
writeFileSync,
7
-
readFileSync,
8
-
unlinkSync,
9
-
chmodSync,
10
-
} from "node:fs";
11
-
import { join } from "node:path";
12
-
import type { SessionData } from "../session.js";
13
-
14
-
const TEST_DIR = join(process.cwd(), ".test-session");
15
-
const TEST_SESSION_PATH = join(TEST_DIR, ".session");
16
-
17
-
// Helper functions that mimic session.ts but use TEST_SESSION_PATH
18
-
function testLoadSession(): SessionData | null {
19
-
try {
20
-
if (!existsSync(TEST_SESSION_PATH)) {
21
-
return null;
22
-
}
23
-
24
-
const data = readFileSync(TEST_SESSION_PATH, "utf-8");
25
-
const session = JSON.parse(data) as SessionData;
26
-
27
-
if (!session.accessJwt || !session.refreshJwt || !session.did) {
28
-
return null;
29
-
}
30
-
31
-
return session;
32
-
} catch (error) {
33
-
return null;
34
-
}
35
-
}
36
-
37
-
function testSaveSession(session: SessionData): void {
38
-
try {
39
-
const data = JSON.stringify(session, null, 2);
40
-
writeFileSync(TEST_SESSION_PATH, data, "utf-8");
41
-
chmodSync(TEST_SESSION_PATH, 0o600);
42
-
} catch (error) {
43
-
// Ignore errors for test
44
-
}
45
-
}
46
-
47
-
function testClearSession(): void {
48
-
try {
49
-
if (existsSync(TEST_SESSION_PATH)) {
50
-
unlinkSync(TEST_SESSION_PATH);
51
-
}
52
-
} catch (error) {
53
-
// Ignore errors for test
54
-
}
55
-
}
56
-
57
-
describe("session", () => {
58
-
beforeEach(() => {
59
-
// Create test directory
60
-
if (!existsSync(TEST_DIR)) {
61
-
mkdirSync(TEST_DIR, { recursive: true });
62
-
}
63
-
});
64
-
65
-
afterEach(() => {
66
-
// Clean up test directory
67
-
if (existsSync(TEST_DIR)) {
68
-
rmSync(TEST_DIR, { recursive: true, force: true });
69
-
}
70
-
});
71
-
72
-
describe("saveSession", () => {
73
-
it("should save session to file with proper permissions", () => {
74
-
const session: SessionData = {
75
-
accessJwt: "access-token",
76
-
refreshJwt: "refresh-token",
77
-
did: "did:plc:test123",
78
-
handle: "test.bsky.social",
79
-
active: true,
80
-
};
81
-
82
-
testSaveSession(session);
83
-
84
-
expect(existsSync(TEST_SESSION_PATH)).toBe(true);
85
-
});
86
-
87
-
it("should save all session fields correctly", () => {
88
-
const session: SessionData = {
89
-
accessJwt: "access-token",
90
-
refreshJwt: "refresh-token",
91
-
did: "did:plc:test123",
92
-
handle: "test.bsky.social",
93
-
email: "test@example.com",
94
-
emailConfirmed: true,
95
-
emailAuthFactor: false,
96
-
active: true,
97
-
status: "active",
98
-
};
99
-
100
-
testSaveSession(session);
101
-
102
-
const loaded = testLoadSession();
103
-
expect(loaded).toEqual(session);
104
-
});
105
-
});
106
-
107
-
describe("loadSession", () => {
108
-
it("should return null if session file does not exist", () => {
109
-
const session = testLoadSession();
110
-
expect(session).toBeNull();
111
-
});
112
-
113
-
it("should load valid session from file", () => {
114
-
const session: SessionData = {
115
-
accessJwt: "access-token",
116
-
refreshJwt: "refresh-token",
117
-
did: "did:plc:test123",
118
-
handle: "test.bsky.social",
119
-
active: true,
120
-
};
121
-
122
-
testSaveSession(session);
123
-
const loaded = testLoadSession();
124
-
125
-
expect(loaded).toEqual(session);
126
-
});
127
-
128
-
it("should return null for corrupted session file", () => {
129
-
writeFileSync(TEST_SESSION_PATH, "{ invalid json", "utf-8");
130
-
131
-
const session = testLoadSession();
132
-
expect(session).toBeNull();
133
-
});
134
-
135
-
it("should return null for session missing required fields", () => {
136
-
writeFileSync(
137
-
TEST_SESSION_PATH,
138
-
JSON.stringify({ accessJwt: "token" }),
139
-
"utf-8"
140
-
);
141
-
142
-
const session = testLoadSession();
143
-
expect(session).toBeNull();
144
-
});
145
-
146
-
it("should return null for session missing did", () => {
147
-
writeFileSync(
148
-
TEST_SESSION_PATH,
149
-
JSON.stringify({
150
-
accessJwt: "access",
151
-
refreshJwt: "refresh",
152
-
handle: "test.bsky.social",
153
-
}),
154
-
"utf-8"
155
-
);
156
-
157
-
const session = testLoadSession();
158
-
expect(session).toBeNull();
159
-
});
160
-
});
161
-
162
-
describe("clearSession", () => {
163
-
it("should remove session file if it exists", () => {
164
-
const session: SessionData = {
165
-
accessJwt: "access-token",
166
-
refreshJwt: "refresh-token",
167
-
did: "did:plc:test123",
168
-
handle: "test.bsky.social",
169
-
active: true,
170
-
};
171
-
172
-
testSaveSession(session);
173
-
expect(existsSync(TEST_SESSION_PATH)).toBe(true);
174
-
175
-
testClearSession();
176
-
expect(existsSync(TEST_SESSION_PATH)).toBe(false);
177
-
});
178
-
179
-
it("should not throw if session file does not exist", () => {
180
-
expect(() => testClearSession()).not.toThrow();
181
-
});
182
-
});
183
-
});
+11
src/types.ts
+11
src/types.ts
···
68
68
comment: string; // Comment for the label
69
69
expires?: string; // Optional expiration date (ISO 8601) - check will be skipped after this date
70
70
}
71
+
72
+
export interface AccountThresholdConfig {
73
+
labels: string | string[]; // Single label or array for OR matching
74
+
threshold: number; // Number of labeled posts required to trigger account action
75
+
accountLabel: string; // Label to apply to the account
76
+
accountComment: string; // Comment for the account action
77
+
windowDays: number; // Rolling window in days
78
+
reportAcct: boolean; // Whether to report the account
79
+
commentAcct: boolean; // Whether to comment on the account
80
+
toLabel?: boolean; // Whether to apply label (defaults to true)
81
+
}