+1
.env.example
+1
.env.example
+170
src/account/age.ts
+170
src/account/age.ts
···
1
+
import { agent, isLoggedIn } from "../agent.js";
2
+
import { logger } from "../logger.js";
3
+
import { createAccountLabel } from "../moderation.js";
4
+
import { ACCOUNT_AGE_CHECKS } from "./ageConstants.js";
5
+
import { PLC_URL } from "../config.js";
6
+
7
+
interface ReplyContext {
8
+
replyToDid: string;
9
+
replyingDid: string;
10
+
atURI: string;
11
+
time: number;
12
+
}
13
+
14
+
/**
15
+
* Gets the account creation date from a DID
16
+
* Uses the plc directory to get DID document creation timestamp
17
+
*/
18
+
export const getAccountCreationDate = async (
19
+
did: string,
20
+
): Promise<Date | null> => {
21
+
try {
22
+
await isLoggedIn;
23
+
24
+
// For plc DIDs, try to extract creation from the DID document
25
+
if (did.startsWith("did:plc:")) {
26
+
try {
27
+
const response = await fetch(`https://${PLC_URL}/${did}`);
28
+
if (response.ok) {
29
+
const didDoc = await response.json();
30
+
31
+
// The plc directory returns an array of operations, first one is creation
32
+
if (Array.isArray(didDoc) && didDoc.length > 0) {
33
+
const createdAt = didDoc[0].createdAt;
34
+
if (createdAt) {
35
+
return new Date(createdAt);
36
+
}
37
+
}
38
+
} else {
39
+
logger.debug(
40
+
{ process: "ACCOUNT_AGE", did },
41
+
"Failed to fetch DID document, trying profile fallback",
42
+
);
43
+
}
44
+
} catch (plcError) {
45
+
logger.debug(
46
+
{ process: "ACCOUNT_AGE", did },
47
+
"Error fetching from plc directory, trying profile fallback",
48
+
);
49
+
}
50
+
}
51
+
52
+
// Fallback: try getting profile for any DID type
53
+
try {
54
+
const profile = await agent.getProfile({ actor: did });
55
+
if (profile.data.indexedAt) {
56
+
return new Date(profile.data.indexedAt);
57
+
}
58
+
} catch (profileError) {
59
+
logger.debug(
60
+
{ process: "ACCOUNT_AGE", did },
61
+
"Failed to get profile",
62
+
);
63
+
}
64
+
65
+
logger.warn(
66
+
{ process: "ACCOUNT_AGE", did },
67
+
"Could not determine account creation date",
68
+
);
69
+
return null;
70
+
} catch (error) {
71
+
logger.error(
72
+
{ process: "ACCOUNT_AGE", did, error },
73
+
"Error fetching account creation date",
74
+
);
75
+
return null;
76
+
}
77
+
};
78
+
79
+
/**
80
+
* Calculates the age of an account in days at a specific reference date
81
+
*/
82
+
export const calculateAccountAge = (
83
+
creationDate: Date,
84
+
referenceDate: Date,
85
+
): number => {
86
+
const diffMs = referenceDate.getTime() - creationDate.getTime();
87
+
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
88
+
};
89
+
90
+
/**
91
+
* Checks if a reply meets age criteria and applies labels accordingly
92
+
*/
93
+
export const checkAccountAge = async (
94
+
context: ReplyContext,
95
+
): Promise<void> => {
96
+
// Skip if no checks configured
97
+
if (ACCOUNT_AGE_CHECKS.length === 0) {
98
+
return;
99
+
}
100
+
101
+
// Check each configuration
102
+
for (const check of ACCOUNT_AGE_CHECKS) {
103
+
// Check if this reply is to a monitored DID
104
+
if (!check.monitoredDIDs.includes(context.replyToDid)) {
105
+
continue;
106
+
}
107
+
108
+
logger.debug(
109
+
{
110
+
process: "ACCOUNT_AGE",
111
+
replyingDid: context.replyingDid,
112
+
replyToDid: context.replyToDid,
113
+
},
114
+
"Checking account age for reply to monitored DID",
115
+
);
116
+
117
+
// Get account creation date
118
+
const creationDate = await getAccountCreationDate(context.replyingDid);
119
+
if (!creationDate) {
120
+
logger.warn(
121
+
{
122
+
process: "ACCOUNT_AGE",
123
+
replyingDid: context.replyingDid,
124
+
},
125
+
"Could not determine creation date, skipping",
126
+
);
127
+
continue;
128
+
}
129
+
130
+
// Calculate age at anchor date
131
+
const anchorDate = new Date(check.anchorDate);
132
+
const accountAge = calculateAccountAge(creationDate, anchorDate);
133
+
134
+
logger.debug(
135
+
{
136
+
process: "ACCOUNT_AGE",
137
+
replyingDid: context.replyingDid,
138
+
creationDate: creationDate.toISOString(),
139
+
anchorDate: check.anchorDate,
140
+
accountAge,
141
+
threshold: check.maxAgeDays,
142
+
},
143
+
"Account age calculated",
144
+
);
145
+
146
+
// Check if account is too new
147
+
if (accountAge < check.maxAgeDays) {
148
+
logger.info(
149
+
{
150
+
process: "ACCOUNT_AGE",
151
+
replyingDid: context.replyingDid,
152
+
replyToDid: context.replyToDid,
153
+
accountAge,
154
+
threshold: check.maxAgeDays,
155
+
atURI: context.atURI,
156
+
},
157
+
"Labeling new account replying to monitored DID",
158
+
);
159
+
160
+
await createAccountLabel(
161
+
context.replyingDid,
162
+
check.label,
163
+
`${context.time}: ${check.comment} - Account age: ${accountAge} days (threshold: ${check.maxAgeDays} days) - Reply: ${context.atURI}`,
164
+
);
165
+
166
+
// Only apply one label per reply
167
+
return;
168
+
}
169
+
}
170
+
};
+25
src/account/ageConstants.ts
+25
src/account/ageConstants.ts
···
1
+
import { AccountAgeCheck } from "../types.js";
2
+
3
+
/**
4
+
* Account age monitoring configurations
5
+
*
6
+
* Each configuration monitors replies to specified DIDs and labels accounts
7
+
* that are newer than the threshold relative to the anchor date.
8
+
*
9
+
* Example use case:
10
+
* - Monitor replies to high-profile accounts during harassment campaigns
11
+
* - Flag sock puppet accounts created to participate in coordinated harassment
12
+
*/
13
+
export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [
14
+
// Example configuration (disabled by default)
15
+
// {
16
+
// monitoredDIDs: [
17
+
// "did:plc:example123", // High-profile account 1
18
+
// "did:plc:example456", // High-profile account 2
19
+
// ],
20
+
// anchorDate: "2025-01-15", // Date when harassment campaign started
21
+
// maxAgeDays: 7, // Flag accounts less than 7 days old
22
+
// label: "new-account-reply",
23
+
// comment: "New account replying to monitored user during campaign",
24
+
// },
25
+
];
+399
src/account/tests/age.test.ts
+399
src/account/tests/age.test.ts
···
1
+
import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
import {
3
+
calculateAccountAge,
4
+
checkAccountAge,
5
+
getAccountCreationDate,
6
+
} from "../age.js";
7
+
import { ACCOUNT_AGE_CHECKS } from "../ageConstants.js";
8
+
9
+
// Mock dependencies
10
+
vi.mock("../../agent.js", () => ({
11
+
agent: {
12
+
getProfile: vi.fn(),
13
+
},
14
+
isLoggedIn: Promise.resolve(true),
15
+
}));
16
+
17
+
vi.mock("../../logger.js", () => ({
18
+
logger: {
19
+
info: vi.fn(),
20
+
debug: vi.fn(),
21
+
warn: vi.fn(),
22
+
error: vi.fn(),
23
+
},
24
+
}));
25
+
26
+
vi.mock("../../moderation.js", () => ({
27
+
createAccountLabel: vi.fn(),
28
+
}));
29
+
30
+
// Mock fetch for DID document lookups
31
+
global.fetch = vi.fn();
32
+
33
+
import { agent } from "../../agent.js";
34
+
import { logger } from "../../logger.js";
35
+
import { createAccountLabel } from "../../moderation.js";
36
+
37
+
describe("Account Age Module", () => {
38
+
beforeEach(() => {
39
+
vi.clearAllMocks();
40
+
});
41
+
42
+
describe("calculateAccountAge", () => {
43
+
it("should calculate age in days correctly", () => {
44
+
const creationDate = new Date("2025-01-01");
45
+
const referenceDate = new Date("2025-01-08");
46
+
47
+
const age = calculateAccountAge(creationDate, referenceDate);
48
+
49
+
expect(age).toBe(7);
50
+
});
51
+
52
+
it("should return 0 for same day", () => {
53
+
const date = new Date("2025-01-15");
54
+
55
+
const age = calculateAccountAge(date, date);
56
+
57
+
expect(age).toBe(0);
58
+
});
59
+
60
+
it("should handle accounts created before reference date", () => {
61
+
const creationDate = new Date("2024-01-01");
62
+
const referenceDate = new Date("2025-01-01");
63
+
64
+
const age = calculateAccountAge(creationDate, referenceDate);
65
+
66
+
expect(age).toBe(366); // 2024 is leap year
67
+
});
68
+
69
+
it("should return negative for accounts created after reference date", () => {
70
+
const creationDate = new Date("2025-01-15");
71
+
const referenceDate = new Date("2025-01-01");
72
+
73
+
const age = calculateAccountAge(creationDate, referenceDate);
74
+
75
+
expect(age).toBe(-14);
76
+
});
77
+
});
78
+
79
+
describe("getAccountCreationDate", () => {
80
+
it("should fetch creation date from plc directory for plc DIDs", async () => {
81
+
const mockDidDoc = [
82
+
{
83
+
createdAt: "2025-01-10T12:00:00.000Z",
84
+
},
85
+
];
86
+
87
+
(global.fetch as any).mockResolvedValueOnce({
88
+
ok: true,
89
+
json: async () => mockDidDoc,
90
+
});
91
+
92
+
const result = await getAccountCreationDate("did:plc:test123");
93
+
94
+
expect(global.fetch).toHaveBeenCalledWith(
95
+
"https://plc.wtf/did:plc:test123",
96
+
);
97
+
expect(result).toEqual(new Date("2025-01-10T12:00:00.000Z"));
98
+
});
99
+
100
+
it("should fall back to profile.indexedAt if plc lookup fails", async () => {
101
+
(global.fetch as any).mockResolvedValueOnce({
102
+
ok: false,
103
+
});
104
+
105
+
(agent.getProfile as any).mockResolvedValueOnce({
106
+
data: {
107
+
indexedAt: "2025-01-12T10:00:00.000Z",
108
+
},
109
+
});
110
+
111
+
const result = await getAccountCreationDate("did:plc:test456");
112
+
113
+
expect(result).toEqual(new Date("2025-01-12T10:00:00.000Z"));
114
+
});
115
+
116
+
it("should return null if both plc and profile fail", async () => {
117
+
(global.fetch as any).mockResolvedValueOnce({
118
+
ok: false,
119
+
});
120
+
121
+
(agent.getProfile as any).mockResolvedValueOnce({
122
+
data: {},
123
+
});
124
+
125
+
const result = await getAccountCreationDate("did:plc:unknown");
126
+
127
+
expect(result).toBeNull();
128
+
expect(logger.warn).toHaveBeenCalled();
129
+
});
130
+
131
+
it("should handle errors gracefully", async () => {
132
+
(global.fetch as any).mockRejectedValueOnce(new Error("Network error"));
133
+
134
+
(agent.getProfile as any).mockResolvedValueOnce({
135
+
data: {},
136
+
});
137
+
138
+
const result = await getAccountCreationDate("did:plc:error");
139
+
140
+
expect(result).toBeNull();
141
+
// Should log debug/warn when handling expected errors, not error
142
+
expect(logger.debug).toHaveBeenCalled();
143
+
});
144
+
});
145
+
146
+
describe("checkAccountAge", () => {
147
+
const TEST_TIME = Date.now() * 1000;
148
+
const TEST_REPLY_URI = "at://did:plc:replier123/app.bsky.feed.post/xyz";
149
+
150
+
beforeEach(() => {
151
+
// Clear the ACCOUNT_AGE_CHECKS array and add test config
152
+
ACCOUNT_AGE_CHECKS.length = 0;
153
+
});
154
+
155
+
it("should skip if no checks configured", async () => {
156
+
await checkAccountAge({
157
+
replyToDid: "did:plc:monitored",
158
+
replyingDid: "did:plc:replier",
159
+
atURI: TEST_REPLY_URI,
160
+
time: TEST_TIME,
161
+
});
162
+
163
+
expect(createAccountLabel).not.toHaveBeenCalled();
164
+
});
165
+
166
+
it("should skip if reply is not to monitored DID", async () => {
167
+
ACCOUNT_AGE_CHECKS.push({
168
+
monitoredDIDs: ["did:plc:monitored1"],
169
+
anchorDate: "2025-01-15",
170
+
maxAgeDays: 7,
171
+
label: "new-account-reply",
172
+
comment: "New account reply",
173
+
});
174
+
175
+
await checkAccountAge({
176
+
replyToDid: "did:plc:other",
177
+
replyingDid: "did:plc:replier",
178
+
atURI: TEST_REPLY_URI,
179
+
time: TEST_TIME,
180
+
});
181
+
182
+
expect(createAccountLabel).not.toHaveBeenCalled();
183
+
});
184
+
185
+
it("should label account if too new", async () => {
186
+
ACCOUNT_AGE_CHECKS.push({
187
+
monitoredDIDs: ["did:plc:monitored"],
188
+
anchorDate: "2025-01-15",
189
+
maxAgeDays: 7,
190
+
label: "new-account-reply",
191
+
comment: "New account replying during campaign",
192
+
});
193
+
194
+
// Mock account created on Jan 12 noon (2.5 days before anchor at midnight = 2 days floored)
195
+
const mockDidDoc = [
196
+
{
197
+
createdAt: "2025-01-12T12:00:00.000Z",
198
+
},
199
+
];
200
+
201
+
(global.fetch as any).mockResolvedValueOnce({
202
+
ok: true,
203
+
json: async () => mockDidDoc,
204
+
});
205
+
206
+
await checkAccountAge({
207
+
replyToDid: "did:plc:monitored",
208
+
replyingDid: "did:plc:newaccount",
209
+
atURI: TEST_REPLY_URI,
210
+
time: TEST_TIME,
211
+
});
212
+
213
+
expect(createAccountLabel).toHaveBeenCalledWith(
214
+
"did:plc:newaccount",
215
+
"new-account-reply",
216
+
expect.stringContaining("Account age: 2 days"),
217
+
);
218
+
});
219
+
220
+
it("should not label account if old enough", async () => {
221
+
ACCOUNT_AGE_CHECKS.push({
222
+
monitoredDIDs: ["did:plc:monitored"],
223
+
anchorDate: "2025-01-15",
224
+
maxAgeDays: 7,
225
+
label: "new-account-reply",
226
+
comment: "New account reply",
227
+
});
228
+
229
+
// Mock account created on Jan 5 (10 days before anchor)
230
+
const mockDidDoc = [
231
+
{
232
+
createdAt: "2025-01-05T12:00:00.000Z",
233
+
},
234
+
];
235
+
236
+
(global.fetch as any).mockResolvedValueOnce({
237
+
ok: true,
238
+
json: async () => mockDidDoc,
239
+
});
240
+
241
+
await checkAccountAge({
242
+
replyToDid: "did:plc:monitored",
243
+
replyingDid: "did:plc:oldaccount",
244
+
atURI: TEST_REPLY_URI,
245
+
time: TEST_TIME,
246
+
});
247
+
248
+
expect(createAccountLabel).not.toHaveBeenCalled();
249
+
});
250
+
251
+
it("should handle multiple monitored DIDs", async () => {
252
+
ACCOUNT_AGE_CHECKS.push({
253
+
monitoredDIDs: ["did:plc:monitored1", "did:plc:monitored2"],
254
+
anchorDate: "2025-01-15",
255
+
maxAgeDays: 7,
256
+
label: "new-account-reply",
257
+
comment: "New account reply",
258
+
});
259
+
260
+
const mockDidDoc = [
261
+
{
262
+
createdAt: "2025-01-14T12:00:00.000Z",
263
+
},
264
+
];
265
+
266
+
(global.fetch as any).mockResolvedValueOnce({
267
+
ok: true,
268
+
json: async () => mockDidDoc,
269
+
});
270
+
271
+
await checkAccountAge({
272
+
replyToDid: "did:plc:monitored2",
273
+
replyingDid: "did:plc:newaccount",
274
+
atURI: TEST_REPLY_URI,
275
+
time: TEST_TIME,
276
+
});
277
+
278
+
expect(createAccountLabel).toHaveBeenCalledOnce();
279
+
});
280
+
281
+
it("should handle multiple check configurations", async () => {
282
+
ACCOUNT_AGE_CHECKS.push(
283
+
{
284
+
monitoredDIDs: ["did:plc:monitored1"],
285
+
anchorDate: "2025-01-15",
286
+
maxAgeDays: 7,
287
+
label: "new-account-campaign1",
288
+
comment: "Campaign 1",
289
+
},
290
+
{
291
+
monitoredDIDs: ["did:plc:monitored2"],
292
+
anchorDate: "2025-02-01",
293
+
maxAgeDays: 14,
294
+
label: "new-account-campaign2",
295
+
comment: "Campaign 2",
296
+
},
297
+
);
298
+
299
+
const mockDidDoc = [
300
+
{
301
+
createdAt: "2025-01-20T12:00:00.000Z",
302
+
},
303
+
];
304
+
305
+
(global.fetch as any).mockResolvedValueOnce({
306
+
ok: true,
307
+
json: async () => mockDidDoc,
308
+
});
309
+
310
+
// Reply to monitored2 - account created Jan 20 noon, checked against Feb 1 midnight (11.5 days = 11 floored)
311
+
await checkAccountAge({
312
+
replyToDid: "did:plc:monitored2",
313
+
replyingDid: "did:plc:newaccount",
314
+
atURI: TEST_REPLY_URI,
315
+
time: TEST_TIME,
316
+
});
317
+
318
+
expect(createAccountLabel).toHaveBeenCalledWith(
319
+
"did:plc:newaccount",
320
+
"new-account-campaign2",
321
+
expect.stringContaining("Account age: 11 days"),
322
+
);
323
+
});
324
+
325
+
it("should skip if creation date cannot be determined", async () => {
326
+
ACCOUNT_AGE_CHECKS.push({
327
+
monitoredDIDs: ["did:plc:monitored"],
328
+
anchorDate: "2025-01-15",
329
+
maxAgeDays: 7,
330
+
label: "new-account-reply",
331
+
comment: "New account reply",
332
+
});
333
+
334
+
(global.fetch as any).mockResolvedValueOnce({
335
+
ok: false,
336
+
});
337
+
338
+
(agent.getProfile as any).mockResolvedValueOnce({
339
+
data: {},
340
+
});
341
+
342
+
await checkAccountAge({
343
+
replyToDid: "did:plc:monitored",
344
+
replyingDid: "did:plc:unknown",
345
+
atURI: TEST_REPLY_URI,
346
+
time: TEST_TIME,
347
+
});
348
+
349
+
expect(createAccountLabel).not.toHaveBeenCalled();
350
+
expect(logger.warn).toHaveBeenCalled();
351
+
});
352
+
353
+
it("should only apply one label per reply", async () => {
354
+
// Add overlapping configurations
355
+
ACCOUNT_AGE_CHECKS.push(
356
+
{
357
+
monitoredDIDs: ["did:plc:monitored"],
358
+
anchorDate: "2025-01-15",
359
+
maxAgeDays: 7,
360
+
label: "label1",
361
+
comment: "First check",
362
+
},
363
+
{
364
+
monitoredDIDs: ["did:plc:monitored"],
365
+
anchorDate: "2025-01-15",
366
+
maxAgeDays: 14,
367
+
label: "label2",
368
+
comment: "Second check",
369
+
},
370
+
);
371
+
372
+
const mockDidDoc = [
373
+
{
374
+
createdAt: "2025-01-14T12:00:00.000Z",
375
+
},
376
+
];
377
+
378
+
(global.fetch as any).mockResolvedValueOnce({
379
+
ok: true,
380
+
json: async () => mockDidDoc,
381
+
});
382
+
383
+
await checkAccountAge({
384
+
replyToDid: "did:plc:monitored",
385
+
replyingDid: "did:plc:newaccount",
386
+
atURI: TEST_REPLY_URI,
387
+
time: TEST_TIME,
388
+
});
389
+
390
+
// Should only call once (first matching check)
391
+
expect(createAccountLabel).toHaveBeenCalledOnce();
392
+
expect(createAccountLabel).toHaveBeenCalledWith(
393
+
"did:plc:newaccount",
394
+
"label1",
395
+
expect.any(String),
396
+
);
397
+
});
398
+
});
399
+
});
+1
src/config.ts
+1
src/config.ts
···
12
12
: 4101; // Left this intact from the code I adapted this from
13
13
export const FIREHOSE_URL =
14
14
process.env.FIREHOSE_URL ?? "wss://jetstream.atproto.tools/subscribe";
15
+
export const PLC_URL = process.env.PLC_URL ?? "plc.wtf";
15
16
export const WANTED_COLLECTION = [
16
17
"app.bsky.feed.post",
17
18
"app.bsky.actor.defs",
+16
src/main.ts
+16
src/main.ts
···
19
19
import { checkHandle } from "./checkHandles.js";
20
20
import { checkDescription, checkDisplayName } from "./checkProfiles.js";
21
21
import { checkFacetSpam } from "./rules/facets/facets.js";
22
+
import { checkAccountAge } from "./account/age.js";
22
23
23
24
let cursor = 0;
24
25
let cursorUpdateInterval: NodeJS.Timeout;
···
112
113
const hasText = event.commit.record.hasOwnProperty("text");
113
114
114
115
const tasks: Promise<void>[] = [];
116
+
117
+
// Check account age for replies to monitored DIDs
118
+
if (event.commit.record.reply) {
119
+
const parentUri = event.commit.record.reply.parent.uri;
120
+
const replyToDid = parentUri.split("/")[2]; // Extract DID from at://did/...
121
+
122
+
tasks.push(
123
+
checkAccountAge({
124
+
replyToDid,
125
+
replyingDid: event.did,
126
+
atURI,
127
+
time: event.time_us,
128
+
}),
129
+
);
130
+
}
115
131
116
132
// Check if the record has facets
117
133
if (hasFacets) {
+28
src/rules/facets/tests/facets.test.ts
+28
src/rules/facets/tests/facets.test.ts
···
153
153
expect(createAccountLabel).not.toHaveBeenCalled();
154
154
expect(logger.info).not.toHaveBeenCalled();
155
155
});
156
+
157
+
it("should not label when DID is on allowlist", async () => {
158
+
// Add test DID to allowlist temporarily
159
+
FACET_SPAM_ALLOWLIST.push(TEST_DID);
160
+
161
+
const facets: Facet[] = [
162
+
{
163
+
index: { byteStart: 0, byteEnd: 1 },
164
+
features: [{ $type: "app.bsky.richtext.facet#mention", did: "did:plc:user1" }],
165
+
},
166
+
{
167
+
index: { byteStart: 0, byteEnd: 1 },
168
+
features: [{ $type: "app.bsky.richtext.facet#mention", did: "did:plc:user2" }],
169
+
},
170
+
];
171
+
172
+
await checkFacetSpam(TEST_DID, TEST_TIME, TEST_URI, facets);
173
+
174
+
// Should not trigger - allowlisted
175
+
expect(createAccountLabel).not.toHaveBeenCalled();
176
+
expect(logger.debug).toHaveBeenCalledWith(
177
+
{ process: "FACET_SPAM", did: TEST_DID, atURI: TEST_URI },
178
+
"Allowlisted DID"
179
+
);
180
+
181
+
// Clean up
182
+
FACET_SPAM_ALLOWLIST.pop();
183
+
});
156
184
});
157
185
158
186
describe("when spam is detected", () => {
+9
src/types.ts
+9
src/types.ts
···
58
58
index: FacetIndex;
59
59
features: Array<{ $type: string; [key: string]: any }>;
60
60
}
61
+
62
+
export interface AccountAgeCheck {
63
+
monitoredDIDs: string[]; // DIDs to monitor for replies
64
+
anchorDate: string; // ISO 8601 date string (e.g., "2025-01-15")
65
+
maxAgeDays: number; // Maximum account age in days
66
+
label: string; // Label to apply if account is too new
67
+
comment: string; // Comment for the label
68
+
}
69
+