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

Resolved merge conflict

Skywatch d2ec70a2 78e42d35

Changed files
+184 -178
.claude
src
+1 -3
.claude/settings.local.json
··· 18 18 "ask": [] 19 19 }, 20 20 "enableAllProjectMcpServers": true, 21 - "enabledMcpjsonServers": [ 22 - "git-mcp-server" 23 - ] 21 + "enabledMcpjsonServers": ["git-mcp-server"] 24 22 }
-89
src/rules/handles/constants.example.ts
··· 1 - import type { Checks } from "../../types.js"; 2 - 3 - /** 4 - * Example handle check configurations 5 - * 6 - * This file demonstrates how to configure handle-based moderation rules. 7 - * Copy this file to constants.ts and customize for your labeler's needs. 8 - * 9 - * Each check can match against handles, display names, and/or descriptions 10 - * based on the flags you set (description: true, displayName: true). 11 - */ 12 - 13 - export const HANDLE_CHECKS: Checks[] = [ 14 - // Example 1: Simple pattern matching with whitelist 15 - { 16 - label: "spam-indicator", 17 - comment: "Handle matches common spam patterns", 18 - reportAcct: false, 19 - commentAcct: false, 20 - toLabel: true, 21 - check: new RegExp( 22 - "follow.*?back|gain.*?followers|crypto.*?giveaway|free.*?money", 23 - "i", 24 - ), 25 - whitelist: new RegExp("legitimate.*?business", "i"), 26 - }, 27 - 28 - // Example 2: Check specific domain patterns 29 - { 30 - label: "suspicious-domain", 31 - comment: "Handle uses suspicious domain pattern", 32 - reportAcct: false, 33 - commentAcct: false, 34 - toLabel: true, 35 - check: new RegExp("(?:suspicious-site\\.example)", "i"), 36 - }, 37 - 38 - // Example 3: Check with display name and description matching 39 - { 40 - label: "potential-impersonator", 41 - comment: "Account may be impersonating verified entities", 42 - description: true, 43 - displayName: true, 44 - reportAcct: false, 45 - commentAcct: false, 46 - toLabel: true, 47 - check: new RegExp( 48 - "official.*?support|customer.*?service.*?rep|verified.*?account", 49 - "i", 50 - ), 51 - // Exclude accounts that are actually legitimate 52 - ignoredDIDs: [ 53 - "did:plc:example123", // Real customer support account 54 - "did:plc:example456", // Verified business account 55 - ], 56 - }, 57 - 58 - // Example 4: Pattern with specific character variations 59 - { 60 - label: "suspicious-pattern", 61 - comment: "Handle contains suspicious character patterns", 62 - reportAcct: false, 63 - commentAcct: false, 64 - toLabel: true, 65 - check: new RegExp("[a-z]{2,}[0-9]{6,}|random.*?numbers.*?[0-9]{4,}", "i"), 66 - whitelist: new RegExp("year[0-9]{4}", "i"), 67 - ignoredDIDs: [ 68 - "did:plc:example789", // Legitimate account with number pattern 69 - ], 70 - }, 71 - 72 - // Example 5: Brand protection 73 - { 74 - label: "brand-impersonation", 75 - comment: "Potential brand impersonation detected", 76 - reportAcct: false, 77 - commentAcct: false, 78 - toLabel: true, 79 - check: new RegExp("example-?brand|cool-?company|awesome-?corp", "i"), 80 - whitelist: new RegExp( 81 - "anti-example-brand|not-cool-company|parody.*awesome-corp", 82 - "i", 83 - ), 84 - ignoredDIDs: [ 85 - "did:plc:exampleabc", // Official brand account 86 - "did:plc:exampledef", // Authorized partner 87 - ], 88 - }, 89 - ];
-31
src/rules/posts/constants.example.ts
··· 1 - import type { Checks } from "../../types.js"; 2 - 3 - export const LINK_SHORTENER = /bit\.ly|tinyurl\.com|ow\.ly/i; 4 - 5 - export const POST_CHECKS: Checks[] = [ 6 - // Example 1: Spam detection 7 - { 8 - label: "spam", 9 - comment: "Post contains spam indicators", 10 - reportPost: true, 11 - reportAcct: false, 12 - commentAcct: false, 13 - toLabel: true, 14 - check: new RegExp( 15 - "click.*?here|limited.*?time.*?offer|act.*?now|100%.*?free", 16 - "i", 17 - ), 18 - whitelist: new RegExp("legitimate.*?offer", "i"), 19 - }, 20 - 21 - // Example 2: Promotional content 22 - { 23 - label: "promotional", 24 - comment: "Promotional content detected", 25 - reportPost: false, 26 - reportAcct: false, 27 - commentAcct: false, 28 - toLabel: true, 29 - check: new RegExp("buy.*?now|discount.*?code|promo.*?link", "i"), 30 - }, 31 - ];
-55
src/rules/profiles/constants.example.ts
··· 1 - import type { Checks } from "../../types.js"; 2 - 3 - /** 4 - * Example profile check configurations 5 - * 6 - * This file demonstrates how to configure profile moderation rules. 7 - * Copy this file to constants.ts and customize for your labeler's needs. 8 - * 9 - * Profile checks can match against display names and/or descriptions. 10 - */ 11 - 12 - export const PROFILE_CHECKS: Checks[] = [ 13 - // Example 1: Suspicious bio patterns 14 - { 15 - label: "suspicious-bio", 16 - comment: "Profile contains suspicious patterns", 17 - description: true, 18 - displayName: false, 19 - reportAcct: false, 20 - commentAcct: false, 21 - toLabel: true, 22 - check: new RegExp( 23 - "dm.*?for.*?promo|follow.*?for.*?follow|gain.*?followers", 24 - "i", 25 - ), 26 - }, 27 - 28 - // Example 2: Display name checks 29 - { 30 - label: "impersonation-risk", 31 - comment: "Display name may indicate impersonation", 32 - description: false, 33 - displayName: true, 34 - reportAcct: false, 35 - commentAcct: false, 36 - toLabel: true, 37 - check: new RegExp("official|verified|admin|support", "i"), 38 - whitelist: new RegExp("unofficial|parody|fan", "i"), 39 - ignoredDIDs: [ 40 - "did:plc:example123", // Actual official account 41 - ], 42 - }, 43 - 44 - // Example 3: Both display name and description 45 - { 46 - label: "crypto-spam", 47 - comment: "Profile suggests crypto spam activity", 48 - description: true, 49 - displayName: true, 50 - reportAcct: false, 51 - commentAcct: false, 52 - toLabel: true, 53 - check: new RegExp("crypto.*?giveaway|nft.*?drop|airdrop", "i"), 54 - }, 55 - ];
+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 + });