Added redis caching to prevent label duplication #1

merged
opened by skywatch.blue targeting main from fix-label-duplication

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.
+19
bun.lock
···
"pino": "^9.9.0",
"pino-pretty": "^13.1.1",
"prom-client": "^15.1.3",
+
"redis": "^4.7.0",
"undici": "^7.15.0",
},
"devDependencies": {
···
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
+
"@redis/bloom": ["@redis/bloom@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg=="],
+
+
"@redis/client": ["@redis/client@1.6.1", "", { "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", "yallist": "4.0.0" } }, "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw=="],
+
+
"@redis/graph": ["@redis/graph@1.1.1", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw=="],
+
+
"@redis/json": ["@redis/json@1.0.7", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ=="],
+
+
"@redis/search": ["@redis/search@1.2.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw=="],
+
+
"@redis/time-series": ["@redis/time-series@1.1.0", "", { "peerDependencies": { "@redis/client": "^1.0.0" } }, "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g=="],
+
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="],
···
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
"generic-pool": ["generic-pool@3.9.0", "", {}, "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g=="],
+
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
···
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
+
"redis": ["redis@4.7.1", "", { "dependencies": { "@redis/bloom": "1.2.0", "@redis/client": "1.6.1", "@redis/graph": "1.1.1", "@redis/json": "1.0.7", "@redis/search": "1.2.0", "@redis/time-series": "1.1.0" } }, "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ=="],
+
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
···
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
+
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
+
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
+33
compose.yaml
···
version: "3.8"
services:
+
redis:
+
image: redis:7-alpine
+
container_name: skywatch-automod-redis
+
restart: unless-stopped
+
volumes:
+
- redis-data:/data
+
networks:
+
- skywatch-network
+
healthcheck:
+
test: ["CMD", "redis-cli", "ping"]
+
interval: 10s
+
timeout: 3s
+
retries: 3
+
automod:
# Build the Docker image from the Dockerfile in the current directory.
build: .
···
env_file:
- .env
+
# Wait for Redis to be healthy before starting
+
depends_on:
+
redis:
+
condition: service_healthy
+
+
networks:
+
- skywatch-network
+
# Mount a volume to persist the firehose cursor.
# This links the `cursor.txt` file from your host into the container at `/app/cursor.txt`.
# Persisting this file allows the automod to resume from where it left off
# after a restart, preventing it from reprocessing old events or skipping new ones.
volumes:
- ./cursor.txt:/app/cursor.txt
+
+
environment:
+
- NODE_ENV=production
+
- REDIS_URL=redis://redis:6379
+
+
volumes:
+
redis-data:
+
+
networks:
+
skywatch-network:
+
driver: bridge
+1
package.json
···
"pino": "^9.9.0",
"pino-pretty": "^13.1.1",
"prom-client": "^15.1.3",
+
"redis": "^4.7.0",
"undici": "^7.15.0"
},
"trustedDependencies": [
+1
src/config.ts
···
: 60000;
export const LABEL_LIMIT = process.env.LABEL_LIMIT;
export const LABEL_LIMIT_WAIT = process.env.LABEL_LIMIT_WAIT;
+
export const REDIS_URL = process.env.REDIS_URL || "redis://redis:6379";
+6 -1
src/main.ts
···
} from "./config.js";
import { logger } from "./logger.js";
import { startMetricsServer } from "./metrics.js";
+
import { connectRedis, disconnectRedis } from "./redis.js";
import { checkAccountAge } from "./rules/account/age.js";
import { checkFacetSpam } from "./rules/facets/facets.js";
import { checkHandle } from "./rules/handles/checkHandles.js";
···
}
});*/
+
logger.info({ process: "MAIN" }, "Connecting to Redis");
+
await connectRedis();
+
jetstream.start();
-
function shutdown() {
+
async function shutdown() {
try {
logger.info({ process: "MAIN" }, "Shutting down gracefully");
fs.writeFileSync("cursor.txt", jetstream.cursor!.toString(), "utf8");
jetstream.close();
metricsServer.close();
+
await disconnectRedis();
} catch (error) {
logger.error({ process: "MAIN", error }, "Error shutting down gracefully");
process.exit(1);
+49 -2
src/moderation.ts
···
import { MOD_DID } from "./config.js";
import { limit } from "./limits.js";
import { logger } from "./logger.js";
+
import {
+
tryClaimAccountComment,
+
tryClaimAccountLabel,
+
tryClaimPostLabel,
+
} from "./redis.js";
const doesLabelExist = (
labels: { val: string }[] | undefined,
···
label: string,
comment: string,
duration: number | undefined,
+
did?: string,
) => {
await isLoggedIn;
+
const claimed = await tryClaimPostLabel(uri, label);
+
if (!claimed) {
+
logger.debug(
+
{ process: "MODERATION", uri, label },
+
"Post label already claimed in Redis, skipping",
+
);
+
return;
+
}
+
const hasLabel = await checkRecordLabels(uri, label);
if (hasLabel) {
logger.debug(
···
return;
}
+
logger.info(
+
{ process: "MODERATION", label, did, atURI: uri },
+
"Labeling post",
+
);
+
await limit(async () => {
try {
const event: {
···
event.durationInHours = duration;
}
-
return agent.tools.ozone.moderation.emitEvent(
+
await agent.tools.ozone.moderation.emitEvent(
{
event: event,
// specify the labeled post by strongRef
···
) => {
await isLoggedIn;
+
const claimed = await tryClaimAccountLabel(did, label);
+
if (!claimed) {
+
logger.debug(
+
{ process: "MODERATION", did, label },
+
"Account label already claimed in Redis, skipping",
+
);
+
return;
+
}
+
const hasLabel = await checkAccountLabels(did, label);
if (hasLabel) {
logger.debug(
···
return;
}
+
logger.info({ process: "MODERATION", did, label }, "Labeling account");
+
await limit(async () => {
try {
await agent.tools.ozone.moderation.emitEvent(
···
});
};
-
export const createAccountComment = async (did: string, comment: string) => {
+
export const createAccountComment = async (
+
did: string,
+
comment: string,
+
atURI: string,
+
) => {
await isLoggedIn;
+
+
const claimed = await tryClaimAccountComment(did, atURI);
+
if (!claimed) {
+
logger.debug(
+
{ process: "MODERATION", did, atURI },
+
"Account comment already claimed in Redis, skipping",
+
);
+
return;
+
}
+
+
logger.info({ process: "MODERATION", did, atURI }, "Commenting on account");
+
await limit(async () => {
try {
await agent.tools.ozone.moderation.emitEvent(
+109
src/redis.ts
···
+
import { createClient } from "redis";
+
import { REDIS_URL } from "./config.js";
+
import { logger } from "./logger.js";
+
+
export const redisClient = createClient({
+
url: REDIS_URL,
+
});
+
+
redisClient.on("error", (err: Error) => {
+
logger.error({ err }, "Redis client error");
+
});
+
+
redisClient.on("connect", () => {
+
logger.info("Redis client connected");
+
});
+
+
redisClient.on("ready", () => {
+
logger.info("Redis client ready");
+
});
+
+
redisClient.on("reconnecting", () => {
+
logger.warn("Redis client reconnecting");
+
});
+
+
export async function connectRedis(): Promise<void> {
+
try {
+
await redisClient.connect();
+
} catch (err) {
+
logger.error({ err }, "Failed to connect to Redis");
+
throw err;
+
}
+
}
+
+
export async function disconnectRedis(): Promise<void> {
+
try {
+
await redisClient.quit();
+
logger.info("Redis client disconnected");
+
} catch (err) {
+
logger.error({ err }, "Error disconnecting Redis");
+
}
+
}
+
+
function getPostLabelCacheKey(atURI: string, label: string): string {
+
return `post-label:${atURI}:${label}`;
+
}
+
+
function getAccountLabelCacheKey(did: string, label: string): string {
+
return `account-label:${did}:${label}`;
+
}
+
+
export async function tryClaimPostLabel(
+
atURI: string,
+
label: string,
+
): Promise<boolean> {
+
try {
+
const key = getPostLabelCacheKey(atURI, label);
+
const result = await redisClient.set(key, "1", {
+
NX: true,
+
EX: 60 * 60 * 24 * 7,
+
});
+
return result === "OK";
+
} catch (err) {
+
logger.warn(
+
{ err, atURI, label },
+
"Error claiming post label in Redis, allowing through",
+
);
+
return true;
+
}
+
}
+
+
export async function tryClaimAccountLabel(
+
did: string,
+
label: string,
+
): Promise<boolean> {
+
try {
+
const key = getAccountLabelCacheKey(did, label);
+
const result = await redisClient.set(key, "1", {
+
NX: true,
+
EX: 60 * 60 * 24 * 7,
+
});
+
return result === "OK";
+
} catch (err) {
+
logger.warn(
+
{ err, did, label },
+
"Error claiming account label in Redis, allowing through",
+
);
+
return true;
+
}
+
}
+
+
export async function tryClaimAccountComment(
+
did: string,
+
atURI: string,
+
): Promise<boolean> {
+
try {
+
const key = `account-comment:${did}:${atURI}`;
+
const result = await redisClient.set(key, "1", {
+
NX: true,
+
EX: 60 * 60 * 24 * 7,
+
});
+
return result === "OK";
+
} catch (err) {
+
logger.warn(
+
{ err, did, atURI },
+
"Error claiming account comment in Redis, allowing through",
+
);
+
return true;
+
}
+
}
+38
src/rules/account/ageConstants.ts
···
* - Detect brigading on specific controversial posts
*/
export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [
+
{
+
monitoredDIDs: [
+
"did:plc:b2ecyhl2z2tro25ltrcyiytd", // DHS
+
"did:plc:iw2wxg46hm4ezguswhwej6t6", // actual whitehouse
+
"did:plc:fhnl65q3us5evynqc4f2qak6", // HHS
+
"did:plc:wrz4athzuf2u5js2ltrktiqk", // DOL
+
"did:plc:3mqcgvyu4exg3pkx4bkfppih", // VA
+
"did:plc:pqn2sfkx5klnytms4uwqt5wo", // Treasurer
+
"did:plc:v4kvjftk6kr5ci3zqmfawwpb", // State
+
"did:plc:rlymk4d5qmq5udjdznojmvel", // Interior
+
"did:plc:f7a5etif42x56oyrbzuek6so", // USDA
+
"did:plc:7kusimwlnf4v5jo757jvkeaj", // DOE
+
"did:plc:jgq3vko3g6zg72457bda2snd", // SBA
+
"did:plc:h2iujdjlry6fpniofjtiqqmb", // DoD
+
"did:plc:jwncvpznkwe4luzvdroes45b", // CBP
+
"did:plc:azfxx5mdxcuoc2bkuqizs4kd",
+
"did:plc:vostkism5vbzjqfcmllmd6gz",
+
"did:plc:etthv4ychwti4b6i2hhe76c2",
+
"did:plc:swf7zddjselkcpbn6iw323gy",
+
"did:plc:h3zq65wioggctyxpovfpi6ec",
+
"did:plc:nofnc2xpdihktxkufkq7tn3w",
+
"did:plc:quezcqejcqw6g5t3om7wldns",
+
"did:plc:vlvqht2v3nsc4k7xaho6bjaf",
+
"did:plc:syyfuvqiabipi5mf3x632qij",
+
"did:plc:6vpxzm6mxjzcfvccnuw2pyd7",
+
"did:plc:yxqdgravj27gtxkpqhrnzhlx",
+
"did:plc:nrhrdxqa2v7hfxw2jnuy7rk7",
+
"did:plc:pr27argcmniiwxp7d7facqwy",
+
"did:plc:azfxx5mdxcuoc2bkuqizs4kd",
+
"did:plc:y42muzveli3sjyr3tufaq765",
+
"did:plc:22wazjq4e4yjafxlew2c6kov",
+
"did:plc:iw64z65wzkmqvftssb2nldj5",
+
],
+
anchorDate: "2025-10-17", // Date when harassment campaign started
+
maxAgeDays: 7, // Flag accounts less than 7 days old
+
label: "suspect-inauthentic",
+
comment: "New account replying to monitored user during campaign",
+
},
// Example: Monitor replies to specific accounts
// {
// monitoredDIDs: [
+3
src/rules/handles/checkHandles.test.ts
···
expect(createAccountComment).toHaveBeenCalledWith(
"did:plc:user1",
`${time}: Scam detected - scam-account`,
+
"handle:did:plc:user1:scam-account",
);
});
});
···
expect(createAccountComment).toHaveBeenCalledWith(
"did:plc:user1",
`${time}: Scam detected - scam-user`,
+
"handle:did:plc:user1:scam-user",
);
});
···
expect(createAccountComment).toHaveBeenCalledWith(
"did:plc:user1",
`${time}: Multi-action triggered - dangerous-account`,
+
"handle:did:plc:user1:dangerous-account",
);
expect(createAccountLabel).toHaveBeenCalledWith(
"did:plc:user1",
+8 -14
src/rules/handles/checkHandles.ts
···
}
if (checkList.toLabel === true) {
-
logger.info(
-
{ process: "CHECKHANDLE", did, handle, time, label: checkList.label },
-
"Labeling account",
+
createAccountLabel(
+
did,
+
`${checkList.label}`,
+
`${time}: ${checkList.comment} - ${handle}`,
);
-
{
-
createAccountLabel(
-
did,
-
`${checkList.label}`,
-
`${time}: ${checkList.comment} - ${handle}`,
-
);
-
}
}
if (checkList.reportAcct === true) {
···
}
if (checkList.commentAcct === true) {
-
logger.info(
-
{ process: "CHECKHANDLE", did, handle, time, label: checkList.label },
-
"Commenting on account",
+
createAccountComment(
+
did,
+
`${time}: ${checkList.comment} - ${handle}`,
+
`handle:${did}:${handle}`,
);
-
createAccountComment(did, `${time}: ${checkList.comment} - ${handle}`);
}
}
});
+2 -18
src/rules/posts/checkPosts.ts
···
countStarterPacks(post[0].did, post[0].time);
if (checkPost.toLabel === true) {
-
logger.info(
-
{
-
process: "CHECKPOSTS",
-
label: checkPost.label,
-
did: post[0].did,
-
atURI: post[0].atURI,
-
},
-
"Labeling post",
-
);
createPostLabel(
post[0].atURI,
post[0].cid,
`${checkPost.label}`,
`${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
checkPost.duration,
+
post[0].did,
);
}
···
}
if (checkPost.commentAcct === true) {
-
logger.info(
-
{
-
process: "CHECKPOSTS",
-
label: checkPost.label,
-
did: post[0].did,
-
atURI: post[0].atURI,
-
},
-
"Commenting on account",
-
);
createAccountComment(
post[0].did,
`${post[0].time}: ${checkPost.comment} at ${post[0].atURI} with text "${post[0].text}"`,
+
post[0].atURI,
);
}
}
+6 -32
src/rules/posts/tests/checkPosts.test.ts
···
await checkPosts(post);
-
expect(logger.info).toHaveBeenCalledWith(
-
{
-
process: "CHECKPOSTS",
-
label: "test-label",
-
did: post[0].did,
-
atURI: post[0].atURI,
-
},
-
"Labeling post",
-
);
expect(createPostLabel).toHaveBeenCalledWith(
post[0].atURI,
post[0].cid,
"test-label",
expect.stringContaining("Test comment"),
undefined,
+
post[0].did,
);
});
···
"language-specific",
expect.any(String),
undefined,
+
post[0].did,
);
});
···
"whitelisted-test",
expect.any(String),
undefined,
+
post[0].did,
);
});
});
···
"ignored-did",
expect.any(String),
undefined,
+
"did:plc:notignored",
);
});
});
···
"all-actions",
expect.any(String),
undefined,
+
post[0].did,
);
expect(createPostReport).toHaveBeenCalledWith(
post[0].atURI,
···
expect(createAccountComment).toHaveBeenCalledWith(
post[0].did,
expect.any(String),
-
);
-
});
-
-
it("should log all moderation actions", async () => {
-
const post = createMockPost({ text: "report this" });
-
-
await checkPosts(post);
-
-
expect(logger.info).toHaveBeenCalledWith(
-
expect.objectContaining({ label: "all-actions" }),
-
"Labeling post",
-
);
-
expect(logger.info).toHaveBeenCalledWith(
-
expect.objectContaining({ label: "all-actions" }),
-
"Reporting post",
-
);
-
expect(logger.info).toHaveBeenCalledWith(
-
expect.objectContaining({ label: "all-actions" }),
-
"Reporting account",
-
);
-
expect(logger.info).toHaveBeenCalledWith(
-
expect.objectContaining({ label: "all-actions" }),
-
"Commenting on account",
+
expect.any(String),
);
});
});
+2 -44
src/rules/profiles/checkProfiles.ts
···
`${checkProfiles.label}`,
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
);
-
logger.info(
-
{
-
process: "CHECKDESCRIPTION",
-
did,
-
time,
-
displayName,
-
description,
-
label: checkProfiles.label,
-
},
-
"Labeling account",
-
);
}
if (checkProfiles.reportAcct === true) {
···
createAccountComment(
did,
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
-
);
-
logger.info(
-
{
-
process: "CHECKDESCRIPTION",
-
did,
-
time,
-
displayName,
-
description,
-
label: checkProfiles.label,
-
},
-
"Commenting on account",
+
`profile:${did}:${time}`,
);
}
}
···
`${checkProfiles.label}`,
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
);
-
logger.info(
-
{
-
process: "CHECKDISPLAYNAME",
-
did,
-
time,
-
displayName,
-
description,
-
label: checkProfiles.label,
-
},
-
"Labeling account",
-
);
}
if (checkProfiles.reportAcct === true) {
···
createAccountComment(
did,
`${time}: ${checkProfiles.comment} - ${displayName} - ${description}`,
-
);
-
logger.info(
-
{
-
process: "CHECKDISPLAYNAME",
-
did,
-
time,
-
displayName,
-
description,
-
label: checkProfiles.label,
-
},
-
"Commenting on account",
+
`profile:${did}:${time}`,
);
}
}
+1 -43
src/rules/profiles/tests/checkProfiles.test.ts
···
"This is spam content",
);
-
expect(logger.info).toHaveBeenCalledWith(
-
{
-
process: "CHECKDESCRIPTION",
-
did: mockDid,
-
time: mockTime,
-
displayName: mockDisplayName,
-
description: "This is spam content",
-
label: "test-description",
-
},
-
"Labeling account",
-
);
expect(createAccountLabel).toHaveBeenCalledWith(
mockDid,
"test-description",
···
expect(createAccountComment).toHaveBeenCalledWith(
mockDid,
expect.any(String),
+
expect.any(String),
);
});
-
it("should log all moderation actions", async () => {
-
await checkDescription(
-
mockDid,
-
mockTime,
-
mockDisplayName,
-
"report this",
-
);
-
-
expect(logger.info).toHaveBeenCalledWith(
-
expect.objectContaining({ label: "all-actions" }),
-
"Labeling account",
-
);
-
expect(logger.info).toHaveBeenCalledWith(
-
expect.objectContaining({ label: "all-actions" }),
-
"Reporting account",
-
);
-
expect(logger.info).toHaveBeenCalledWith(
-
expect.objectContaining({ label: "all-actions" }),
-
"Commenting on account",
-
);
-
});
});
});
···
mockDescription,
);
-
expect(logger.info).toHaveBeenCalledWith(
-
{
-
process: "CHECKDISPLAYNAME",
-
did: mockDid,
-
time: mockTime,
-
displayName: "fake account",
-
description: mockDescription,
-
label: "test-displayname",
-
},
-
"Labeling account",
-
);
expect(createAccountLabel).toHaveBeenCalledWith(
mockDid,
"test-displayname",
+67 -93
src/tests/moderation.test.ts
···
import { beforeEach, describe, expect, it, vi } from "vitest";
-
import { agent } from "../agent.js";
-
import { logger } from "../logger.js";
-
import { checkAccountLabels } from "../moderation.js";
-
// Mock dependencies
+
// --- Mocks First ---
+
vi.mock("../agent.js", () => ({
agent: {
tools: {
ozone: {
moderation: {
getRepo: vi.fn(),
+
getRecord: vi.fn(),
+
emitEvent: vi.fn(),
},
},
},
···
isLoggedIn: Promise.resolve(true),
}));
+
vi.mock("../redis.js", () => ({
+
tryClaimPostLabel: vi.fn(),
+
tryClaimAccountLabel: vi.fn(),
+
}));
+
vi.mock("../logger.js", () => ({
logger: {
info: vi.fn(),
···
limit: vi.fn((fn) => fn()),
}));
-
describe("checkAccountLabels", () => {
+
// --- Imports Second ---
+
+
import { agent } from "../agent.js";
+
import { checkAccountLabels, createPostLabel } from "../moderation.js";
+
import { tryClaimPostLabel } from "../redis.js";
+
import { logger } from "../logger.js";
+
+
describe("Moderation Logic", () => {
beforeEach(() => {
vi.clearAllMocks();
});
-
it("should return true if label exists on account", async () => {
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
-
data: {
-
labels: [
-
{ val: "spam" },
-
{ val: "harassment" },
-
{ val: "window-reply" },
-
],
-
},
-
});
-
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
-
-
expect(result).toBe(true);
-
expect(agent.tools.ozone.moderation.getRepo).toHaveBeenCalledWith(
-
{ did: "did:plc:test123" },
-
{
-
headers: {
-
"atproto-proxy": "did:plc:moderator123#atproto_labeler",
-
"atproto-accept-labelers": "did:plc:ar7c4by46qjdydhdevvrndac;redact",
+
describe("checkAccountLabels", () => {
+
it("should return true if label exists on account", async () => {
+
vi.mocked(agent.tools.ozone.moderation.getRepo).mockResolvedValueOnce({
+
data: {
+
labels: [
+
{ val: "spam", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" },
+
{ val: "window-reply", src: "did:plc:test", uri: "at://test", cts: "2024-01-01T00:00:00Z" }
+
]
},
-
},
-
);
-
});
-
-
it("should return false if label does not exist on account", async () => {
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
-
data: {
-
labels: [{ val: "spam" }, { val: "harassment" }],
-
},
+
} as any);
+
const result = await checkAccountLabels("did:plc:test123", "window-reply");
+
expect(result).toBe(true);
});
-
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
-
-
expect(result).toBe(false);
});
-
it("should return false if account has no labels", async () => {
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
-
data: {
-
labels: [],
-
},
-
});
+
describe("createPostLabel with Caching", () => {
+
const URI = "at://did:plc:test/app.bsky.feed.post/123";
+
const CID = "bafybeig6xv5nwph5j7grrlp3pdeolqptpep5nfljmdkmtcf2l4wisa2mfa";
+
const LABEL = "test-label";
+
const COMMENT = "test comment";
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
+
it("should skip if claim fails (already claimed)", async () => {
+
vi.mocked(tryClaimPostLabel).mockResolvedValue(false);
-
expect(result).toBe(false);
-
});
+
await createPostLabel(URI, CID, LABEL, COMMENT, undefined);
-
it("should return false if labels property is undefined", async () => {
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
-
data: {},
+
expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL);
+
expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).not.toHaveBeenCalled();
+
expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled();
});
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
-
-
expect(result).toBe(false);
-
});
-
-
it("should handle API errors gracefully", async () => {
-
(agent.tools.ozone.moderation.getRepo as any).mockRejectedValueOnce(
-
new Error("API Error"),
-
);
-
-
const result = await checkAccountLabels("did:plc:test123", "window-reply");
+
it("should skip event if claimed but already labeled via API", async () => {
+
vi.mocked(tryClaimPostLabel).mockResolvedValue(true);
+
vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({
+
data: { labels: [{ val: LABEL, src: "did:plc:test", uri: URI, cts: "2024-01-01T00:00:00Z" }] },
+
} as any);
-
expect(result).toBe(false);
-
expect(logger.error).toHaveBeenCalledWith(
-
{
-
process: "MODERATION",
-
did: "did:plc:test123",
-
error: expect.any(Error),
-
},
-
"Failed to check account labels",
-
);
-
});
+
await createPostLabel(URI, CID, LABEL, COMMENT, undefined);
-
it("should perform case-sensitive label matching", async () => {
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
-
data: {
-
labels: [{ val: "window-reply" }],
-
},
+
expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL);
+
expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith(
+
{ uri: URI },
+
expect.any(Object),
+
);
+
expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).not.toHaveBeenCalled();
});
-
const resultLower = await checkAccountLabels(
-
"did:plc:test123",
-
"window-reply",
-
);
-
expect(resultLower).toBe(true);
-
-
(agent.tools.ozone.moderation.getRepo as any).mockResolvedValueOnce({
-
data: {
-
labels: [{ val: "window-reply" }],
-
},
+
it("should emit event if claimed and not labeled anywhere", async () => {
+
vi.mocked(tryClaimPostLabel).mockResolvedValue(true);
+
vi.mocked(agent.tools.ozone.moderation.getRecord).mockResolvedValue({
+
data: { labels: [] },
+
} as any);
+
vi.mocked(agent.tools.ozone.moderation.emitEvent).mockResolvedValue({ success: true } as any);
+
+
await createPostLabel(URI, CID, LABEL, COMMENT, undefined);
+
+
expect(vi.mocked(tryClaimPostLabel)).toHaveBeenCalledWith(URI, LABEL);
+
expect(vi.mocked(agent.tools.ozone.moderation.getRecord)).toHaveBeenCalledWith(
+
{ uri: URI },
+
expect.any(Object),
+
);
+
expect(vi.mocked(agent.tools.ozone.moderation.emitEvent)).toHaveBeenCalled();
});
-
-
const resultUpper = await checkAccountLabels(
-
"did:plc:test123",
-
"Window-Reply",
-
);
-
expect(resultUpper).toBe(false);
});
-
});
+
});
+106
src/tests/redis.test.ts
···
+
import { afterEach, describe, expect, it, vi } from 'vitest';
+
+
// Mock the 'redis' module in a way that avoids hoisting issues.
+
// The mock implementation is self-contained.
+
vi.mock('redis', () => {
+
const mockClient = {
+
on: vi.fn(),
+
connect: vi.fn(),
+
quit: vi.fn(),
+
exists: vi.fn(),
+
set: vi.fn(),
+
};
+
return {
+
createClient: vi.fn(() => mockClient),
+
};
+
});
+
+
// Import the mocked redis first to get a reference to the mock client
+
import { createClient } from 'redis';
+
const mockRedisClient = createClient();
+
+
// Import the modules to be tested
+
import {
+
tryClaimPostLabel,
+
tryClaimAccountLabel,
+
connectRedis,
+
disconnectRedis,
+
} from '../redis.js';
+
import { logger } from '../logger.js';
+
+
// Suppress logger output during tests
+
vi.mock('../logger.js', () => ({
+
logger: {
+
info: vi.fn(),
+
warn: vi.fn(),
+
error: vi.fn(),
+
debug: vi.fn(),
+
},
+
}));
+
+
describe('Redis Cache Logic', () => {
+
afterEach(() => {
+
vi.clearAllMocks();
+
});
+
+
describe('Connection', () => {
+
it('should call redisClient.connect on connectRedis', async () => {
+
await connectRedis();
+
expect(mockRedisClient.connect).toHaveBeenCalled();
+
});
+
+
it('should call redisClient.quit on disconnectRedis', async () => {
+
await disconnectRedis();
+
expect(mockRedisClient.quit).toHaveBeenCalled();
+
});
+
});
+
+
describe('tryClaimPostLabel', () => {
+
it('should return true and set key if key does not exist', async () => {
+
vi.mocked(mockRedisClient.set).mockResolvedValue('OK');
+
const result = await tryClaimPostLabel('at://uri', 'test-label');
+
expect(result).toBe(true);
+
expect(mockRedisClient.set).toHaveBeenCalledWith(
+
'post-label:at://uri:test-label',
+
'1',
+
{ NX: true, EX: 60 * 60 * 24 * 7 }
+
);
+
});
+
+
it('should return false if key already exists', async () => {
+
vi.mocked(mockRedisClient.set).mockResolvedValue(null);
+
const result = await tryClaimPostLabel('at://uri', 'test-label');
+
expect(result).toBe(false);
+
});
+
+
it('should return true and log warning on Redis error', async () => {
+
const redisError = new Error('Redis down');
+
vi.mocked(mockRedisClient.set).mockRejectedValue(redisError);
+
const result = await tryClaimPostLabel('at://uri', 'test-label');
+
expect(result).toBe(true);
+
expect(logger.warn).toHaveBeenCalledWith(
+
{ err: redisError, atURI: 'at://uri', label: 'test-label' },
+
'Error claiming post label in Redis, allowing through'
+
);
+
});
+
});
+
+
describe('tryClaimAccountLabel', () => {
+
it('should return true and set key if key does not exist', async () => {
+
vi.mocked(mockRedisClient.set).mockResolvedValue('OK');
+
const result = await tryClaimAccountLabel('did:plc:123', 'test-label');
+
expect(result).toBe(true);
+
expect(mockRedisClient.set).toHaveBeenCalledWith(
+
'account-label:did:plc:123:test-label',
+
'1',
+
{ NX: true, EX: 60 * 60 * 24 * 7 }
+
);
+
});
+
+
it('should return false if key already exists', async () => {
+
vi.mocked(mockRedisClient.set).mockResolvedValue(null);
+
const result = await tryClaimAccountLabel('did:plc:123', 'test-label');
+
expect(result).toBe(false);
+
});
+
});
+
});