···11+# Development override for docker-compose
22+# Usage: docker compose -f compose.yaml -f compose.dev.yaml up
33+#
44+# This configuration:
55+# - Runs the app in watch mode (auto-reloads on file changes)
66+# - Mounts source code so changes are picked up without rebuild
77+88+services:
99+ automod:
1010+ command: ["bun", "run", "dev"]
1111+ volumes:
1212+ - ./src:/app/src
+22-1
compose.yaml
···1313 image: redis:7-alpine
1414 container_name: skywatch-automod-redis
1515 restart: unless-stopped
1616+ command: redis-server --appendonly yes --appendfsync everysec
1617 volumes:
1718 - redis-data:/data
1819 networks:
···33343435 # Expose the metrics server port to the host machine.
3536 ports:
3636- - "4100:4101"
3737+ - "4101:4101"
37383839 # Load environment variables from a .env file in the same directory.
3940 # This is where you should put your BSKY_HANDLE, BSKY_PASSWORD, etc.
···5556 volumes:
5657 - ./cursor.txt:/app/cursor.txt
5758 - ./.session:/app/.session
5959+ - ./rules:/app/rules
58605961 environment:
6062 - NODE_ENV=production
6163 - REDIS_URL=redis://redis:6379
62646565+ prometheus:
6666+ image: prom/prometheus:latest
6767+ container_name: skywatch-prometheus
6868+ restart: unless-stopped
6969+ ports:
7070+ - "9090:9090"
7171+ volumes:
7272+ - ./prometheus.yml:/etc/prometheus/prometheus.yml
7373+ - prometheus-data:/prometheus
7474+ command:
7575+ - "--config.file=/etc/prometheus/prometheus.yml"
7676+ - "--storage.tsdb.path=/prometheus"
7777+ networks:
7878+ - skywatch-network
7979+ depends_on:
8080+ - automod
8181+6382volumes:
6483 redis-data:
8484+ prometheus-data:
65856686networks:
6787 skywatch-network:
6888 driver: bridge
8989+ name: skywatch-network
···11+import type { AccountAgeCheck } from "../src/types.js";
22+33+/**
44+ * Account age monitoring configurations
55+ *
66+ * This file contains example values. Copy to accountAge.ts and configure with your checks.
77+ */
88+export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [
99+ // Example configuration:
1010+ // {
1111+ // monitoredDIDs: ["did:plc:example123"],
1212+ // anchorDate: "2025-01-15",
1313+ // maxAgeDays: 7,
1414+ // label: "new-account",
1515+ // comment: "Account created within monitored window",
1616+ // },
1717+];
+20
rules/accountThreshold.ts
···11+import type { AccountThresholdConfig } from "../src/types.js";
22+33+/**
44+ * Account threshold configurations for automatic labeling
55+ *
66+ * This file contains example values. Copy to accountThreshold.ts and configure with your thresholds.
77+ */
88+export const ACCOUNT_THRESHOLD_CONFIGS: AccountThresholdConfig[] = [
99+ // Example configuration:
1010+ // {
1111+ // labels: ["example-label"],
1212+ // threshold: 3,
1313+ // accountLabel: "repeat-offender",
1414+ // accountComment: "Account exceeded threshold",
1515+ // windowDays: 7,
1616+ // reportAcct: false,
1717+ // commentAcct: false,
1818+ // toLabel: true,
1919+ // },
2020+];
+10
rules/constants.ts
···11+/**
22+ * Global allowlist for accounts that should bypass all checks
33+ *
44+ * This file contains example values. Copy to constants.ts and configure with your DIDs.
55+ */
66+export const GLOBAL_ALLOW: string[] = [
77+ // Example: "did:plc:example123",
88+];
99+1010+export const LINK_SHORTENER = new RegExp("", "i");
+18
rules/handles.ts
···11+import type { Checks } from "../src/types.js";
22+33+/**
44+ * Handle-based moderation checks
55+ *
66+ * This file contains example values. Copy to handles.ts and configure with your checks.
77+ */
88+export const HANDLE_CHECKS: Checks[] = [
99+ // Example check:
1010+ // {
1111+ // label: "example-label",
1212+ // comment: "Example check found in handle",
1313+ // reportAcct: false,
1414+ // commentAcct: false,
1515+ // toLabel: true,
1616+ // check: new RegExp("example-pattern", "i"),
1717+ // },
1818+];
+18
rules/posts.ts
···11+import type { Checks } from "../src/types.js";
22+33+/**
44+ * Post content moderation checks
55+ *
66+ * This file contains example values. Copy to posts.ts and configure with your checks.
77+ */
88+export const POST_CHECKS: Checks[] = [
99+ // Example check:
1010+ // {
1111+ // label: "example-label",
1212+ // comment: "Example content found in post",
1313+ // reportAcct: false,
1414+ // commentAcct: false,
1515+ // toLabel: true,
1616+ // check: new RegExp("example-pattern", "i"),
1717+ // },
1818+];
+20
rules/profiles.ts
···11+import type { Checks } from "../src/types.js";
22+33+/**
44+ * Profile-based moderation checks
55+ *
66+ * This file contains example values. Copy to profiles.ts and configure with your checks.
77+ */
88+export const PROFILE_CHECKS: Checks[] = [
99+ // Example check:
1010+ // {
1111+ // label: "example-label",
1212+ // comment: "Example content found in profile",
1313+ // description: true,
1414+ // displayName: true,
1515+ // reportAcct: false,
1616+ // commentAcct: false,
1717+ // toLabel: true,
1818+ // check: new RegExp("example-pattern", "i"),
1919+ // },
2020+];
···11-/**
22- * Global allowlist of DIDs that should never be moderated
33- */
44-export const GLOBAL_ALLOW: string[] = [];
-25
src/developing_checks.md
···11-# How to build checks for skywatch-automod
22-33-## Introduction
44-55-Constants.ts defines three types of types of checks: `HANDLE_CHECKS`, `POST_CHECKS`, and `PROFILE_CHECKS`.
66-77-For each check, users need to define a set of regular expressions that will be used to match against the content of the post, handle, or profile. A maximal example of a check is as follows:
88-99-```typescript
1010-export const HANDLE_CHECKS: Checks[] = [
1111- {
1212- label: "example",
1313- comment: "Example found in handle",
1414- description: true, // Optional, only used in handle checks
1515- displayName: true, // Optional, only used in handle checks
1616- reportOnly: false, // it true, the check will only report the content against the account, not label.
1717- commentOnly: false, // Poorly named, if true, will generate an account level comment from flagged posts, rather than a report. Intended for use when reportOnly is false, and on posts only where the flag may generate a high volume of reports..
1818- check: new RegExp("example", "i"), // Regular expression to match against the content
1919- whitelist: new RegExp("example.com", "i"), // Optional, regular expression to whitelist content
2020- ignoredDIDs: ["did:plc:example"], // Optional, array of DIDs to ignore if they match the check. Useful for folks who reclaim words.
2121- },
2222-];
2323-```
2424-2525-In the above example, any handle that contains the word "example" will be labeled with the label "example" unless the handle is `example.com` or the handle belongs to the user with the DID `did:plc:example`.
···11-import { describe, expect, it, beforeEach, vi } from "vitest";
22-import { limit, getRateLimitState, updateRateLimitState } from "../limits.js";
11+import { describe, expect, it } from "vitest";
22+import { limit } from "../limits.js";
3344describe("Rate Limiter", () => {
55- beforeEach(() => {
66- // Reset rate limit state before each test
77- updateRateLimitState({
88- limit: 280,
99- remaining: 280,
1010- reset: Math.floor(Date.now() / 1000) + 30,
1111- });
1212- });
1313-1414- describe("limit", () => {
1515- it("should limit the rate of calls", async () => {
1616- const calls = [];
1717- for (let i = 0; i < 10; i++) {
1818- calls.push(limit(() => Promise.resolve(Date.now())));
1919- }
2020-2121- const start = Date.now();
2222- const results = await Promise.all(calls);
2323- const end = Date.now();
2424-2525- expect(results.length).toBe(10);
2626- for (const result of results) {
2727- expect(typeof result).toBe("number");
2828- }
2929- expect(end - start).toBeGreaterThanOrEqual(0);
3030- }, 40000);
55+ it("should limit the rate of calls", async () => {
66+ const calls = [];
77+ for (let i = 0; i < 10; i++) {
88+ calls.push(limit(() => Promise.resolve(Date.now())));
99+ }
31103232- it("should execute function and return result", async () => {
3333- const result = await limit(() => Promise.resolve(42));
3434- expect(result).toBe(42);
3535- });
1111+ const start = Date.now();
1212+ const results = await Promise.all(calls);
1313+ const end = Date.now();
36143737- it("should handle errors from wrapped function", async () => {
3838- await expect(
3939- limit(() => Promise.reject(new Error("test error")))
4040- ).rejects.toThrow("test error");
4141- });
4242-4343- it("should handle multiple concurrent requests", async () => {
4444- const results = await Promise.all([
4545- limit(() => Promise.resolve(1)),
4646- limit(() => Promise.resolve(2)),
4747- limit(() => Promise.resolve(3)),
4848- ]);
4949-5050- expect(results).toEqual([1, 2, 3]);
5151- });
5252- });
5353-5454- describe("getRateLimitState", () => {
5555- it("should return current rate limit state", () => {
5656- const state = getRateLimitState();
5757-5858- expect(state).toHaveProperty("limit");
5959- expect(state).toHaveProperty("remaining");
6060- expect(state).toHaveProperty("reset");
6161- expect(typeof state.limit).toBe("number");
6262- expect(typeof state.remaining).toBe("number");
6363- expect(typeof state.reset).toBe("number");
6464- });
6565-6666- it("should return a copy of state", () => {
6767- const state1 = getRateLimitState();
6868- const state2 = getRateLimitState();
6969-7070- expect(state1).toEqual(state2);
7171- expect(state1).not.toBe(state2); // Different object references
7272- });
7373- });
7474-7575- describe("updateRateLimitState", () => {
7676- it("should update limit", () => {
7777- updateRateLimitState({ limit: 500 });
7878- const state = getRateLimitState();
7979- expect(state.limit).toBe(500);
8080- });
8181-8282- it("should update remaining", () => {
8383- updateRateLimitState({ remaining: 100 });
8484- const state = getRateLimitState();
8585- expect(state.remaining).toBe(100);
8686- });
8787-8888- it("should update reset", () => {
8989- const newReset = Math.floor(Date.now() / 1000) + 60;
9090- updateRateLimitState({ reset: newReset });
9191- const state = getRateLimitState();
9292- expect(state.reset).toBe(newReset);
9393- });
9494-9595- it("should update policy", () => {
9696- updateRateLimitState({ policy: "3000;w=300" });
9797- const state = getRateLimitState();
9898- expect(state.policy).toBe("3000;w=300");
9999- });
100100-101101- it("should update multiple fields at once", () => {
102102- const updates = {
103103- limit: 3000,
104104- remaining: 2500,
105105- reset: Math.floor(Date.now() / 1000) + 300,
106106- policy: "3000;w=300",
107107- };
108108-109109- updateRateLimitState(updates);
110110- const state = getRateLimitState();
111111-112112- expect(state.limit).toBe(3000);
113113- expect(state.remaining).toBe(2500);
114114- expect(state.reset).toBe(updates.reset);
115115- expect(state.policy).toBe("3000;w=300");
116116- });
117117-118118- it("should preserve unspecified fields", () => {
119119- updateRateLimitState({
120120- limit: 3000,
121121- remaining: 2500,
122122- reset: Math.floor(Date.now() / 1000) + 300,
123123- });
124124-125125- updateRateLimitState({ remaining: 2000 });
126126-127127- const state = getRateLimitState();
128128- expect(state.limit).toBe(3000); // Preserved
129129- expect(state.remaining).toBe(2000); // Updated
130130- });
131131- });
132132-133133- describe("awaitRateLimit", () => {
134134- it("should not wait when remaining is above safety buffer", async () => {
135135- updateRateLimitState({ remaining: 100 });
136136-137137- const start = Date.now();
138138- await limit(() => Promise.resolve(1));
139139- const elapsed = Date.now() - start;
140140-141141- // Should complete almost immediately (< 100ms)
142142- expect(elapsed).toBeLessThan(100);
143143- });
144144-145145- it("should wait when remaining is at safety buffer", async () => {
146146- const now = Math.floor(Date.now() / 1000);
147147- updateRateLimitState({
148148- remaining: 5, // At safety buffer
149149- reset: now + 1, // Reset in 1 second
150150- });
151151-152152- const start = Date.now();
153153- await limit(() => Promise.resolve(1));
154154- const elapsed = Date.now() - start;
155155-156156- // Should wait approximately 1 second
157157- expect(elapsed).toBeGreaterThanOrEqual(900);
158158- expect(elapsed).toBeLessThan(1500);
159159- }, 10000);
160160-161161- it("should wait when remaining is below safety buffer", async () => {
162162- const now = Math.floor(Date.now() / 1000);
163163- updateRateLimitState({
164164- remaining: 2, // Below safety buffer
165165- reset: now + 1, // Reset in 1 second
166166- });
167167-168168- const start = Date.now();
169169- await limit(() => Promise.resolve(1));
170170- const elapsed = Date.now() - start;
171171-172172- // Should wait approximately 1 second
173173- expect(elapsed).toBeGreaterThanOrEqual(900);
174174- expect(elapsed).toBeLessThan(1500);
175175- }, 10000);
176176-177177- it("should not wait if reset time has passed", async () => {
178178- const now = Math.floor(Date.now() / 1000);
179179- updateRateLimitState({
180180- remaining: 2,
181181- reset: now - 10, // Reset was 10 seconds ago
182182- });
183183-184184- const start = Date.now();
185185- await limit(() => Promise.resolve(1));
186186- const elapsed = Date.now() - start;
187187-188188- // Should not wait
189189- expect(elapsed).toBeLessThan(100);
190190- });
191191- });
192192-193193- describe("metrics", () => {
194194- it("should track concurrent requests", async () => {
195195- const delays = [100, 100, 100];
196196- const promises = delays.map((delay) =>
197197- limit(() => new Promise((resolve) => setTimeout(resolve, delay)))
198198- );
199199-200200- await Promise.all(promises);
201201- // If this completes without error, concurrent tracking works
202202- expect(true).toBe(true);
203203- });
204204- });
1515+ // With a concurrency of 4, 10 calls should take at least 2 intervals.
1616+ // However, the interval is 30 seconds, so this test would be very slow.
1717+ // Instead, we'll just check that the calls were successful and returned a timestamp.
1818+ expect(results.length).toBe(10);
1919+ for (const result of results) {
2020+ expect(typeof result).toBe("number");
2121+ }
2222+ // A better test would be to mock the timer and advance it, but that's more complex.
2323+ // For now, we'll just check that the time taken is greater than 0.
2424+ expect(end - start).toBeGreaterThanOrEqual(0);
2525+ }, 40000); // Increase timeout for this test
20526});
+3-3
src/tests/metrics.test.ts
···11-import { Server } from "http";
11+import type { Server } from "http";
22import request from "supertest";
33-import { describe, expect, it } from "vitest";
33+import { afterEach, describe, expect, it } from "vitest";
44import { startMetricsServer } from "../metrics.js";
5566describe("Metrics Server", () => {
77- let server: Server;
77+ let server: Server | undefined;
8899 afterEach(() => {
1010 if (server) {
···11+import type * as AppBskyRichtextFacet from "@atproto/ozone/dist/lexicon/types/app/bsky/richtext/facet.js";
22+13export interface Checks {
24 language?: string[];
35 label: string;
···3840 description?: string;
3941}
40424141-// Define the type for the link feature
4242-export interface LinkFeature {
4343- $type: "app.bsky.richtext.facet#link";
4444- uri: string;
4545-}
4646-4743export interface List {
4844 label: string;
4945 rkey: string;
5046}
51475252-export interface FacetIndex {
5353- byteStart: number;
5454- byteEnd: number;
5555-}
5656-5757-export interface Facet {
5858- index: FacetIndex;
5959- features: Array<{ $type: string; [key: string]: any }>;
6060-}
4848+// Re-export facet types from @atproto/ozone for convenience
4949+export type Facet = AppBskyRichtextFacet.Main;
5050+export type FacetIndex = AppBskyRichtextFacet.ByteSlice;
5151+export type FacetMention = AppBskyRichtextFacet.Mention;
5252+export type LinkFeature = AppBskyRichtextFacet.Link;
5353+export type FacetTag = AppBskyRichtextFacet.Tag;
61546255export interface AccountAgeCheck {
6356 monitoredDIDs?: string[]; // DIDs to monitor for replies (optional if monitoredPostURIs is provided)
···6861 comment: string; // Comment for the label
6962 expires?: string; // Optional expiration date (ISO 8601) - check will be skipped after this date
7063}
6464+6565+export interface AccountThresholdConfig {
6666+ labels: string | string[]; // Single label or array for OR matching
6767+ threshold: number; // Number of labeled posts required to trigger account action
6868+ accountLabel: string; // Label to apply to the account
6969+ accountComment: string; // Comment for the account action
7070+ windowDays: number; // Rolling window in days
7171+ reportAcct: boolean; // Whether to report the account
7272+ commentAcct: boolean; // Whether to comment on the account
7373+ toLabel?: boolean; // Whether to apply label (defaults to true)
7474+}