+6
-2
.claude/settings.local.json
+6
-2
.claude/settings.local.json
···
10
"mcp__git-mcp-server__git_status",
11
"mcp__git-mcp-server__git_log",
12
"mcp__git-mcp-server__git_set_working_dir",
13
+
"Bash(npm run test:run:*)",
14
+
"Bash(bunx eslint:*)",
15
+
"Bash(bun test:run:*)"
16
],
17
"deny": [],
18
"ask": []
19
},
20
"enableAllProjectMcpServers": true,
21
+
"enabledMcpjsonServers": [
22
+
"git-mcp-server"
23
+
]
24
}
+2
-2
.github/workflows/ci.yml
+2
-2
.github/workflows/ci.yml
+1
bun.lockb
+1
bun.lockb
···
···
1
+
+19
-1
eslint.config.mjs
+19
-1
eslint.config.mjs
···
8
export default defineConfig(
9
eslint.configs.recommended,
10
...tseslint.configs.strictTypeChecked,
11
prettier,
12
{
13
languageOptions: {
···
121
"*.config.mjs",
122
"coverage/",
123
],
124
+
},
125
+
// Test file overrides
126
+
{
127
+
files: ["**/*.test.ts", "**/*.test.tsx"],
128
+
rules: {
129
+
"@typescript-eslint/unbound-method": "off",
130
+
"@typescript-eslint/no-unsafe-argument": "off",
131
+
"@typescript-eslint/no-unsafe-assignment": "off",
132
+
"@typescript-eslint/no-unsafe-call": "off",
133
+
"@typescript-eslint/no-unsafe-member-access": "off",
134
+
"@typescript-eslint/no-unsafe-return": "off",
135
+
"@typescript-eslint/no-explicit-any": "off",
136
+
"@typescript-eslint/require-await": "off",
137
+
"@typescript-eslint/await-thenable": "off",
138
+
"@typescript-eslint/no-confusing-void-expression": "off",
139
+
"@typescript-eslint/restrict-template-expressions": "off",
140
+
"@typescript-eslint/no-unnecessary-type-conversion": "off",
141
+
"@typescript-eslint/no-deprecated": "off",
142
+
},
143
},
144
);
+13
-13
src/accountModeration.ts
+13
-13
src/accountModeration.ts
···
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",
···
78
{
79
encoding: "application/json",
80
headers: {
81
-
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
82
"atproto-accept-labelers":
83
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
84
},
···
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",
···
134
{
135
encoding: "application/json",
136
headers: {
137
-
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
138
"atproto-accept-labelers":
139
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
140
},
···
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",
···
175
{
176
encoding: "application/json",
177
headers: {
178
-
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
179
"atproto-accept-labelers":
180
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
181
},
···
201
{ did },
202
{
203
headers: {
204
-
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
205
"atproto-accept-labelers":
206
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
207
},
···
59
{
60
event: {
61
$type: "tools.ozone.moderation.defs#modEventLabel",
62
+
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,
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",
···
78
{
79
encoding: "application/json",
80
headers: {
81
+
"atproto-proxy": `${MOD_DID}#atproto_labeler`,
82
"atproto-accept-labelers":
83
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
84
},
···
117
{
118
event: {
119
$type: "tools.ozone.moderation.defs#modEventComment",
120
+
comment,
121
},
122
// specify the labeled post by strongRef
123
subject: {
124
$type: "com.atproto.admin.defs#repoRef",
125
+
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",
···
134
{
135
encoding: "application/json",
136
headers: {
137
+
"atproto-proxy": `${MOD_DID}#atproto_labeler`,
138
"atproto-accept-labelers":
139
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
140
},
···
157
{
158
event: {
159
$type: "tools.ozone.moderation.defs#modEventReport",
160
+
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,
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",
···
175
{
176
encoding: "application/json",
177
headers: {
178
+
"atproto-proxy": `${MOD_DID}#atproto_labeler`,
179
"atproto-accept-labelers":
180
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
181
},
···
201
{ did },
202
{
203
headers: {
204
+
"atproto-proxy": `${MOD_DID}#atproto_labeler`,
205
"atproto-accept-labelers":
206
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
207
},
+3
-3
src/accountThreshold.ts
+3
-3
src/accountThreshold.ts
···
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",
···
153
}
154
155
if (config.commentAcct) {
156
-
const atURI = `threshold-comment:${config.accountLabel}:${timestamp}`;
157
await createAccountComment(did, config.accountComment, atURI);
158
accountLabelsThresholdAppliedCounter.inc({
159
account_label: config.accountLabel,
···
14
getPostLabelCountInWindow,
15
trackPostLabelForAccount,
16
} from "./redis.js";
17
+
import type { 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.length === 0) {
25
logger.warn(
26
{ process: "ACCOUNT_THRESHOLD" },
27
"No account threshold configs found",
···
153
}
154
155
if (config.commentAcct) {
156
+
const atURI = `threshold-comment:${config.accountLabel}:${timestamp.toString()}`;
157
await createAccountComment(did, config.accountComment, atURI);
158
accountLabelsThresholdAppliedCounter.inc({
159
account_label: config.accountLabel,
+9
-8
src/agent.ts
+9
-8
src/agent.ts
···
27
limit: parseInt(limitHeader, 10),
28
remaining: parseInt(remainingHeader, 10),
29
reset: parseInt(resetHeader, 10),
30
-
policy: policyHeader || undefined,
31
});
32
}
33
···
46
async function refreshSession(): Promise<void> {
47
try {
48
logger.info("Refreshing session tokens");
49
-
await agent.resumeSession(agent.session!);
50
51
-
if (agent.session) {
52
-
saveSession(agent.session as SessionData);
53
-
scheduleSessionRefresh();
54
-
}
55
-
} catch (error) {
56
logger.error({ error }, "Failed to refresh session, will re-authenticate");
57
await performLogin();
58
}
···
69
);
70
71
refreshTimer = setTimeout(() => {
72
-
refreshSession().catch((error) => {
73
logger.error({ error }, "Scheduled session refresh failed");
74
});
75
}, refreshIn);
···
27
limit: parseInt(limitHeader, 10),
28
remaining: parseInt(remainingHeader, 10),
29
reset: parseInt(resetHeader, 10),
30
+
policy: policyHeader ?? undefined,
31
});
32
}
33
···
46
async function refreshSession(): Promise<void> {
47
try {
48
logger.info("Refreshing session tokens");
49
+
if (!agent.session) {
50
+
throw new Error("No active session to refresh");
51
+
}
52
+
await agent.resumeSession(agent.session);
53
54
+
saveSession(agent.session as SessionData);
55
+
scheduleSessionRefresh();
56
+
} catch (error: unknown) {
57
logger.error({ error }, "Failed to refresh session, will re-authenticate");
58
await performLogin();
59
}
···
70
);
71
72
refreshTimer = setTimeout(() => {
73
+
refreshSession().catch((error: unknown) => {
74
logger.error({ error }, "Scheduled session refresh failed");
75
});
76
}, refreshIn);
+3
-3
src/config.ts
+3
-3
src/config.ts
···
20
export const CURSOR_UPDATE_INTERVAL = process.env.CURSOR_UPDATE_INTERVAL
21
? Number(process.env.CURSOR_UPDATE_INTERVAL)
22
: 60000;
23
-
export const LABEL_LIMIT = process.env.LABEL_LIMIT;
24
-
export const LABEL_LIMIT_WAIT = process.env.LABEL_LIMIT_WAIT;
25
-
export const REDIS_URL = process.env.REDIS_URL || "redis://redis:6379";
···
20
export const CURSOR_UPDATE_INTERVAL = process.env.CURSOR_UPDATE_INTERVAL
21
? Number(process.env.CURSOR_UPDATE_INTERVAL)
22
: 60000;
23
+
export const {LABEL_LIMIT} = process.env;
24
+
export const {LABEL_LIMIT_WAIT} = process.env;
25
+
export const REDIS_URL = process.env.REDIS_URL ?? "redis://redis:6379";
+1
-1
src/limits.ts
+1
-1
src/limits.ts
+1
-1
src/logger.ts
+1
-1
src/logger.ts
+91
-65
src/main.ts
+91
-65
src/main.ts
···
1
import fs from "node:fs";
2
-
import {
3
CommitCreateEvent,
4
CommitUpdateEvent,
5
-
IdentityEvent,
6
Jetstream,
7
} from "@skyware/jetstream";
8
import {
···
22
checkDescription,
23
checkDisplayName,
24
} from "./rules/profiles/checkProfiles.js";
25
-
import { Handle, LinkFeature, Post } from "./types.js";
26
27
let cursor = 0;
28
let cursorUpdateInterval: NodeJS.Timeout;
···
55
const jetstream = new Jetstream({
56
wantedCollections: WANTED_COLLECTION,
57
endpoint: FIREHOSE_URL,
58
-
cursor: cursor,
59
});
60
61
jetstream.on("open", () => {
···
111
"app.bsky.feed.post",
112
(event: CommitCreateEvent<"app.bsky.feed.post">) => {
113
const atURI = `at://${event.did}/app.bsky.feed.post/${event.commit.rkey}`;
114
-
const hasEmbed = event.commit.record.hasOwnProperty("embed");
115
-
const hasFacets = event.commit.record.hasOwnProperty("facets");
116
-
const hasText = event.commit.record.hasOwnProperty("text");
117
118
const tasks: Promise<void>[] = [];
119
···
135
136
// Check account age for quote posts
137
if (hasEmbed) {
138
-
const embed = event.commit.record.embed;
139
if (
140
embed &&
141
(embed.$type === "app.bsky.embed.record" ||
142
embed.$type === "app.bsky.embed.recordWithMedia")
143
) {
144
const record =
145
embed.$type === "app.bsky.embed.record"
146
-
? embed.record
147
-
: embed.record.record;
148
-
if (record && record.uri) {
149
const quotedPostURI = record.uri;
150
const quotedDid = quotedPostURI.split("/")[2]; // Extract DID from at://did/...
151
-
152
-
tasks.push(
153
-
checkAccountAge({
154
-
actorDid: event.did,
155
-
quotedDid,
156
-
quotedPostURI,
157
-
atURI,
158
-
time: event.time_us,
159
-
}),
160
-
);
161
}
162
}
163
}
···
165
// Check if the record has facets
166
if (hasFacets) {
167
// Check for facet spam (hidden mentions with duplicate byte positions)
168
tasks.push(
169
checkFacetSpam(
170
event.did,
171
event.time_us,
172
atURI,
173
-
event.commit.record.facets!,
174
),
175
);
176
177
-
const hasLinkType = event.commit.record.facets!.some((facet) =>
178
facet.features.some(
179
(feature) => feature.$type === "app.bsky.richtext.facet#link",
180
),
181
);
182
183
-
if (hasLinkType) {
184
-
const urls = event.commit.record
185
-
.facets!.flatMap((facet) =>
186
-
facet.features.filter(
187
-
(feature) => feature.$type === "app.bsky.richtext.facet#link",
188
-
),
189
-
)
190
-
.map((feature: LinkFeature) => feature.uri);
191
192
-
urls.forEach((url) => {
193
-
const posts: Post[] = [
194
-
{
195
-
did: event.did,
196
-
time: event.time_us,
197
-
rkey: event.commit.rkey,
198
-
atURI: atURI,
199
-
text: url,
200
-
cid: event.commit.cid,
201
-
},
202
-
];
203
-
tasks.push(checkPosts(posts));
204
-
});
205
}
206
}
207
···
211
did: event.did,
212
time: event.time_us,
213
rkey: event.commit.rkey,
214
-
atURI: atURI,
215
text: event.commit.record.text,
216
cid: event.commit.cid,
217
},
···
220
}
221
222
if (hasEmbed) {
223
-
const embed = event.commit.record.embed;
224
-
if (embed && embed.$type === "app.bsky.embed.external") {
225
const posts: Post[] = [
226
{
227
did: event.did,
228
time: event.time_us,
229
rkey: event.commit.rkey,
230
-
atURI: atURI,
231
-
text: embed.external.uri,
232
cid: event.commit.cid,
233
},
234
];
235
tasks.push(checkPosts(posts));
236
}
237
238
-
if (embed && embed.$type === "app.bsky.embed.recordWithMedia") {
239
-
if (embed.media.$type === "app.bsky.embed.external") {
240
const posts: Post[] = [
241
{
242
did: event.did,
243
time: event.time_us,
244
rkey: event.commit.rkey,
245
-
atURI: atURI,
246
-
text: embed.media.external.uri,
247
cid: event.commit.cid,
248
},
249
];
···
257
// Check for profile updates
258
jetstream.onUpdate(
259
"app.bsky.actor.profile",
260
async (event: CommitUpdateEvent<"app.bsky.actor.profile">) => {
261
try {
262
if (event.commit.record.displayName || event.commit.record.description) {
263
-
checkDescription(
264
event.did,
265
event.time_us,
266
event.commit.record.displayName as string,
267
event.commit.record.description as string,
268
);
269
-
checkDisplayName(
270
event.did,
271
event.time_us,
272
event.commit.record.displayName as string,
···
283
284
jetstream.onCreate(
285
"app.bsky.actor.profile",
286
async (event: CommitCreateEvent<"app.bsky.actor.profile">) => {
287
try {
288
if (event.commit.record.displayName || event.commit.record.description) {
289
-
checkDescription(
290
event.did,
291
event.time_us,
292
event.commit.record.displayName as string,
293
event.commit.record.description as string,
294
);
295
-
checkDisplayName(
296
event.did,
297
event.time_us,
298
event.commit.record.displayName as string,
···
306
);
307
308
// Check for handle updates
309
-
jetstream.on("identity", async (event: IdentityEvent) => {
310
-
if (event.identity.handle) {
311
-
checkHandle(event.identity.did, event.identity.handle, event.time_us);
312
-
}
313
-
});
314
315
const metricsServer = startMetricsServer(METRICS_PORT);
316
···
322
async function shutdown() {
323
try {
324
logger.info({ process: "MAIN" }, "Shutting down gracefully");
325
-
fs.writeFileSync("cursor.txt", jetstream.cursor!.toString(), "utf8");
326
jetstream.close();
327
metricsServer.close();
328
await disconnectRedis();
···
332
}
333
}
334
335
-
process.on("SIGINT", shutdown);
336
-
process.on("SIGTERM", shutdown);
···
1
import fs from "node:fs";
2
+
import type {
3
CommitCreateEvent,
4
CommitUpdateEvent,
5
+
IdentityEvent} from "@skyware/jetstream";
6
+
import {
7
Jetstream,
8
} from "@skyware/jetstream";
9
import {
···
23
checkDescription,
24
checkDisplayName,
25
} from "./rules/profiles/checkProfiles.js";
26
+
import type { Post } from "./types.js";
27
28
let cursor = 0;
29
let cursorUpdateInterval: NodeJS.Timeout;
···
56
const jetstream = new Jetstream({
57
wantedCollections: WANTED_COLLECTION,
58
endpoint: FIREHOSE_URL,
59
+
cursor,
60
});
61
62
jetstream.on("open", () => {
···
112
"app.bsky.feed.post",
113
(event: CommitCreateEvent<"app.bsky.feed.post">) => {
114
const atURI = `at://${event.did}/app.bsky.feed.post/${event.commit.rkey}`;
115
+
const hasEmbed = Object.prototype.hasOwnProperty.call(event.commit.record, "embed");
116
+
const hasFacets = Object.prototype.hasOwnProperty.call(event.commit.record, "facets");
117
+
const hasText = Object.prototype.hasOwnProperty.call(event.commit.record, "text");
118
119
const tasks: Promise<void>[] = [];
120
···
136
137
// Check account age for quote posts
138
if (hasEmbed) {
139
+
const {embed} = event.commit.record;
140
if (
141
embed &&
142
+
typeof embed === "object" &&
143
+
"$type" in embed &&
144
(embed.$type === "app.bsky.embed.record" ||
145
embed.$type === "app.bsky.embed.recordWithMedia")
146
) {
147
const record =
148
embed.$type === "app.bsky.embed.record"
149
+
? (embed as { record: { uri?: string } }).record
150
+
: (embed as { record: { record: { uri?: string } } }).record.record;
151
+
if (record.uri && typeof record.uri === "string") {
152
const quotedPostURI = record.uri;
153
const quotedDid = quotedPostURI.split("/")[2]; // Extract DID from at://did/...
154
+
if (quotedDid) {
155
+
tasks.push(
156
+
checkAccountAge({
157
+
actorDid: event.did,
158
+
quotedDid,
159
+
quotedPostURI,
160
+
atURI,
161
+
time: event.time_us,
162
+
}),
163
+
);
164
+
}
165
}
166
}
167
}
···
169
// Check if the record has facets
170
if (hasFacets) {
171
// Check for facet spam (hidden mentions with duplicate byte positions)
172
+
const facets = event.commit.record.facets ?? null;
173
tasks.push(
174
checkFacetSpam(
175
event.did,
176
event.time_us,
177
atURI,
178
+
facets,
179
),
180
);
181
182
+
const hasLinkType = facets?.some((facet) =>
183
facet.features.some(
184
(feature) => feature.$type === "app.bsky.richtext.facet#link",
185
),
186
);
187
188
+
if (hasLinkType && facets) {
189
+
for (const facet of facets) {
190
+
const linkFeatures = facet.features.filter(
191
+
(feature) => feature.$type === "app.bsky.richtext.facet#link",
192
+
);
193
194
+
for (const feature of linkFeatures) {
195
+
if ("uri" in feature && typeof feature.uri === "string") {
196
+
const posts: Post[] = [
197
+
{
198
+
did: event.did,
199
+
time: event.time_us,
200
+
rkey: event.commit.rkey,
201
+
atURI,
202
+
text: feature.uri,
203
+
cid: event.commit.cid,
204
+
},
205
+
];
206
+
tasks.push(checkPosts(posts));
207
+
}
208
+
}
209
+
}
210
}
211
}
212
···
216
did: event.did,
217
time: event.time_us,
218
rkey: event.commit.rkey,
219
+
atURI,
220
text: event.commit.record.text,
221
cid: event.commit.cid,
222
},
···
225
}
226
227
if (hasEmbed) {
228
+
const {embed} = event.commit.record;
229
+
if (
230
+
embed &&
231
+
typeof embed === "object" &&
232
+
"$type" in embed &&
233
+
embed.$type === "app.bsky.embed.external"
234
+
) {
235
+
const {external} = embed as { external: { uri: string } };
236
const posts: Post[] = [
237
{
238
did: event.did,
239
time: event.time_us,
240
rkey: event.commit.rkey,
241
+
atURI,
242
+
text: external.uri,
243
cid: event.commit.cid,
244
},
245
];
246
tasks.push(checkPosts(posts));
247
}
248
249
+
if (
250
+
embed &&
251
+
typeof embed === "object" &&
252
+
"$type" in embed &&
253
+
embed.$type === "app.bsky.embed.recordWithMedia"
254
+
) {
255
+
const {media} = embed as { media: { $type: string; external?: { uri: string } } };
256
+
if (media.$type === "app.bsky.embed.external" && media.external) {
257
const posts: Post[] = [
258
{
259
did: event.did,
260
time: event.time_us,
261
rkey: event.commit.rkey,
262
+
atURI,
263
+
text: media.external.uri,
264
cid: event.commit.cid,
265
},
266
];
···
274
// Check for profile updates
275
jetstream.onUpdate(
276
"app.bsky.actor.profile",
277
+
// eslint-disable-next-line @typescript-eslint/no-misused-promises, @typescript-eslint/require-await
278
async (event: CommitUpdateEvent<"app.bsky.actor.profile">) => {
279
try {
280
if (event.commit.record.displayName || event.commit.record.description) {
281
+
void checkDescription(
282
event.did,
283
event.time_us,
284
event.commit.record.displayName as string,
285
event.commit.record.description as string,
286
);
287
+
void checkDisplayName(
288
event.did,
289
event.time_us,
290
event.commit.record.displayName as string,
···
301
302
jetstream.onCreate(
303
"app.bsky.actor.profile",
304
+
// eslint-disable-next-line @typescript-eslint/no-misused-promises, @typescript-eslint/require-await
305
async (event: CommitCreateEvent<"app.bsky.actor.profile">) => {
306
try {
307
if (event.commit.record.displayName || event.commit.record.description) {
308
+
void checkDescription(
309
event.did,
310
event.time_us,
311
event.commit.record.displayName as string,
312
event.commit.record.description as string,
313
);
314
+
void checkDisplayName(
315
event.did,
316
event.time_us,
317
event.commit.record.displayName as string,
···
325
);
326
327
// Check for handle updates
328
+
jetstream.on(
329
+
"identity",
330
+
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-misused-promises
331
+
async (event: IdentityEvent) => {
332
+
if (event.identity.handle) {
333
+
// checkHandle is sync but calls async functions with void
334
+
checkHandle(event.identity.did, event.identity.handle, event.time_us);
335
+
}
336
+
},
337
+
);
338
339
const metricsServer = startMetricsServer(METRICS_PORT);
340
···
346
async function shutdown() {
347
try {
348
logger.info({ process: "MAIN" }, "Shutting down gracefully");
349
+
if (jetstream.cursor !== undefined) {
350
+
fs.writeFileSync("cursor.txt", jetstream.cursor.toString(), "utf8");
351
+
}
352
jetstream.close();
353
metricsServer.close();
354
await disconnectRedis();
···
358
}
359
}
360
361
+
process.on("SIGINT", () => void shutdown());
362
+
process.on("SIGTERM", () => void shutdown());
+13
-13
src/moderation.ts
+13
-13
src/moderation.ts
···
70
durationInHours?: number;
71
} = {
72
$type: "tools.ozone.moderation.defs#modEventLabel",
73
-
comment: comment,
74
createLabelVals: [label],
75
negateLabelVals: [],
76
};
···
81
82
await agent.tools.ozone.moderation.emitEvent(
83
{
84
-
event: event,
85
// specify the labeled post by strongRef
86
subject: {
87
$type: "com.atproto.repo.strongRef",
88
-
uri: uri,
89
-
cid: cid,
90
},
91
// put in the rest of the metadata
92
-
createdBy: `${agent.did}`,
93
createdAt: new Date().toISOString(),
94
modTool: {
95
name: "skywatch/skywatch-automod",
···
98
{
99
encoding: "application/json",
100
headers: {
101
-
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
102
"atproto-accept-labelers":
103
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
104
},
···
138
await isLoggedIn;
139
await limit(async () => {
140
try {
141
-
return agent.tools.ozone.moderation.emitEvent(
142
{
143
event: {
144
$type: "tools.ozone.moderation.defs#modEventReport",
145
-
comment: comment,
146
reportType: "com.atproto.moderation.defs#reasonOther",
147
},
148
// specify the labeled post by strongRef
149
subject: {
150
$type: "com.atproto.repo.strongRef",
151
-
uri: uri,
152
-
cid: cid,
153
},
154
// put in the rest of the metadata
155
-
createdBy: `${agent.did}`,
156
createdAt: new Date().toISOString(),
157
modTool: {
158
name: "skywatch/skywatch-automod",
···
161
{
162
encoding: "application/json",
163
headers: {
164
-
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
165
"atproto-accept-labelers":
166
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
167
},
···
187
{ uri },
188
{
189
headers: {
190
-
"atproto-proxy": `${MOD_DID!}#atproto_labeler`,
191
"atproto-accept-labelers":
192
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
193
},
···
70
durationInHours?: number;
71
} = {
72
$type: "tools.ozone.moderation.defs#modEventLabel",
73
+
comment,
74
createLabelVals: [label],
75
negateLabelVals: [],
76
};
···
81
82
await agent.tools.ozone.moderation.emitEvent(
83
{
84
+
event,
85
// specify the labeled post by strongRef
86
subject: {
87
$type: "com.atproto.repo.strongRef",
88
+
uri,
89
+
cid,
90
},
91
// put in the rest of the metadata
92
+
createdBy: agent.did ?? "",
93
createdAt: new Date().toISOString(),
94
modTool: {
95
name: "skywatch/skywatch-automod",
···
98
{
99
encoding: "application/json",
100
headers: {
101
+
"atproto-proxy": `${MOD_DID}#atproto_labeler`,
102
"atproto-accept-labelers":
103
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
104
},
···
138
await isLoggedIn;
139
await limit(async () => {
140
try {
141
+
return await agent.tools.ozone.moderation.emitEvent(
142
{
143
event: {
144
$type: "tools.ozone.moderation.defs#modEventReport",
145
+
comment,
146
reportType: "com.atproto.moderation.defs#reasonOther",
147
},
148
// specify the labeled post by strongRef
149
subject: {
150
$type: "com.atproto.repo.strongRef",
151
+
uri,
152
+
cid,
153
},
154
// put in the rest of the metadata
155
+
createdBy: agent.did ?? "",
156
createdAt: new Date().toISOString(),
157
modTool: {
158
name: "skywatch/skywatch-automod",
···
161
{
162
encoding: "application/json",
163
headers: {
164
+
"atproto-proxy": `${MOD_DID}#atproto_labeler`,
165
"atproto-accept-labelers":
166
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
167
},
···
187
{ uri },
188
{
189
headers: {
190
+
"atproto-proxy": `${MOD_DID}#atproto_labeler`,
191
"atproto-accept-labelers":
192
"did:plc:ar7c4by46qjdydhdevvrndac;redact",
193
},
+1
-1
src/redis.ts
+1
-1
src/redis.ts
+7
-7
src/rules/account/age.ts
+7
-7
src/rules/account/age.ts
···
39
try {
40
const response = await fetch(`https://${PLC_URL}/${did}/log/audit`);
41
if (response.ok) {
42
-
const didDoc = await response.json();
43
44
// The plc directory returns an array of operations, first one is creation
45
if (Array.isArray(didDoc) && didDoc.length > 0) {
46
-
const createdAt = didDoc[0].createdAt;
47
-
if (createdAt) {
48
-
return new Date(createdAt);
49
}
50
}
51
} else {
···
54
"Failed to fetch DID document, trying profile fallback",
55
);
56
}
57
-
} catch (plcError) {
58
logger.debug(
59
{ process: "ACCOUNT_AGE", did },
60
"Error fetching from plc directory, trying profile fallback",
···
68
if (profile.data.createdAt) {
69
return new Date(profile.data.createdAt);
70
}
71
-
} catch (profileError) {
72
logger.debug({ process: "ACCOUNT_AGE", did }, "Failed to get profile");
73
}
74
···
240
await createAccountLabel(
241
context.actorDid,
242
check.label,
243
-
`${context.time}: ${check.comment} - Account created within monitored range - Interaction: ${context.atURI}`,
244
);
245
246
// Only apply one label per interaction
···
39
try {
40
const response = await fetch(`https://${PLC_URL}/${did}/log/audit`);
41
if (response.ok) {
42
+
const didDoc = (await response.json()) as unknown;
43
44
// The plc directory returns an array of operations, first one is creation
45
if (Array.isArray(didDoc) && didDoc.length > 0) {
46
+
const firstOp = didDoc[0] as { createdAt?: string };
47
+
if (firstOp.createdAt) {
48
+
return new Date(firstOp.createdAt);
49
}
50
}
51
} else {
···
54
"Failed to fetch DID document, trying profile fallback",
55
);
56
}
57
+
} catch {
58
logger.debug(
59
{ process: "ACCOUNT_AGE", did },
60
"Error fetching from plc directory, trying profile fallback",
···
68
if (profile.data.createdAt) {
69
return new Date(profile.data.createdAt);
70
}
71
+
} catch {
72
logger.debug({ process: "ACCOUNT_AGE", did }, "Failed to get profile");
73
}
74
···
240
await createAccountLabel(
241
context.actorDid,
242
check.label,
243
+
`${context.time.toString()}: ${check.comment} - Account created within monitored range - Interaction: ${context.atURI}`,
244
);
245
246
// Only apply one label per interaction
+2
-2
src/rules/account/countStarterPacks.ts
+2
-2
src/rules/account/countStarterPacks.ts
+2
-1
src/rules/account/tests/age.test.ts
+2
-1
src/rules/account/tests/age.test.ts
···
6
createAccountLabel,
7
} from "../../../accountModeration.js";
8
import { agent } from "../../../agent.js";
9
import { logger } from "../../../logger.js";
10
import {
11
calculateAccountAge,
···
100
const result = await getAccountCreationDate("did:plc:test123");
101
102
expect(global.fetch).toHaveBeenCalledWith(
103
-
"https://plc.directory/did:plc:test123/log/audit",
104
);
105
expect(result).toEqual(new Date("2025-01-10T12:00:00.000Z"));
106
});
···
6
createAccountLabel,
7
} from "../../../accountModeration.js";
8
import { agent } from "../../../agent.js";
9
+
import { PLC_URL } from "../../../config.js";
10
import { logger } from "../../../logger.js";
11
import {
12
calculateAccountAge,
···
101
const result = await getAccountCreationDate("did:plc:test123");
102
103
expect(global.fetch).toHaveBeenCalledWith(
104
+
`https://${PLC_URL}/did:plc:test123/log/audit`,
105
);
106
expect(result).toEqual(new Date("2025-01-10T12:00:00.000Z"));
107
});
+3
src/rules/account/tests/countStarterPacks.test.ts
+3
src/rules/account/tests/countStarterPacks.test.ts
+8
-5
src/rules/facets/facets.ts
+8
-5
src/rules/facets/facets.ts
···
1
import { createAccountLabel } from "../../accountModeration.js";
2
import { logger } from "../../logger.js";
3
-
import { Facet } from "../../types.js";
4
5
// Threshold for duplicate facet positions before flagging as spam
6
export const FACET_SPAM_THRESHOLD = 1;
···
23
did: string,
24
time: number,
25
atURI: string,
26
-
facets: Facet[],
27
): Promise<void> => {
28
// Check allowlist
29
if (FACET_SPAM_ALLOWLIST.includes(did)) {
···
47
);
48
49
if (mentionFeature && "did" in mentionFeature) {
50
-
const key = `${facet.index.byteStart}:${facet.index.byteEnd}`;
51
if (!positionMap.has(key)) {
52
positionMap.set(key, new Set());
53
}
54
-
positionMap.get(key)!.add(mentionFeature.did as string);
55
}
56
}
57
···
73
await createAccountLabel(
74
did,
75
FACET_SPAM_LABEL,
76
-
`${time}: ${FACET_SPAM_COMMENT} - ${uniqueCount} unique mentions at position ${position} in ${atURI}`,
77
);
78
79
// Only label once per post even if multiple positions are suspicious
···
1
import { createAccountLabel } from "../../accountModeration.js";
2
import { logger } from "../../logger.js";
3
+
import type { Facet } from "../../types.js";
4
5
// Threshold for duplicate facet positions before flagging as spam
6
export const FACET_SPAM_THRESHOLD = 1;
···
23
did: string,
24
time: number,
25
atURI: string,
26
+
facets: Facet[] | null,
27
): Promise<void> => {
28
// Check allowlist
29
if (FACET_SPAM_ALLOWLIST.includes(did)) {
···
47
);
48
49
if (mentionFeature && "did" in mentionFeature) {
50
+
const key = `${facet.index.byteStart.toString()}:${facet.index.byteEnd.toString()}`;
51
if (!positionMap.has(key)) {
52
positionMap.set(key, new Set());
53
}
54
+
const dids = positionMap.get(key);
55
+
if (dids && "did" in mentionFeature && typeof mentionFeature.did === "string") {
56
+
dids.add(mentionFeature.did);
57
+
}
58
}
59
}
60
···
76
await createAccountLabel(
77
did,
78
FACET_SPAM_LABEL,
79
+
`${time.toString()}: ${FACET_SPAM_COMMENT} - ${uniqueCount.toString()} unique mentions at position ${position} in ${atURI}`,
80
);
81
82
// Only label once per post even if multiple positions are suspicious
+2
-1
src/rules/facets/tests/facets.test.ts
+2
-1
src/rules/facets/tests/facets.test.ts
+9
-2
src/rules/handles/checkHandles.test.ts
+9
-2
src/rules/handles/checkHandles.test.ts
···
1
import { beforeEach, describe, expect, it, vi } from "vitest";
2
import {
3
createAccountComment,
···
222
it("should process all matching rules", async () => {
223
vi.resetModules();
224
// Re-import with a mock that has overlapping patterns
225
-
vi.doMock("./constants.js", () => ({
226
HANDLE_CHECKS: [
227
{
228
label: "pattern1",
···
270
});
271
272
it("should handle very long handles", async () => {
273
-
const longHandle = "spam-" + "a".repeat(1000);
274
const time = Date.now();
275
await checkHandle("did:plc:user1", longHandle, time);
276
···
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
import { beforeEach, describe, expect, it, vi } from "vitest";
9
import {
10
createAccountComment,
···
229
it("should process all matching rules", async () => {
230
vi.resetModules();
231
// Re-import with a mock that has overlapping patterns
232
+
vi.doMock("../../../rules/handles.js", () => ({
233
HANDLE_CHECKS: [
234
{
235
label: "pattern1",
···
277
});
278
279
it("should handle very long handles", async () => {
280
+
const longHandle = `spam-${ "a".repeat(1000)}`;
281
const time = Date.now();
282
await checkHandle("did:plc:user1", longHandle, time);
283
+11
-11
src/rules/handles/checkHandles.ts
+11
-11
src/rules/handles/checkHandles.ts
···
7
} from "../../accountModeration.js";
8
import { logger } from "../../logger.js";
9
10
-
export const checkHandle = async (
11
did: string,
12
handle: string,
13
time: number,
14
-
) => {
15
// Check if DID is whitelisted
16
if (GLOBAL_ALLOW.includes(did)) {
17
logger.warn(
···
45
}
46
}
47
48
-
if (checkList.toLabel === true) {
49
-
createAccountLabel(
50
did,
51
-
`${checkList.label}`,
52
-
`${time}: ${checkList.comment} - ${handle}`,
53
);
54
}
55
56
-
if (checkList.reportAcct === true) {
57
logger.info(
58
{ process: "CHECKHANDLE", did, handle, time, label: checkList.label },
59
"Reporting account",
60
);
61
-
createAccountReport(did, `${time}: ${checkList.comment} - ${handle}`);
62
}
63
64
-
if (checkList.commentAcct === true) {
65
-
createAccountComment(
66
did,
67
-
`${time}: ${checkList.comment} - ${handle}`,
68
`handle:${did}:${handle}`,
69
);
70
}
···
7
} from "../../accountModeration.js";
8
import { logger } from "../../logger.js";
9
10
+
export const checkHandle = (
11
did: string,
12
handle: string,
13
time: number,
14
+
): void => {
15
// Check if DID is whitelisted
16
if (GLOBAL_ALLOW.includes(did)) {
17
logger.warn(
···
45
}
46
}
47
48
+
if (checkList.toLabel) {
49
+
void createAccountLabel(
50
did,
51
+
checkList.label,
52
+
`${time.toString()}: ${checkList.comment} - ${handle}`,
53
);
54
}
55
56
+
if (checkList.reportAcct) {
57
logger.info(
58
{ process: "CHECKHANDLE", did, handle, time, label: checkList.label },
59
"Reporting account",
60
);
61
+
void createAccountReport(did, `${time.toString()}: ${checkList.comment} - ${handle}`);
62
}
63
64
+
if (checkList.commentAcct) {
65
+
void createAccountComment(
66
did,
67
+
`${time.toString()}: ${checkList.comment} - ${handle}`,
68
`handle:${did}:${handle}`,
69
);
70
}
+1
-1
src/rules/handles/constants.example.ts
+1
-1
src/rules/handles/constants.example.ts
+14
-14
src/rules/posts/checkPosts.ts
+14
-14
src/rules/posts/checkPosts.ts
···
6
} from "../../accountModeration.js";
7
import { logger } from "../../logger.js";
8
import { createPostLabel, createPostReport } from "../../moderation.js";
9
-
import { Post } from "../../types.js";
10
import { getFinalUrl } from "../../utils/getFinalUrl.js";
11
import { getLanguage } from "../../utils/getLanguage.js";
12
import { countStarterPacks } from "../account/countStarterPacks.js";
···
102
}
103
}
104
105
-
countStarterPacks(post[0].did, post[0].time);
106
107
-
if (checkPost.toLabel === true) {
108
-
createPostLabel(
109
post[0].atURI,
110
post[0].cid,
111
-
`${checkPost.label}`,
112
-
`${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
113
checkPost.duration,
114
post[0].did,
115
post[0].time,
···
126
},
127
"Reporting post",
128
);
129
-
createPostReport(
130
post[0].atURI,
131
post[0].cid,
132
-
`${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
133
);
134
}
135
136
-
if (checkPost.reportAcct === true) {
137
logger.info(
138
{
139
process: "CHECKPOSTS",
···
143
},
144
"Reporting account",
145
);
146
-
createAccountReport(
147
post[0].did,
148
-
`${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
149
);
150
}
151
152
-
if (checkPost.commentAcct === true) {
153
-
createAccountComment(
154
post[0].did,
155
-
`${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
156
post[0].atURI,
157
);
158
}
···
6
} from "../../accountModeration.js";
7
import { logger } from "../../logger.js";
8
import { createPostLabel, createPostReport } from "../../moderation.js";
9
+
import type { Post } from "../../types.js";
10
import { getFinalUrl } from "../../utils/getFinalUrl.js";
11
import { getLanguage } from "../../utils/getLanguage.js";
12
import { countStarterPacks } from "../account/countStarterPacks.js";
···
102
}
103
}
104
105
+
void countStarterPacks(post[0].did, post[0].time);
106
107
+
if (checkPost.toLabel) {
108
+
void createPostLabel(
109
post[0].atURI,
110
post[0].cid,
111
+
checkPost.label,
112
+
`${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
113
checkPost.duration,
114
post[0].did,
115
post[0].time,
···
126
},
127
"Reporting post",
128
);
129
+
void createPostReport(
130
post[0].atURI,
131
post[0].cid,
132
+
`${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
133
);
134
}
135
136
+
if (checkPost.reportAcct) {
137
logger.info(
138
{
139
process: "CHECKPOSTS",
···
143
},
144
"Reporting account",
145
);
146
+
void createAccountReport(
147
post[0].did,
148
+
`${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
149
);
150
}
151
152
+
if (checkPost.commentAcct) {
153
+
void createAccountComment(
154
post[0].did,
155
+
`${post[0].time.toString()}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
156
post[0].atURI,
157
);
158
}
+7
-1
src/rules/posts/tests/checkPosts.test.ts
+7
-1
src/rules/posts/tests/checkPosts.test.ts
···
1
import { beforeEach, describe, expect, it, vi } from "vitest";
2
import {
3
createAccountComment,
···
5
} from "../../../accountModeration.js";
6
import { logger } from "../../../logger.js";
7
import { createPostLabel, createPostReport } from "../../../moderation.js";
8
-
import { Post } from "../../../types.js";
9
import { getFinalUrl } from "../../../utils/getFinalUrl.js";
10
import { getLanguage } from "../../../utils/getLanguage.js";
11
import { countStarterPacks } from "../../account/countStarterPacks.js";
···
1
+
2
+
3
+
4
+
5
+
6
+
7
import { beforeEach, describe, expect, it, vi } from "vitest";
8
import {
9
createAccountComment,
···
11
} from "../../../accountModeration.js";
12
import { logger } from "../../../logger.js";
13
import { createPostLabel, createPostReport } from "../../../moderation.js";
14
+
import type { Post } from "../../../types.js";
15
import { getFinalUrl } from "../../../utils/getFinalUrl.js";
16
import { getLanguage } from "../../../utils/getLanguage.js";
17
import { countStarterPacks } from "../../account/countStarterPacks.js";
+22
-22
src/rules/profiles/checkProfiles.ts
+22
-22
src/rules/profiles/checkProfiles.ts
···
64
}
65
}
66
67
-
if (checkProfiles.toLabel === true) {
68
-
createAccountLabel(
69
did,
70
-
`${checkProfiles.label}`,
71
-
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
72
);
73
}
74
75
-
if (checkProfiles.reportAcct === true) {
76
-
createAccountReport(
77
did,
78
-
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
79
);
80
logger.info(
81
{
···
90
);
91
}
92
93
-
if (checkProfiles.commentAcct === true) {
94
-
createAccountComment(
95
did,
96
-
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
97
-
`profile:${did}:${time}`,
98
);
99
}
100
}
···
159
}
160
}
161
162
-
if (checkProfiles.toLabel === true) {
163
-
createAccountLabel(
164
did,
165
-
`${checkProfiles.label}`,
166
-
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
167
);
168
}
169
170
-
if (checkProfiles.reportAcct === true) {
171
-
createAccountReport(
172
did,
173
-
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
174
);
175
logger.info(
176
{
···
185
);
186
}
187
188
-
if (checkProfiles.commentAcct === true) {
189
-
createAccountComment(
190
did,
191
-
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
192
-
`profile:${did}:${time}`,
193
);
194
}
195
}
···
64
}
65
}
66
67
+
if (checkProfiles.toLabel) {
68
+
void createAccountLabel(
69
did,
70
+
checkProfiles.label,
71
+
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
72
);
73
}
74
75
+
if (checkProfiles.reportAcct) {
76
+
void createAccountReport(
77
did,
78
+
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
79
);
80
logger.info(
81
{
···
90
);
91
}
92
93
+
if (checkProfiles.commentAcct) {
94
+
void createAccountComment(
95
did,
96
+
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
97
+
`profile:${did}:${time.toString()}`,
98
);
99
}
100
}
···
159
}
160
}
161
162
+
if (checkProfiles.toLabel) {
163
+
void createAccountLabel(
164
did,
165
+
checkProfiles.label,
166
+
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
167
);
168
}
169
170
+
if (checkProfiles.reportAcct) {
171
+
void createAccountReport(
172
did,
173
+
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
174
);
175
logger.info(
176
{
···
185
);
186
}
187
188
+
if (checkProfiles.commentAcct) {
189
+
void createAccountComment(
190
did,
191
+
`${time.toString()}: ${checkProfiles.comment} - ${displayName} - ${description}`,
192
+
`profile:${did}:${time.toString()}`,
193
);
194
}
195
}
+6
src/rules/profiles/tests/checkProfiles.test.ts
+6
src/rules/profiles/tests/checkProfiles.test.ts
+1
src/tests/accountThreshold.test.ts
+1
src/tests/accountThreshold.test.ts
+6
-3
src/tests/agent.test.ts
+6
-3
src/tests/agent.test.ts
···
1
import { beforeEach, describe, expect, it, vi } from "vitest";
2
3
describe("Agent", () => {
···
30
const { agent, login } = await import("../agent.js");
31
32
// Check that the agent was created with the correct service URL
33
-
expect(mockConstructor).toHaveBeenCalledWith({
34
-
service: "https://pds.test.com",
35
-
});
36
expect(agent.service.toString()).toBe("https://pds.test.com/");
37
38
// Check that the login function calls the mockLogin function
···
1
+
2
import { beforeEach, describe, expect, it, vi } from "vitest";
3
4
describe("Agent", () => {
···
31
const { agent, login } = await import("../agent.js");
32
33
// Check that the agent was created with the correct service URL
34
+
expect(mockConstructor).toHaveBeenCalledWith(
35
+
expect.objectContaining({
36
+
service: "https://pds.test.com",
37
+
}),
38
+
);
39
expect(agent.service.toString()).toBe("https://pds.test.com/");
40
41
// Check that the login function calls the mockLogin function
+3
-3
src/tests/metrics.test.ts
+3
-3
src/tests/metrics.test.ts
···
1
+
import type { Server } from "http";
2
import request from "supertest";
3
+
import { afterEach, describe, expect, it } from "vitest";
4
import { startMetricsServer } from "../metrics.js";
5
6
describe("Metrics Server", () => {
7
+
let server: Server | undefined;
8
9
afterEach(() => {
10
if (server) {
+5
-1
src/tests/moderation.test.ts
+5
-1
src/tests/moderation.test.ts
···
1
import { beforeEach, describe, expect, it, vi } from "vitest";
2
// --- Imports Second ---
3
import { checkAccountLabels } from "../accountModeration.js";
4
import { agent } from "../agent.js";
5
-
import { logger } from "../logger.js";
6
import { createPostLabel } from "../moderation.js";
7
import { tryClaimPostLabel } from "../redis.js";
8
···
1
+
2
+
3
+
4
+
5
+
6
import { beforeEach, describe, expect, it, vi } from "vitest";
7
// --- Imports Second ---
8
import { checkAccountLabels } from "../accountModeration.js";
9
import { agent } from "../agent.js";
10
import { createPostLabel } from "../moderation.js";
11
import { tryClaimPostLabel } from "../redis.js";
12
+1
src/tests/redis.test.ts
+1
src/tests/redis.test.ts
+8
-15
src/types.ts
+8
-15
src/types.ts
···
1
export interface Checks {
2
language?: string[];
3
label: string;
···
38
description?: string;
39
}
40
41
-
// Define the type for the link feature
42
-
export interface LinkFeature {
43
-
$type: "app.bsky.richtext.facet#link";
44
-
uri: string;
45
-
}
46
-
47
export interface List {
48
label: string;
49
rkey: string;
50
}
51
52
-
export interface FacetIndex {
53
-
byteStart: number;
54
-
byteEnd: number;
55
-
}
56
-
57
-
export interface Facet {
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 (optional if monitoredPostURIs is provided)
···
1
+
import type * as AppBskyRichtextFacet from "@atproto/ozone/dist/lexicon/types/app/bsky/richtext/facet.js";
2
+
3
export interface Checks {
4
language?: string[];
5
label: string;
···
40
description?: string;
41
}
42
43
export interface List {
44
label: string;
45
rkey: string;
46
}
47
48
+
// Re-export facet types from @atproto/ozone for convenience
49
+
export type Facet = AppBskyRichtextFacet.Main;
50
+
export type FacetIndex = AppBskyRichtextFacet.ByteSlice;
51
+
export type FacetMention = AppBskyRichtextFacet.Mention;
52
+
export type LinkFeature = AppBskyRichtextFacet.Link;
53
+
export type FacetTag = AppBskyRichtextFacet.Tag;
54
55
export interface AccountAgeCheck {
56
monitoredDIDs?: string[]; // DIDs to monitor for replies (optional if monitoredPostURIs is provided)
+7
-3
src/utils/getFinalUrl.ts
+7
-3
src/utils/getFinalUrl.ts
···
2
3
export async function getFinalUrl(url: string): Promise<string> {
4
const controller = new AbortController();
5
-
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15-second timeout
6
7
const headers = {
8
"User-Agent":
···
19
});
20
clearTimeout(timeoutId);
21
return response.url;
22
-
} catch (headError) {
23
clearTimeout(timeoutId);
24
25
// Some services block HEAD requests, try GET as fallback
26
const getController = new AbortController();
27
-
const getTimeoutId = setTimeout(() => getController.abort(), 15000);
28
29
try {
30
logger.debug(
···
2
3
export async function getFinalUrl(url: string): Promise<string> {
4
const controller = new AbortController();
5
+
const timeoutId = setTimeout(() => {
6
+
controller.abort();
7
+
}, 15000); // 15-second timeout
8
9
const headers = {
10
"User-Agent":
···
21
});
22
clearTimeout(timeoutId);
23
return response.url;
24
+
} catch {
25
clearTimeout(timeoutId);
26
27
// Some services block HEAD requests, try GET as fallback
28
const getController = new AbortController();
29
+
const getTimeoutId = setTimeout(() => {
30
+
getController.abort();
31
+
}, 15000);
32
33
try {
34
logger.debug(
-2
src/utils/homoglyphs.ts
-2
src/utils/homoglyphs.ts