···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
···0000000000000000000000000
···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+];
···12 : 4101; // Left this intact from the code I adapted this from
13export const FIREHOSE_URL =
14 process.env.FIREHOSE_URL ?? "wss://jetstream.atproto.tools/subscribe";
015export const WANTED_COLLECTION = [
16 "app.bsky.feed.post",
17 "app.bsky.actor.defs",
···12 : 4101; // Left this intact from the code I adapted this from
13export const FIREHOSE_URL =
14 process.env.FIREHOSE_URL ?? "wss://jetstream.atproto.tools/subscribe";
15+export const PLC_URL = process.env.PLC_URL ?? "plc.wtf";
16export const WANTED_COLLECTION = [
17 "app.bsky.feed.post",
18 "app.bsky.actor.defs",
+16
src/main.ts
···19import { checkHandle } from "./checkHandles.js";
20import { checkDescription, checkDisplayName } from "./checkProfiles.js";
21import { checkFacetSpam } from "./rules/facets/facets.js";
02223let cursor = 0;
24let cursorUpdateInterval: NodeJS.Timeout;
···112 const hasText = event.commit.record.hasOwnProperty("text");
113114 const tasks: Promise<void>[] = [];
000000000000000115116 // Check if the record has facets
117 if (hasFacets) {
···19import { checkHandle } from "./checkHandles.js";
20import { checkDescription, checkDisplayName } from "./checkProfiles.js";
21import { checkFacetSpam } from "./rules/facets/facets.js";
22+import { checkAccountAge } from "./account/age.js";
2324let cursor = 0;
25let cursorUpdateInterval: NodeJS.Timeout;
···113 const hasText = event.commit.record.hasOwnProperty("text");
114115 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+ }
131132 // Check if the record has facets
133 if (hasFacets) {
···58 index: FacetIndex;
59 features: Array<{ $type: string; [key: string]: any }>;
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+