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

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.

Skywatch 1364bfea ed6c4069

+19
bun.lock
··· 24 24 "pino": "^9.9.0", 25 25 "pino-pretty": "^13.1.1", 26 26 "prom-client": "^15.1.3", 27 + "redis": "^4.7.0", 27 28 "undici": "^7.15.0", 28 29 }, 29 30 "devDependencies": { ··· 364 365 365 366 "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], 366 367 368 + "@redis/bloom": ["@redis/bloom@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg=="], 369 + 370 + "@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=="], 371 + 372 + "@redis/graph": ["@redis/graph@1.1.1", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw=="], 373 + 374 + "@redis/json": ["@redis/json@1.0.7", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ=="], 375 + 376 + "@redis/search": ["@redis/search@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw=="], 377 + 378 + "@redis/time-series": ["@redis/time-series@1.1.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g=="], 379 + 367 380 "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="], 368 381 369 382 "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="], ··· 797 810 "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 798 811 799 812 "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 813 + 814 + "generic-pool": ["generic-pool@3.9.0", "", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="], 800 815 801 816 "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], 802 817 ··· 1156 1171 1157 1172 "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], 1158 1173 1174 + "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=="], 1175 + 1159 1176 "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], 1160 1177 1161 1178 "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], ··· 1375 1392 "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], 1376 1393 1377 1394 "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], 1395 + 1396 + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], 1378 1397 1379 1398 "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], 1380 1399
+33
compose.yaml
··· 9 9 version: "3.8" 10 10 11 11 services: 12 + redis: 13 + image: redis:7-alpine 14 + container_name: skywatch-automod-redis 15 + restart: unless-stopped 16 + volumes: 17 + - redis-data:/data 18 + networks: 19 + - skywatch-network 20 + healthcheck: 21 + test: ["CMD", "redis-cli", "ping"] 22 + interval: 10s 23 + timeout: 3s 24 + retries: 3 25 + 12 26 automod: 13 27 # Build the Docker image from the Dockerfile in the current directory. 14 28 build: . ··· 26 40 env_file: 27 41 - .env 28 42 43 + # Wait for Redis to be healthy before starting 44 + depends_on: 45 + redis: 46 + condition: service_healthy 47 + 48 + networks: 49 + - skywatch-network 50 + 29 51 # Mount a volume to persist the firehose cursor. 30 52 # This links the `cursor.txt` file from your host into the container at `/app/cursor.txt`. 31 53 # Persisting this file allows the automod to resume from where it left off 32 54 # after a restart, preventing it from reprocessing old events or skipping new ones. 33 55 volumes: 34 56 - ./cursor.txt:/app/cursor.txt 57 + 58 + environment: 59 + - NODE_ENV=production 60 + - REDIS_URL=redis://redis:6379 61 + 62 + volumes: 63 + redis-data: 64 + 65 + networks: 66 + skywatch-network: 67 + driver: bridge
+1
package.json
··· 59 59 "pino": "^9.9.0", 60 60 "pino-pretty": "^13.1.1", 61 61 "prom-client": "^15.1.3", 62 + "redis": "^4.7.0", 62 63 "undici": "^7.15.0" 63 64 }, 64 65 "trustedDependencies": [
+1
src/config.ts
··· 23 23 : 60000; 24 24 export const LABEL_LIMIT = process.env.LABEL_LIMIT; 25 25 export const LABEL_LIMIT_WAIT = process.env.LABEL_LIMIT_WAIT; 26 + export const REDIS_URL = process.env.REDIS_URL || "redis://redis:6379";
+6 -1
src/main.ts
··· 13 13 } from "./config.js"; 14 14 import { logger } from "./logger.js"; 15 15 import { startMetricsServer } from "./metrics.js"; 16 + import { connectRedis, disconnectRedis } from "./redis.js"; 16 17 import { checkAccountAge } from "./rules/account/age.js"; 17 18 import { checkFacetSpam } from "./rules/facets/facets.js"; 18 19 import { checkHandle } from "./rules/handles/checkHandles.js"; ··· 321 322 } 322 323 });*/ 323 324 325 + logger.info({ process: "MAIN" }, "Connecting to Redis"); 326 + await connectRedis(); 327 + 324 328 jetstream.start(); 325 329 326 - function shutdown() { 330 + async function shutdown() { 327 331 try { 328 332 logger.info({ process: "MAIN" }, "Shutting down gracefully"); 329 333 fs.writeFileSync("cursor.txt", jetstream.cursor!.toString(), "utf8"); 330 334 jetstream.close(); 331 335 metricsServer.close(); 336 + await disconnectRedis(); 332 337 } catch (error) { 333 338 logger.error({ process: "MAIN", error }, "Error shutting down gracefully"); 334 339 process.exit(1);
+49 -2
src/moderation.ts
··· 2 2 import { MOD_DID } from "./config.js"; 3 3 import { limit } from "./limits.js"; 4 4 import { logger } from "./logger.js"; 5 + import { 6 + tryClaimAccountComment, 7 + tryClaimAccountLabel, 8 + tryClaimPostLabel, 9 + } from "./redis.js"; 5 10 6 11 const doesLabelExist = ( 7 12 labels: { val: string }[] | undefined, ··· 19 24 label: string, 20 25 comment: string, 21 26 duration: number | undefined, 27 + did?: string, 22 28 ) => { 23 29 await isLoggedIn; 24 30 31 + const claimed = await tryClaimPostLabel(uri, label); 32 + if (!claimed) { 33 + logger.debug( 34 + { process: "MODERATION", uri, label }, 35 + "Post label already claimed in Redis, skipping", 36 + ); 37 + return; 38 + } 39 + 25 40 const hasLabel = await checkRecordLabels(uri, label); 26 41 if (hasLabel) { 27 42 logger.debug( ··· 30 45 ); 31 46 return; 32 47 } 48 + 49 + logger.info( 50 + { process: "MODERATION", label, did, atURI: uri }, 51 + "Labeling post", 52 + ); 33 53 34 54 await limit(async () => { 35 55 try { ··· 50 70 event.durationInHours = duration; 51 71 } 52 72 53 - return agent.tools.ozone.moderation.emitEvent( 73 + await agent.tools.ozone.moderation.emitEvent( 54 74 { 55 75 event: event, 56 76 // specify the labeled post by strongRef ··· 91 111 ) => { 92 112 await isLoggedIn; 93 113 114 + const claimed = await tryClaimAccountLabel(did, label); 115 + if (!claimed) { 116 + logger.debug( 117 + { process: "MODERATION", did, label }, 118 + "Account label already claimed in Redis, skipping", 119 + ); 120 + return; 121 + } 122 + 94 123 const hasLabel = await checkAccountLabels(did, label); 95 124 if (hasLabel) { 96 125 logger.debug( ··· 99 128 ); 100 129 return; 101 130 } 131 + 132 + logger.info({ process: "MODERATION", did, label }, "Labeling account"); 102 133 103 134 await limit(async () => { 104 135 try { ··· 186 217 }); 187 218 }; 188 219 189 - export const createAccountComment = async (did: string, comment: string) => { 220 + export const createAccountComment = async ( 221 + did: string, 222 + comment: string, 223 + atURI: string, 224 + ) => { 190 225 await isLoggedIn; 226 + 227 + const claimed = await tryClaimAccountComment(did, atURI); 228 + if (!claimed) { 229 + logger.debug( 230 + { process: "MODERATION", did, atURI }, 231 + "Account comment already claimed in Redis, skipping", 232 + ); 233 + return; 234 + } 235 + 236 + logger.info({ process: "MODERATION", did, atURI }, "Commenting on account"); 237 + 191 238 await limit(async () => { 192 239 try { 193 240 await agent.tools.ozone.moderation.emitEvent(
+109
src/redis.ts
··· 1 + import { createClient } from "redis"; 2 + import { REDIS_URL } from "./config.js"; 3 + import { logger } from "./logger.js"; 4 + 5 + export const redisClient = createClient({ 6 + url: REDIS_URL, 7 + }); 8 + 9 + redisClient.on("error", (err: Error) => { 10 + logger.error({ err }, "Redis client error"); 11 + }); 12 + 13 + redisClient.on("connect", () => { 14 + logger.info("Redis client connected"); 15 + }); 16 + 17 + redisClient.on("ready", () => { 18 + logger.info("Redis client ready"); 19 + }); 20 + 21 + redisClient.on("reconnecting", () => { 22 + logger.warn("Redis client reconnecting"); 23 + }); 24 + 25 + export async function connectRedis(): Promise<void> { 26 + try { 27 + await redisClient.connect(); 28 + } catch (err) { 29 + logger.error({ err }, "Failed to connect to Redis"); 30 + throw err; 31 + } 32 + } 33 + 34 + export async function disconnectRedis(): Promise<void> { 35 + try { 36 + await redisClient.quit(); 37 + logger.info("Redis client disconnected"); 38 + } catch (err) { 39 + logger.error({ err }, "Error disconnecting Redis"); 40 + } 41 + } 42 + 43 + function getPostLabelCacheKey(atURI: string, label: string): string { 44 + return `post-label:${atURI}:${label}`; 45 + } 46 + 47 + function getAccountLabelCacheKey(did: string, label: string): string { 48 + return `account-label:${did}:${label}`; 49 + } 50 + 51 + export async function tryClaimPostLabel( 52 + atURI: string, 53 + label: string, 54 + ): Promise<boolean> { 55 + try { 56 + const key = getPostLabelCacheKey(atURI, label); 57 + const result = await redisClient.set(key, "1", { 58 + NX: true, 59 + EX: 60 * 60 * 24 * 7, 60 + }); 61 + return result === "OK"; 62 + } catch (err) { 63 + logger.warn( 64 + { err, atURI, label }, 65 + "Error claiming post label in Redis, allowing through", 66 + ); 67 + return true; 68 + } 69 + } 70 + 71 + export async function tryClaimAccountLabel( 72 + did: string, 73 + label: string, 74 + ): Promise<boolean> { 75 + try { 76 + const key = getAccountLabelCacheKey(did, label); 77 + const result = await redisClient.set(key, "1", { 78 + NX: true, 79 + EX: 60 * 60 * 24 * 7, 80 + }); 81 + return result === "OK"; 82 + } catch (err) { 83 + logger.warn( 84 + { err, did, label }, 85 + "Error claiming account label in Redis, allowing through", 86 + ); 87 + return true; 88 + } 89 + } 90 + 91 + export async function tryClaimAccountComment( 92 + did: string, 93 + atURI: string, 94 + ): Promise<boolean> { 95 + try { 96 + const key = `account-comment:${did}:${atURI}`; 97 + const result = await redisClient.set(key, "1", { 98 + NX: true, 99 + EX: 60 * 60 * 24 * 7, 100 + }); 101 + return result === "OK"; 102 + } catch (err) { 103 + logger.warn( 104 + { err, did, atURI }, 105 + "Error claiming account comment in Redis, allowing through", 106 + ); 107 + return true; 108 + } 109 + }
+38
src/rules/account/ageConstants.ts
··· 12 12 * - Detect brigading on specific controversial posts 13 13 */ 14 14 export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [ 15 + { 16 + monitoredDIDs: [ 17 + "did:plc:b2ecyhl2z2tro25ltrcyiytd", // DHS 18 + "did:plc:iw2wxg46hm4ezguswhwej6t6", // actual whitehouse 19 + "did:plc:fhnl65q3us5evynqc4f2qak6", // HHS 20 + "did:plc:wrz4athzuf2u5js2ltrktiqk", // DOL 21 + "did:plc:3mqcgvyu4exg3pkx4bkfppih", // VA 22 + "did:plc:pqn2sfkx5klnytms4uwqt5wo", // Treasurer 23 + "did:plc:v4kvjftk6kr5ci3zqmfawwpb", // State 24 + "did:plc:rlymk4d5qmq5udjdznojmvel", // Interior 25 + "did:plc:f7a5etif42x56oyrbzuek6so", // USDA 26 + "did:plc:7kusimwlnf4v5jo757jvkeaj", // DOE 27 + "did:plc:jgq3vko3g6zg72457bda2snd", // SBA 28 + "did:plc:h2iujdjlry6fpniofjtiqqmb", // DoD 29 + "did:plc:jwncvpznkwe4luzvdroes45b", // CBP 30 + "did:plc:azfxx5mdxcuoc2bkuqizs4kd", 31 + "did:plc:vostkism5vbzjqfcmllmd6gz", 32 + "did:plc:etthv4ychwti4b6i2hhe76c2", 33 + "did:plc:swf7zddjselkcpbn6iw323gy", 34 + "did:plc:h3zq65wioggctyxpovfpi6ec", 35 + "did:plc:nofnc2xpdihktxkufkq7tn3w", 36 + "did:plc:quezcqejcqw6g5t3om7wldns", 37 + "did:plc:vlvqht2v3nsc4k7xaho6bjaf", 38 + "did:plc:syyfuvqiabipi5mf3x632qij", 39 + "did:plc:6vpxzm6mxjzcfvccnuw2pyd7", 40 + "did:plc:yxqdgravj27gtxkpqhrnzhlx", 41 + "did:plc:nrhrdxqa2v7hfxw2jnuy7rk7", 42 + "did:plc:pr27argcmniiwxp7d7facqwy", 43 + "did:plc:azfxx5mdxcuoc2bkuqizs4kd", 44 + "did:plc:y42muzveli3sjyr3tufaq765", 45 + "did:plc:22wazjq4e4yjafxlew2c6kov", 46 + "did:plc:iw64z65wzkmqvftssb2nldj5", 47 + ], 48 + anchorDate: "2025-10-17", // Date when harassment campaign started 49 + maxAgeDays: 7, // Flag accounts less than 7 days old 50 + label: "suspect-inauthentic", 51 + comment: "New account replying to monitored user during campaign", 52 + }, 15 53 // Example: Monitor replies to specific accounts 16 54 // { 17 55 // monitoredDIDs: [
+3
src/rules/handles/checkHandles.test.ts
··· 140 140 expect(createAccountComment).toHaveBeenCalledWith( 141 141 "did:plc:user1", 142 142 `${time}: Scam detected - scam-account`, 143 + "handle:did:plc:user1:scam-account", 143 144 ); 144 145 }); 145 146 }); ··· 181 182 expect(createAccountComment).toHaveBeenCalledWith( 182 183 "did:plc:user1", 183 184 `${time}: Scam detected - scam-user`, 185 + "handle:did:plc:user1:scam-user", 184 186 ); 185 187 }); 186 188 ··· 206 208 expect(createAccountComment).toHaveBeenCalledWith( 207 209 "did:plc:user1", 208 210 `${time}: Multi-action triggered - dangerous-account`, 211 + "handle:did:plc:user1:dangerous-account", 209 212 ); 210 213 expect(createAccountLabel).toHaveBeenCalledWith( 211 214 "did:plc:user1",
+8 -14
src/rules/handles/checkHandles.ts
··· 46 46 } 47 47 48 48 if (checkList.toLabel === true) { 49 - logger.info( 50 - { process: "CHECKHANDLE", did, handle, time, label: checkList.label }, 51 - "Labeling account", 49 + createAccountLabel( 50 + did, 51 + `${checkList.label}`, 52 + `${time}: ${checkList.comment} - ${handle}`, 52 53 ); 53 - { 54 - createAccountLabel( 55 - did, 56 - `${checkList.label}`, 57 - `${time}: ${checkList.comment} - ${handle}`, 58 - ); 59 - } 60 54 } 61 55 62 56 if (checkList.reportAcct === true) { ··· 68 62 } 69 63 70 64 if (checkList.commentAcct === true) { 71 - logger.info( 72 - { process: "CHECKHANDLE", did, handle, time, label: checkList.label }, 73 - "Commenting on account", 65 + createAccountComment( 66 + did, 67 + `${time}: ${checkList.comment} - ${handle}`, 68 + `handle:${did}:${handle}`, 74 69 ); 75 - createAccountComment(did, `${time}: ${checkList.comment} - ${handle}`); 76 70 } 77 71 } 78 72 });
+2 -18
src/rules/posts/checkPosts.ts
··· 106 106 countStarterPacks(post[0].did, post[0].time); 107 107 108 108 if (checkPost.toLabel === true) { 109 - logger.info( 110 - { 111 - process: "CHECKPOSTS", 112 - label: checkPost.label, 113 - did: post[0].did, 114 - atURI: post[0].atURI, 115 - }, 116 - "Labeling post", 117 - ); 118 109 createPostLabel( 119 110 post[0].atURI, 120 111 post[0].cid, 121 112 `${checkPost.label}`, 122 113 `${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 123 114 checkPost.duration, 115 + post[0].did, 124 116 ); 125 117 } 126 118 ··· 158 150 } 159 151 160 152 if (checkPost.commentAcct === true) { 161 - logger.info( 162 - { 163 - process: "CHECKPOSTS", 164 - label: checkPost.label, 165 - did: post[0].did, 166 - atURI: post[0].atURI, 167 - }, 168 - "Commenting on account", 169 - ); 170 153 createAccountComment( 171 154 post[0].did, 172 155 `${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 156 + post[0].atURI, 173 157 ); 174 158 } 175 159 }
+6 -32
src/rules/posts/tests/checkPosts.test.ts
··· 244 244 245 245 await checkPosts(post); 246 246 247 - expect(logger.info).toHaveBeenCalledWith( 248 - { 249 - process: "CHECKPOSTS", 250 - label: "test-label", 251 - did: post[0].did, 252 - atURI: post[0].atURI, 253 - }, 254 - "Labeling post", 255 - ); 256 247 expect(createPostLabel).toHaveBeenCalledWith( 257 248 post[0].atURI, 258 249 post[0].cid, 259 250 "test-label", 260 251 expect.stringContaining("Test comment"), 261 252 undefined, 253 + post[0].did, 262 254 ); 263 255 }); 264 256 ··· 292 284 "language-specific", 293 285 expect.any(String), 294 286 undefined, 287 + post[0].did, 295 288 ); 296 289 }); 297 290 ··· 345 338 "whitelisted-test", 346 339 expect.any(String), 347 340 undefined, 341 + post[0].did, 348 342 ); 349 343 }); 350 344 }); ··· 389 383 "ignored-did", 390 384 expect.any(String), 391 385 undefined, 386 + "did:plc:notignored", 392 387 ); 393 388 }); 394 389 }); ··· 405 400 "all-actions", 406 401 expect.any(String), 407 402 undefined, 403 + post[0].did, 408 404 ); 409 405 expect(createPostReport).toHaveBeenCalledWith( 410 406 post[0].atURI, ··· 418 414 expect(createAccountComment).toHaveBeenCalledWith( 419 415 post[0].did, 420 416 expect.any(String), 421 - ); 422 - }); 423 - 424 - it("should log all moderation actions", async () => { 425 - const post = createMockPost({ text: "report this" }); 426 - 427 - await checkPosts(post); 428 - 429 - expect(logger.info).toHaveBeenCalledWith( 430 - expect.objectContaining({ label: "all-actions" }), 431 - "Labeling post", 432 - ); 433 - expect(logger.info).toHaveBeenCalledWith( 434 - expect.objectContaining({ label: "all-actions" }), 435 - "Reporting post", 436 - ); 437 - expect(logger.info).toHaveBeenCalledWith( 438 - expect.objectContaining({ label: "all-actions" }), 439 - "Reporting account", 440 - ); 441 - expect(logger.info).toHaveBeenCalledWith( 442 - expect.objectContaining({ label: "all-actions" }), 443 - "Commenting on account", 417 + expect.any(String), 444 418 ); 445 419 }); 446 420 });
+2 -44
src/rules/profiles/checkProfiles.ts
··· 70 70 `${checkProfiles.label}`, 71 71 `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 72 72 ); 73 - logger.info( 74 - { 75 - process: "CHECKDESCRIPTION", 76 - did, 77 - time, 78 - displayName, 79 - description, 80 - label: checkProfiles.label, 81 - }, 82 - "Labeling account", 83 - ); 84 73 } 85 74 86 75 if (checkProfiles.reportAcct === true) { ··· 105 94 createAccountComment( 106 95 did, 107 96 `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 108 - ); 109 - logger.info( 110 - { 111 - process: "CHECKDESCRIPTION", 112 - did, 113 - time, 114 - displayName, 115 - description, 116 - label: checkProfiles.label, 117 - }, 118 - "Commenting on account", 97 + `profile:${did}:${time}`, 119 98 ); 120 99 } 121 100 } ··· 186 165 `${checkProfiles.label}`, 187 166 `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 188 167 ); 189 - logger.info( 190 - { 191 - process: "CHECKDISPLAYNAME", 192 - did, 193 - time, 194 - displayName, 195 - description, 196 - label: checkProfiles.label, 197 - }, 198 - "Labeling account", 199 - ); 200 168 } 201 169 202 170 if (checkProfiles.reportAcct === true) { ··· 221 189 createAccountComment( 222 190 did, 223 191 `${time}: ${checkProfiles.comment} - ${displayName} - ${description}`, 224 - ); 225 - logger.info( 226 - { 227 - process: "CHECKDISPLAYNAME", 228 - did, 229 - time, 230 - displayName, 231 - description, 232 - label: checkProfiles.label, 233 - }, 234 - "Commenting on account", 192 + `profile:${did}:${time}`, 235 193 ); 236 194 } 237 195 }
+1 -43
src/rules/profiles/tests/checkProfiles.test.ts
··· 167 167 "This is spam content", 168 168 ); 169 169 170 - expect(logger.info).toHaveBeenCalledWith( 171 - { 172 - process: "CHECKDESCRIPTION", 173 - did: mockDid, 174 - time: mockTime, 175 - displayName: mockDisplayName, 176 - description: "This is spam content", 177 - label: "test-description", 178 - }, 179 - "Labeling account", 180 - ); 181 170 expect(createAccountLabel).toHaveBeenCalledWith( 182 171 mockDid, 183 172 "test-description", ··· 365 354 expect(createAccountComment).toHaveBeenCalledWith( 366 355 mockDid, 367 356 expect.any(String), 357 + expect.any(String), 368 358 ); 369 359 }); 370 360 371 - it("should log all moderation actions", async () => { 372 - await checkDescription( 373 - mockDid, 374 - mockTime, 375 - mockDisplayName, 376 - "report this", 377 - ); 378 - 379 - expect(logger.info).toHaveBeenCalledWith( 380 - expect.objectContaining({ label: "all-actions" }), 381 - "Labeling account", 382 - ); 383 - expect(logger.info).toHaveBeenCalledWith( 384 - expect.objectContaining({ label: "all-actions" }), 385 - "Reporting account", 386 - ); 387 - expect(logger.info).toHaveBeenCalledWith( 388 - expect.objectContaining({ label: "all-actions" }), 389 - "Commenting on account", 390 - ); 391 - }); 392 361 }); 393 362 }); 394 363 ··· 434 403 mockDescription, 435 404 ); 436 405 437 - expect(logger.info).toHaveBeenCalledWith( 438 - { 439 - process: "CHECKDISPLAYNAME", 440 - did: mockDid, 441 - time: mockTime, 442 - displayName: "fake account", 443 - description: mockDescription, 444 - label: "test-displayname", 445 - }, 446 - "Labeling account", 447 - ); 448 406 expect(createAccountLabel).toHaveBeenCalledWith( 449 407 mockDid, 450 408 "test-displayname",
+66 -92
src/tests/moderation.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { agent } from "../agent.js"; 3 - import { logger } from "../logger.js"; 4 - import { checkAccountLabels } from "../moderation.js"; 5 2 6 - // Mock dependencies 3 + // --- Mocks First --- 4 + 7 5 vi.mock("../agent.js", () => ({ 8 6 agent: { 9 7 tools: { 10 8 ozone: { 11 9 moderation: { 12 10 getRepo: vi.fn(), 11 + getRecord: vi.fn(), 12 + emitEvent: vi.fn(), 13 13 }, 14 14 }, 15 15 }, ··· 17 17 isLoggedIn: Promise.resolve(true), 18 18 })); 19 19 20 + vi.mock("../redis.js", () => ({ 21 + tryClaimPostLabel: vi.fn(), 22 + tryClaimAccountLabel: vi.fn(), 23 + })); 24 + 20 25 vi.mock("../logger.js", () => ({ 21 26 logger: { 22 27 info: vi.fn(), ··· 34 39 limit: vi.fn((fn) => fn()), 35 40 })); 36 41 37 - describe("checkAccountLabels", () => { 42 + // --- Imports Second --- 43 + 44 + import { agent } from "../agent.js"; 45 + import { checkAccountLabels, createPostLabel } from "../moderation.js"; 46 + import { tryClaimPostLabel } from "../redis.js"; 47 + import { logger } from "../logger.js"; 48 + 49 + describe("Moderation Logic", () => { 38 50 beforeEach(() => { 39 51 vi.clearAllMocks(); 40 52 }); 41 53 42 - it("should return true if label exists on account", async () => { 43 - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 44 - data: { 45 - labels: [ 46 - { val: "spam" }, 47 - { val: "harassment" }, 48 - { val: "window-reply" }, 49 - ], 50 - }, 51 - }); 52 - 53 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 54 - 55 - expect(result).toBe(true); 56 - expect(agent.tools.ozone.moderation.getRepo).toHaveBeenCalledWith( 57 - { did: "did:plc:test123" }, 58 - { 59 - headers: { 60 - "atproto-proxy": "did:plc:moderator123#atproto_labeler", 61 - "atproto-accept-labelers": "did:plc:ar7c4by46qjdydhdevvrndac;redact", 54 + describe("checkAccountLabels", () => { 55 + it("should return true if label exists on account", async () => { 56 + vi.mocked(agent.tools.ozone.moderation.getRepo).mockResolvedValueOnce({ 57 + data: { 58 + labels: [ 59 + { val: "spam", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" }, 60 + { val: "window-reply", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" } 61 + ] 62 62 }, 63 - }, 64 - ); 65 - }); 66 - 67 - it("should return false if label does not exist on account", async () => { 68 - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 69 - data: { 70 - labels: [{ val: "spam" }, { val: "harassment" }], 71 - }, 63 + } as any); 64 + const result = await checkAccountLabels("did:plc:test123", "window-reply"); 65 + expect(result).toBe(true); 72 66 }); 73 - 74 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 75 - 76 - expect(result).toBe(false); 77 67 }); 78 68 79 - it("should return false if account has no labels", async () => { 80 - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 81 - data: { 82 - labels: [], 83 - }, 84 - }); 69 + describe("createPostLabel with Caching", () => { 70 + const URI = "at://did:plc:test/app.bsky.feed.post/123"; 71 + const CID = "bafybeig6xv5nwph5j7grrlp3pdeolqptpep5nfljmdkmtcf2l4wisa2mfa"; 72 + const LABEL = "test-label"; 73 + const COMMENT = "test comment"; 85 74 86 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 75 + it("should skip if claim fails (already claimed)", async () => { 76 + vi.mocked(tryClaimPostLabel).mockResolvedValue(false); 87 77 88 - expect(result).toBe(false); 89 - }); 78 + await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 90 79 91 - it("should return false if labels property is undefined", async () => { 92 - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 93 - data: {}, 80 + expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 81 + expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).not.toHaveBeenCalled(); 82 + expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled(); 94 83 }); 95 84 96 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 85 + it("should skip event if claimed but already labeled via API", async () => { 86 + vi.mocked(tryClaimPostLabel).mockResolvedValue(true); 87 + vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 88 + data: { labels: [{ val: LABEL, src: "did:plc:test", uri: URI, cts: "2024-01-01T00:00:00Z" }] }, 89 + } as any); 97 90 98 - expect(result).toBe(false); 99 - }); 91 + await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 100 92 101 - it("should handle API errors gracefully", async () => { 102 - (agent.tools.ozone.moderation.getRepo as any).mockRejectedValueOnce( 103 - new Error("API Error"), 104 - ); 93 + expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 94 + expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith( 95 + { uri: URI }, 96 + expect.any(Object), 97 + ); 98 + expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled(); 99 + }); 105 100 106 - const result = await checkAccountLabels("did:plc:test123", "window-reply"); 101 + it("should emit event if claimed and not labeled anywhere", async () => { 102 + vi.mocked(tryClaimPostLabel).mockResolvedValue(true); 103 + vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({ 104 + data: { labels: [] }, 105 + } as any); 106 + vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({ success: true } as any); 107 107 108 - expect(result).toBe(false); 109 - expect(logger.error).toHaveBeenCalledWith( 110 - { 111 - process: "MODERATION", 112 - did: "did:plc:test123", 113 - error: expect.any(Error), 114 - }, 115 - "Failed to check account labels", 116 - ); 117 - }); 108 + await createPostLabel(URI, CID, LABEL, COMMENT, undefined); 118 109 119 - it("should perform case-sensitive label matching", async () => { 120 - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 121 - data: { 122 - labels: [{ val: "window-reply" }], 123 - }, 110 + expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL); 111 + expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith( 112 + { uri: URI }, 113 + expect.any(Object), 114 + ); 115 + expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).toHaveBeenCalled(); 124 116 }); 125 - 126 - const resultLower = await checkAccountLabels( 127 - "did:plc:test123", 128 - "window-reply", 129 - ); 130 - expect(resultLower).toBe(true); 131 - 132 - (agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({ 133 - data: { 134 - labels: [{ val: "window-reply" }], 135 - }, 136 - }); 137 - 138 - const resultUpper = await checkAccountLabels( 139 - "did:plc:test123", 140 - "Window-Reply", 141 - ); 142 - expect(resultUpper).toBe(false); 143 117 }); 144 - }); 118 + });
+106
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(), 9 + quit: vi.fn(), 10 + exists: vi.fn(), 11 + set: vi.fn(), 12 + }; 13 + return { 14 + createClient: vi.fn(() => mockClient), 15 + }; 16 + }); 17 + 18 + // Import the mocked redis first to get a reference to the mock client 19 + import { createClient } from 'redis'; 20 + const mockRedisClient = createClient(); 21 + 22 + // Import the modules to be tested 23 + import { 24 + tryClaimPostLabel, 25 + tryClaimAccountLabel, 26 + connectRedis, 27 + disconnectRedis, 28 + } from '../redis.js'; 29 + import { logger } from '../logger.js'; 30 + 31 + // Suppress logger output during tests 32 + vi.mock('../logger.js', () => ({ 33 + logger: { 34 + info: vi.fn(), 35 + warn: vi.fn(), 36 + error: vi.fn(), 37 + debug: vi.fn(), 38 + }, 39 + })); 40 + 41 + describe('Redis Cache Logic', () => { 42 + afterEach(() => { 43 + vi.clearAllMocks(); 44 + }); 45 + 46 + describe('Connection', () => { 47 + it('should call redisClient.connect on connectRedis', async () => { 48 + await connectRedis(); 49 + expect(mockRedisClient.connect).toHaveBeenCalled(); 50 + }); 51 + 52 + it('should call redisClient.quit on disconnectRedis', async () => { 53 + await disconnectRedis(); 54 + expect(mockRedisClient.quit).toHaveBeenCalled(); 55 + }); 56 + }); 57 + 58 + describe('tryClaimPostLabel', () => { 59 + it('should return true and set key if key does not exist', async () => { 60 + vi.mocked(mockRedisClient.set).mockResolvedValue('OK'); 61 + const result = await tryClaimPostLabel('at://uri', 'test-label'); 62 + expect(result).toBe(true); 63 + expect(mockRedisClient.set).toHaveBeenCalledWith( 64 + 'post-label:at://uri:test-label', 65 + '1', 66 + { NX: true, EX: 60 * 60 * 24 * 7 } 67 + ); 68 + }); 69 + 70 + it('should return false if key already exists', async () => { 71 + vi.mocked(mockRedisClient.set).mockResolvedValue(null); 72 + const result = await tryClaimPostLabel('at://uri', 'test-label'); 73 + expect(result).toBe(false); 74 + }); 75 + 76 + it('should return true and log warning on Redis error', async () => { 77 + const redisError = new Error('Redis down'); 78 + vi.mocked(mockRedisClient.set).mockRejectedValue(redisError); 79 + const result = await tryClaimPostLabel('at://uri', 'test-label'); 80 + expect(result).toBe(true); 81 + expect(logger.warn).toHaveBeenCalledWith( 82 + { err: redisError, atURI: 'at://uri', label: 'test-label' }, 83 + 'Error claiming post label in Redis, allowing through' 84 + ); 85 + }); 86 + }); 87 + 88 + describe('tryClaimAccountLabel', () => { 89 + it('should return true and set key if key does not exist', async () => { 90 + vi.mocked(mockRedisClient.set).mockResolvedValue('OK'); 91 + const result = await tryClaimAccountLabel('did:plc:123', 'test-label'); 92 + expect(result).toBe(true); 93 + expect(mockRedisClient.set).toHaveBeenCalledWith( 94 + 'account-label:did:plc:123:test-label', 95 + '1', 96 + { NX: true, EX: 60 * 60 * 24 * 7 } 97 + ); 98 + }); 99 + 100 + it('should return false if key already exists', async () => { 101 + vi.mocked(mockRedisClient.set).mockResolvedValue(null); 102 + const result = await tryClaimAccountLabel('did:plc:123', 'test-label'); 103 + expect(result).toBe(false); 104 + }); 105 + }); 106 + });