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

Implement automatic account labeling when accounts exceed a threshold of labeled posts within a rolling time window.

Skywatch c91f68e6 ec7f1c7b

+12
compose.dev.yaml
··· 1 + # Development override for docker-compose 2 + # Usage: docker compose -f compose.yaml -f compose.dev.yaml up 3 + # 4 + # This configuration: 5 + # - Runs the app in watch mode (auto-reloads on file changes) 6 + # - Mounts source code so changes are picked up without rebuild 7 + 8 + services: 9 + automod: 10 + command: ["bun", "run", "dev"] 11 + volumes: 12 + - ./src:/app/src
+22 -1
compose.yaml
··· 13 13 image: redis:7-alpine 14 14 container_name: skywatch-automod-redis 15 15 restart: unless-stopped 16 + command: redis-server --appendonly yes --appendfsync everysec 16 17 volumes: 17 18 - redis-data:/data 18 19 networks: ··· 33 34 34 35 # Expose the metrics server port to the host machine. 35 36 ports: 36 - - "4100:4101" 37 + - "4101:4101" 37 38 38 39 # Load environment variables from a .env file in the same directory. 39 40 # This is where you should put your BSKY_HANDLE, BSKY_PASSWORD, etc. ··· 55 56 volumes: 56 57 - ./cursor.txt:/app/cursor.txt 57 58 - ./.session:/app/.session 59 + - ./rules:/app/rules 58 60 59 61 environment: 60 62 - NODE_ENV=production 61 63 - REDIS_URL=redis://redis:6379 62 64 65 + prometheus: 66 + image: prom/prometheus:latest 67 + container_name: skywatch-prometheus 68 + restart: unless-stopped 69 + ports: 70 + - "9090:9090" 71 + volumes: 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: 80 + - automod 81 + 63 82 volumes: 64 83 redis-data: 84 + prometheus-data: 65 85 66 86 networks: 67 87 skywatch-network: 68 88 driver: bridge 89 + name: skywatch-network
+10
prometheus.yml
··· 1 + global: 2 + scrape_interval: 15s 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'
+220
src/accountModeration.ts
··· 1 + import { agent, isLoggedIn } from "./agent.js"; 2 + import { MOD_DID } from "./config.js"; 3 + import { limit } from "./limits.js"; 4 + import { logger } from "./logger.js"; 5 + import { labelsAppliedCounter, labelsCachedCounter } from "./metrics.js"; 6 + import { tryClaimAccountComment, tryClaimAccountLabel } from "./redis.js"; 7 + 8 + const doesLabelExist = ( 9 + labels: { val: string }[] | undefined, 10 + labelVal: string, 11 + ): boolean => { 12 + if (!labels) { 13 + return false; 14 + } 15 + return labels.some((label) => label.val === labelVal); 16 + }; 17 + 18 + export const createAccountLabel = async ( 19 + did: string, 20 + label: string, 21 + comment: string, 22 + ) => { 23 + await isLoggedIn; 24 + 25 + const claimed = await tryClaimAccountLabel(did, label); 26 + if (!claimed) { 27 + logger.debug( 28 + { process: "MODERATION", did, label }, 29 + "Account label already claimed in Redis, skipping", 30 + ); 31 + labelsCachedCounter.inc({ 32 + label_type: label, 33 + target_type: "account", 34 + reason: "redis_cache", 35 + }); 36 + return; 37 + } 38 + 39 + const hasLabel = await checkAccountLabels(did, label); 40 + if (hasLabel) { 41 + logger.debug( 42 + { process: "MODERATION", did, label }, 43 + "Account already has label, skipping", 44 + ); 45 + labelsCachedCounter.inc({ 46 + label_type: label, 47 + target_type: "account", 48 + reason: "existing_label", 49 + }); 50 + return; 51 + } 52 + 53 + logger.info({ process: "MODERATION", did, label }, "Labeling account"); 54 + labelsAppliedCounter.inc({ label_type: label, target_type: "account" }); 55 + 56 + await limit(async () => { 57 + try { 58 + await agent.tools.ozone.moderation.emitEvent( 59 + { 60 + event: { 61 + $type: "tools.ozone.moderation.defs#modEventLabel", 62 + comment: comment, 63 + createLabelVals: [label], 64 + negateLabelVals: [], 65 + }, 66 + // specify the labeled post by strongRef 67 + subject: { 68 + $type: "com.atproto.admin.defs#repoRef", 69 + did: did, 70 + }, 71 + // put in the rest of the metadata 72 + createdBy: `${agent.did}`, 73 + createdAt: new Date().toISOString(), 74 + modTool: { 75 + name: "skywatch/skywatch-automod", 76 + }, 77 + }, 78 + { 79 + encoding: "application/json", 80 + headers: { 81 + "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 82 + "atproto-accept-labelers": 83 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 84 + }, 85 + }, 86 + ); 87 + } catch (e) { 88 + logger.error( 89 + { process: "MODERATION", error: e }, 90 + "Failed to create account label", 91 + ); 92 + } 93 + }); 94 + }; 95 + 96 + export const createAccountComment = async ( 97 + did: string, 98 + comment: string, 99 + atURI: string, 100 + ) => { 101 + await isLoggedIn; 102 + 103 + const claimed = await tryClaimAccountComment(did, atURI); 104 + if (!claimed) { 105 + logger.debug( 106 + { process: "MODERATION", did, atURI }, 107 + "Account comment already claimed in Redis, skipping", 108 + ); 109 + return; 110 + } 111 + 112 + logger.info({ process: "MODERATION", did, atURI }, "Commenting on account"); 113 + 114 + await limit(async () => { 115 + try { 116 + await agent.tools.ozone.moderation.emitEvent( 117 + { 118 + event: { 119 + $type: "tools.ozone.moderation.defs#modEventComment", 120 + comment: comment, 121 + }, 122 + // specify the labeled post by strongRef 123 + subject: { 124 + $type: "com.atproto.admin.defs#repoRef", 125 + did: did, 126 + }, 127 + // put in the rest of the metadata 128 + createdBy: `${agent.did}`, 129 + createdAt: new Date().toISOString(), 130 + modTool: { 131 + name: "skywatch/skywatch-automod", 132 + }, 133 + }, 134 + { 135 + encoding: "application/json", 136 + headers: { 137 + "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 138 + "atproto-accept-labelers": 139 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 140 + }, 141 + }, 142 + ); 143 + } catch (e) { 144 + logger.error( 145 + { process: "MODERATION", error: e }, 146 + "Failed to create account comment", 147 + ); 148 + } 149 + }); 150 + }; 151 + 152 + export const createAccountReport = async (did: string, comment: string) => { 153 + await isLoggedIn; 154 + await limit(async () => { 155 + try { 156 + await agent.tools.ozone.moderation.emitEvent( 157 + { 158 + event: { 159 + $type: "tools.ozone.moderation.defs#modEventReport", 160 + comment: comment, 161 + reportType: "com.atproto.moderation.defs#reasonOther", 162 + }, 163 + // specify the labeled post by strongRef 164 + subject: { 165 + $type: "com.atproto.admin.defs#repoRef", 166 + did: did, 167 + }, 168 + // put in the rest of the metadata 169 + createdBy: `${agent.did}`, 170 + createdAt: new Date().toISOString(), 171 + modTool: { 172 + name: "skywatch/skywatch-automod", 173 + }, 174 + }, 175 + { 176 + encoding: "application/json", 177 + headers: { 178 + "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 179 + "atproto-accept-labelers": 180 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 181 + }, 182 + }, 183 + ); 184 + } catch (e) { 185 + logger.error( 186 + { process: "MODERATION", error: e }, 187 + "Failed to create account report", 188 + ); 189 + } 190 + }); 191 + }; 192 + 193 + export const checkAccountLabels = async ( 194 + did: string, 195 + label: string, 196 + ): Promise<boolean> => { 197 + await isLoggedIn; 198 + return await limit(async () => { 199 + try { 200 + const response = await agent.tools.ozone.moderation.getRepo( 201 + { did }, 202 + { 203 + headers: { 204 + "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 205 + "atproto-accept-labelers": 206 + "did:plc:ar7c4by46qjdydhdevvrndac;redact", 207 + }, 208 + }, 209 + ); 210 + 211 + return doesLabelExist(response.data.labels, label); 212 + } catch (e) { 213 + logger.error( 214 + { process: "MODERATION", did, error: e }, 215 + "Failed to check account labels", 216 + ); 217 + return false; 218 + } 219 + }); 220 + };
+168
src/accountThreshold.ts
··· 1 + import { ACCOUNT_THRESHOLD_CONFIGS } from "../rules/accountThreshold.js"; 2 + import { 3 + createAccountComment, 4 + createAccountLabel, 5 + createAccountReport, 6 + } from "./accountModeration.js"; 7 + import { logger } from "./logger.js"; 8 + import { 9 + accountLabelsThresholdAppliedCounter, 10 + accountThresholdChecksCounter, 11 + accountThresholdMetCounter, 12 + } from "./metrics.js"; 13 + import { 14 + getPostLabelCountInWindow, 15 + trackPostLabelForAccount, 16 + } from "./redis.js"; 17 + import { AccountThresholdConfig } from "./types.js"; 18 + 19 + function normalizeLabels(labels: string | string[]): string[] { 20 + return Array.isArray(labels) ? labels : [labels]; 21 + } 22 + 23 + function validateAndLoadConfigs(): AccountThresholdConfig[] { 24 + if (!ACCOUNT_THRESHOLD_CONFIGS || ACCOUNT_THRESHOLD_CONFIGS.length === 0) { 25 + logger.warn( 26 + { process: "ACCOUNT_THRESHOLD" }, 27 + "No account threshold configs found", 28 + ); 29 + return []; 30 + } 31 + 32 + for (const config of ACCOUNT_THRESHOLD_CONFIGS) { 33 + const labels = normalizeLabels(config.labels); 34 + if (labels.length === 0) { 35 + throw new Error( 36 + `Invalid account threshold config: labels cannot be empty`, 37 + ); 38 + } 39 + if (config.threshold <= 0) { 40 + throw new Error( 41 + `Invalid account threshold config: threshold must be positive`, 42 + ); 43 + } 44 + if (config.windowDays <= 0) { 45 + throw new Error( 46 + `Invalid account threshold config: windowDays must be positive`, 47 + ); 48 + } 49 + } 50 + 51 + logger.info( 52 + { process: "ACCOUNT_THRESHOLD", count: ACCOUNT_THRESHOLD_CONFIGS.length }, 53 + "Loaded account threshold configs", 54 + ); 55 + 56 + return ACCOUNT_THRESHOLD_CONFIGS; 57 + } 58 + 59 + // Load and cache configs at module initialization 60 + const cachedConfigs = validateAndLoadConfigs(); 61 + 62 + export function loadThresholdConfigs(): AccountThresholdConfig[] { 63 + return cachedConfigs; 64 + } 65 + 66 + export async function checkAccountThreshold( 67 + did: string, 68 + postLabel: string, 69 + timestamp: number, 70 + ): Promise<void> { 71 + try { 72 + const configs = loadThresholdConfigs(); 73 + 74 + const matchingConfigs = configs.filter((config) => { 75 + const labels = normalizeLabels(config.labels); 76 + return labels.includes(postLabel); 77 + }); 78 + 79 + if (matchingConfigs.length === 0) { 80 + logger.debug( 81 + { process: "ACCOUNT_THRESHOLD", did, postLabel }, 82 + "No matching threshold configs for post label", 83 + ); 84 + return; 85 + } 86 + 87 + accountThresholdChecksCounter.inc({ post_label: postLabel }); 88 + 89 + for (const config of matchingConfigs) { 90 + const labels = normalizeLabels(config.labels); 91 + 92 + await trackPostLabelForAccount( 93 + did, 94 + postLabel, 95 + timestamp, 96 + config.windowDays, 97 + ); 98 + 99 + const count = await getPostLabelCountInWindow( 100 + did, 101 + labels, 102 + config.windowDays, 103 + timestamp, 104 + ); 105 + 106 + logger.debug( 107 + { 108 + process: "ACCOUNT_THRESHOLD", 109 + did, 110 + labels, 111 + count, 112 + threshold: config.threshold, 113 + windowDays: config.windowDays, 114 + }, 115 + "Checked account threshold", 116 + ); 117 + 118 + if (count >= config.threshold) { 119 + accountThresholdMetCounter.inc({ account_label: config.accountLabel }); 120 + 121 + logger.info( 122 + { 123 + process: "ACCOUNT_THRESHOLD", 124 + did, 125 + postLabel, 126 + accountLabel: config.accountLabel, 127 + count, 128 + threshold: config.threshold, 129 + }, 130 + "Account threshold met", 131 + ); 132 + 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", 140 + }); 141 + } 142 + 143 + if (config.reportAcct) { 144 + await createAccountReport(did, config.accountComment); 145 + accountLabelsThresholdAppliedCounter.inc({ 146 + account_label: config.accountLabel, 147 + action: "report", 148 + }); 149 + } 150 + 151 + if (config.commentAcct) { 152 + const atURI = `threshold-comment:${config.accountLabel}:${timestamp}`; 153 + await createAccountComment(did, config.accountComment, atURI); 154 + accountLabelsThresholdAppliedCounter.inc({ 155 + account_label: config.accountLabel, 156 + action: "comment", 157 + }); 158 + } 159 + } 160 + } 161 + } catch (error) { 162 + logger.error( 163 + { process: "ACCOUNT_THRESHOLD", did, postLabel, error }, 164 + "Error checking account threshold", 165 + ); 166 + throw error; 167 + } 168 + }
+7 -1
src/agent.ts
··· 5 5 import { updateRateLimitState } from "./limits.js"; 6 6 import { logger } from "./logger.js"; 7 7 8 - setGlobalDispatcher(new Agent({ connect: { timeout: 20_000 } })); 8 + setGlobalDispatcher( 9 + new Agent({ 10 + connect: { timeout: 20_000 }, 11 + keepAliveTimeout: 10_000, 12 + keepAliveMaxTimeout: 20_000, 13 + }), 14 + ); 9 15 10 16 const customFetch: typeof fetch = async (input, init) => { 11 17 const response = await fetch(input, init);
+1 -2
src/config.ts
··· 5 5 export const OZONE_PDS = process.env.OZONE_PDS ?? ""; 6 6 export const BSKY_HANDLE = process.env.BSKY_HANDLE ?? ""; 7 7 export const BSKY_PASSWORD = process.env.BSKY_PASSWORD ?? ""; 8 - export const HOST = process.env.HOST ?? "127.0.0.1"; 9 - export const PORT = process.env.PORT ? Number(process.env.PORT) : 4100; 8 + export const HOST = process.env.HOST ?? "0.0.0.0"; 10 9 export const METRICS_PORT = process.env.METRICS_PORT 11 10 ? Number(process.env.METRICS_PORT) 12 11 : 4101; // Left this intact from the code I adapted this from
-8
src/main.ts
··· 314 314 315 315 const metricsServer = startMetricsServer(METRICS_PORT); 316 316 317 - /* labelerServer.app.listen({ port: PORT, host: HOST }, (error, address) => { 318 - if (error) { 319 - logger.error("Error starting server: %s", error); 320 - } else { 321 - logger.info(`Labeler server listening on ${address}`); 322 - } 323 - });*/ 324 - 325 317 logger.info({ process: "MAIN" }, "Connecting to Redis"); 326 318 await connectRedis(); 327 319
+40 -4
src/metrics.ts
··· 1 1 import express from "express"; 2 - import { Registry, collectDefaultMetrics } from "prom-client"; 2 + import { Counter, Registry, collectDefaultMetrics } from "prom-client"; 3 + import { HOST } from "./config.js"; 3 4 import { logger } from "./logger.js"; 4 5 5 6 const register = new Registry(); 6 7 collectDefaultMetrics({ register }); 7 8 9 + export const labelsAppliedCounter = new Counter({ 10 + name: "skywatch_labels_applied_total", 11 + help: "Total number of labels applied by type", 12 + labelNames: ["label_type", "target_type"], 13 + registers: [register], 14 + }); 15 + 16 + export const labelsCachedCounter = new Counter({ 17 + name: "skywatch_labels_cached_total", 18 + help: "Total number of labels skipped due to cache/existing label", 19 + labelNames: ["label_type", "target_type", "reason"], 20 + registers: [register], 21 + }); 22 + 23 + export const accountLabelsThresholdAppliedCounter = new Counter({ 24 + name: "skywatch_account_labels_threshold_applied_total", 25 + help: "Total number of account actions applied due to threshold", 26 + labelNames: ["account_label", "action"], 27 + registers: [register], 28 + }); 29 + 30 + export const accountThresholdChecksCounter = new Counter({ 31 + name: "skywatch_account_threshold_checks_total", 32 + help: "Total number of account threshold checks performed", 33 + labelNames: ["post_label"], 34 + registers: [register], 35 + }); 36 + 37 + export const accountThresholdMetCounter = new Counter({ 38 + name: "skywatch_account_threshold_met_total", 39 + help: "Total number of times account thresholds were met", 40 + labelNames: ["account_label"], 41 + registers: [register], 42 + }); 43 + 8 44 const app = express(); 9 45 10 46 app.get("/metrics", (req, res) => { ··· 23 59 }); 24 60 }); 25 61 26 - export const startMetricsServer = (port: number, host = "127.0.0.1") => { 27 - return app.listen(port, host, () => { 62 + export const startMetricsServer = (port: number) => { 63 + return app.listen(port, HOST, () => { 28 64 logger.info( 29 - { process: "METRICS", host, port }, 65 + { process: "METRICS", host: HOST, port }, 30 66 "Metrics server is listening", 31 67 ); 32 68 });
+31 -196
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 + import { labelsAppliedCounter, labelsCachedCounter } from "./metrics.js"; 6 + import { tryClaimPostLabel } from "./redis.js"; 10 7 11 8 const doesLabelExist = ( 12 9 labels: { val: string }[] | undefined, ··· 25 22 comment: string, 26 23 duration: number | undefined, 27 24 did?: string, 25 + time?: number, 28 26 ) => { 29 27 await isLoggedIn; 30 28 ··· 34 32 { process: "MODERATION", uri, label }, 35 33 "Post label already claimed in Redis, skipping", 36 34 ); 35 + labelsCachedCounter.inc({ 36 + label_type: label, 37 + target_type: "post", 38 + reason: "redis_cache", 39 + }); 37 40 return; 38 41 } 39 42 ··· 43 46 { process: "MODERATION", uri, label }, 44 47 "Post already has label, skipping", 45 48 ); 49 + labelsCachedCounter.inc({ 50 + label_type: label, 51 + target_type: "post", 52 + reason: "existing_label", 53 + }); 46 54 return; 47 55 } 48 56 ··· 50 58 { process: "MODERATION", label, did, atURI: uri }, 51 59 "Labeling post", 52 60 ); 61 + labelsAppliedCounter.inc({ label_type: label, target_type: "post" }); 53 62 54 63 await limit(async () => { 55 64 try { ··· 95 104 }, 96 105 }, 97 106 ); 107 + 108 + if (did && time) { 109 + try { 110 + // Dynamic import to avoid circular dependency: 111 + // accountThreshold imports from moderation (createAccountLabel, etc.) 112 + // moderation imports from accountThreshold (checkAccountThreshold) 113 + const { checkAccountThreshold } = await import( 114 + "./accountThreshold.js" 115 + ); 116 + await checkAccountThreshold(did, label, time); 117 + } catch (error) { 118 + logger.error( 119 + { process: "ACCOUNT_THRESHOLD", did, label, error }, 120 + "Failed to check account threshold", 121 + ); 122 + } 123 + } 98 124 } catch (e) { 99 125 logger.error( 100 126 { process: "MODERATION", error: e }, ··· 104 130 }); 105 131 }; 106 132 107 - export const createAccountLabel = async ( 108 - did: string, 109 - label: string, 110 - comment: string, 111 - ) => { 112 - await isLoggedIn; 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 - 123 - const hasLabel = await checkAccountLabels(did, label); 124 - if (hasLabel) { 125 - logger.debug( 126 - { process: "MODERATION", did, label }, 127 - "Account already has label, skipping", 128 - ); 129 - return; 130 - } 131 - 132 - logger.info({ process: "MODERATION", did, label }, "Labeling account"); 133 - 134 - await limit(async () => { 135 - try { 136 - await agent.tools.ozone.moderation.emitEvent( 137 - { 138 - event: { 139 - $type: "tools.ozone.moderation.defs#modEventLabel", 140 - comment: comment, 141 - createLabelVals: [label], 142 - negateLabelVals: [], 143 - }, 144 - // specify the labeled post by strongRef 145 - subject: { 146 - $type: "com.atproto.admin.defs#repoRef", 147 - did: did, 148 - }, 149 - // put in the rest of the metadata 150 - createdBy: `${agent.did}`, 151 - createdAt: new Date().toISOString(), 152 - modTool: { 153 - name: "skywatch/skywatch-automod", 154 - }, 155 - }, 156 - { 157 - encoding: "application/json", 158 - headers: { 159 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 160 - "atproto-accept-labelers": 161 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 162 - }, 163 - }, 164 - ); 165 - } catch (e) { 166 - logger.error( 167 - { process: "MODERATION", error: e }, 168 - "Failed to create account label", 169 - ); 170 - } 171 - }); 172 - }; 173 133 174 134 export const createPostReport = async ( 175 135 uri: string, ··· 217 177 }); 218 178 }; 219 179 220 - export const createAccountComment = async ( 221 - did: string, 222 - comment: string, 223 - atURI: string, 224 - ) => { 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 - 238 - await limit(async () => { 239 - try { 240 - await agent.tools.ozone.moderation.emitEvent( 241 - { 242 - event: { 243 - $type: "tools.ozone.moderation.defs#modEventComment", 244 - comment: comment, 245 - }, 246 - // specify the labeled post by strongRef 247 - subject: { 248 - $type: "com.atproto.admin.defs#repoRef", 249 - did: did, 250 - }, 251 - // put in the rest of the metadata 252 - createdBy: `${agent.did}`, 253 - createdAt: new Date().toISOString(), 254 - modTool: { 255 - name: "skywatch/skywatch-automod", 256 - }, 257 - }, 258 - { 259 - encoding: "application/json", 260 - headers: { 261 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 262 - "atproto-accept-labelers": 263 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 264 - }, 265 - }, 266 - ); 267 - } catch (e) { 268 - logger.error( 269 - { process: "MODERATION", error: e }, 270 - "Failed to create account comment", 271 - ); 272 - } 273 - }); 274 - }; 275 - 276 - export const createAccountReport = async (did: string, comment: string) => { 277 - await isLoggedIn; 278 - await limit(async () => { 279 - try { 280 - await agent.tools.ozone.moderation.emitEvent( 281 - { 282 - event: { 283 - $type: "tools.ozone.moderation.defs#modEventReport", 284 - comment: comment, 285 - reportType: "com.atproto.moderation.defs#reasonOther", 286 - }, 287 - // specify the labeled post by strongRef 288 - subject: { 289 - $type: "com.atproto.admin.defs#repoRef", 290 - did: did, 291 - }, 292 - // put in the rest of the metadata 293 - createdBy: `${agent.did}`, 294 - createdAt: new Date().toISOString(), 295 - modTool: { 296 - name: "skywatch/skywatch-automod", 297 - }, 298 - }, 299 - { 300 - encoding: "application/json", 301 - headers: { 302 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 303 - "atproto-accept-labelers": 304 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 305 - }, 306 - }, 307 - ); 308 - } catch (e) { 309 - logger.error( 310 - { process: "MODERATION", error: e }, 311 - "Failed to create account report", 312 - ); 313 - } 314 - }); 315 - }; 316 - 317 - export const checkAccountLabels = async ( 318 - did: string, 319 - label: string, 320 - ): Promise<boolean> => { 321 - await isLoggedIn; 322 - return await limit(async () => { 323 - try { 324 - const response = await agent.tools.ozone.moderation.getRepo( 325 - { did }, 326 - { 327 - headers: { 328 - "atproto-proxy": `${MOD_DID!}#atproto_labeler`, 329 - "atproto-accept-labelers": 330 - "did:plc:ar7c4by46qjdydhdevvrndac;redact", 331 - }, 332 - }, 333 - ); 334 - 335 - return doesLabelExist(response.data.labels, label); 336 - } catch (e) { 337 - logger.error( 338 - { process: "MODERATION", did, error: e }, 339 - "Failed to check account labels", 340 - ); 341 - return false; 342 - } 343 - }); 344 - }; 345 180 346 181 export const checkRecordLabels = async ( 347 182 uri: string,
+72
src/redis.ts
··· 107 107 return true; 108 108 } 109 109 } 110 + 111 + function getPostLabelTrackingKey( 112 + did: string, 113 + label: string, 114 + windowDays: number, 115 + ): string { 116 + return `account-post-labels:${did}:${label}:${windowDays}`; 117 + } 118 + 119 + export async function trackPostLabelForAccount( 120 + did: string, 121 + label: string, 122 + timestamp: number, 123 + windowDays: number, 124 + ): Promise<void> { 125 + try { 126 + const key = getPostLabelTrackingKey(did, label, windowDays); 127 + const windowStartTime = timestamp - windowDays * 24 * 60 * 60 * 1000000; 128 + 129 + await redisClient.zRemRangeByScore(key, "-inf", windowStartTime); 130 + 131 + await redisClient.zAdd(key, { 132 + score: timestamp, 133 + value: timestamp.toString(), 134 + }); 135 + 136 + const ttlSeconds = (windowDays + 1) * 24 * 60 * 60; 137 + await redisClient.expire(key, ttlSeconds); 138 + 139 + logger.debug( 140 + { did, label, timestamp, windowDays }, 141 + "Tracked post label for account", 142 + ); 143 + } catch (err) { 144 + logger.error( 145 + { err, did, label, timestamp, windowDays }, 146 + "Error tracking post label in Redis", 147 + ); 148 + throw err; 149 + } 150 + } 151 + 152 + export async function getPostLabelCountInWindow( 153 + did: string, 154 + labels: string[], 155 + windowDays: number, 156 + currentTime: number, 157 + ): Promise<number> { 158 + try { 159 + const windowStartTime = currentTime - windowDays * 24 * 60 * 60 * 1000000; 160 + let totalCount = 0; 161 + 162 + for (const label of labels) { 163 + const key = getPostLabelTrackingKey(did, label, windowDays); 164 + const count = await redisClient.zCount(key, windowStartTime, "+inf"); 165 + totalCount += count; 166 + } 167 + 168 + logger.debug( 169 + { did, labels, windowDays, totalCount }, 170 + "Retrieved post label count in window", 171 + ); 172 + 173 + return totalCount; 174 + } catch (err) { 175 + logger.error( 176 + { err, did, labels, windowDays }, 177 + "Error getting post label count from Redis", 178 + ); 179 + throw err; 180 + } 181 + }
+3 -3
src/rules/account/age.ts
··· 1 1 import { agent, isLoggedIn } from "../../agent.js"; 2 2 import { PLC_URL } from "../../config.js"; 3 - import { GLOBAL_ALLOW } from "../../constants.js"; 3 + import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 4 4 import { logger } from "../../logger.js"; 5 - import { checkAccountLabels, createAccountLabel } from "../../moderation.js"; 6 - import { ACCOUNT_AGE_CHECKS } from "./ageConstants.js"; 5 + import { checkAccountLabels, createAccountLabel } from "../../accountModeration.js"; 6 + import { ACCOUNT_AGE_CHECKS } from "../../../rules/accountAge.js"; 7 7 8 8 interface InteractionContext { 9 9 // For replies
-78
src/rules/account/ageConstants.ts
··· 1 - import { AccountAgeCheck } from "../../types.js"; 2 - 3 - /** 4 - * Account age monitoring configurations 5 - * 6 - * Each configuration monitors replies and/or quote posts to specified DIDs or posts 7 - * and labels accounts that were created within a specific time window. 8 - * 9 - * Example use cases: 10 - * - Monitor replies/quotes to high-profile accounts during harassment campaigns 11 - * - Flag sock puppet accounts created to participate in coordinated harassment 12 - * - Detect brigading on specific controversial posts 13 - */ 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 - }, 53 - // Example: Monitor replies to specific accounts 54 - // { 55 - // monitoredDIDs: [ 56 - // "did:plc:example123", // High-profile account 1 57 - // "did:plc:example456", // High-profile account 2 58 - // ], 59 - // anchorDate: "2025-01-15", // Date when harassment campaign started 60 - // maxAgeDays: 7, // Flag accounts less than 7 days old 61 - // label: "new-account-reply", 62 - // comment: "New account replying to monitored user during campaign", 63 - // expires: "2025-02-15", // Optional: automatically stop this check after this date 64 - // }, 65 - // 66 - // Example: Monitor replies to specific posts 67 - // { 68 - // monitoredPostURIs: [ 69 - // "at://did:plc:example123/app.bsky.feed.post/abc123", 70 - // "at://did:plc:example456/app.bsky.feed.post/def456", 71 - // ], 72 - // anchorDate: "2025-01-15", 73 - // maxAgeDays: 7, 74 - // label: "brigading-suspect", 75 - // comment: "New account replying to specific targeted post", 76 - // expires: "2025-02-15", 77 - // }, 78 - ];
+1 -1
src/rules/account/countStarterPacks.ts
··· 1 1 import { agent, isLoggedIn } from "../../agent.js"; 2 2 import { limit } from "../../limits.js"; 3 3 import { logger } from "../../logger.js"; 4 - import { createAccountLabel } from "../../moderation.js"; 4 + import { createAccountLabel } from "../../accountModeration.js"; 5 5 6 6 const ALLOWED_DIDS = ["did:plc:gpunjjgvlyb4racypz3yfiq4"]; 7 7
+5 -5
src/rules/account/tests/age.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 2 import { agent } from "../../../agent.js"; 3 - import { GLOBAL_ALLOW } from "../../../constants.js"; 3 + import { GLOBAL_ALLOW } from "../../../../rules/constants.js"; 4 4 import { logger } from "../../../logger.js"; 5 - import { checkAccountLabels, createAccountLabel } from "../../../moderation.js"; 5 + import { checkAccountLabels, createAccountLabel } from "../../../accountModeration.js"; 6 6 import { 7 7 calculateAccountAge, 8 8 checkAccountAge, 9 9 getAccountCreationDate, 10 10 } from "../age.js"; 11 - import { ACCOUNT_AGE_CHECKS } from "../ageConstants.js"; 11 + import { ACCOUNT_AGE_CHECKS } from "../../../../rules/accountAge.js"; 12 12 13 13 // Mock dependencies 14 14 vi.mock("../../../agent.js", () => ({ ··· 27 27 }, 28 28 })); 29 29 30 - vi.mock("../../../moderation.js", () => ({ 30 + vi.mock("../../../accountModeration.js", () => ({ 31 31 createAccountLabel: vi.fn(), 32 32 checkAccountLabels: vi.fn(), 33 33 })); 34 34 35 - vi.mock("../../../constants.js", () => ({ 35 + vi.mock("../../../../rules/constants.js", () => ({ 36 36 GLOBAL_ALLOW: [], 37 37 })); 38 38
+2 -2
src/rules/account/tests/countStarterPacks.test.ts
··· 2 2 import { agent } from "../../../agent.js"; 3 3 import { limit } from "../../../limits.js"; 4 4 import { logger } from "../../../logger.js"; 5 - import { createAccountLabel } from "../../../moderation.js"; 5 + import { createAccountLabel } from "../../../accountModeration.js"; 6 6 import { countStarterPacks } from "../countStarterPacks.js"; 7 7 8 8 // Mock dependencies ··· 28 28 }, 29 29 })); 30 30 31 - vi.mock("../../../moderation.js", () => ({ 31 + vi.mock("../../../accountModeration.js", () => ({ 32 32 createAccountLabel: vi.fn(), 33 33 })); 34 34
+1 -1
src/rules/facets/facets.ts
··· 1 1 import { logger } from "../../logger.js"; 2 - import { createAccountLabel } from "../../moderation.js"; 2 + import { createAccountLabel } from "../../accountModeration.js"; 3 3 import { Facet } from "../../types.js"; 4 4 5 5 // Threshold for duplicate facet positions before flagging as spam
+2 -2
src/rules/facets/tests/facets.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 2 import { logger } from "../../../logger.js"; 3 - import { createAccountLabel } from "../../../moderation.js"; 3 + import { createAccountLabel } from "../../../accountModeration.js"; 4 4 import { Facet } from "../../../types.js"; 5 5 import { 6 6 FACET_SPAM_ALLOWLIST, ··· 11 11 } from "../facets.js"; 12 12 13 13 // Mock dependencies 14 - vi.mock("../../../moderation.js", () => ({ 14 + vi.mock("../../../accountModeration.js", () => ({ 15 15 createAccountLabel: vi.fn(), 16 16 })); 17 17
+4 -4
src/rules/handles/checkHandles.test.ts
··· 3 3 createAccountComment, 4 4 createAccountLabel, 5 5 createAccountReport, 6 - } from "../../moderation.js"; 6 + } from "../../accountModeration.js"; 7 7 import { checkHandle } from "./checkHandles.js"; 8 8 9 9 // Mock dependencies 10 - vi.mock("../../moderation.js", () => ({ 10 + vi.mock("../../accountModeration.js", () => ({ 11 11 createAccountReport: vi.fn(), 12 12 createAccountComment: vi.fn(), 13 13 createAccountLabel: vi.fn(), ··· 21 21 }, 22 22 })); 23 23 24 - vi.mock("../../constants.js", () => ({ 24 + vi.mock("../../../rules/constants.js", () => ({ 25 25 GLOBAL_ALLOW: ["did:plc:globalallow"], 26 26 })); 27 27 28 28 // Mock HANDLE_CHECKS with various test scenarios 29 - vi.mock("./constants.js", () => ({ 29 + vi.mock("../../../rules/handles.js", () => ({ 30 30 HANDLE_CHECKS: [ 31 31 { 32 32 label: "spam",
+3 -3
src/rules/handles/checkHandles.ts
··· 1 - import { GLOBAL_ALLOW } from "../../constants.js"; 1 + import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 2 2 import { logger } from "../../logger.js"; 3 3 import { 4 4 createAccountComment, 5 5 createAccountLabel, 6 6 createAccountReport, 7 - } from "../../moderation.js"; 8 - import { HANDLE_CHECKS } from "./constants.js"; 7 + } from "../../accountModeration.js"; 8 + import { HANDLE_CHECKS } from "../../../rules/handles.js"; 9 9 10 10 export const checkHandle = async ( 11 11 did: string,
+6 -6
src/rules/posts/checkPosts.ts
··· 1 - import { GLOBAL_ALLOW } from "../../constants.js"; 2 - import { logger } from "../../logger.js"; 1 + import { GLOBAL_ALLOW, LINK_SHORTENER } from "../../../rules/constants.js"; 2 + import { POST_CHECKS } from "../../../rules/posts.js"; 3 3 import { 4 4 createAccountComment, 5 5 createAccountReport, 6 - createPostLabel, 7 - createPostReport, 8 - } from "../../moderation.js"; 6 + } from "../../accountModeration.js"; 7 + import { logger } from "../../logger.js"; 8 + import { createPostLabel, createPostReport } from "../../moderation.js"; 9 9 import { Post } from "../../types.js"; 10 10 import { getFinalUrl } from "../../utils/getFinalUrl.js"; 11 11 import { getLanguage } from "../../utils/getLanguage.js"; 12 12 import { countStarterPacks } from "../account/countStarterPacks.js"; 13 - import { LINK_SHORTENER, POST_CHECKS } from "./constants.js"; 14 13 15 14 export const checkPosts = async (post: Post[]) => { 16 15 if (GLOBAL_ALLOW.includes(post[0].did)) { ··· 113 112 `${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`, 114 113 checkPost.duration, 115 114 post[0].did, 115 + post[0].time, 116 116 ); 117 117 } 118 118
+18 -11
src/rules/posts/tests/checkPosts.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import { logger } from "../../../logger.js"; 3 2 import { 4 3 createAccountComment, 5 4 createAccountReport, 6 - createPostLabel, 7 - createPostReport, 8 - } from "../../../moderation.js"; 5 + } from "../../../accountModeration.js"; 6 + import { logger } from "../../../logger.js"; 7 + import { createPostLabel, createPostReport } from "../../../moderation.js"; 9 8 import { Post } from "../../../types.js"; 10 9 import { getFinalUrl } from "../../../utils/getFinalUrl.js"; 11 10 import { getLanguage } from "../../../utils/getLanguage.js"; ··· 13 12 import { checkPosts } from "../checkPosts.js"; 14 13 15 14 // Mock dependencies 16 - vi.mock("../constants.js", () => ({ 15 + vi.mock("../../../../rules/constants.js", () => ({ 16 + GLOBAL_ALLOW: ["did:plc:globalallow"], 17 17 LINK_SHORTENER: /tinyurl\.com|bit\.ly/i, 18 + })); 19 + 20 + vi.mock("../../../../rules/posts.js", () => ({ 18 21 POST_CHECKS: [ 19 22 { 20 23 label: "test-label", ··· 80 83 countStarterPacks: vi.fn(), 81 84 })); 82 85 86 + vi.mock("../../../accountModeration.js", () => ({ 87 + createAccountReport: vi.fn(), 88 + createAccountComment: vi.fn(), 89 + })); 90 + 83 91 vi.mock("../../../moderation.js", () => ({ 84 92 createPostLabel: vi.fn(), 85 - createAccountReport: vi.fn(), 86 - createAccountComment: vi.fn(), 87 93 createPostReport: vi.fn(), 88 94 })); 89 95 ··· 95 101 getFinalUrl: vi.fn(), 96 102 })); 97 103 98 - vi.mock("../../../constants.js", () => ({ 99 - GLOBAL_ALLOW: ["did:plc:globalallow"], 100 - })); 101 - 102 104 describe("checkPosts", () => { 103 105 beforeEach(() => { 104 106 vi.clearAllMocks(); ··· 251 253 expect.stringContaining("Test comment"), 252 254 undefined, 253 255 post[0].did, 256 + post[0].time, 254 257 ); 255 258 }); 256 259 ··· 285 288 expect.any(String), 286 289 undefined, 287 290 post[0].did, 291 + post[0].time, 288 292 ); 289 293 }); 290 294 ··· 339 343 expect.any(String), 340 344 undefined, 341 345 post[0].did, 346 + post[0].time, 342 347 ); 343 348 }); 344 349 }); ··· 384 389 expect.any(String), 385 390 undefined, 386 391 "did:plc:notignored", 392 + post[0].time, 387 393 ); 388 394 }); 389 395 }); ··· 401 407 expect.any(String), 402 408 undefined, 403 409 post[0].did, 410 + post[0].time, 404 411 ); 405 412 expect(createPostReport).toHaveBeenCalledWith( 406 413 post[0].atURI,
+3 -3
src/rules/profiles/checkProfiles.ts
··· 1 - import { GLOBAL_ALLOW } from "../../constants.js"; 1 + import { GLOBAL_ALLOW } from "../../../rules/constants.js"; 2 2 import { logger } from "../../logger.js"; 3 3 import { 4 4 createAccountComment, 5 5 createAccountLabel, 6 6 createAccountReport, 7 - } from "../../moderation.js"; 8 - import { PROFILE_CHECKS } from "../../rules/profiles/constants.js"; 7 + } from "../../accountModeration.js"; 8 + import { PROFILE_CHECKS } from "../../../rules/profiles.js"; 9 9 import { getLanguage } from "../../utils/getLanguage.js"; 10 10 11 11 export const checkDescription = async (
+4 -4
src/rules/profiles/tests/checkProfiles.test.ts
··· 4 4 createAccountComment, 5 5 createAccountLabel, 6 6 createAccountReport, 7 - } from "../../../moderation.js"; 7 + } from "../../../accountModeration.js"; 8 8 import { getLanguage } from "../../../utils/getLanguage.js"; 9 9 import { checkDescription, checkDisplayName } from "../checkProfiles.js"; 10 10 11 11 // Mock dependencies 12 - vi.mock("../constants.js", () => ({ 12 + vi.mock("../../../../rules/profiles.js", () => ({ 13 13 PROFILE_CHECKS: [ 14 14 { 15 15 label: "test-description", ··· 96 96 }, 97 97 })); 98 98 99 - vi.mock("../../../moderation.js", () => ({ 99 + vi.mock("../../../accountModeration.js", () => ({ 100 100 createAccountLabel: vi.fn(), 101 101 createAccountReport: vi.fn(), 102 102 createAccountComment: vi.fn(), ··· 106 106 getLanguage: vi.fn().mockResolvedValue("eng"), 107 107 })); 108 108 109 - vi.mock("../../../constants.js", () => ({ 109 + vi.mock("../../../../rules/constants.js", () => ({ 110 110 GLOBAL_ALLOW: ["did:plc:globalallow"], 111 111 })); 112 112
+297
src/tests/accountThreshold.test.ts
··· 1 + import { afterEach, describe, expect, it, vi } from "vitest"; 2 + 3 + vi.mock("../logger.js", () => ({ 4 + logger: { 5 + info: vi.fn(), 6 + warn: vi.fn(), 7 + error: vi.fn(), 8 + debug: vi.fn(), 9 + }, 10 + })); 11 + 12 + vi.mock("../../rules/accountThreshold.js", () => ({ 13 + ACCOUNT_THRESHOLD_CONFIGS: [ 14 + { 15 + labels: ["test-label"], 16 + threshold: 3, 17 + accountLabel: "test-account-label", 18 + accountComment: "Test comment", 19 + windowDays: 5, 20 + reportAcct: false, 21 + commentAcct: false, 22 + toLabel: true, 23 + }, 24 + { 25 + labels: ["label-1", "label-2", "label-3"], 26 + threshold: 5, 27 + accountLabel: "multi-label-account", 28 + accountComment: "Multi label comment", 29 + windowDays: 7, 30 + reportAcct: true, 31 + commentAcct: true, 32 + toLabel: true, 33 + }, 34 + { 35 + labels: "monitor-only-label", 36 + threshold: 2, 37 + accountLabel: "monitored", 38 + accountComment: "Monitoring comment", 39 + windowDays: 3, 40 + reportAcct: true, 41 + commentAcct: false, 42 + toLabel: false, 43 + }, 44 + { 45 + labels: ["label-1", "shared-label"], 46 + threshold: 2, 47 + accountLabel: "shared-config", 48 + accountComment: "Shared config comment", 49 + windowDays: 4, 50 + reportAcct: false, 51 + commentAcct: false, 52 + toLabel: true, 53 + }, 54 + ], 55 + })); 56 + 57 + vi.mock("../redis.js", () => ({ 58 + trackPostLabelForAccount: vi.fn(), 59 + getPostLabelCountInWindow: vi.fn(), 60 + })); 61 + 62 + vi.mock("../accountModeration.js", () => ({ 63 + createAccountLabel: vi.fn(), 64 + createAccountReport: vi.fn(), 65 + createAccountComment: vi.fn(), 66 + })); 67 + 68 + vi.mock("../metrics.js", () => ({ 69 + accountLabelsThresholdAppliedCounter: { 70 + inc: vi.fn(), 71 + }, 72 + accountThresholdChecksCounter: { 73 + inc: vi.fn(), 74 + }, 75 + accountThresholdMetCounter: { 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(() => { 102 + vi.clearAllMocks(); 103 + }); 104 + 105 + describe("loadThresholdConfigs", () => { 106 + it("should load and cache configs successfully", () => { 107 + const configs = loadThresholdConfigs(); 108 + expect(configs).toHaveLength(4); 109 + expect(configs[0].labels).toEqual(["test-label"]); 110 + expect(configs[1].labels).toEqual(["label-1", "label-2", "label-3"]); 111 + }); 112 + 113 + it("should return cached configs on subsequent calls", () => { 114 + const configs1 = loadThresholdConfigs(); 115 + const configs2 = loadThresholdConfigs(); 116 + expect(configs1).toBe(configs2); 117 + }); 118 + }); 119 + 120 + describe("checkAccountThreshold", () => { 121 + const testDid = "did:plc:test123"; 122 + const testTimestamp = 1640000000000000; 123 + 124 + it("should not check threshold for non-matching labels", async () => { 125 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 126 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(0); 127 + 128 + await checkAccountThreshold(testDid, "non-matching-label", testTimestamp); 129 + 130 + expect(trackPostLabelForAccount).not.toHaveBeenCalled(); 131 + expect(getPostLabelCountInWindow).not.toHaveBeenCalled(); 132 + }); 133 + 134 + it("should track and check threshold for matching single label", async () => { 135 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 136 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2); 137 + 138 + await checkAccountThreshold(testDid, "test-label", testTimestamp); 139 + 140 + expect(accountThresholdChecksCounter.inc).toHaveBeenCalledWith({ 141 + post_label: "test-label", 142 + }); 143 + expect(trackPostLabelForAccount).toHaveBeenCalledWith( 144 + testDid, 145 + "test-label", 146 + testTimestamp, 147 + 5, 148 + ); 149 + expect(getPostLabelCountInWindow).toHaveBeenCalledWith( 150 + testDid, 151 + ["test-label"], 152 + 5, 153 + testTimestamp, 154 + ); 155 + }); 156 + 157 + it("should apply account label when threshold is met", async () => { 158 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 159 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(3); 160 + vi.mocked(createAccountLabel).mockResolvedValue(); 161 + 162 + await checkAccountThreshold(testDid, "test-label", testTimestamp); 163 + 164 + expect(accountThresholdMetCounter.inc).toHaveBeenCalledWith({ 165 + account_label: "test-account-label", 166 + }); 167 + expect(createAccountLabel).toHaveBeenCalledWith( 168 + testDid, 169 + "test-account-label", 170 + "Test comment", 171 + ); 172 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 173 + account_label: "test-account-label", 174 + action: "label", 175 + }); 176 + }); 177 + 178 + it("should not apply label when threshold not met", async () => { 179 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 180 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2); 181 + 182 + await checkAccountThreshold(testDid, "test-label", testTimestamp); 183 + 184 + expect(accountThresholdMetCounter.inc).not.toHaveBeenCalled(); 185 + expect(createAccountLabel).not.toHaveBeenCalled(); 186 + }); 187 + 188 + it("should handle multi-label config with OR logic", async () => { 189 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 190 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(5); 191 + vi.mocked(createAccountLabel).mockResolvedValue(); 192 + vi.mocked(createAccountReport).mockResolvedValue(); 193 + vi.mocked(createAccountComment).mockResolvedValue(); 194 + 195 + await checkAccountThreshold(testDid, "label-2", testTimestamp); 196 + 197 + expect(getPostLabelCountInWindow).toHaveBeenCalledWith( 198 + testDid, 199 + ["label-1", "label-2", "label-3"], 200 + 7, 201 + testTimestamp, 202 + ); 203 + expect(createAccountLabel).toHaveBeenCalledWith( 204 + testDid, 205 + "multi-label-account", 206 + "Multi label comment", 207 + ); 208 + expect(createAccountReport).toHaveBeenCalledWith( 209 + testDid, 210 + "Multi label comment", 211 + ); 212 + expect(createAccountComment).toHaveBeenCalled(); 213 + }); 214 + 215 + it("should track but not label when toLabel is false", async () => { 216 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 217 + vi.mocked(getPostLabelCountInWindow).mockResolvedValue(2); 218 + vi.mocked(createAccountReport).mockResolvedValue(); 219 + 220 + await checkAccountThreshold(testDid, "monitor-only-label", testTimestamp); 221 + 222 + expect(trackPostLabelForAccount).toHaveBeenCalled(); 223 + expect(getPostLabelCountInWindow).toHaveBeenCalled(); 224 + expect(createAccountLabel).not.toHaveBeenCalled(); 225 + expect(createAccountReport).toHaveBeenCalled(); 226 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 227 + account_label: "monitored", 228 + action: "report", 229 + }); 230 + }); 231 + 232 + it("should increment all action metrics when threshold met", async () => { 233 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 234 + vi.mocked(getPostLabelCountInWindow) 235 + .mockResolvedValueOnce(5) 236 + .mockResolvedValueOnce(1); 237 + vi.mocked(createAccountLabel).mockResolvedValue(); 238 + vi.mocked(createAccountReport).mockResolvedValue(); 239 + vi.mocked(createAccountComment).mockResolvedValue(); 240 + 241 + await checkAccountThreshold(testDid, "label-1", testTimestamp); 242 + 243 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledTimes(3); 244 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 245 + account_label: "multi-label-account", 246 + action: "label", 247 + }); 248 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 249 + account_label: "multi-label-account", 250 + action: "report", 251 + }); 252 + expect(accountLabelsThresholdAppliedCounter.inc).toHaveBeenCalledWith({ 253 + account_label: "multi-label-account", 254 + action: "comment", 255 + }); 256 + }); 257 + 258 + it("should handle Redis errors in trackPostLabelForAccount", async () => { 259 + const redisError = new Error("Redis connection failed"); 260 + vi.mocked(trackPostLabelForAccount).mockRejectedValue(redisError); 261 + 262 + await expect( 263 + checkAccountThreshold(testDid, "test-label", testTimestamp), 264 + ).rejects.toThrow("Redis connection failed"); 265 + 266 + expect(logger.error).toHaveBeenCalled(); 267 + }); 268 + 269 + it("should handle Redis errors in getPostLabelCountInWindow", async () => { 270 + const redisError = new Error("Redis query failed"); 271 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 272 + vi.mocked(getPostLabelCountInWindow).mockRejectedValue(redisError); 273 + 274 + await expect( 275 + checkAccountThreshold(testDid, "test-label", testTimestamp), 276 + ).rejects.toThrow("Redis query failed"); 277 + 278 + expect(logger.error).toHaveBeenCalled(); 279 + }); 280 + 281 + it("should handle multiple matching configs", async () => { 282 + vi.mocked(trackPostLabelForAccount).mockResolvedValue(); 283 + vi.mocked(getPostLabelCountInWindow) 284 + .mockResolvedValueOnce(5) 285 + .mockResolvedValueOnce(3); 286 + vi.mocked(createAccountLabel).mockResolvedValue(); 287 + vi.mocked(createAccountReport).mockResolvedValue(); 288 + vi.mocked(createAccountComment).mockResolvedValue(); 289 + 290 + await checkAccountThreshold(testDid, "label-1", testTimestamp); 291 + 292 + expect(trackPostLabelForAccount).toHaveBeenCalledTimes(2); 293 + expect(getPostLabelCountInWindow).toHaveBeenCalledTimes(2); 294 + expect(createAccountLabel).toHaveBeenCalledTimes(2); 295 + }); 296 + }); 297 + });
+18 -255
src/tests/agent.test.ts
··· 1 1 import { beforeEach, describe, expect, it, vi } from "vitest"; 2 - import type { SessionData } from "../session.js"; 3 - 4 - // TODO: Fix TypeScript mocking issues with AtpAgent 5 - describe.skip("Agent", () => { 6 - let mockLogin: any; 7 - let mockResumeSession: any; 8 - let mockGetProfile: any; 9 - let loadSessionMock: any; 10 - let saveSessionMock: any; 11 2 3 + describe("Agent", () => { 12 4 beforeEach(() => { 13 - vi.clearAllMocks(); 5 + vi.resetModules(); 6 + }); 14 7 8 + it("should create an agent and login", async () => { 15 9 // Mock the config variables 16 10 vi.doMock("../config.js", () => ({ 17 11 BSKY_HANDLE: "test.bsky.social", ··· 19 13 OZONE_PDS: "pds.test.com", 20 14 })); 21 15 22 - // Create mock functions 23 - mockLogin = vi.fn(() => 24 - Promise.resolve({ 25 - success: true, 26 - data: { 27 - accessJwt: "new-access-token", 28 - refreshJwt: "new-refresh-token", 29 - did: "did:plc:test123", 30 - handle: "test.bsky.social", 31 - }, 32 - }) 33 - ); 34 - mockResumeSession = vi.fn(() => Promise.resolve()); 35 - mockGetProfile = vi.fn(() => 36 - Promise.resolve({ 37 - success: true, 38 - data: { did: "did:plc:test123", handle: "test.bsky.social" }, 39 - }) 40 - ); 41 - 42 16 // Mock the AtpAgent 17 + const mockLogin = vi.fn(() => Promise.resolve()); 18 + const mockConstructor = vi.fn(); 43 19 vi.doMock("@atproto/api", () => ({ 44 20 AtpAgent: class { 45 21 login = mockLogin; 46 - resumeSession = mockResumeSession; 47 - getProfile = mockGetProfile; 48 22 service: URL; 49 - session: SessionData | null = null; 50 - 51 - constructor(options: { service: string; fetch?: typeof fetch }) { 23 + constructor(options: { service: string }) { 24 + mockConstructor(options); 52 25 this.service = new URL(options.service); 53 - // Store fetch function if provided for rate limit header testing 54 - if (options.fetch) { 55 - this.fetch = options.fetch; 56 - } 57 26 } 58 - 59 - fetch?: typeof fetch; 60 27 }, 61 28 })); 62 29 63 - // Mock session functions 64 - loadSessionMock = vi.fn(() => null); 65 - saveSessionMock = vi.fn(); 30 + const { agent, login } = await import("../agent.js"); 66 31 67 - vi.doMock("../session.js", () => ({ 68 - loadSession: loadSessionMock, 69 - saveSession: saveSessionMock, 70 - })); 71 - 72 - // Mock updateRateLimitState 73 - vi.doMock("../limits.js", () => ({ 74 - updateRateLimitState: vi.fn(), 75 - })); 76 - 77 - // Mock logger 78 - vi.doMock("../logger.js", () => ({ 79 - logger: { 80 - info: vi.fn(), 81 - warn: vi.fn(), 82 - error: vi.fn(), 83 - debug: vi.fn(), 84 - }, 85 - })); 86 - }); 87 - 88 - describe("agent initialization", () => { 89 - it("should create an agent with correct service URL", async () => { 90 - const { agent } = await import("../agent.js"); 91 - expect(agent.service.toString()).toBe("https://pds.test.com/"); 92 - }); 93 - 94 - it("should provide custom fetch function for rate limit headers", async () => { 95 - const { agent } = await import("../agent.js"); 96 - // @ts-expect-error - Testing custom fetch 97 - expect(agent.fetch).toBeDefined(); 98 - }); 99 - }); 100 - 101 - describe("authentication with no saved session", () => { 102 - it("should perform fresh login when no session exists", async () => { 103 - loadSessionMock.mockReturnValue(null); 104 - 105 - const { login } = await import("../agent.js"); 106 - const result = await login(); 107 - 108 - expect(loadSessionMock).toHaveBeenCalled(); 109 - expect(mockLogin).toHaveBeenCalledWith({ 110 - identifier: "test.bsky.social", 111 - password: "password", 112 - }); 113 - expect(result).toBe(true); 32 + // Check that the agent was created with the correct service URL 33 + expect(mockConstructor).toHaveBeenCalledWith({ 34 + service: "https://pds.test.com", 114 35 }); 115 - 116 - it("should save session after successful login", async () => { 117 - loadSessionMock.mockReturnValue(null); 118 - 119 - const mockSession: SessionData = { 120 - accessJwt: "new-access-token", 121 - refreshJwt: "new-refresh-token", 122 - did: "did:plc:test123", 123 - handle: "test.bsky.social", 124 - active: true, 125 - }; 126 - 127 - mockLogin.mockResolvedValue({ 128 - success: true, 129 - data: mockSession, 130 - }); 131 - 132 - // Need to manually set agent.session since we're mocking 133 - const { login, agent } = await import("../agent.js"); 134 - // @ts-expect-error - Mocking session for tests 135 - agent.session = mockSession; 36 + expect(agent.service.toString()).toBe("https://pds.test.com/"); 136 37 137 - await login(); 138 - 139 - expect(saveSessionMock).toHaveBeenCalledWith(mockSession); 140 - }); 141 - }); 142 - 143 - describe("authentication with saved session", () => { 144 - it("should resume session when valid session exists", async () => { 145 - const savedSession: SessionData = { 146 - accessJwt: "saved-access-token", 147 - refreshJwt: "saved-refresh-token", 148 - did: "did:plc:test123", 149 - handle: "test.bsky.social", 150 - active: true, 151 - }; 152 - 153 - loadSessionMock.mockReturnValue(savedSession); 154 - 155 - const { login } = await import("../agent.js"); 156 - await login(); 157 - 158 - expect(loadSessionMock).toHaveBeenCalled(); 159 - expect(mockResumeSession).toHaveBeenCalledWith(savedSession); 160 - expect(mockGetProfile).toHaveBeenCalledWith({ actor: savedSession.did }); 161 - }); 162 - 163 - it("should fallback to login when session resume fails", async () => { 164 - const savedSession: SessionData = { 165 - accessJwt: "invalid-token", 166 - refreshJwt: "invalid-refresh", 167 - did: "did:plc:test123", 168 - handle: "test.bsky.social", 169 - active: true, 170 - }; 171 - 172 - loadSessionMock.mockReturnValue(savedSession); 173 - mockResumeSession.mockRejectedValue(new Error("Invalid session")); 174 - 175 - const { login } = await import("../agent.js"); 176 - await login(); 177 - 178 - expect(mockResumeSession).toHaveBeenCalled(); 179 - expect(mockLogin).toHaveBeenCalled(); 180 - }); 181 - 182 - it("should fallback to login when profile validation fails", async () => { 183 - const savedSession: SessionData = { 184 - accessJwt: "saved-token", 185 - refreshJwt: "saved-refresh", 186 - did: "did:plc:test123", 187 - handle: "test.bsky.social", 188 - active: true, 189 - }; 190 - 191 - loadSessionMock.mockReturnValue(savedSession); 192 - mockGetProfile.mockRejectedValue(new Error("Profile not found")); 193 - 194 - const { login } = await import("../agent.js"); 195 - await login(); 196 - 197 - expect(mockResumeSession).toHaveBeenCalled(); 198 - expect(mockGetProfile).toHaveBeenCalled(); 199 - expect(mockLogin).toHaveBeenCalled(); 200 - }); 201 - }); 202 - 203 - describe("rate limit header extraction", () => { 204 - it("should extract rate limit headers from responses", async () => { 205 - const { updateRateLimitState } = await import("../limits.js"); 206 - const { agent } = await import("../agent.js"); 207 - 208 - // Simulate a response with rate limit headers 209 - const mockResponse = new Response(JSON.stringify({ success: true }), { 210 - headers: { 211 - "ratelimit-limit": "3000", 212 - "ratelimit-remaining": "2500", 213 - "ratelimit-reset": "1760927355", 214 - "ratelimit-policy": "3000;w=300", 215 - }, 216 - }); 217 - 218 - // @ts-expect-error - Testing custom fetch 219 - if (agent.fetch) { 220 - // @ts-expect-error - Testing custom fetch 221 - await agent.fetch("https://test.com", {}); 222 - } 223 - 224 - // updateRateLimitState should have been called if headers are processed 225 - // This is a basic check - actual implementation depends on fetch wrapper 226 - }); 227 - }); 228 - 229 - describe("session refresh", () => { 230 - it("should schedule session refresh after login", async () => { 231 - vi.useFakeTimers(); 232 - 233 - loadSessionMock.mockReturnValue(null); 234 - 235 - const mockSession: SessionData = { 236 - accessJwt: "access-token", 237 - refreshJwt: "refresh-token", 238 - did: "did:plc:test123", 239 - handle: "test.bsky.social", 240 - active: true, 241 - }; 242 - 243 - mockLogin.mockResolvedValue({ 244 - success: true, 245 - data: mockSession, 246 - }); 247 - 248 - const { login, agent } = await import("../agent.js"); 249 - // @ts-expect-error - Mocking session for tests 250 - agent.session = mockSession; 251 - 252 - await login(); 253 - 254 - // Fast-forward time to trigger refresh (2 hours * 0.8 = 96 minutes) 255 - vi.advanceTimersByTime(96 * 60 * 1000); 256 - 257 - vi.useRealTimers(); 258 - }); 259 - }); 260 - 261 - describe("error handling", () => { 262 - it("should return false on login failure", async () => { 263 - loadSessionMock.mockReturnValue(null); 264 - mockLogin.mockResolvedValue({ success: false }); 265 - 266 - const { login } = await import("../agent.js"); 267 - const result = await login(); 268 - 269 - expect(result).toBe(false); 270 - }); 271 - 272 - it("should return false when login throws error", async () => { 273 - loadSessionMock.mockReturnValue(null); 274 - mockLogin.mockRejectedValue(new Error("Network error")); 275 - 276 - const { login } = await import("../agent.js"); 277 - const result = await login(); 278 - 279 - expect(result).toBe(false); 38 + // Check that the login function calls the mockLogin function 39 + await login(); 40 + expect(mockLogin).toHaveBeenCalledWith({ 41 + identifier: "test.bsky.social", 42 + password: "password", 280 43 }); 281 44 }); 282 45 });
+21 -200
src/tests/limits.test.ts
··· 1 - import { describe, expect, it, beforeEach, vi } from "vitest"; 2 - import { limit, getRateLimitState, updateRateLimitState } from "../limits.js"; 1 + import { describe, expect, it } from "vitest"; 2 + import { limit } from "../limits.js"; 3 3 4 4 describe("Rate Limiter", () => { 5 - beforeEach(() => { 6 - // Reset rate limit state before each test 7 - updateRateLimitState({ 8 - limit: 280, 9 - remaining: 280, 10 - reset: Math.floor(Date.now() / 1000) + 30, 11 - }); 12 - }); 13 - 14 - describe("limit", () => { 15 - it("should limit the rate of calls", async () => { 16 - const calls = []; 17 - for (let i = 0; i < 10; i++) { 18 - calls.push(limit(() => Promise.resolve(Date.now()))); 19 - } 20 - 21 - const start = Date.now(); 22 - const results = await Promise.all(calls); 23 - const end = Date.now(); 24 - 25 - expect(results.length).toBe(10); 26 - for (const result of results) { 27 - expect(typeof result).toBe("number"); 28 - } 29 - expect(end - start).toBeGreaterThanOrEqual(0); 30 - }, 40000); 5 + it("should limit the rate of calls", async () => { 6 + const calls = []; 7 + for (let i = 0; i < 10; i++) { 8 + calls.push(limit(() => Promise.resolve(Date.now()))); 9 + } 31 10 32 - it("should execute function and return result", async () => { 33 - const result = await limit(() => Promise.resolve(42)); 34 - expect(result).toBe(42); 35 - }); 11 + const start = Date.now(); 12 + const results = await Promise.all(calls); 13 + const end = Date.now(); 36 14 37 - it("should handle errors from wrapped function", async () => { 38 - await expect( 39 - limit(() => Promise.reject(new Error("test error"))) 40 - ).rejects.toThrow("test error"); 41 - }); 42 - 43 - it("should handle multiple concurrent requests", async () => { 44 - const results = await Promise.all([ 45 - limit(() => Promise.resolve(1)), 46 - limit(() => Promise.resolve(2)), 47 - limit(() => Promise.resolve(3)), 48 - ]); 49 - 50 - expect(results).toEqual([1, 2, 3]); 51 - }); 52 - }); 53 - 54 - describe("getRateLimitState", () => { 55 - it("should return current rate limit state", () => { 56 - const state = getRateLimitState(); 57 - 58 - expect(state).toHaveProperty("limit"); 59 - expect(state).toHaveProperty("remaining"); 60 - expect(state).toHaveProperty("reset"); 61 - expect(typeof state.limit).toBe("number"); 62 - expect(typeof state.remaining).toBe("number"); 63 - expect(typeof state.reset).toBe("number"); 64 - }); 65 - 66 - it("should return a copy of state", () => { 67 - const state1 = getRateLimitState(); 68 - const state2 = getRateLimitState(); 69 - 70 - expect(state1).toEqual(state2); 71 - expect(state1).not.toBe(state2); // Different object references 72 - }); 73 - }); 74 - 75 - describe("updateRateLimitState", () => { 76 - it("should update limit", () => { 77 - updateRateLimitState({ limit: 500 }); 78 - const state = getRateLimitState(); 79 - expect(state.limit).toBe(500); 80 - }); 81 - 82 - it("should update remaining", () => { 83 - updateRateLimitState({ remaining: 100 }); 84 - const state = getRateLimitState(); 85 - expect(state.remaining).toBe(100); 86 - }); 87 - 88 - it("should update reset", () => { 89 - const newReset = Math.floor(Date.now() / 1000) + 60; 90 - updateRateLimitState({ reset: newReset }); 91 - const state = getRateLimitState(); 92 - expect(state.reset).toBe(newReset); 93 - }); 94 - 95 - it("should update policy", () => { 96 - updateRateLimitState({ policy: "3000;w=300" }); 97 - const state = getRateLimitState(); 98 - expect(state.policy).toBe("3000;w=300"); 99 - }); 100 - 101 - it("should update multiple fields at once", () => { 102 - const updates = { 103 - limit: 3000, 104 - remaining: 2500, 105 - reset: Math.floor(Date.now() / 1000) + 300, 106 - policy: "3000;w=300", 107 - }; 108 - 109 - updateRateLimitState(updates); 110 - const state = getRateLimitState(); 111 - 112 - expect(state.limit).toBe(3000); 113 - expect(state.remaining).toBe(2500); 114 - expect(state.reset).toBe(updates.reset); 115 - expect(state.policy).toBe("3000;w=300"); 116 - }); 117 - 118 - it("should preserve unspecified fields", () => { 119 - updateRateLimitState({ 120 - limit: 3000, 121 - remaining: 2500, 122 - reset: Math.floor(Date.now() / 1000) + 300, 123 - }); 124 - 125 - updateRateLimitState({ remaining: 2000 }); 126 - 127 - const state = getRateLimitState(); 128 - expect(state.limit).toBe(3000); // Preserved 129 - expect(state.remaining).toBe(2000); // Updated 130 - }); 131 - }); 132 - 133 - describe("awaitRateLimit", () => { 134 - it("should not wait when remaining is above safety buffer", async () => { 135 - updateRateLimitState({ remaining: 100 }); 136 - 137 - const start = Date.now(); 138 - await limit(() => Promise.resolve(1)); 139 - const elapsed = Date.now() - start; 140 - 141 - // Should complete almost immediately (< 100ms) 142 - expect(elapsed).toBeLessThan(100); 143 - }); 144 - 145 - it("should wait when remaining is at safety buffer", async () => { 146 - const now = Math.floor(Date.now() / 1000); 147 - updateRateLimitState({ 148 - remaining: 5, // At safety buffer 149 - reset: now + 1, // Reset in 1 second 150 - }); 151 - 152 - const start = Date.now(); 153 - await limit(() => Promise.resolve(1)); 154 - const elapsed = Date.now() - start; 155 - 156 - // Should wait approximately 1 second 157 - expect(elapsed).toBeGreaterThanOrEqual(900); 158 - expect(elapsed).toBeLessThan(1500); 159 - }, 10000); 160 - 161 - it("should wait when remaining is below safety buffer", async () => { 162 - const now = Math.floor(Date.now() / 1000); 163 - updateRateLimitState({ 164 - remaining: 2, // Below safety buffer 165 - reset: now + 1, // Reset in 1 second 166 - }); 167 - 168 - const start = Date.now(); 169 - await limit(() => Promise.resolve(1)); 170 - const elapsed = Date.now() - start; 171 - 172 - // Should wait approximately 1 second 173 - expect(elapsed).toBeGreaterThanOrEqual(900); 174 - expect(elapsed).toBeLessThan(1500); 175 - }, 10000); 176 - 177 - it("should not wait if reset time has passed", async () => { 178 - const now = Math.floor(Date.now() / 1000); 179 - updateRateLimitState({ 180 - remaining: 2, 181 - reset: now - 10, // Reset was 10 seconds ago 182 - }); 183 - 184 - const start = Date.now(); 185 - await limit(() => Promise.resolve(1)); 186 - const elapsed = Date.now() - start; 187 - 188 - // Should not wait 189 - expect(elapsed).toBeLessThan(100); 190 - }); 191 - }); 192 - 193 - describe("metrics", () => { 194 - it("should track concurrent requests", async () => { 195 - const delays = [100, 100, 100]; 196 - const promises = delays.map((delay) => 197 - limit(() => new Promise((resolve) => setTimeout(resolve, delay))) 198 - ); 199 - 200 - await Promise.all(promises); 201 - // If this completes without error, concurrent tracking works 202 - expect(true).toBe(true); 203 - }); 204 - }); 15 + // With a concurrency of 4, 10 calls should take at least 2 intervals. 16 + // However, the interval is 30 seconds, so this test would be very slow. 17 + // Instead, we'll just check that the calls were successful and returned a timestamp. 18 + expect(results.length).toBe(10); 19 + for (const result of results) { 20 + expect(typeof result).toBe("number"); 21 + } 22 + // A better test would be to mock the timer and advance it, but that's more complex. 23 + // For now, we'll just check that the time taken is greater than 0. 24 + expect(end - start).toBeGreaterThanOrEqual(0); 25 + }, 40000); // Increase timeout for this test 205 26 });
+3 -2
src/tests/moderation.test.ts
··· 41 41 42 42 // --- Imports Second --- 43 43 44 + import { checkAccountLabels } from "../accountModeration.js"; 44 45 import { agent } from "../agent.js"; 45 - import { checkAccountLabels, createPostLabel } from "../moderation.js"; 46 + import { logger } from "../logger.js"; 47 + import { createPostLabel } from "../moderation.js"; 46 48 import { tryClaimPostLabel } from "../redis.js"; 47 - import { logger } from "../logger.js"; 48 49 49 50 describe("Moderation Logic", () => { 50 51 beforeEach(() => {
+113
src/tests/redis.test.ts
··· 9 9 quit: vi.fn(), 10 10 exists: vi.fn(), 11 11 set: vi.fn(), 12 + zAdd: vi.fn(), 13 + zRemRangeByScore: vi.fn(), 14 + zCount: vi.fn(), 15 + expire: vi.fn(), 12 16 }; 13 17 return { 14 18 createClient: vi.fn(() => mockClient), ··· 25 29 tryClaimAccountLabel, 26 30 connectRedis, 27 31 disconnectRedis, 32 + trackPostLabelForAccount, 33 + getPostLabelCountInWindow, 28 34 } from '../redis.js'; 29 35 import { logger } from '../logger.js'; 30 36 ··· 101 107 vi.mocked(mockRedisClient.set).mockResolvedValue(null); 102 108 const result = await tryClaimAccountLabel('did:plc:123', 'test-label'); 103 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); 118 + 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, 134 + value: timestamp.toString(), 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) 180 + .mockResolvedValueOnce(1); 181 + 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(); 104 217 }); 105 218 }); 106 219 });
-183
src/tests/session.test.ts
··· 1 - import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2 - import { 3 - existsSync, 4 - mkdirSync, 5 - rmSync, 6 - writeFileSync, 7 - readFileSync, 8 - unlinkSync, 9 - chmodSync, 10 - } from "node:fs"; 11 - import { join } from "node:path"; 12 - import type { SessionData } from "../session.js"; 13 - 14 - const TEST_DIR = join(process.cwd(), ".test-session"); 15 - const TEST_SESSION_PATH = join(TEST_DIR, ".session"); 16 - 17 - // Helper functions that mimic session.ts but use TEST_SESSION_PATH 18 - function testLoadSession(): SessionData | null { 19 - try { 20 - if (!existsSync(TEST_SESSION_PATH)) { 21 - return null; 22 - } 23 - 24 - const data = readFileSync(TEST_SESSION_PATH, "utf-8"); 25 - const session = JSON.parse(data) as SessionData; 26 - 27 - if (!session.accessJwt || !session.refreshJwt || !session.did) { 28 - return null; 29 - } 30 - 31 - return session; 32 - } catch (error) { 33 - return null; 34 - } 35 - } 36 - 37 - function testSaveSession(session: SessionData): void { 38 - try { 39 - const data = JSON.stringify(session, null, 2); 40 - writeFileSync(TEST_SESSION_PATH, data, "utf-8"); 41 - chmodSync(TEST_SESSION_PATH, 0o600); 42 - } catch (error) { 43 - // Ignore errors for test 44 - } 45 - } 46 - 47 - function testClearSession(): void { 48 - try { 49 - if (existsSync(TEST_SESSION_PATH)) { 50 - unlinkSync(TEST_SESSION_PATH); 51 - } 52 - } catch (error) { 53 - // Ignore errors for test 54 - } 55 - } 56 - 57 - describe("session", () => { 58 - beforeEach(() => { 59 - // Create test directory 60 - if (!existsSync(TEST_DIR)) { 61 - mkdirSync(TEST_DIR, { recursive: true }); 62 - } 63 - }); 64 - 65 - afterEach(() => { 66 - // Clean up test directory 67 - if (existsSync(TEST_DIR)) { 68 - rmSync(TEST_DIR, { recursive: true, force: true }); 69 - } 70 - }); 71 - 72 - describe("saveSession", () => { 73 - it("should save session to file with proper permissions", () => { 74 - const session: SessionData = { 75 - accessJwt: "access-token", 76 - refreshJwt: "refresh-token", 77 - did: "did:plc:test123", 78 - handle: "test.bsky.social", 79 - active: true, 80 - }; 81 - 82 - testSaveSession(session); 83 - 84 - expect(existsSync(TEST_SESSION_PATH)).toBe(true); 85 - }); 86 - 87 - it("should save all session fields correctly", () => { 88 - const session: SessionData = { 89 - accessJwt: "access-token", 90 - refreshJwt: "refresh-token", 91 - did: "did:plc:test123", 92 - handle: "test.bsky.social", 93 - email: "test@example.com", 94 - emailConfirmed: true, 95 - emailAuthFactor: false, 96 - active: true, 97 - status: "active", 98 - }; 99 - 100 - testSaveSession(session); 101 - 102 - const loaded = testLoadSession(); 103 - expect(loaded).toEqual(session); 104 - }); 105 - }); 106 - 107 - describe("loadSession", () => { 108 - it("should return null if session file does not exist", () => { 109 - const session = testLoadSession(); 110 - expect(session).toBeNull(); 111 - }); 112 - 113 - it("should load valid session from file", () => { 114 - const session: SessionData = { 115 - accessJwt: "access-token", 116 - refreshJwt: "refresh-token", 117 - did: "did:plc:test123", 118 - handle: "test.bsky.social", 119 - active: true, 120 - }; 121 - 122 - testSaveSession(session); 123 - const loaded = testLoadSession(); 124 - 125 - expect(loaded).toEqual(session); 126 - }); 127 - 128 - it("should return null for corrupted session file", () => { 129 - writeFileSync(TEST_SESSION_PATH, "{ invalid json", "utf-8"); 130 - 131 - const session = testLoadSession(); 132 - expect(session).toBeNull(); 133 - }); 134 - 135 - it("should return null for session missing required fields", () => { 136 - writeFileSync( 137 - TEST_SESSION_PATH, 138 - JSON.stringify({ accessJwt: "token" }), 139 - "utf-8" 140 - ); 141 - 142 - const session = testLoadSession(); 143 - expect(session).toBeNull(); 144 - }); 145 - 146 - it("should return null for session missing did", () => { 147 - writeFileSync( 148 - TEST_SESSION_PATH, 149 - JSON.stringify({ 150 - accessJwt: "access", 151 - refreshJwt: "refresh", 152 - handle: "test.bsky.social", 153 - }), 154 - "utf-8" 155 - ); 156 - 157 - const session = testLoadSession(); 158 - expect(session).toBeNull(); 159 - }); 160 - }); 161 - 162 - describe("clearSession", () => { 163 - it("should remove session file if it exists", () => { 164 - const session: SessionData = { 165 - accessJwt: "access-token", 166 - refreshJwt: "refresh-token", 167 - did: "did:plc:test123", 168 - handle: "test.bsky.social", 169 - active: true, 170 - }; 171 - 172 - testSaveSession(session); 173 - expect(existsSync(TEST_SESSION_PATH)).toBe(true); 174 - 175 - testClearSession(); 176 - expect(existsSync(TEST_SESSION_PATH)).toBe(false); 177 - }); 178 - 179 - it("should not throw if session file does not exist", () => { 180 - expect(() => testClearSession()).not.toThrow(); 181 - }); 182 - }); 183 - });
+11
src/types.ts
··· 68 68 comment: string; // Comment for the label 69 69 expires?: string; // Optional expiration date (ISO 8601) - check will be skipped after this date 70 70 } 71 + 72 + export interface AccountThresholdConfig { 73 + labels: string | string[]; // Single label or array for OR matching 74 + threshold: number; // Number of labeled posts required to trigger account action 75 + accountLabel: string; // Label to apply to the account 76 + accountComment: string; // Comment for the account action 77 + windowDays: number; // Rolling window in days 78 + reportAcct: boolean; // Whether to report the account 79 + commentAcct: boolean; // Whether to comment on the account 80 + toLabel?: boolean; // Whether to apply label (defaults to true) 81 + }