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

Formatting

Skywatch e794f14c c91f68e6

+2 -2
compose.yaml
··· 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:
··· 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:
+38 -27
eslint.config.mjs
··· 1 import eslint from "@eslint/js"; 2 import stylistic from "@stylistic/eslint-plugin"; 3 import prettier from "eslint-config-prettier"; 4 import importPlugin from "eslint-plugin-import"; 5 import tseslint from "typescript-eslint"; 6 7 - export default tseslint.config( 8 eslint.configs.recommended, 9 ...tseslint.configs.strictTypeChecked, 10 ...tseslint.configs.stylisticTypeChecked, ··· 25 rules: { 26 // TypeScript specific rules 27 "@typescript-eslint/no-unused-vars": [ 28 - "error", 29 { argsIgnorePattern: "^_" }, 30 ], 31 - "@typescript-eslint/no-explicit-any": "error", 32 "@typescript-eslint/no-unsafe-assignment": "error", 33 "@typescript-eslint/no-unsafe-member-access": "error", 34 "@typescript-eslint/no-unsafe-call": "error", 35 "@typescript-eslint/no-unsafe-return": "error", 36 "@typescript-eslint/no-unsafe-argument": "error", 37 - "@typescript-eslint/prefer-nullish-coalescing": "error", 38 - "@typescript-eslint/prefer-optional-chain": "error", 39 "@typescript-eslint/no-non-null-assertion": "error", 40 "@typescript-eslint/consistent-type-imports": "error", 41 "@typescript-eslint/consistent-type-exports": "error", ··· 45 "no-console": "warn", 46 "no-debugger": "error", 47 "no-var": "error", 48 - "prefer-const": "error", 49 - "prefer-template": "error", 50 - "object-shorthand": "error", 51 - "prefer-destructuring": ["error", { object: true, array: false }], 52 53 // Import rules 54 "import/order": [ 55 - "error", 56 { 57 groups: [ 58 "builtin", ··· 62 "sibling", 63 "index", 64 ], 65 - "newlines-between": "always", 66 alphabetize: { order: "asc", caseInsensitive: true }, 67 }, 68 ], 69 - "import/no-duplicates": "error", 70 "import/no-unresolved": "off", // TypeScript handles this 71 72 // Security-focused rules ··· 81 "no-unreachable": "error", 82 "no-unreachable-loop": "error", 83 84 - // Style preferences 85 - "@stylistic/indent": ["error", 2], 86 - "@stylistic/quotes": ["error", "double"], 87 - "@stylistic/semi": ["error", "always"], 88 - //"@stylistic/comma-dangle": ["error", "es5"], 89 - "@stylistic/object-curly-spacing": ["error", "always"], 90 - "@stylistic/array-bracket-spacing": ["error", "never"], 91 - "@stylistic/space-before-function-paren": [ 92 - "error", 93 - { 94 - anonymous: "always", 95 - named: "never", 96 - asyncArrow: "always", 97 - }, 98 - ], 99 }, 100 }, 101 {
··· 1 import eslint from "@eslint/js"; 2 import stylistic from "@stylistic/eslint-plugin"; 3 + import { defineConfig } from "eslint/config"; 4 import prettier from "eslint-config-prettier"; 5 import importPlugin from "eslint-plugin-import"; 6 import tseslint from "typescript-eslint"; 7 8 + export default defineConfig( 9 eslint.configs.recommended, 10 ...tseslint.configs.strictTypeChecked, 11 ...tseslint.configs.stylisticTypeChecked, ··· 26 rules: { 27 // TypeScript specific rules 28 "@typescript-eslint/no-unused-vars": [ 29 + "warn", 30 { argsIgnorePattern: "^_" }, 31 ], 32 + "@typescript-eslint/no-explicit-any": "warn", 33 "@typescript-eslint/no-unsafe-assignment": "error", 34 "@typescript-eslint/no-unsafe-member-access": "error", 35 "@typescript-eslint/no-unsafe-call": "error", 36 "@typescript-eslint/no-unsafe-return": "error", 37 "@typescript-eslint/no-unsafe-argument": "error", 38 + "@typescript-eslint/prefer-nullish-coalescing": "warn", 39 + "@typescript-eslint/prefer-optional-chain": "warn", 40 "@typescript-eslint/no-non-null-assertion": "error", 41 "@typescript-eslint/consistent-type-imports": "error", 42 "@typescript-eslint/consistent-type-exports": "error", ··· 46 "no-console": "warn", 47 "no-debugger": "error", 48 "no-var": "error", 49 + "prefer-const": "warn", 50 + "prefer-template": "warn", 51 + "object-shorthand": "warn", 52 + "prefer-destructuring": ["warn", { object: true, array: false }], 53 54 // Import rules 55 "import/order": [ 56 + "warn", 57 { 58 groups: [ 59 "builtin", ··· 63 "sibling", 64 "index", 65 ], 66 + pathGroups: [ 67 + { 68 + pattern: "@atproto/**", 69 + group: "external", 70 + position: "after", 71 + }, 72 + { 73 + pattern: "@skyware/**", 74 + group: "external", 75 + position: "after", 76 + }, 77 + { 78 + pattern: "@clavata/**", 79 + group: "external", 80 + position: "after", 81 + }, 82 + ], 83 + pathGroupsExcludedImportTypes: ["builtin"], 84 + "newlines-between": "never", 85 alphabetize: { order: "asc", caseInsensitive: true }, 86 }, 87 ], 88 + "import/no-duplicates": "warn", 89 "import/no-unresolved": "off", // TypeScript handles this 90 91 // Security-focused rules ··· 100 "no-unreachable": "error", 101 "no-unreachable-loop": "error", 102 103 + // Style preferences (prettier handles these) 104 + "@stylistic/indent": "off", 105 + "@stylistic/quotes": "off", 106 + "@stylistic/semi": "off", 107 + "@stylistic/object-curly-spacing": "off", 108 + "@stylistic/array-bracket-spacing": "off", 109 + "@stylistic/space-before-function-paren": "off", 110 }, 111 }, 112 {
+3 -3
prometheus.yml
··· 3 evaluation_interval: 15s 4 5 scrape_configs: 6 - - job_name: 'skywatch-automod' 7 static_configs: 8 - - targets: ['automod:4101'] 9 labels: 10 - service: 'automod'
··· 3 evaluation_interval: 15s 4 5 scrape_configs: 6 + - job_name: "skywatch-automod" 7 static_configs: 8 + - targets: ["automod:4101"] 9 labels: 10 + service: "automod"
+5 -1
src/accountThreshold.ts
··· 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",
··· 133 const shouldLabel = config.toLabel !== false; 134 135 if (shouldLabel) { 136 + await createAccountLabel( 137 + did, 138 + config.accountLabel, 139 + config.accountComment, 140 + ); 141 accountLabelsThresholdAppliedCounter.inc({ 142 account_label: config.accountLabel, 143 action: "label",
+4 -2
src/agent.ts
··· 1 import { Agent, setGlobalDispatcher } from "undici"; 2 import { AtpAgent } from "@atproto/api"; 3 import { BSKY_HANDLE, BSKY_PASSWORD, OZONE_PDS } from "./config.js"; 4 - import { loadSession, saveSession, type SessionData } from "./session.js"; 5 import { updateRateLimitState } from "./limits.js"; 6 import { logger } from "./logger.js"; 7 8 setGlobalDispatcher( 9 new Agent({ ··· 64 } 65 66 const refreshIn = JWT_LIFETIME_MS * REFRESH_AT_PERCENT; 67 - logger.debug(`Scheduling session refresh in ${(refreshIn / 1000 / 60).toFixed(1)} minutes`); 68 69 refreshTimer = setTimeout(() => { 70 refreshSession().catch((error) => {
··· 1 import { Agent, setGlobalDispatcher } from "undici"; 2 import { AtpAgent } from "@atproto/api"; 3 import { BSKY_HANDLE, BSKY_PASSWORD, OZONE_PDS } from "./config.js"; 4 import { updateRateLimitState } from "./limits.js"; 5 import { logger } from "./logger.js"; 6 + import { type SessionData, loadSession, saveSession } from "./session.js"; 7 8 setGlobalDispatcher( 9 new Agent({ ··· 64 } 65 66 const refreshIn = JWT_LIFETIME_MS * REFRESH_AT_PERCENT; 67 + logger.debug( 68 + `Scheduling session refresh in ${(refreshIn / 1000 / 60).toFixed(1)} minutes`, 69 + ); 70 71 refreshTimer = setTimeout(() => { 72 refreshSession().catch((error) => {
+3 -3
src/limits.ts
··· 1 import { pRateLimit } from "p-ratelimit"; 2 - import { logger } from "./logger.js"; 3 import { Counter, Gauge, Histogram } from "prom-client"; 4 5 interface RateLimitState { 6 limit: number; ··· 76 remaining: rateLimitState.remaining, 77 resetIn: rateLimitState.reset - Math.floor(Date.now() / 1000), 78 }, 79 - "Rate limit state updated" 80 ); 81 } 82 ··· 93 94 if (delayMs > 0) { 95 logger.warn( 96 - `Rate limit critical (${state.remaining}/${state.limit} remaining). Waiting ${delaySeconds}s until reset...` 97 ); 98 99 const waitStart = Date.now();
··· 1 import { pRateLimit } from "p-ratelimit"; 2 import { Counter, Gauge, Histogram } from "prom-client"; 3 + import { logger } from "./logger.js"; 4 5 interface RateLimitState { 6 limit: number; ··· 76 remaining: rateLimitState.remaining, 77 resetIn: rateLimitState.reset - Math.floor(Date.now() / 1000), 78 }, 79 + "Rate limit state updated", 80 ); 81 } 82 ··· 93 94 if (delayMs > 0) { 95 logger.warn( 96 + `Rate limit critical (${state.remaining}/${state.limit} remaining). Waiting ${delaySeconds}s until reset...`, 97 ); 98 99 const waitStart = Date.now();
-2
src/moderation.ts
··· 130 }); 131 }; 132 133 - 134 export const createPostReport = async ( 135 uri: string, 136 cid: string, ··· 176 } 177 }); 178 }; 179 - 180 181 export const checkRecordLabels = async ( 182 uri: string,
··· 130 }); 131 }; 132 133 export const createPostReport = async ( 134 uri: string, 135 cid: string, ··· 175 } 176 }); 177 }; 178 179 export const checkRecordLabels = async ( 180 uri: string,
+6 -3
src/rules/account/age.ts
··· 1 import { agent, isLoggedIn } from "../../agent.js"; 2 import { PLC_URL } from "../../config.js"; 3 - import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 4 import { logger } from "../../logger.js"; 5 - import { checkAccountLabels, createAccountLabel } from "../../accountModeration.js"; 6 - import { ACCOUNT_AGE_CHECKS } from "../../../rules/accountAge.js"; 7 8 interface InteractionContext { 9 // For replies
··· 1 + import { ACCOUNT_AGE_CHECKS } from "../../../rules/accountAge.js"; 2 + import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 3 + import { 4 + checkAccountLabels, 5 + createAccountLabel, 6 + } from "../../accountModeration.js"; 7 import { agent, isLoggedIn } from "../../agent.js"; 8 import { PLC_URL } from "../../config.js"; 9 import { logger } from "../../logger.js"; 10 11 interface InteractionContext { 12 // For replies
+1 -1
src/rules/account/countStarterPacks.ts
··· 1 import { agent, isLoggedIn } from "../../agent.js"; 2 import { limit } from "../../limits.js"; 3 import { logger } from "../../logger.js"; 4 - import { createAccountLabel } from "../../accountModeration.js"; 5 6 const ALLOWED_DIDS = ["did:plc:gpunjjgvlyb4racypz3yfiq4"]; 7
··· 1 + import { createAccountLabel } from "../../accountModeration.js"; 2 import { agent, isLoggedIn } from "../../agent.js"; 3 import { limit } from "../../limits.js"; 4 import { logger } from "../../logger.js"; 5 6 const ALLOWED_DIDS = ["did:plc:gpunjjgvlyb4racypz3yfiq4"]; 7
+6 -3
src/rules/account/tests/age.test.ts
··· 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 import { agent } from "../../../agent.js"; 3 - import { GLOBAL_ALLOW } from "../../../../rules/constants.js"; 4 import { logger } from "../../../logger.js"; 5 - import { checkAccountLabels, createAccountLabel } from "../../../accountModeration.js"; 6 import { 7 calculateAccountAge, 8 checkAccountAge, 9 getAccountCreationDate, 10 } from "../age.js"; 11 - import { ACCOUNT_AGE_CHECKS } from "../../../../rules/accountAge.js"; 12 13 // Mock dependencies 14 vi.mock("../../../agent.js", () => ({
··· 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { ACCOUNT_AGE_CHECKS } from "../../../../rules/accountAge.js"; 3 + import { GLOBAL_ALLOW } from "../../../../rules/constants.js"; 4 + import { 5 + checkAccountLabels, 6 + createAccountLabel, 7 + } from "../../../accountModeration.js"; 8 import { agent } from "../../../agent.js"; 9 import { logger } from "../../../logger.js"; 10 import { 11 calculateAccountAge, 12 checkAccountAge, 13 getAccountCreationDate, 14 } from "../age.js"; 15 16 // Mock dependencies 17 vi.mock("../../../agent.js", () => ({
+1 -1
src/rules/account/tests/countStarterPacks.test.ts
··· 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 import { agent } from "../../../agent.js"; 3 import { limit } from "../../../limits.js"; 4 import { logger } from "../../../logger.js"; 5 - import { createAccountLabel } from "../../../accountModeration.js"; 6 import { countStarterPacks } from "../countStarterPacks.js"; 7 8 // Mock dependencies
··· 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { createAccountLabel } from "../../../accountModeration.js"; 3 import { agent } from "../../../agent.js"; 4 import { limit } from "../../../limits.js"; 5 import { logger } from "../../../logger.js"; 6 import { countStarterPacks } from "../countStarterPacks.js"; 7 8 // Mock dependencies
+1 -1
src/rules/facets/facets.ts
··· 1 - import { logger } from "../../logger.js"; 2 import { createAccountLabel } from "../../accountModeration.js"; 3 import { Facet } from "../../types.js"; 4 5 // Threshold for duplicate facet positions before flagging as spam
··· 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
+1 -1
src/rules/facets/tests/facets.test.ts
··· 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { logger } from "../../../logger.js"; 3 import { createAccountLabel } from "../../../accountModeration.js"; 4 import { Facet } from "../../../types.js"; 5 import { 6 FACET_SPAM_ALLOWLIST,
··· 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 import { createAccountLabel } from "../../../accountModeration.js"; 3 + import { logger } from "../../../logger.js"; 4 import { Facet } from "../../../types.js"; 5 import { 6 FACET_SPAM_ALLOWLIST,
+2 -2
src/rules/handles/checkHandles.ts
··· 1 import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 2 - import { logger } from "../../logger.js"; 3 import { 4 createAccountComment, 5 createAccountLabel, 6 createAccountReport, 7 } from "../../accountModeration.js"; 8 - import { HANDLE_CHECKS } from "../../../rules/handles.js"; 9 10 export const checkHandle = async ( 11 did: string,
··· 1 import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 2 + import { HANDLE_CHECKS } from "../../../rules/handles.js"; 3 import { 4 createAccountComment, 5 createAccountLabel, 6 createAccountReport, 7 } from "../../accountModeration.js"; 8 + import { logger } from "../../logger.js"; 9 10 export const checkHandle = async ( 11 did: string,
+2 -2
src/rules/profiles/checkProfiles.ts
··· 1 import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 2 - import { logger } from "../../logger.js"; 3 import { 4 createAccountComment, 5 createAccountLabel, 6 createAccountReport, 7 } from "../../accountModeration.js"; 8 - import { PROFILE_CHECKS } from "../../../rules/profiles.js"; 9 import { getLanguage } from "../../utils/getLanguage.js"; 10 11 export const checkDescription = async (
··· 1 import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 2 + import { PROFILE_CHECKS } from "../../../rules/profiles.js"; 3 import { 4 createAccountComment, 5 createAccountLabel, 6 createAccountReport, 7 } from "../../accountModeration.js"; 8 + import { logger } from "../../logger.js"; 9 import { getLanguage } from "../../utils/getLanguage.js"; 10 11 export const checkDescription = async (
+1 -2
src/rules/profiles/tests/checkProfiles.test.ts
··· 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { logger } from "../../../logger.js"; 3 import { 4 createAccountComment, 5 createAccountLabel, 6 createAccountReport, 7 } from "../../../accountModeration.js"; 8 import { getLanguage } from "../../../utils/getLanguage.js"; 9 import { checkDescription, checkDisplayName } from "../checkProfiles.js"; 10 ··· 357 expect.any(String), 358 ); 359 }); 360 - 361 }); 362 }); 363
··· 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 import { 3 createAccountComment, 4 createAccountLabel, 5 createAccountReport, 6 } from "../../../accountModeration.js"; 7 + import { logger } from "../../../logger.js"; 8 import { getLanguage } from "../../../utils/getLanguage.js"; 9 import { checkDescription, checkDisplayName } from "../checkProfiles.js"; 10 ··· 357 expect.any(String), 358 ); 359 }); 360 }); 361 }); 362
+11 -2
src/session.ts
··· 1 - import { readFileSync, writeFileSync, unlinkSync, chmodSync, existsSync } from "node:fs"; 2 import { join } from "node:path"; 3 import { logger } from "./logger.js"; 4 ··· 34 logger.info("Loaded existing session from file"); 35 return session; 36 } catch (error) { 37 - logger.error({ error }, "Failed to load session file, will authenticate fresh"); 38 return null; 39 } 40 }
··· 1 + import { 2 + chmodSync, 3 + existsSync, 4 + readFileSync, 5 + unlinkSync, 6 + writeFileSync, 7 + } from "node:fs"; 8 import { join } from "node:path"; 9 import { logger } from "./logger.js"; 10 ··· 40 logger.info("Loaded existing session from file"); 41 return session; 42 } catch (error) { 43 + logger.error( 44 + { error }, 45 + "Failed to load session file, will authenticate fresh", 46 + ); 47 return null; 48 } 49 }
+19 -20
src/tests/accountThreshold.test.ts
··· 1 import { afterEach, describe, expect, it, vi } from "vitest"; 2 3 vi.mock("../logger.js", () => ({ 4 logger: { ··· 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(() => {
··· 1 import { afterEach, describe, expect, it, vi } from "vitest"; 2 + import { 3 + createAccountComment, 4 + createAccountLabel, 5 + createAccountReport, 6 + } from "../accountModeration.js"; 7 + import { 8 + checkAccountThreshold, 9 + loadThresholdConfigs, 10 + } from "../accountThreshold.js"; 11 + import { logger } from "../logger.js"; 12 + import { 13 + accountLabelsThresholdAppliedCounter, 14 + accountThresholdChecksCounter, 15 + accountThresholdMetCounter, 16 + } from "../metrics.js"; 17 + import { 18 + getPostLabelCountInWindow, 19 + trackPostLabelForAccount, 20 + } from "../redis.js"; 21 22 vi.mock("../logger.js", () => ({ 23 logger: { ··· 95 inc: vi.fn(), 96 }, 97 })); 98 99 describe("Account Threshold Logic", () => { 100 afterEach(() => {
+55 -27
src/tests/moderation.test.ts
··· 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 3 // --- Mocks First --- 4 ··· 39 limit: vi.fn((fn) => fn()), 40 })); 41 42 - // --- Imports Second --- 43 - 44 - import { checkAccountLabels } from "../accountModeration.js"; 45 - import { agent } from "../agent.js"; 46 - import { logger } from "../logger.js"; 47 - import { createPostLabel } from "../moderation.js"; 48 - import { tryClaimPostLabel } from "../redis.js"; 49 - 50 describe("Moderation Logic", () => { 51 beforeEach(() => { 52 vi.clearAllMocks(); ··· 57 vi.mocked(agent.tools.ozone.moderation.getRepo).mockResolvedValueOnce({ 58 data: { 59 labels: [ 60 - { val: "spam", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" }, 61 - { val: "window-reply", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" } 62 - ] 63 }, 64 } as any); 65 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 66 expect(result).toBe(true); 67 }); 68 }); ··· 79 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 80 81 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 82 - expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).not.toHaveBeenCalled(); 83 - expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled(); 84 }); 85 86 it("should skip event if claimed but already labeled via API", async () => { 87 vi.mocked(tryClaimPostLabel).mockResolvedValue(true); 88 vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 89 - data: { labels: [{ val: LABEL, src: "did:plc:test", uri: URI, cts: "2024-01-01T00:00:00Z" }] }, 90 } as any); 91 92 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 93 94 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 95 - expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith( 96 - { uri: URI }, 97 - expect.any(Object), 98 - ); 99 - expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled(); 100 }); 101 102 it("should emit event if claimed and not labeled anywhere", async () => { ··· 104 vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 105 data: { labels: [] }, 106 } as any); 107 - vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({ success: true } as any); 108 109 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 110 111 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 112 - expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith( 113 - { uri: URI }, 114 - expect.any(Object), 115 - ); 116 - expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).toHaveBeenCalled(); 117 }); 118 }); 119 - });
··· 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 9 // --- Mocks First --- 10 ··· 45 limit: vi.fn((fn) => fn()), 46 })); 47 48 describe("Moderation Logic", () => { 49 beforeEach(() => { 50 vi.clearAllMocks(); ··· 55 vi.mocked(agent.tools.ozone.moderation.getRepo).mockResolvedValueOnce({ 56 data: { 57 labels: [ 58 + { 59 + val: "spam", 60 + src: "did:plc:test", 61 + uri: "at://test", 62 + cts: "2024-01-01T00:00:00Z", 63 + }, 64 + { 65 + val: "window-reply", 66 + src: "did:plc:test", 67 + uri: "at://test", 68 + cts: "2024-01-01T00:00:00Z", 69 + }, 70 + ], 71 }, 72 } as any); 73 + const result = await checkAccountLabels( 74 + "did:plc:test123", 75 + "window-reply", 76 + ); 77 expect(result).toBe(true); 78 }); 79 }); ··· 90 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 91 92 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 93 + expect( 94 + vi.mocked(agent.tools.ozone.moderation.getRecord), 95 + ).not.toHaveBeenCalled(); 96 + expect( 97 + vi.mocked(agent.tools.ozone.moderation.emitEvent), 98 + ).not.toHaveBeenCalled(); 99 }); 100 101 it("should skip event if claimed but already labeled via API", async () => { 102 vi.mocked(tryClaimPostLabel).mockResolvedValue(true); 103 vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 104 + data: { 105 + labels: [ 106 + { 107 + val: LABEL, 108 + src: "did:plc:test", 109 + uri: URI, 110 + cts: "2024-01-01T00:00:00Z", 111 + }, 112 + ], 113 + }, 114 } as any); 115 116 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 117 118 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 119 + expect( 120 + vi.mocked(agent.tools.ozone.moderation.getRecord), 121 + ).toHaveBeenCalledWith({ uri: URI }, expect.any(Object)); 122 + expect( 123 + vi.mocked(agent.tools.ozone.moderation.emitEvent), 124 + ).not.toHaveBeenCalled(); 125 }); 126 127 it("should emit event if claimed and not labeled anywhere", async () => { ··· 129 vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 130 data: { labels: [] }, 131 } as any); 132 + vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({ 133 + success: true, 134 + } as any); 135 136 await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 137 138 expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 139 + expect( 140 + vi.mocked(agent.tools.ozone.moderation.getRecord), 141 + ).toHaveBeenCalledWith({ uri: URI }, expect.any(Object)); 142 + expect( 143 + vi.mocked(agent.tools.ozone.moderation.emitEvent), 144 + ).toHaveBeenCalled(); 145 }); 146 }); 147 + });
+88 -74
src/tests/redis.test.ts
··· 1 - import { afterEach, describe, expect, it, vi } from 'vitest'; 2 3 // Mock the 'redis' module in a way that avoids hoisting issues. 4 // The mock implementation is self-contained. 5 - vi.mock('redis', () => { 6 const mockClient = { 7 on: vi.fn(), 8 connect: vi.fn(), ··· 19 }; 20 }); 21 22 - // Import the mocked redis first to get a reference to the mock client 23 - import { createClient } from 'redis'; 24 const mockRedisClient = createClient(); 25 26 - // Import the modules to be tested 27 - import { 28 - tryClaimPostLabel, 29 - tryClaimAccountLabel, 30 - connectRedis, 31 - disconnectRedis, 32 - trackPostLabelForAccount, 33 - getPostLabelCountInWindow, 34 - } from '../redis.js'; 35 - import { logger } from '../logger.js'; 36 - 37 // Suppress logger output during tests 38 - vi.mock('../logger.js', () => ({ 39 logger: { 40 info: vi.fn(), 41 warn: vi.fn(), ··· 44 }, 45 })); 46 47 - describe('Redis Cache Logic', () => { 48 afterEach(() => { 49 vi.clearAllMocks(); 50 }); 51 52 - describe('Connection', () => { 53 - it('should call redisClient.connect on connectRedis', async () => { 54 await connectRedis(); 55 expect(mockRedisClient.connect).toHaveBeenCalled(); 56 }); 57 58 - it('should call redisClient.quit on disconnectRedis', async () => { 59 await disconnectRedis(); 60 expect(mockRedisClient.quit).toHaveBeenCalled(); 61 }); 62 }); 63 64 - describe('tryClaimPostLabel', () => { 65 - it('should return true and set key if key does not exist', async () => { 66 - vi.mocked(mockRedisClient.set).mockResolvedValue('OK'); 67 - const result = await tryClaimPostLabel('at://uri', 'test-label'); 68 expect(result).toBe(true); 69 expect(mockRedisClient.set).toHaveBeenCalledWith( 70 - 'post-label:at://uri:test-label', 71 - '1', 72 - { NX: true, EX: 60 * 60 * 24 * 7 } 73 ); 74 }); 75 76 - it('should return false if key already exists', async () => { 77 vi.mocked(mockRedisClient.set).mockResolvedValue(null); 78 - const result = await tryClaimPostLabel('at://uri', 'test-label'); 79 expect(result).toBe(false); 80 }); 81 82 - it('should return true and log warning on Redis error', async () => { 83 - const redisError = new Error('Redis down'); 84 vi.mocked(mockRedisClient.set).mockRejectedValue(redisError); 85 - const result = await tryClaimPostLabel('at://uri', 'test-label'); 86 expect(result).toBe(true); 87 expect(logger.warn).toHaveBeenCalledWith( 88 - { err: redisError, atURI: 'at://uri', label: 'test-label' }, 89 - 'Error claiming post label in Redis, allowing through' 90 ); 91 }); 92 }); 93 94 - describe('tryClaimAccountLabel', () => { 95 - it('should return true and set key if key does not exist', async () => { 96 - vi.mocked(mockRedisClient.set).mockResolvedValue('OK'); 97 - const result = await tryClaimAccountLabel('did:plc:123', 'test-label'); 98 expect(result).toBe(true); 99 expect(mockRedisClient.set).toHaveBeenCalledWith( 100 - 'account-label:did:plc:123:test-label', 101 - '1', 102 - { NX: true, EX: 60 * 60 * 24 * 7 } 103 ); 104 }); 105 106 - it('should return false if key already exists', async () => { 107 vi.mocked(mockRedisClient.set).mockResolvedValue(null); 108 - const result = await tryClaimAccountLabel('did:plc:123', 'test-label'); 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); ··· 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, ··· 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) ··· 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(); 217 }); 218 }); 219 - });
··· 1 + // Import the mocked redis first to get a reference to the mock client 2 + import { createClient } from "redis"; 3 + import { afterEach, describe, expect, it, vi } from "vitest"; 4 + import { logger } from "../logger.js"; 5 + // Import the modules to be tested 6 + import { 7 + connectRedis, 8 + disconnectRedis, 9 + getPostLabelCountInWindow, 10 + trackPostLabelForAccount, 11 + tryClaimAccountLabel, 12 + tryClaimPostLabel, 13 + } from "../redis.js"; 14 15 // Mock the 'redis' module in a way that avoids hoisting issues. 16 // The mock implementation is self-contained. 17 + vi.mock("redis", () => { 18 const mockClient = { 19 on: vi.fn(), 20 connect: vi.fn(), ··· 31 }; 32 }); 33 34 const mockRedisClient = createClient(); 35 36 // Suppress logger output during tests 37 + vi.mock("../logger.js", () => ({ 38 logger: { 39 info: vi.fn(), 40 warn: vi.fn(), ··· 43 }, 44 })); 45 46 + describe("Redis Cache Logic", () => { 47 afterEach(() => { 48 vi.clearAllMocks(); 49 }); 50 51 + describe("Connection", () => { 52 + it("should call redisClient.connect on connectRedis", async () => { 53 await connectRedis(); 54 expect(mockRedisClient.connect).toHaveBeenCalled(); 55 }); 56 57 + it("should call redisClient.quit on disconnectRedis", async () => { 58 await disconnectRedis(); 59 expect(mockRedisClient.quit).toHaveBeenCalled(); 60 }); 61 }); 62 63 + describe("tryClaimPostLabel", () => { 64 + it("should return true and set key if key does not exist", async () => { 65 + vi.mocked(mockRedisClient.set).mockResolvedValue("OK"); 66 + const result = await tryClaimPostLabel("at://uri", "test-label"); 67 expect(result).toBe(true); 68 expect(mockRedisClient.set).toHaveBeenCalledWith( 69 + "post-label:at://uri:test-label", 70 + "1", 71 + { NX: true, EX: 60 * 60 * 24 * 7 }, 72 ); 73 }); 74 75 + it("should return false if key already exists", async () => { 76 vi.mocked(mockRedisClient.set).mockResolvedValue(null); 77 + const result = await tryClaimPostLabel("at://uri", "test-label"); 78 expect(result).toBe(false); 79 }); 80 81 + it("should return true and log warning on Redis error", async () => { 82 + const redisError = new Error("Redis down"); 83 vi.mocked(mockRedisClient.set).mockRejectedValue(redisError); 84 + const result = await tryClaimPostLabel("at://uri", "test-label"); 85 expect(result).toBe(true); 86 expect(logger.warn).toHaveBeenCalledWith( 87 + { err: redisError, atURI: "at://uri", label: "test-label" }, 88 + "Error claiming post label in Redis, allowing through", 89 ); 90 }); 91 }); 92 93 + describe("tryClaimAccountLabel", () => { 94 + it("should return true and set key if key does not exist", async () => { 95 + vi.mocked(mockRedisClient.set).mockResolvedValue("OK"); 96 + const result = await tryClaimAccountLabel("did:plc:123", "test-label"); 97 expect(result).toBe(true); 98 expect(mockRedisClient.set).toHaveBeenCalledWith( 99 + "account-label:did:plc:123:test-label", 100 + "1", 101 + { NX: true, EX: 60 * 60 * 24 * 7 }, 102 ); 103 }); 104 105 + it("should return false if key already exists", async () => { 106 vi.mocked(mockRedisClient.set).mockResolvedValue(null); 107 + const result = await tryClaimAccountLabel("did:plc:123", "test-label"); 108 expect(result).toBe(false); 109 }); 110 }); 111 112 + describe("trackPostLabelForAccount", () => { 113 + it("should track post label with correct timestamp and TTL", async () => { 114 vi.mocked(mockRedisClient.zRemRangeByScore).mockResolvedValue(0); 115 vi.mocked(mockRedisClient.zAdd).mockResolvedValue(1); 116 vi.mocked(mockRedisClient.expire).mockResolvedValue(true); ··· 118 const timestamp = 1640000000000000; // microseconds 119 const windowDays = 5; 120 121 + await trackPostLabelForAccount( 122 + "did:plc:123", 123 + "test-label", 124 + timestamp, 125 + windowDays, 126 + ); 127 128 + const expectedKey = "account-post-labels:did:plc:123:test-label:5"; 129 const windowStartTime = timestamp - windowDays * 24 * 60 * 60 * 1000000; 130 131 expect(mockRedisClient.zRemRangeByScore).toHaveBeenCalledWith( 132 expectedKey, 133 + "-inf", 134 + windowStartTime, 135 ); 136 expect(mockRedisClient.zAdd).toHaveBeenCalledWith(expectedKey, { 137 score: timestamp, ··· 139 }); 140 expect(mockRedisClient.expire).toHaveBeenCalledWith( 141 expectedKey, 142 + (windowDays + 1) * 24 * 60 * 60, 143 ); 144 }); 145 146 + it("should throw error on Redis failure", async () => { 147 + const redisError = new Error("Redis down"); 148 vi.mocked(mockRedisClient.zRemRangeByScore).mockRejectedValue(redisError); 149 150 await expect( 151 + trackPostLabelForAccount( 152 + "did:plc:123", 153 + "test-label", 154 + 1640000000000000, 155 + 5, 156 + ), 157 + ).rejects.toThrow("Redis down"); 158 159 expect(logger.error).toHaveBeenCalled(); 160 }); 161 }); 162 163 + describe("getPostLabelCountInWindow", () => { 164 + it("should count posts for single label", async () => { 165 vi.mocked(mockRedisClient.zCount).mockResolvedValue(3); 166 167 const currentTime = 1640000000000000; 168 const windowDays = 5; 169 const count = await getPostLabelCountInWindow( 170 + "did:plc:123", 171 + ["test-label"], 172 windowDays, 173 + currentTime, 174 ); 175 176 expect(count).toBe(3); 177 const windowStartTime = currentTime - windowDays * 24 * 60 * 60 * 1000000; 178 expect(mockRedisClient.zCount).toHaveBeenCalledWith( 179 + "account-post-labels:did:plc:123:test-label:5", 180 windowStartTime, 181 + "+inf", 182 ); 183 }); 184 185 + it("should sum counts for multiple labels (OR logic)", async () => { 186 vi.mocked(mockRedisClient.zCount) 187 .mockResolvedValueOnce(3) 188 .mockResolvedValueOnce(2) ··· 191 const currentTime = 1640000000000000; 192 const windowDays = 5; 193 const count = await getPostLabelCountInWindow( 194 + "did:plc:123", 195 + ["label-1", "label-2", "label-3"], 196 windowDays, 197 + currentTime, 198 ); 199 200 expect(count).toBe(6); 201 expect(mockRedisClient.zCount).toHaveBeenCalledTimes(3); 202 }); 203 204 + it("should return 0 when no posts in window", async () => { 205 vi.mocked(mockRedisClient.zCount).mockResolvedValue(0); 206 207 const count = await getPostLabelCountInWindow( 208 + "did:plc:123", 209 + ["test-label"], 210 5, 211 + 1640000000000000, 212 ); 213 214 expect(count).toBe(0); 215 }); 216 217 + it("should throw error on Redis failure", async () => { 218 + const redisError = new Error("Redis down"); 219 vi.mocked(mockRedisClient.zCount).mockRejectedValue(redisError); 220 221 await expect( 222 + getPostLabelCountInWindow( 223 + "did:plc:123", 224 + ["test-label"], 225 + 5, 226 + 1640000000000000, 227 + ), 228 + ).rejects.toThrow("Redis down"); 229 230 expect(logger.error).toHaveBeenCalled(); 231 }); 232 }); 233 + });