A tool for parsing traffic on the jetstream and applying a moderation workstream based on regexp based rules

feat: Add check to avoid duplicate account age labels

This commit adds a check to the `checkAccountAge` function to prevent
duplicate labels from being applied to the same account. It uses the
`checkAccountLabels` function to determine if a label already exists
before attempting to create a new one. This prevents redundant labeling
and improves efficiency. Also includes unit tests for the
`checkAccountLabels` function to ensure correct behavior.

Changed files
+264 -2
src
rules
account
tests
+20 -1
src/rules/account/age.ts
··· 1 1 import { agent, isLoggedIn } from "../../agent.js"; 2 2 import { logger } from "../../logger.js"; 3 - import { createAccountLabel } from "../../moderation.js"; 3 + import { createAccountLabel, checkAccountLabels } from "../../moderation.js"; 4 4 import { ACCOUNT_AGE_CHECKS } from "./ageConstants.js"; 5 5 import { PLC_URL } from "../../config.js"; 6 6 import { GLOBAL_ALLOW } from "../../constants.js"; ··· 156 156 157 157 // Check if account was created within the window 158 158 if (creationDate >= windowStart && creationDate <= windowEnd) { 159 + // Check if the label already exists to prevent duplicates 160 + const labelExists = await checkAccountLabels( 161 + context.replyingDid, 162 + check.label, 163 + ); 164 + 165 + if (labelExists) { 166 + logger.debug( 167 + { 168 + process: "ACCOUNT_AGE", 169 + replyingDid: context.replyingDid, 170 + label: check.label, 171 + }, 172 + "Label already exists, skipping duplicate", 173 + ); 174 + // Only apply one label per reply 175 + return; 176 + } 177 + 159 178 logger.info( 160 179 { 161 180 process: "ACCOUNT_AGE",
+83 -1
src/rules/account/tests/age.test.ts
··· 25 25 26 26 vi.mock("../../../moderation.js", () => ({ 27 27 createAccountLabel: vi.fn(), 28 + checkAccountLabels: vi.fn(), 28 29 })); 29 30 30 31 vi.mock("../../../constants.js", () => ({ ··· 36 37 37 38 import { agent } from "../../../agent.js"; 38 39 import { logger } from "../../../logger.js"; 39 - import { createAccountLabel } from "../../../moderation.js"; 40 + import { 41 + createAccountLabel, 42 + checkAccountLabels, 43 + } from "../../../moderation.js"; 40 44 import { GLOBAL_ALLOW } from "../../../constants.js"; 41 45 42 46 describe("Account Age Module", () => { ··· 362 366 "did:plc:newaccount", 363 367 "label1", 364 368 expect.any(String), 369 + ); 370 + }); 371 + 372 + it("should skip labeling if label already exists on account", async () => { 373 + ACCOUNT_AGE_CHECKS.push({ 374 + monitoredDIDs: ["did:plc:monitored"], 375 + anchorDate: "2025-10-15", 376 + maxAgeDays: 7, 377 + label: "window-reply", 378 + comment: "Account created in window", 379 + }); 380 + 381 + // Mock account created within window 382 + const mockDidDoc = [{ createdAt: "2025-10-18T12:00:00.000Z" }]; 383 + (global.fetch as any).mockResolvedValueOnce({ 384 + ok: true, 385 + json: async () => mockDidDoc, 386 + }); 387 + 388 + // Mock that label already exists 389 + (checkAccountLabels as any).mockResolvedValueOnce(true); 390 + 391 + await checkAccountAge({ 392 + replyToDid: "did:plc:monitored", 393 + replyingDid: "did:plc:alreadylabeled", 394 + atURI: TEST_REPLY_URI, 395 + time: TEST_TIME, 396 + }); 397 + 398 + expect(checkAccountLabels).toHaveBeenCalledWith( 399 + "did:plc:alreadylabeled", 400 + "window-reply", 401 + ); 402 + expect(createAccountLabel).not.toHaveBeenCalled(); 403 + expect(logger.debug).toHaveBeenCalledWith( 404 + { 405 + process: "ACCOUNT_AGE", 406 + replyingDid: "did:plc:alreadylabeled", 407 + label: "window-reply", 408 + }, 409 + "Label already exists, skipping duplicate", 410 + ); 411 + }); 412 + 413 + it("should create label if it does not already exist", async () => { 414 + ACCOUNT_AGE_CHECKS.push({ 415 + monitoredDIDs: ["did:plc:monitored"], 416 + anchorDate: "2025-10-15", 417 + maxAgeDays: 7, 418 + label: "window-reply", 419 + comment: "Account created in window", 420 + }); 421 + 422 + // Mock account created within window 423 + const mockDidDoc = [{ createdAt: "2025-10-18T12:00:00.000Z" }]; 424 + (global.fetch as any).mockResolvedValueOnce({ 425 + ok: true, 426 + json: async () => mockDidDoc, 427 + }); 428 + 429 + // Mock that label does NOT exist 430 + (checkAccountLabels as any).mockResolvedValueOnce(false); 431 + 432 + await checkAccountAge({ 433 + replyToDid: "did:plc:monitored", 434 + replyingDid: "did:plc:newlabel", 435 + atURI: TEST_REPLY_URI, 436 + time: TEST_TIME, 437 + }); 438 + 439 + expect(checkAccountLabels).toHaveBeenCalledWith( 440 + "did:plc:newlabel", 441 + "window-reply", 442 + ); 443 + expect(createAccountLabel).toHaveBeenCalledWith( 444 + "did:plc:newlabel", 445 + "window-reply", 446 + expect.stringContaining("Account created within monitored range"), 365 447 ); 366 448 }); 367 449
+161
src/tests/moderation.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from "vitest"; 2 + import { checkAccountLabels } from "../moderation.js"; 3 + 4 + // Mock dependencies 5 + vi.mock("../agent.js", () => ({ 6 + agent: { 7 + tools: { 8 + ozone: { 9 + moderation: { 10 + getRepo: vi.fn(), 11 + }, 12 + }, 13 + }, 14 + }, 15 + isLoggedIn: Promise.resolve(true), 16 + })); 17 + 18 + vi.mock("../logger.js", () => ({ 19 + logger: { 20 + info: vi.fn(), 21 + debug: vi.fn(), 22 + warn: vi.fn(), 23 + error: vi.fn(), 24 + }, 25 + })); 26 + 27 + vi.mock("../config.js", () => ({ 28 + MOD_DID: "did:plc:moderator123", 29 + })); 30 + 31 + vi.mock("../limits.js", () => ({ 32 + limit: vi.fn((fn) => fn()), 33 + })); 34 + 35 + import { agent } from "../agent.js"; 36 + import { logger } from "../logger.js"; 37 + 38 + describe("checkAccountLabels", () => { 39 + beforeEach(() => { 40 + vi.clearAllMocks(); 41 + }); 42 + 43 + it("should return true if label exists on account", async () => { 44 + (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 45 + data: { 46 + labels: [ 47 + { val: "spam" }, 48 + { val: "harassment" }, 49 + { val: "window-reply" }, 50 + ], 51 + }, 52 + }); 53 + 54 + const result = await checkAccountLabels( 55 + "did:plc:test123", 56 + "window-reply", 57 + ); 58 + 59 + expect(result).toBe(true); 60 + expect(agent.tools.ozone.moderation.getRepo).toHaveBeenCalledWith( 61 + { did: "did:plc:test123" }, 62 + { 63 + headers: { 64 + "atproto-proxy": "did:plc:moderator123#atproto_labeler", 65 + "atproto-accept-labelers": 66 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 67 + }, 68 + }, 69 + ); 70 + }); 71 + 72 + it("should return false if label does not exist on account", async () => { 73 + (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 74 + data: { 75 + labels: [{ val: "spam" }, { val: "harassment" }], 76 + }, 77 + }); 78 + 79 + const result = await checkAccountLabels( 80 + "did:plc:test123", 81 + "window-reply", 82 + ); 83 + 84 + expect(result).toBe(false); 85 + }); 86 + 87 + it("should return false if account has no labels", async () => { 88 + (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 89 + data: { 90 + labels: [], 91 + }, 92 + }); 93 + 94 + const result = await checkAccountLabels( 95 + "did:plc:test123", 96 + "window-reply", 97 + ); 98 + 99 + expect(result).toBe(false); 100 + }); 101 + 102 + it("should return false if labels property is undefined", async () => { 103 + (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 104 + data: {}, 105 + }); 106 + 107 + const result = await checkAccountLabels( 108 + "did:plc:test123", 109 + "window-reply", 110 + ); 111 + 112 + expect(result).toBe(false); 113 + }); 114 + 115 + it("should handle API errors gracefully", async () => { 116 + (agent.tools.ozone.moderation.getRepo as any).mockRejectedValueOnce( 117 + new Error("API Error"), 118 + ); 119 + 120 + const result = await checkAccountLabels( 121 + "did:plc:test123", 122 + "window-reply", 123 + ); 124 + 125 + expect(result).toBe(false); 126 + expect(logger.error).toHaveBeenCalledWith( 127 + { 128 + process: "MODERATION", 129 + did: "did:plc:test123", 130 + error: expect.any(Error), 131 + }, 132 + "Failed to check account labels", 133 + ); 134 + }); 135 + 136 + it("should perform case-sensitive label matching", async () => { 137 + (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 138 + data: { 139 + labels: [{ val: "window-reply" }], 140 + }, 141 + }); 142 + 143 + const resultLower = await checkAccountLabels( 144 + "did:plc:test123", 145 + "window-reply", 146 + ); 147 + expect(resultLower).toBe(true); 148 + 149 + (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 150 + data: { 151 + labels: [{ val: "window-reply" }], 152 + }, 153 + }); 154 + 155 + const resultUpper = await checkAccountLabels( 156 + "did:plc:test123", 157 + "Window-Reply", 158 + ); 159 + expect(resultUpper).toBe(false); 160 + }); 161 + });