From 0ebb77d8a07852541b5561701524a0aafa41e45a Mon Sep 17 00:00:00 2001 From: Skywatch Date: Sun, 19 Oct 2025 21:57:15 -0400 Subject: [PATCH] feat: Add Redis caching and connection This commit introduces Redis caching to prevent redundant moderation actions, reducing the load on the Bluesky API. - Added the `redis` package as a dependency. - Implemented `connectRedis` and `disconnectRedis` functions to manage the Redis connection. - Added `tryClaimPostLabel`, `tryClaimAccountLabel`, and `tryClaimAccountComment` functions to manage and claim resources for caching purposes. - Modified `src/config.ts` to include the `REDIS_URL` environment variable. - Added `src/redis.ts` which contains the Redis client and connection management. - Integrated the caching logic into moderation functions to ensure that actions are performed only once per resource. - Added Redis healthcheck to compose.yaml. - Updated package.json and bun.lock. --- bun.lock | 19 +++ compose.yaml | 33 ++++ package.json | 1 + src/config.ts | 1 + src/main.ts | 7 +- src/moderation.ts | 51 +++++- src/redis.ts | 109 ++++++++++++ src/rules/account/ageConstants.ts | 38 +++++ src/rules/handles/checkHandles.test.ts | 3 + src/rules/handles/checkHandles.ts | 22 +-- src/rules/posts/checkPosts.ts | 20 +-- src/rules/posts/tests/checkPosts.test.ts | 38 +---- src/rules/profiles/checkProfiles.ts | 46 +---- .../profiles/tests/checkProfiles.test.ts | 44 +---- src/tests/moderation.test.ts | 160 ++++++++---------- src/tests/redis.test.ts | 106 ++++++++++++ 16 files changed, 451 insertions(+), 247 deletions(-) create mode 100644 src/redis.ts create mode 100644 src/tests/redis.test.ts diff --git a/bun.lock b/bun.lock index f73a2c3..f48ba40 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "pino": "^9.9.0", "pino-pretty": "^13.1.1", "prom-client": "^15.1.3", + "redis": "^4.7.0", "undici": "^7.15.0", }, "devDependencies": { @@ -364,6 +365,18 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@redis/bloom": ["@redis/bloom@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg=="], + + "@redis/client": ["@redis/client@1.6.1", "", { "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", "yallist": "4.0.0" } }, "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw=="], + + "@redis/graph": ["@redis/graph@1.1.1", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw=="], + + "@redis/json": ["@redis/json@1.0.7", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ=="], + + "@redis/search": ["@redis/search@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw=="], + + "@redis/time-series": ["@redis/time-series@1.1.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="], @@ -798,6 +811,8 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "generic-pool": ["generic-pool@3.9.0", "", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], @@ -1156,6 +1171,8 @@ "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], + "redis": ["redis@4.7.1", "", { "dependencies": { "@redis/bloom": "1.2.0", "@redis/client": "1.6.1", "@redis/graph": "1.1.1", "@redis/json": "1.0.7", "@redis/search": "1.2.0", "@redis/time-series": "1.1.0" } }, "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ=="], + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], @@ -1376,6 +1393,8 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], diff --git a/compose.yaml b/compose.yaml index 777f0fe..7f4a02e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,6 +9,20 @@ version: "3.8" services: + redis: + image: redis:7-alpine + container_name: skywatch-automod-redis + restart: unless-stopped + volumes: + - redis-data:/data + networks: + - skywatch-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + automod: # Build the Docker image from the Dockerfile in the current directory. build: . @@ -26,9 +40,28 @@ services: env_file: - .env + # Wait for Redis to be healthy before starting + depends_on: + redis: + condition: service_healthy + + networks: + - skywatch-network + # Mount a volume to persist the firehose cursor. # This links the `cursor.txt` file from your host into the container at `/app/cursor.txt`. # Persisting this file allows the automod to resume from where it left off # after a restart, preventing it from reprocessing old events or skipping new ones. volumes: - ./cursor.txt:/app/cursor.txt + + environment: + - NODE_ENV=production + - REDIS_URL=redis://redis:6379 + +volumes: + redis-data: + +networks: + skywatch-network: + driver: bridge diff --git a/package.json b/package.json index bc60657..7fd3688 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "pino": "^9.9.0", "pino-pretty": "^13.1.1", "prom-client": "^15.1.3", + "redis": "^4.7.0", "undici": "^7.15.0" }, "trustedDependencies": [ diff --git a/src/config.ts b/src/config.ts index 7949892..6d0b61b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,3 +23,4 @@ export const CURSOR_UPDATE_INTERVAL = process.env.CURSOR_UPDATE_INTERVAL : 60000; export const LABEL_LIMIT = process.env.LABEL_LIMIT; export const LABEL_LIMIT_WAIT = process.env.LABEL_LIMIT_WAIT; +export const REDIS_URL = process.env.REDIS_URL || "redis://redis:6379"; diff --git a/src/main.ts b/src/main.ts index 62e6209..edd1a44 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,7 @@ import { } from "./config.js"; import { logger } from "./logger.js"; import { startMetricsServer } from "./metrics.js"; +import { connectRedis, disconnectRedis } from "./redis.js"; import { checkAccountAge } from "./rules/account/age.js"; import { checkFacetSpam } from "./rules/facets/facets.js"; import { checkHandle } from "./rules/handles/checkHandles.js"; @@ -321,14 +322,18 @@ const metricsServer = startMetricsServer(METRICS_PORT); } });*/ +logger.info({ process: "MAIN" }, "Connecting to Redis"); +await connectRedis(); + jetstream.start(); -function shutdown() { +async function shutdown() { try { logger.info({ process: "MAIN" }, "Shutting down gracefully"); fs.writeFileSync("cursor.txt", jetstream.cursor!.toString(), "utf8"); jetstream.close(); metricsServer.close(); + await disconnectRedis(); } catch (error) { logger.error({ process: "MAIN", error }, "Error shutting down gracefully"); process.exit(1); diff --git a/src/moderation.ts b/src/moderation.ts index 1740ec9..d33f6a3 100644 --- a/src/moderation.ts +++ b/src/moderation.ts @@ -2,6 +2,11 @@ import { agent, isLoggedIn } from "./agent.js"; import { MOD_DID } from "./config.js"; import { limit } from "./limits.js"; import { logger } from "./logger.js"; +import { + tryClaimAccountComment, + tryClaimAccountLabel, + tryClaimPostLabel, +} from "./redis.js"; const doesLabelExist = ( labels: { val: string }[] | undefined, @@ -19,9 +24,19 @@ export const createPostLabel = async ( label: string, comment: string, duration: number | undefined, + did?: string, ) => { await isLoggedIn; + const claimed = await tryClaimPostLabel(uri, label); + if (!claimed) { + logger.debug( + { process: "MODERATION", uri, label }, + "Post label already claimed in Redis, skipping", + ); + return; + } + const hasLabel = await checkRecordLabels(uri, label); if (hasLabel) { logger.debug( @@ -31,6 +46,11 @@ export const createPostLabel = async ( return; } + logger.info( + { process: "MODERATION", label, did, atURI: uri }, + "Labeling post", + ); + await limit(async () => { try { const event: { @@ -50,7 +70,7 @@ export const createPostLabel = async ( event.durationInHours = duration; } - return agent.tools.ozone.moderation.emitEvent( + await agent.tools.ozone.moderation.emitEvent( { event: event, // specify the labeled post by strongRef @@ -91,6 +111,15 @@ export const createAccountLabel = async ( ) => { await isLoggedIn; + const claimed = await tryClaimAccountLabel(did, label); + if (!claimed) { + logger.debug( + { process: "MODERATION", did, label }, + "Account label already claimed in Redis, skipping", + ); + return; + } + const hasLabel = await checkAccountLabels(did, label); if (hasLabel) { logger.debug( @@ -100,6 +129,8 @@ export const createAccountLabel = async ( return; } + logger.info({ process: "MODERATION", did, label }, "Labeling account"); + await limit(async () => { try { await agent.tools.ozone.moderation.emitEvent( @@ -186,8 +217,24 @@ export const createPostReport = async ( }); }; -export const createAccountComment = async (did: string, comment: string) => { +export const createAccountComment = async ( + did: string, + comment: string, + atURI: string, +) => { await isLoggedIn; + + const claimed = await tryClaimAccountComment(did, atURI); + if (!claimed) { + logger.debug( + { process: "MODERATION", did, atURI }, + "Account comment already claimed in Redis, skipping", + ); + return; + } + + logger.info({ process: "MODERATION", did, atURI }, "Commenting on account"); + await limit(async () => { try { await agent.tools.ozone.moderation.emitEvent( diff --git a/src/redis.ts b/src/redis.ts new file mode 100644 index 0000000..9826b07 --- /dev/null +++ b/src/redis.ts @@ -0,0 +1,109 @@ +import { createClient } from "redis"; +import { REDIS_URL } from "./config.js"; +import { logger } from "./logger.js"; + +export const redisClient = createClient({ + url: REDIS_URL, +}); + +redisClient.on("error", (err: Error) => { + logger.error({ err }, "Redis client error"); +}); + +redisClient.on("connect", () => { + logger.info("Redis client connected"); +}); + +redisClient.on("ready", () => { + logger.info("Redis client ready"); +}); + +redisClient.on("reconnecting", () => { + logger.warn("Redis client reconnecting"); +}); + +export async function connectRedis(): Promise { + try { + await redisClient.connect(); + } catch (err) { + logger.error({ err }, "Failed to connect to Redis"); + throw err; + } +} + +export async function disconnectRedis(): Promise { + try { + await redisClient.quit(); + logger.info("Redis client disconnected"); + } catch (err) { + logger.error({ err }, "Error disconnecting Redis"); + } +} + +function getPostLabelCacheKey(atURI: string, label: string): string { + return `post-label:${atURI}:${label}`; +} + +function getAccountLabelCacheKey(did: string, label: string): string { + return `account-label:${did}:${label}`; +} + +export async function tryClaimPostLabel( + atURI: string, + label: string, +): Promise { + try { + const key = getPostLabelCacheKey(atURI, label); + const result = await redisClient.set(key, "1", { + NX: true, + EX: 60 * 60 * 24 * 7, + }); + return result === "OK"; + } catch (err) { + logger.warn( + { err, atURI, label }, + "Error claiming post label in Redis, allowing through", + ); + return true; + } +} + +export async function tryClaimAccountLabel( + did: string, + label: string, +): Promise { + try { + const key = getAccountLabelCacheKey(did, label); + const result = await redisClient.set(key, "1", { + NX: true, + EX: 60 * 60 * 24 * 7, + }); + return result === "OK"; + } catch (err) { + logger.warn( + { err, did, label }, + "Error claiming account label in Redis, allowing through", + ); + return true; + } +} + +export async function tryClaimAccountComment( + did: string, + atURI: string, +): Promise { + try { + const key = `account-comment:${did}:${atURI}`; + const result = await redisClient.set(key, "1", { + NX: true, + EX: 60 * 60 * 24 * 7, + }); + return result === "OK"; + } catch (err) { + logger.warn( + { err, did, atURI }, + "Error claiming account comment in Redis, allowing through", + ); + return true; + } +} diff --git a/src/rules/account/ageConstants.ts b/src/rules/account/ageConstants.ts index 6d5a575..fc27a3e 100644 --- a/src/rules/account/ageConstants.ts +++ b/src/rules/account/ageConstants.ts @@ -12,6 +12,44 @@ import { AccountAgeCheck } from "../../types.js"; * - Detect brigading on specific controversial posts */ export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [ + { + monitoredDIDs: [ + "did:plc:b2ecyhl2z2tro25ltrcyiytd", // DHS + "did:plc:iw2wxg46hm4ezguswhwej6t6", // actual whitehouse + "did:plc:fhnl65q3us5evynqc4f2qak6", // HHS + "did:plc:wrz4athzuf2u5js2ltrktiqk", // DOL + "did:plc:3mqcgvyu4exg3pkx4bkfppih", // VA + "did:plc:pqn2sfkx5klnytms4uwqt5wo", // Treasurer + "did:plc:v4kvjftk6kr5ci3zqmfawwpb", // State + "did:plc:rlymk4d5qmq5udjdznojmvel", // Interior + "did:plc:f7a5etif42x56oyrbzuek6so", // USDA + "did:plc:7kusimwlnf4v5jo757jvkeaj", // DOE + "did:plc:jgq3vko3g6zg72457bda2snd", // SBA + "did:plc:h2iujdjlry6fpniofjtiqqmb", // DoD + "did:plc:jwncvpznkwe4luzvdroes45b", // CBP + "did:plc:azfxx5mdxcuoc2bkuqizs4kd", + "did:plc:vostkism5vbzjqfcmllmd6gz", + "did:plc:etthv4ychwti4b6i2hhe76c2", + "did:plc:swf7zddjselkcpbn6iw323gy", + "did:plc:h3zq65wioggctyxpovfpi6ec", + "did:plc:nofnc2xpdihktxkufkq7tn3w", + "did:plc:quezcqejcqw6g5t3om7wldns", + "did:plc:vlvqht2v3nsc4k7xaho6bjaf", + "did:plc:syyfuvqiabipi5mf3x632qij", + "did:plc:6vpxzm6mxjzcfvccnuw2pyd7", + "did:plc:yxqdgravj27gtxkpqhrnzhlx", + "did:plc:nrhrdxqa2v7hfxw2jnuy7rk7", + "did:plc:pr27argcmniiwxp7d7facqwy", + "did:plc:azfxx5mdxcuoc2bkuqizs4kd", + "did:plc:y42muzveli3sjyr3tufaq765", + "did:plc:22wazjq4e4yjafxlew2c6kov", + "did:plc:iw64z65wzkmqvftssb2nldj5", + ], + anchorDate: "2025-10-17", // Date when harassment campaign started + maxAgeDays: 7, // Flag accounts less than 7 days old + label: "suspect-inauthentic", + comment: "New account replying to monitored user during campaign", + }, // Example: Monitor replies to specific accounts // { // monitoredDIDs: [ diff --git a/src/rules/handles/checkHandles.test.ts b/src/rules/handles/checkHandles.test.ts index cd3521c..9fa8f0e 100644 --- a/src/rules/handles/checkHandles.test.ts +++ b/src/rules/handles/checkHandles.test.ts @@ -140,6 +140,7 @@ describe("checkHandle", () => { expect(createAccountComment).toHaveBeenCalledWith( "did:plc:user1", `${time}: Scam detected - scam-account`, + "handle:did:plc:user1:scam-account", ); }); }); @@ -181,6 +182,7 @@ describe("checkHandle", () => { expect(createAccountComment).toHaveBeenCalledWith( "did:plc:user1", `${time}: Scam detected - scam-user`, + "handle:did:plc:user1:scam-user", ); }); @@ -206,6 +208,7 @@ describe("checkHandle", () => { expect(createAccountComment).toHaveBeenCalledWith( "did:plc:user1", `${time}: Multi-action triggered - dangerous-account`, + "handle:did:plc:user1:dangerous-account", ); expect(createAccountLabel).toHaveBeenCalledWith( "did:plc:user1", diff --git a/src/rules/handles/checkHandles.ts b/src/rules/handles/checkHandles.ts index 4551913..f53ac6b 100644 --- a/src/rules/handles/checkHandles.ts +++ b/src/rules/handles/checkHandles.ts @@ -46,17 +46,11 @@ export const checkHandle = async ( } if (checkList.toLabel === true) { - logger.info( - { process: "CHECKHANDLE", did, handle, time, label: checkList.label }, - "Labeling account", + createAccountLabel( + did, + `${checkList.label}`, + `${time}: ${checkList.comment} - ${handle}`, ); - { - createAccountLabel( - did, - `${checkList.label}`, - `${time}: ${checkList.comment} - ${handle}`, - ); - } } if (checkList.reportAcct === true) { @@ -68,11 +62,11 @@ export const checkHandle = async ( } if (checkList.commentAcct === true) { - logger.info( - { process: "CHECKHANDLE", did, handle, time, label: checkList.label }, - "Commenting on account", + createAccountComment( + did, + `${time}: ${checkList.comment} - ${handle}`, + `handle:${did}:${handle}`, ); - createAccountComment(did, `${time}: ${checkList.comment} - ${handle}`); } } }); diff --git a/src/rules/posts/checkPosts.ts b/src/rules/posts/checkPosts.ts index ddedf67..6e9c2b9 100644 --- a/src/rules/posts/checkPosts.ts +++ b/src/rules/posts/checkPosts.ts @@ -106,21 +106,13 @@ export const checkPosts = async (post: Post[]) => { countStarterPacks(post[0].did, post[0].time); if (checkPost.toLabel === true) { - logger.info( - { - process: "CHECKPOSTS", - label: checkPost.label, - did: post[0].did, - atURI: post[0].atURI, - }, - "Labeling post", - ); createPostLabel( post[0].atURI, post[0].cid, `${checkPost.label}`, `${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, checkPost.duration, + post[0].did, ); } @@ -158,18 +150,10 @@ export const checkPosts = async (post: Post[]) => { } if (checkPost.commentAcct === true) { - logger.info( - { - process: "CHECKPOSTS", - label: checkPost.label, - did: post[0].did, - atURI: post[0].atURI, - }, - "Commenting on account", - ); createAccountComment( post[0].did, `${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, + post[0].atURI, ); } } diff --git a/src/rules/posts/tests/checkPosts.test.ts b/src/rules/posts/tests/checkPosts.test.ts index b24a57c..8a7d026 100644 --- a/src/rules/posts/tests/checkPosts.test.ts +++ b/src/rules/posts/tests/checkPosts.test.ts @@ -244,21 +244,13 @@ describe("checkPosts", () => { await checkPosts(post); - expect(logger.info).toHaveBeenCalledWith( - { - process: "CHECKPOSTS", - label: "test-label", - did: post[0].did, - atURI: post[0].atURI, - }, - "Labeling post", - ); expect(createPostLabel).toHaveBeenCalledWith( post[0].atURI, post[0].cid, "test-label", expect.stringContaining("Test comment"), undefined, + post[0].did, ); }); @@ -292,6 +284,7 @@ describe("checkPosts", () => { "language-specific", expect.any(String), undefined, + post[0].did, ); }); @@ -345,6 +338,7 @@ describe("checkPosts", () => { "whitelisted-test", expect.any(String), undefined, + post[0].did, ); }); }); @@ -389,6 +383,7 @@ describe("checkPosts", () => { "ignored-did", expect.any(String), undefined, + "did:plc:notignored", ); }); }); @@ -405,6 +400,7 @@ describe("checkPosts", () => { "all-actions", expect.any(String), undefined, + post[0].did, ); expect(createPostReport).toHaveBeenCalledWith( post[0].atURI, @@ -418,29 +414,7 @@ describe("checkPosts", () => { expect(createAccountComment).toHaveBeenCalledWith( post[0].did, expect.any(String), - ); - }); - - it("should log all moderation actions", async () => { - const post = createMockPost({ text: "report this" }); - - await checkPosts(post); - - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ label: "all-actions" }), - "Labeling post", - ); - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ label: "all-actions" }), - "Reporting post", - ); - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ label: "all-actions" }), - "Reporting account", - ); - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ label: "all-actions" }), - "Commenting on account", + expect.any(String), ); }); }); diff --git a/src/rules/profiles/checkProfiles.ts b/src/rules/profiles/checkProfiles.ts index 117637c..d5338d2 100644 --- a/src/rules/profiles/checkProfiles.ts +++ b/src/rules/profiles/checkProfiles.ts @@ -70,17 +70,6 @@ export const checkDescription = async ( `${checkProfiles.label}`, `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, ); - logger.info( - { - process: "CHECKDESCRIPTION", - did, - time, - displayName, - description, - label: checkProfiles.label, - }, - "Labeling account", - ); } if (checkProfiles.reportAcct === true) { @@ -105,17 +94,7 @@ export const checkDescription = async ( createAccountComment( did, `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, - ); - logger.info( - { - process: "CHECKDESCRIPTION", - did, - time, - displayName, - description, - label: checkProfiles.label, - }, - "Commenting on account", + `profile:${did}:${time}`, ); } } @@ -186,17 +165,6 @@ export const checkDisplayName = async ( `${checkProfiles.label}`, `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, ); - logger.info( - { - process: "CHECKDISPLAYNAME", - did, - time, - displayName, - description, - label: checkProfiles.label, - }, - "Labeling account", - ); } if (checkProfiles.reportAcct === true) { @@ -221,17 +189,7 @@ export const checkDisplayName = async ( createAccountComment( did, `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, - ); - logger.info( - { - process: "CHECKDISPLAYNAME", - did, - time, - displayName, - description, - label: checkProfiles.label, - }, - "Commenting on account", + `profile:${did}:${time}`, ); } } diff --git a/src/rules/profiles/tests/checkProfiles.test.ts b/src/rules/profiles/tests/checkProfiles.test.ts index 874a771..d3ed232 100644 --- a/src/rules/profiles/tests/checkProfiles.test.ts +++ b/src/rules/profiles/tests/checkProfiles.test.ts @@ -167,17 +167,6 @@ describe("checkProfiles", () => { "This is spam content", ); - expect(logger.info).toHaveBeenCalledWith( - { - process: "CHECKDESCRIPTION", - did: mockDid, - time: mockTime, - displayName: mockDisplayName, - description: "This is spam content", - label: "test-description", - }, - "Labeling account", - ); expect(createAccountLabel).toHaveBeenCalledWith( mockDid, "test-description", @@ -365,30 +354,10 @@ describe("checkProfiles", () => { expect(createAccountComment).toHaveBeenCalledWith( mockDid, expect.any(String), + expect.any(String), ); }); - it("should log all moderation actions", async () => { - await checkDescription( - mockDid, - mockTime, - mockDisplayName, - "report this", - ); - - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ label: "all-actions" }), - "Labeling account", - ); - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ label: "all-actions" }), - "Reporting account", - ); - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ label: "all-actions" }), - "Commenting on account", - ); - }); }); }); @@ -434,17 +403,6 @@ describe("checkProfiles", () => { mockDescription, ); - expect(logger.info).toHaveBeenCalledWith( - { - process: "CHECKDISPLAYNAME", - did: mockDid, - time: mockTime, - displayName: "fake account", - description: mockDescription, - label: "test-displayname", - }, - "Labeling account", - ); expect(createAccountLabel).toHaveBeenCalledWith( mockDid, "test-displayname", diff --git a/src/tests/moderation.test.ts b/src/tests/moderation.test.ts index 43c2741..0937671 100644 --- a/src/tests/moderation.test.ts +++ b/src/tests/moderation.test.ts @@ -1,15 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { agent } from "../agent.js"; -import { logger } from "../logger.js"; -import { checkAccountLabels } from "../moderation.js"; -// Mock dependencies +// --- Mocks First --- + vi.mock("../agent.js", () => ({ agent: { tools: { ozone: { moderation: { getRepo: vi.fn(), + getRecord: vi.fn(), + emitEvent: vi.fn(), }, }, }, @@ -17,6 +17,11 @@ vi.mock("../agent.js", () => ({ isLoggedIn: Promise.resolve(true), })); +vi.mock("../redis.js", () => ({ + tryClaimPostLabel: vi.fn(), + tryClaimAccountLabel: vi.fn(), +})); + vi.mock("../logger.js", () => ({ logger: { info: vi.fn(), @@ -34,111 +39,80 @@ vi.mock("../limits.js", () => ({ limit: vi.fn((fn) => fn()), })); -describe("checkAccountLabels", () => { +// --- Imports Second --- + +import { agent } from "../agent.js"; +import { checkAccountLabels, createPostLabel } from "../moderation.js"; +import { tryClaimPostLabel } from "../redis.js"; +import { logger } from "../logger.js"; + +describe("Moderation Logic", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("should return true if label exists on account", async () => { - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ - data: { - labels: [ - { val: "spam" }, - { val: "harassment" }, - { val: "window-reply" }, - ], - }, - }); - - const result = await checkAccountLabels("did:plc:test123", "window-reply"); - - expect(result).toBe(true); - expect(agent.tools.ozone.moderation.getRepo).toHaveBeenCalledWith( - { did: "did:plc:test123" }, - { - headers: { - "atproto-proxy": "did:plc:moderator123#atproto_labeler", - "atproto-accept-labelers": "did:plc:ar7c4by46qjdydhdevvrndac;redact", + describe("checkAccountLabels", () => { + it("should return true if label exists on account", async () => { + vi.mocked(agent.tools.ozone.moderation.getRepo).mockResolvedValueOnce({ + data: { + labels: [ + { val: "spam", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" }, + { val: "window-reply", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" } + ] }, - }, - ); - }); - - it("should return false if label does not exist on account", async () => { - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ - data: { - labels: [{ val: "spam" }, { val: "harassment" }], - }, + } as any); + const result = await checkAccountLabels("did:plc:test123", "window-reply"); + expect(result).toBe(true); }); - - const result = await checkAccountLabels("did:plc:test123", "window-reply"); - - expect(result).toBe(false); }); - it("should return false if account has no labels", async () => { - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ - data: { - labels: [], - }, - }); + describe("createPostLabel with Caching", () => { + const URI = "at://did:plc:test/app.bsky.feed.post/123"; + const CID = "bafybeig6xv5nwph5j7grrlp3pdeolqptpep5nfljmdkmtcf2l4wisa2mfa"; + const LABEL = "test-label"; + const COMMENT = "test comment"; - const result = await checkAccountLabels("did:plc:test123", "window-reply"); + it("should skip if claim fails (already claimed)", async () => { + vi.mocked(tryClaimPostLabel).mockResolvedValue(false); - expect(result).toBe(false); - }); + await createPostLabel(URI, CID, LABEL, COMMENT, undefined); - it("should return false if labels property is undefined", async () => { - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ - data: {}, + expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); + expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).not.toHaveBeenCalled(); + expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled(); }); - const result = await checkAccountLabels("did:plc:test123", "window-reply"); - - expect(result).toBe(false); - }); - - it("should handle API errors gracefully", async () => { - (agent.tools.ozone.moderation.getRepo as any).mockRejectedValueOnce( - new Error("API Error"), - ); - - const result = await checkAccountLabels("did:plc:test123", "window-reply"); + it("should skip event if claimed but already labeled via API", async () => { + vi.mocked(tryClaimPostLabel).mockResolvedValue(true); + vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ + data: { labels: [{ val: LABEL, src: "did:plc:test", uri: URI, cts: "2024-01-01T00:00:00Z" }] }, + } as any); - expect(result).toBe(false); - expect(logger.error).toHaveBeenCalledWith( - { - process: "MODERATION", - did: "did:plc:test123", - error: expect.any(Error), - }, - "Failed to check account labels", - ); - }); + await createPostLabel(URI, CID, LABEL, COMMENT, undefined); - it("should perform case-sensitive label matching", async () => { - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ - data: { - labels: [{ val: "window-reply" }], - }, + expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); + expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith( + { uri: URI }, + expect.any(Object), + ); + expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled(); }); - const resultLower = await checkAccountLabels( - "did:plc:test123", - "window-reply", - ); - expect(resultLower).toBe(true); - - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ - data: { - labels: [{ val: "window-reply" }], - }, + it("should emit event if claimed and not labeled anywhere", async () => { + vi.mocked(tryClaimPostLabel).mockResolvedValue(true); + vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ + data: { labels: [] }, + } as any); + vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({ success: true } as any); + + await createPostLabel(URI, CID, LABEL, COMMENT, undefined); + + expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); + expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith( + { uri: URI }, + expect.any(Object), + ); + expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).toHaveBeenCalled(); }); - - const resultUpper = await checkAccountLabels( - "did:plc:test123", - "Window-Reply", - ); - expect(resultUpper).toBe(false); }); -}); +}); \ No newline at end of file diff --git a/src/tests/redis.test.ts b/src/tests/redis.test.ts new file mode 100644 index 0000000..a009f72 --- /dev/null +++ b/src/tests/redis.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Mock the 'redis' module in a way that avoids hoisting issues. +// The mock implementation is self-contained. +vi.mock('redis', () => { + const mockClient = { + on: vi.fn(), + connect: vi.fn(), + quit: vi.fn(), + exists: vi.fn(), + set: vi.fn(), + }; + return { + createClient: vi.fn(() => mockClient), + }; +}); + +// Import the mocked redis first to get a reference to the mock client +import { createClient } from 'redis'; +const mockRedisClient = createClient(); + +// Import the modules to be tested +import { + tryClaimPostLabel, + tryClaimAccountLabel, + connectRedis, + disconnectRedis, +} from '../redis.js'; +import { logger } from '../logger.js'; + +// Suppress logger output during tests +vi.mock('../logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +describe('Redis Cache Logic', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Connection', () => { + it('should call redisClient.connect on connectRedis', async () => { + await connectRedis(); + expect(mockRedisClient.connect).toHaveBeenCalled(); + }); + + it('should call redisClient.quit on disconnectRedis', async () => { + await disconnectRedis(); + expect(mockRedisClient.quit).toHaveBeenCalled(); + }); + }); + + describe('tryClaimPostLabel', () => { + it('should return true and set key if key does not exist', async () => { + vi.mocked(mockRedisClient.set).mockResolvedValue('OK'); + const result = await tryClaimPostLabel('at://uri', 'test-label'); + expect(result).toBe(true); + expect(mockRedisClient.set).toHaveBeenCalledWith( + 'post-label:at://uri:test-label', + '1', + { NX: true, EX: 60 * 60 * 24 * 7 } + ); + }); + + it('should return false if key already exists', async () => { + vi.mocked(mockRedisClient.set).mockResolvedValue(null); + const result = await tryClaimPostLabel('at://uri', 'test-label'); + expect(result).toBe(false); + }); + + it('should return true and log warning on Redis error', async () => { + const redisError = new Error('Redis down'); + vi.mocked(mockRedisClient.set).mockRejectedValue(redisError); + const result = await tryClaimPostLabel('at://uri', 'test-label'); + expect(result).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + { err: redisError, atURI: 'at://uri', label: 'test-label' }, + 'Error claiming post label in Redis, allowing through' + ); + }); + }); + + describe('tryClaimAccountLabel', () => { + it('should return true and set key if key does not exist', async () => { + vi.mocked(mockRedisClient.set).mockResolvedValue('OK'); + const result = await tryClaimAccountLabel('did:plc:123', 'test-label'); + expect(result).toBe(true); + expect(mockRedisClient.set).toHaveBeenCalledWith( + 'account-label:did:plc:123:test-label', + '1', + { NX: true, EX: 60 * 60 * 24 * 7 } + ); + }); + + it('should return false if key already exists', async () => { + vi.mocked(mockRedisClient.set).mockResolvedValue(null); + const result = await tryClaimAccountLabel('did:plc:123', 'test-label'); + expect(result).toBe(false); + }); + }); +}); \ No newline at end of file -- 2.43.0