commit c552956516a119432e08a47b27bc1cc873c9d96e Author: Jamie Brunton Date: Sun Jan 4 19:16:31 2026 +0000 Initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f67f5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment variables +.env +.env.local + +# Data directory (contains session and database) +data/ + +# Logs +logs/ +*.log + +# OS files +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# TypeScript +*.tsbuildinfo diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..9c4320a --- /dev/null +++ b/config.yaml @@ -0,0 +1,78 @@ +reddit: + subreddits: + - normalnudes + - ratemyboobs + pollIntervalMs: 120000 + userAgent: "RedditBot/1.0 (by /u/yourusername)" + targetGenders: + - female # Options: male, female, other, unknown + +openrouter: + model: "google/gemini-3-flash-preview" + imageAnalysisPrompt: | + Analyze this NSFW image in explicit detail for adult content messaging. Describe: + - Body: physique, curves, skin tone, any tattoos/piercings, notable physical features, pull out explicit details about their body, sizes, shapes, colours, hair etc. + - Pose: how they're positioned, what's being shown/emphasized, confidence level + - Attire: clothing/lingerie if any, colors, style, how it fits + - Setting: location, lighting, props, aesthetic choices + - Vibe: mood, energy, what makes this image unique or eye-catching + - Standout details: anything specific that would make a good conversation hook + + Be explicit and descriptive - this is for generating personalized adult messages. + Focus on details that could be genuinely complimented. + messageGenerationPrompt: | + Based on this image analysis: {analysis} + Post title: {title} + + Write a casual Reddit DM (2-3 sentences) like a real person would type. + + MUST follow: + - Start sentences with capital letters, use proper capitalisation + - Use words like hot, sexy, stunning, gorgeous to boost their confidence + - Reference something SPECIFIC from the image about their body so they know you actually looked + - Be direct and complimentary, not flowery or poetic + - End with a hook that encourages them to share MORE content (photos/videos) + + GOOD HOOKS (use these styles): + - "Would love to see more of you" + - "Do you post more somewhere else?" + - "You should definitely post more" + - "Got any more pics?" + - "I'd love to see [specific thing] from another angle" + - "You holding out on us? Need to see more" + + BAD HOOKS (never use): + - "How's your day?" + - "What are you up to?" + - Generic small talk questions + - "Tell me about yourself" + + DO NOT: + - Sound like an AI or formal writing + - Use words like "captivating", "alluring", "exquisite", "radiant" + - Write long elaborate sentences + - Use all lowercase + + Examples of good messages: + - "Damn you look stunning in that lace. You should definitely post more 😏" + - "Holy shit those curves are incredible. Got any more pics?" + - "You're sexy as hell. Would love to see more of you" + + Just write the message, nothing else. + +rateLimit: + minDelayBetweenMessagesMs: 300000 + maxMessagesPerHour: 8 + jitterMs: 60000 + +browser: + sessionDir: "./data/browser-session" + headless: false + dryRun: true # Set to false to actually send messages + +database: + path: "./data/reddit-bot.db" + +web: + enabled: true + port: 3000 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0232762 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2533 @@ +{ + "name": "reddit-bot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "reddit-bot", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "axios": "^1.13.2", + "better-sqlite3": "^12.5.0", + "dotenv": "^17.2.3", + "express": "^5.2.1", + "openai": "^6.15.0", + "p-queue": "^9.0.1", + "p-retry": "^7.1.1", + "playwright": "^1.57.0", + "winston": "^3.19.0", + "yaml": "^2.8.2", + "zod": "^4.3.5" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/express": "^5.0.6", + "@types/node": "^25.0.3", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.5.0.tgz", + "integrity": "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", + "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-network-error": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", + "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/openai": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.15.0.tgz", + "integrity": "sha512-F1Lvs5BoVvmZtzkUEVyh8mDQPPFolq4F+xdsx/DO8Hee8YF3IGAlZqUIsF+DVGhqf4aU0a3bTghsxB6OIsRy1g==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-queue": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.0.1.tgz", + "integrity": "sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", + "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "license": "MIT", + "dependencies": { + "is-network-error": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b853e4b --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "reddit-bot", + "version": "1.0.0", + "description": "Reddit bot for monitoring subreddits and sending AI-generated messages", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "tsx src/index.ts", + "login": "tsx scripts/login.ts", + "dev": "tsx watch src/index.ts" + }, + "keywords": [ + "reddit", + "bot", + "ai" + ], + "author": "", + "license": "ISC", + "dependencies": { + "axios": "^1.13.2", + "better-sqlite3": "^12.5.0", + "dotenv": "^17.2.3", + "express": "^5.2.1", + "openai": "^6.15.0", + "p-queue": "^9.0.1", + "p-retry": "^7.1.1", + "playwright": "^1.57.0", + "winston": "^3.19.0", + "yaml": "^2.8.2", + "zod": "^4.3.5" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/express": "^5.0.6", + "@types/node": "^25.0.3", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..6700457 --- /dev/null +++ b/public/index.html @@ -0,0 +1,487 @@ + + + + + + Reddit Bot Dashboard + + + +
+
+

Reddit Bot Dashboard

+
+ Running + +
+
+ +
+
+

Messages Sent

+
0
+
0 / 8 this hour
+
+
+

Responses

+
0
+
0% response rate
+
+
+

Posts Processed

+
0
+
Total analyzed
+
+
+

Success Examples

+
0
+
Learning from responses
+
+
+ +
+
+
Recent Activity
+
+
No activity yet
+
+
+
+
Recent Messages
+
+
No messages sent yet
+
+
+
+ +
+
+
+

Monitoring Subreddits

+
+
+
+

Target Genders

+
+
+
+
+
+ + + + diff --git a/scripts/login.ts b/scripts/login.ts new file mode 100644 index 0000000..d508aa4 --- /dev/null +++ b/scripts/login.ts @@ -0,0 +1,98 @@ +import { chromium } from 'playwright'; +import path from 'path'; +import fs from 'fs'; +import readline from 'readline'; + +const SESSION_DIR = path.resolve(process.cwd(), './data/browser-session'); + +async function waitForEnter(prompt: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(prompt, () => { + rl.close(); + resolve(); + }); + }); +} + +async function manualLogin(): Promise { + console.log('='.repeat(60)); + console.log('Reddit Bot - Manual Login'); + console.log('='.repeat(60)); + console.log(); + + // Ensure session directory exists + if (!fs.existsSync(SESSION_DIR)) { + fs.mkdirSync(SESSION_DIR, { recursive: true }); + } + + console.log(`Session will be saved to: ${SESSION_DIR}`); + console.log(); + + const context = await chromium.launchPersistentContext(SESSION_DIR, { + headless: false, + viewport: { width: 1280, height: 720 }, + args: ['--disable-blink-features=AutomationControlled'], + }); + + const page = await context.newPage(); + + console.log('Opening Reddit login page...'); + await page.goto('https://www.reddit.com/login/'); + + console.log(); + console.log('-'.repeat(60)); + console.log('Please log in to Reddit in the browser window.'); + console.log(); + console.log('After logging in successfully:'); + console.log('1. Make sure you see your username in the top right'); + console.log('2. Come back here and press Enter'); + console.log('-'.repeat(60)); + console.log(); + + await waitForEnter('Press Enter when you have logged in... '); + + // Verify login + console.log(); + console.log('Verifying login status...'); + + await page.goto('https://www.reddit.com'); + await page.waitForTimeout(3000); + + // Check for login indicators + const userButton = await page.$('[data-testid="user-drawer-button"]'); + const avatar = await page.$('[class*="avatar"], [class*="Avatar"]'); + + const isLoggedIn = (userButton && (await userButton.isVisible())) || avatar !== null; + + console.log(); + if (isLoggedIn) { + console.log('SUCCESS! You are logged in.'); + console.log(`Session saved to: ${SESSION_DIR}`); + console.log(); + console.log('You can now run the bot with: npm start'); + } else { + console.log('WARNING: Login could not be verified.'); + console.log('The session has been saved, but you may need to try again.'); + console.log(); + console.log('Tips:'); + console.log('- Make sure you completed the login process'); + console.log('- Try clicking on your profile to ensure the page loaded'); + console.log('- Run this script again if needed'); + } + + console.log(); + console.log('Closing browser...'); + await context.close(); + + console.log('Done!'); +} + +manualLogin().catch((error) => { + console.error('Error during login:', error); + process.exit(1); +}); diff --git a/src/ai/openrouter.ts b/src/ai/openrouter.ts new file mode 100644 index 0000000..d6d6e94 --- /dev/null +++ b/src/ai/openrouter.ts @@ -0,0 +1,173 @@ +import OpenAI from 'openai'; +import type { Config } from '../config/index.js'; +import type { Logger } from '../utils/logger.js'; + +export type DetectedGender = 'male' | 'female' | 'other' | 'unknown'; + +export interface ImageAnalysisResult { + description: string; + detectedGender: DetectedGender; +} + +export class OpenRouterClient { + private client: OpenAI; + + constructor( + private config: Config, + private logger: Logger + ) { + this.client = new OpenAI({ + baseURL: 'https://openrouter.ai/api/v1', + apiKey: config.openrouter.apiKey, + defaultHeaders: { + 'HTTP-Referer': 'https://github.com/reddit-bot', + 'X-Title': 'Reddit Bot', + }, + }); + } + + async detectGenderOnly(imageBase64: string, mimeType: string): Promise { + try { + this.logger.debug('Quick gender detection...'); + + const response = await this.client.chat.completions.create({ + model: this.config.openrouter.model, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'What is the gender of the person in this image? Reply with ONLY one word: male, female, other, or unknown', + }, + { + type: 'image_url', + image_url: { + url: `data:${mimeType};base64,${imageBase64}`, + }, + }, + ], + }, + ], + max_tokens: 10, + }); + + const result = response.choices[0]?.message?.content?.toLowerCase().trim() || 'unknown'; + + if (result.includes('female')) return 'female'; + if (result.includes('male') && !result.includes('female')) return 'male'; + if (result.includes('other')) return 'other'; + return 'unknown'; + } catch (error) { + this.logger.error('Gender detection failed:', error); + return 'unknown'; + } + } + + async analyzeImage(imageBase64: string, mimeType: string): Promise { + try { + this.logger.debug('Analyzing image with AI...'); + + const genderPrompt = `First, identify the gender of the person in this image. Respond with EXACTLY one of these on the first line: GENDER:male, GENDER:female, GENDER:other, or GENDER:unknown + +Then provide your analysis: + +${this.config.openrouter.imageAnalysisPrompt}`; + + const response = await this.client.chat.completions.create({ + model: this.config.openrouter.model, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: genderPrompt, + }, + { + type: 'image_url', + image_url: { + url: `data:${mimeType};base64,${imageBase64}`, + }, + }, + ], + }, + ], + max_tokens: 600, + }); + + const fullResponse = response.choices[0]?.message?.content || ''; + + // Parse gender from response + let detectedGender: DetectedGender = 'unknown'; + const genderMatch = fullResponse.match(/GENDER:(male|female|other|unknown)/i); + if (genderMatch) { + detectedGender = genderMatch[1].toLowerCase() as DetectedGender; + } + + // Remove gender line from description + const description = fullResponse.replace(/GENDER:(male|female|other|unknown)\n?/i, '').trim(); + + this.logger.debug(`Image analysis complete - Gender: ${detectedGender}`); + this.logger.debug(`Description: ${description}`); + + return { description, detectedGender }; + } catch (error) { + this.logger.error('Image analysis failed:', error); + throw error; + } + } + + async generateMessage( + imageAnalysis: string, + postTitle: string, + authorName: string, + successfulExamples: string[] = [] + ): Promise { + try { + this.logger.debug('Generating message...'); + + let prompt = this.config.openrouter.messageGenerationPrompt + .replace('{analysis}', imageAnalysis) + .replace('{title}', postTitle) + .replace('{author}', authorName); + + // Add successful examples if available + if (successfulExamples.length > 0) { + const examplesText = successfulExamples + .map((ex, i) => `Example ${i + 1}:\n${ex}`) + .join('\n\n'); + + prompt = `Here are some examples of messages that got positive responses:\n\n${examplesText}\n\n---\n\nNow generate a new message in a similar style:\n\n${prompt}`; + + this.logger.debug(`Including ${successfulExamples.length} successful examples in prompt`); + } + + const response = await this.client.chat.completions.create({ + model: this.config.openrouter.model, + messages: [{ role: 'user', content: prompt }], + max_tokens: 300, + temperature: 0.8, // Add some creativity + }); + + const message = response.choices[0]?.message?.content || ''; + this.logger.debug(`Message generated: ${message}`); + return message.trim(); + } catch (error) { + this.logger.error('Message generation failed:', error); + throw error; + } + } + + async analyzeAndGenerateMessage( + imageBase64: string, + mimeType: string, + postTitle: string, + authorName: string, + successfulExamples: string[] = [] + ): Promise<{ analysis: ImageAnalysisResult; message: string }> { + const analysis = await this.analyzeImage(imageBase64, mimeType); + const message = await this.generateMessage(analysis.description, postTitle, authorName, successfulExamples); + return { analysis, message }; + } +} diff --git a/src/browser/client.ts b/src/browser/client.ts new file mode 100644 index 0000000..04b135f --- /dev/null +++ b/src/browser/client.ts @@ -0,0 +1,85 @@ +import { chromium, BrowserContext, Page } from 'playwright'; +import type { Config } from '../config/index.js'; +import type { Logger } from '../utils/logger.js'; +import path from 'path'; +import fs from 'fs'; + +export class BrowserClient { + private context: BrowserContext | null = null; + + constructor( + private config: Config, + private logger: Logger + ) {} + + async initialize(): Promise { + const sessionDir = path.resolve(process.cwd(), this.config.browser.sessionDir); + + // Ensure session directory exists + if (!fs.existsSync(sessionDir)) { + fs.mkdirSync(sessionDir, { recursive: true }); + } + + this.logger.info(`Launching browser with session dir: ${sessionDir}`); + + // Use launchPersistentContext to maintain cookies/localStorage + this.context = await chromium.launchPersistentContext(sessionDir, { + headless: this.config.browser.headless, + viewport: { width: 1280, height: 720 }, + userAgent: this.config.reddit.userAgent, + args: [ + '--disable-blink-features=AutomationControlled', + '--no-sandbox', + ], + }); + + return this.context; + } + + async getPage(): Promise { + if (!this.context) { + await this.initialize(); + } + const pages = this.context!.pages(); + return pages.length > 0 ? pages[0] : await this.context!.newPage(); + } + + getContext(): BrowserContext | null { + return this.context; + } + + async close(): Promise { + if (this.context) { + this.logger.info('Closing browser'); + await this.context.close(); + this.context = null; + } + } + + async isSessionValid(): Promise { + try { + const page = await this.getPage(); + await page.goto('https://www.reddit.com', { waitUntil: 'domcontentloaded', timeout: 30000 }); + + // Check for various login indicators + const isLoggedIn = await page.evaluate(() => { + // Check for user menu button + const userButton = document.querySelector('[data-testid="user-drawer-button"]'); + if (userButton) return true; + + // Check for login/signup buttons which indicate NOT logged in + const loginButton = document.querySelector('a[href*="login"]'); + const signupButton = document.querySelector('a[href*="register"]'); + + // If we find login/signup prominently, we're not logged in + // But we need to be careful - some elements may exist in DOM + return !loginButton && !signupButton; + }); + + return isLoggedIn; + } catch (error) { + this.logger.error('Failed to check session validity:', error); + return false; + } + } +} diff --git a/src/browser/message-page.ts b/src/browser/message-page.ts new file mode 100644 index 0000000..74735e0 --- /dev/null +++ b/src/browser/message-page.ts @@ -0,0 +1,184 @@ +import type { Page } from 'playwright'; +import type { Logger } from '../utils/logger.js'; + +export class RedditMessagePage { + private dryRun: boolean = false; + + constructor( + private page: Page, + private logger: Logger + ) {} + + setDryRun(enabled: boolean): void { + this.dryRun = enabled; + if (enabled) { + this.logger.info('Dry run mode enabled - messages will be filled but not sent'); + } + } + + async sendMessage(recipient: string, subject: string, body: string): Promise { + try { + this.logger.info(`Sending message to u/${recipient}`); + + // Navigate to compose page + await this.page.goto('https://www.reddit.com/message/compose/', { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + + // Wait for the form to load - try multiple possible selectors + await this.page.waitForSelector('input[name="to"], #to, [data-testid="message-to"]', { + timeout: 30000, + }); + + // Wait for React to hydrate + await this.page.waitForTimeout(2000); + + // Fill recipient field + const toInput = await this.page.$('input[name="to"]') || await this.page.$('#to'); + if (toInput) { + await toInput.click(); + await toInput.fill(recipient); + } else { + throw new Error('Could not find recipient input field'); + } + + // Fill subject field + const subjectInput = await this.page.$('input[name="subject"]') || await this.page.$('#subject'); + if (subjectInput) { + await subjectInput.click(); + await subjectInput.fill(subject); + } else { + throw new Error('Could not find subject input field'); + } + + // Fill message body - Reddit uses various editor implementations + // Try contenteditable div first (new Reddit) + let bodyFilled = false; + + // Try markdown textarea + const textarea = await this.page.$('textarea[name="text"], textarea#text, textarea.md'); + if (textarea) { + await textarea.click(); + await this.typeWithDelay(body); + bodyFilled = true; + } + + // Try contenteditable div (fancy editor) + if (!bodyFilled) { + const contentEditable = await this.page.$('[contenteditable="true"]'); + if (contentEditable) { + await contentEditable.click(); + await this.typeWithDelay(body); + bodyFilled = true; + } + } + + // Try data-testid selector + if (!bodyFilled) { + const markdownInput = await this.page.$('[data-testid="markdown-input"]'); + if (markdownInput) { + await markdownInput.click(); + await this.typeWithDelay(body); + bodyFilled = true; + } + } + + if (!bodyFilled) { + throw new Error('Could not find message body input'); + } + + // Wait a bit before submitting + await this.page.waitForTimeout(500); + + // Dry run mode - don't actually send + if (this.dryRun) { + this.logger.info(`[DRY RUN] Message filled out for u/${recipient} - NOT sending`); + this.logger.info(`[DRY RUN] Subject: ${subject}`); + this.logger.info(`[DRY RUN] Body: ${body.slice(0, 100)}...`); + this.logger.info(`[DRY RUN] Pausing 10 seconds so you can review the form...`); + await this.page.waitForTimeout(10000); + return true; + } + + // Find and click submit button + const submitButton = + (await this.page.$('button[type="submit"]')) || + (await this.page.$('button:has-text("Send")')) || + (await this.page.$('input[type="submit"]')); + + if (!submitButton) { + throw new Error('Could not find submit button'); + } + + await submitButton.click(); + + // Wait for success indication + try { + await Promise.race([ + this.page.waitForURL(/\/message\/sent/, { timeout: 15000 }), + this.page.waitForSelector('.success, [class*="success"], [class*="Success"]', { timeout: 15000 }), + ]); + + this.logger.info(`Message sent successfully to u/${recipient}`); + return true; + } catch { + // Check if we're on a success page or got any error + const currentUrl = this.page.url(); + if (currentUrl.includes('/message/sent') || currentUrl.includes('success')) { + this.logger.info(`Message sent successfully to u/${recipient}`); + return true; + } + + // Check for error messages + const errorElement = await this.page.$('.error, [class*="error"], [class*="Error"]'); + if (errorElement) { + const errorText = await errorElement.textContent(); + throw new Error(`Reddit error: ${errorText}`); + } + + throw new Error('Message send status unclear'); + } + } catch (error) { + this.logger.error(`Failed to send message to u/${recipient}:`, error); + return false; + } + } + + private async typeWithDelay(text: string): Promise { + // Type with human-like delays + for (const char of text) { + await this.page.keyboard.type(char, { delay: 20 + Math.random() * 30 }); + } + } + + async isLoggedIn(): Promise { + try { + await this.page.goto('https://www.reddit.com', { + waitUntil: 'domcontentloaded', + timeout: 30000, + }); + + // Wait for page to stabilize + await this.page.waitForTimeout(2000); + + // Check for user menu button (indicates logged in) + const userButton = await this.page.$('[data-testid="user-drawer-button"]'); + if (userButton) { + const isVisible = await userButton.isVisible(); + return isVisible; + } + + // Alternative check - look for avatar or user icon + const avatar = await this.page.$('[class*="avatar"], [class*="Avatar"]'); + if (avatar) { + return true; + } + + return false; + } catch (error) { + this.logger.error('Failed to check login status:', error); + return false; + } + } +} diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..e70ea6a --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; +import fs from 'fs'; +import path from 'path'; +import YAML from 'yaml'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export const ConfigSchema = z.object({ + reddit: z.object({ + subreddits: z.array(z.string()).min(1), + pollIntervalMs: z.number().default(120000), + userAgent: z.string().default('RedditBot/1.0'), + targetGenders: z.array(z.enum(['male', 'female', 'other', 'unknown'])).default(['female']), + }), + openrouter: z.object({ + apiKey: z.string(), + model: z.string().default('google/gemini-3-flash-preview'), + imageAnalysisPrompt: z.string(), + messageGenerationPrompt: z.string(), + }), + rateLimit: z.object({ + minDelayBetweenMessagesMs: z.number().default(300000), + maxMessagesPerHour: z.number().default(8), + jitterMs: z.number().default(60000), + }), + browser: z.object({ + sessionDir: z.string().default('./data/browser-session'), + headless: z.boolean().default(false), + dryRun: z.boolean().default(false), + }), + database: z.object({ + path: z.string().default('./data/reddit-bot.db'), + }), + web: z.object({ + enabled: z.boolean().default(true), + port: z.number().default(3000), + }), +}); + +export type Config = z.infer; + +export function loadConfig(): Config { + const configPath = path.resolve(process.cwd(), 'config.yaml'); + + if (!fs.existsSync(configPath)) { + throw new Error(`Config file not found at ${configPath}`); + } + + const configFile = fs.readFileSync(configPath, 'utf-8'); + const rawConfig = YAML.parse(configFile); + + // Inject API key from environment + if (!rawConfig.openrouter) { + rawConfig.openrouter = {}; + } + rawConfig.openrouter.apiKey = process.env.OPENROUTER_API_KEY || rawConfig.openrouter.apiKey; + + if (!rawConfig.openrouter.apiKey) { + throw new Error('OPENROUTER_API_KEY environment variable is required'); + } + + const result = ConfigSchema.safeParse(rawConfig); + + if (!result.success) { + const errors = result.error.issues + .map((e) => ` - ${String(e.path.join('.'))}: ${e.message}`) + .join('\n'); + throw new Error(`Invalid configuration:\n${errors}`); + } + + return result.data; +} diff --git a/src/database/client.ts b/src/database/client.ts new file mode 100644 index 0000000..669b141 --- /dev/null +++ b/src/database/client.ts @@ -0,0 +1,83 @@ +import Database from 'better-sqlite3'; +import type { Config } from '../config/index.js'; +import path from 'path'; +import fs from 'fs'; + +export function createDatabase(config: Config): Database.Database { + const dbPath = path.resolve(process.cwd(), config.database.path); + const dbDir = path.dirname(dbPath); + + // Ensure directory exists + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); + } + + const db = new Database(dbPath); + + // Enable WAL mode for better concurrency + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + + return db; +} + +export function runMigrations(db: Database.Database): void { + // Create tables if they don't exist + db.exec(` + CREATE TABLE IF NOT EXISTS processed_posts ( + id TEXT PRIMARY KEY, + subreddit TEXT NOT NULL, + author TEXT NOT NULL, + title TEXT NOT NULL, + image_url TEXT, + image_analysis TEXT, + generated_message TEXT, + processed_at INTEGER NOT NULL, + message_sent INTEGER DEFAULT 0, + error TEXT + ); + + CREATE TABLE IF NOT EXISTS sent_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id TEXT NOT NULL, + recipient TEXT NOT NULL, + subject TEXT NOT NULL, + message TEXT NOT NULL, + sent_at INTEGER NOT NULL, + FOREIGN KEY (post_id) REFERENCES processed_posts(id) + ); + + CREATE TABLE IF NOT EXISTS successful_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sent_message_id INTEGER NOT NULL, + original_message TEXT NOT NULL, + image_analysis TEXT, + post_title TEXT, + response_snippet TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY (sent_message_id) REFERENCES sent_messages(id) + ); + + CREATE INDEX IF NOT EXISTS idx_posts_subreddit + ON processed_posts(subreddit); + CREATE INDEX IF NOT EXISTS idx_posts_processed_at + ON processed_posts(processed_at); + CREATE INDEX IF NOT EXISTS idx_posts_message_sent + ON processed_posts(message_sent); + `); + + // Add new columns to existing tables (safe to run multiple times) + const addColumnIfNotExists = (table: string, column: string, definition: string) => { + const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; + const exists = columns.some((c) => c.name === column); + if (!exists) { + db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + } + }; + + addColumnIfNotExists('sent_messages', 'got_response', 'INTEGER DEFAULT 0'); + addColumnIfNotExists('sent_messages', 'response_checked_at', 'INTEGER'); + + // Create index for response checking + db.exec(`CREATE INDEX IF NOT EXISTS idx_sent_messages_response ON sent_messages(got_response)`); +} diff --git a/src/database/repositories.ts b/src/database/repositories.ts new file mode 100644 index 0000000..49fcb38 --- /dev/null +++ b/src/database/repositories.ts @@ -0,0 +1,267 @@ +import type Database from 'better-sqlite3'; + +export interface ProcessedPostRecord { + id: string; + subreddit: string; + author: string; + title: string; + imageUrl: string | null; + imageAnalysis: string | null; + generatedMessage: string | null; + processedAt: number; + messageSent: boolean; + error: string | null; +} + +export interface SentMessageRecord { + id: number; + postId: string; + recipient: string; + subject: string; + message: string; + sentAt: number; + gotResponse: boolean; + responseCheckedAt: number | null; +} + +export interface SuccessfulMessageRecord { + id: number; + sentMessageId: number; + originalMessage: string; + imageAnalysis: string | null; + postTitle: string | null; + responseSnippet: string | null; + createdAt: number; +} + +export class PostsRepository { + private insertStmt: Database.Statement; + private existsStmt: Database.Statement; + private markSentStmt: Database.Statement; + private updateAnalysisStmt: Database.Statement; + private setErrorStmt: Database.Statement; + private getByIdStmt: Database.Statement; + + constructor(private db: Database.Database) { + this.insertStmt = db.prepare(` + INSERT INTO processed_posts (id, subreddit, author, title, image_url, processed_at) + VALUES (?, ?, ?, ?, ?, ?) + `); + + this.existsStmt = db.prepare(`SELECT 1 FROM processed_posts WHERE id = ?`); + + this.markSentStmt = db.prepare(`UPDATE processed_posts SET message_sent = 1 WHERE id = ?`); + + this.updateAnalysisStmt = db.prepare(` + UPDATE processed_posts + SET image_analysis = ?, generated_message = ? + WHERE id = ? + `); + + this.setErrorStmt = db.prepare(`UPDATE processed_posts SET error = ? WHERE id = ?`); + + this.getByIdStmt = db.prepare(`SELECT * FROM processed_posts WHERE id = ?`); + } + + isProcessed(postId: string): boolean { + return this.existsStmt.get(postId) !== undefined; + } + + insert(post: { + id: string; + subreddit: string; + author: string; + title: string; + imageUrl: string | null; + processedAt: number; + }): void { + this.insertStmt.run( + post.id, + post.subreddit, + post.author, + post.title, + post.imageUrl, + post.processedAt + ); + } + + updateAnalysis(postId: string, analysis: string, message: string): void { + this.updateAnalysisStmt.run(analysis, message, postId); + } + + markMessageSent(postId: string): void { + this.markSentStmt.run(postId); + } + + setError(postId: string, error: string): void { + this.setErrorStmt.run(error, postId); + } + + getById(postId: string): ProcessedPostRecord | undefined { + const row = this.getByIdStmt.get(postId) as Record | undefined; + if (!row) return undefined; + + return { + id: row.id as string, + subreddit: row.subreddit as string, + author: row.author as string, + title: row.title as string, + imageUrl: row.image_url as string | null, + imageAnalysis: row.image_analysis as string | null, + generatedMessage: row.generated_message as string | null, + processedAt: row.processed_at as number, + messageSent: Boolean(row.message_sent), + error: row.error as string | null, + }; + } +} + +export class MessagesRepository { + private insertStmt: Database.Statement; + private getByPostIdStmt: Database.Statement; + private getRecentStmt: Database.Statement; + private getUncheckedStmt: Database.Statement; + private markResponseStmt: Database.Statement; + private markCheckedStmt: Database.Statement; + + constructor(private db: Database.Database) { + this.insertStmt = db.prepare(` + INSERT INTO sent_messages (post_id, recipient, subject, message, sent_at) + VALUES (?, ?, ?, ?, ?) + `); + + this.getByPostIdStmt = db.prepare(`SELECT * FROM sent_messages WHERE post_id = ?`); + + this.getRecentStmt = db.prepare(` + SELECT * FROM sent_messages ORDER BY sent_at DESC LIMIT ? + `); + + this.getUncheckedStmt = db.prepare(` + SELECT * FROM sent_messages + WHERE got_response = 0 + AND (response_checked_at IS NULL OR response_checked_at < ?) + AND sent_at < ? + ORDER BY sent_at ASC + LIMIT ? + `); + + this.markResponseStmt = db.prepare(` + UPDATE sent_messages SET got_response = 1, response_checked_at = ? WHERE id = ? + `); + + this.markCheckedStmt = db.prepare(` + UPDATE sent_messages SET response_checked_at = ? WHERE id = ? + `); + } + + insert(message: { + postId: string; + recipient: string; + subject: string; + message: string; + sentAt: number; + }): number { + const result = this.insertStmt.run( + message.postId, + message.recipient, + message.subject, + message.message, + message.sentAt + ); + return result.lastInsertRowid as number; + } + + getByPostId(postId: string): SentMessageRecord | undefined { + const row = this.getByPostIdStmt.get(postId) as Record | undefined; + if (!row) return undefined; + return this.mapRow(row); + } + + getRecent(limit: number = 10): SentMessageRecord[] { + const rows = this.getRecentStmt.all(limit) as Record[]; + return rows.map((row) => this.mapRow(row)); + } + + getUncheckedMessages(minAgeMs: number, recheckAfterMs: number, limit: number = 10): SentMessageRecord[] { + const now = Date.now(); + const minSentAt = now - minAgeMs; + const recheckBefore = now - recheckAfterMs; + const rows = this.getUncheckedStmt.all(recheckBefore, minSentAt, limit) as Record[]; + return rows.map((row) => this.mapRow(row)); + } + + markGotResponse(messageId: number): void { + this.markResponseStmt.run(Date.now(), messageId); + } + + markChecked(messageId: number): void { + this.markCheckedStmt.run(Date.now(), messageId); + } + + private mapRow(row: Record): SentMessageRecord { + return { + id: row.id as number, + postId: row.post_id as string, + recipient: row.recipient as string, + subject: row.subject as string, + message: row.message as string, + sentAt: row.sent_at as number, + gotResponse: Boolean(row.got_response), + responseCheckedAt: row.response_checked_at as number | null, + }; + } +} + +export class SuccessfulMessagesRepository { + private insertStmt: Database.Statement; + private getRecentStmt: Database.Statement; + private countStmt: Database.Statement; + + constructor(private db: Database.Database) { + this.insertStmt = db.prepare(` + INSERT INTO successful_messages (sent_message_id, original_message, image_analysis, post_title, response_snippet, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `); + + this.getRecentStmt = db.prepare(` + SELECT * FROM successful_messages ORDER BY created_at DESC LIMIT ? + `); + + this.countStmt = db.prepare(`SELECT COUNT(*) as count FROM successful_messages`); + } + + insert(data: { + sentMessageId: number; + originalMessage: string; + imageAnalysis: string | null; + postTitle: string | null; + responseSnippet: string | null; + }): void { + this.insertStmt.run( + data.sentMessageId, + data.originalMessage, + data.imageAnalysis, + data.postTitle, + data.responseSnippet, + Date.now() + ); + } + + getRecent(limit: number = 5): SuccessfulMessageRecord[] { + const rows = this.getRecentStmt.all(limit) as Record[]; + return rows.map((row) => ({ + id: row.id as number, + sentMessageId: row.sent_message_id as number, + originalMessage: row.original_message as string, + imageAnalysis: row.image_analysis as string | null, + postTitle: row.post_title as string | null, + responseSnippet: row.response_snippet as string | null, + createdAt: row.created_at as number, + })); + } + + count(): number { + const result = this.countStmt.get() as { count: number }; + return result.count; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d38ace7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,244 @@ +import { loadConfig } from './config/index.js'; +import { createDatabase, runMigrations } from './database/client.js'; +import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from './database/repositories.js'; +import { BrowserClient } from './browser/client.js'; +import { RedditMessagePage } from './browser/message-page.js'; +import { RedditApiClient } from './reddit/api.js'; +import { OpenRouterClient } from './ai/openrouter.js'; +import { MonitorService } from './services/monitor.js'; +import { ProcessorService } from './services/processor.js'; +import { ResponseCheckerService } from './services/response-checker.js'; +import { RateLimiter } from './utils/rate-limiter.js'; +import { createLogger } from './utils/logger.js'; +import { withRetry } from './utils/retry.js'; +import { WebServer } from './web/server.js'; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function main(): Promise { + const logger = createLogger(); + + logger.info('='.repeat(60)); + logger.info('Reddit Bot Starting'); + logger.info('='.repeat(60)); + + // Load configuration + let config; + try { + config = loadConfig(); + logger.info(`Loaded configuration for ${config.reddit.subreddits.length} subreddits`); + } catch (error) { + logger.error('Failed to load configuration:', error); + process.exit(1); + } + + // Initialize database + const db = createDatabase(config); + runMigrations(db); + const postsRepo = new PostsRepository(db); + const messagesRepo = new MessagesRepository(db); + const successfulMessagesRepo = new SuccessfulMessagesRepository(db); + logger.info('Database initialized'); + + // Initialize browser + const browserClient = new BrowserClient(config, logger); + await browserClient.initialize(); + const page = await browserClient.getPage(); + const messagePage = new RedditMessagePage(page, logger); + + // Set dry run mode if configured + messagePage.setDryRun(config.browser.dryRun); + + // Verify login + logger.info('Verifying Reddit login status...'); + const isLoggedIn = await messagePage.isLoggedIn(); + + if (!isLoggedIn) { + logger.error('Not logged in to Reddit!'); + logger.error('Please run "npm run login" first to authenticate.'); + await browserClient.close(); + process.exit(1); + } + + logger.info('Reddit login verified'); + + // Initialize services + const redditApi = new RedditApiClient(config, logger); + redditApi.setBrowserContext(browserClient.getContext()!); + const aiClient = new OpenRouterClient(config, logger); + const monitorService = new MonitorService(redditApi, postsRepo, config, logger); + const processorService = new ProcessorService(redditApi, aiClient, config, logger); + const responseChecker = new ResponseCheckerService( + page, + messagesRepo, + successfulMessagesRepo, + postsRepo, + logger + ); + const rateLimiter = new RateLimiter(config, logger); + + // Initialize web dashboard + let webServer: WebServer | null = null; + if (config.web.enabled) { + webServer = new WebServer( + config.web.port, + config, + postsRepo, + messagesRepo, + successfulMessagesRepo, + logger + ); + webServer.start(); + } + + // Load any existing successful examples + const existingExamples = responseChecker.getSuccessfulExamples(3); + processorService.setSuccessfulExamples(existingExamples); + const successCount = responseChecker.getSuccessCount(); + if (successCount > 0) { + logger.info(`Loaded ${successCount} successful message(s) from history`); + } + + // Graceful shutdown handler + let isShuttingDown = false; + + const shutdown = async (): Promise => { + if (isShuttingDown) return; + isShuttingDown = true; + + logger.info('Shutting down gracefully...'); + webServer?.stop(); + await browserClient.close(); + db.close(); + logger.info('Goodbye!'); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + logger.info(''); + logger.info('Bot is now running!'); + logger.info(`Monitoring subreddits: ${config.reddit.subreddits.join(', ')}`); + logger.info(`Poll interval: ${config.reddit.pollIntervalMs / 1000} seconds`); + logger.info(`Min delay between messages: ${config.rateLimit.minDelayBetweenMessagesMs / 1000} seconds`); + logger.info(`Max messages per hour: ${config.rateLimit.maxMessagesPerHour}`); + logger.info(''); + + // Track poll cycles for periodic response checking + let pollCycle = 0; + const RESPONSE_CHECK_INTERVAL = 5; // Check for responses every 5 poll cycles + + // Main loop + while (!isShuttingDown) { + try { + pollCycle++; + + // Periodically check for responses to learn from + if (pollCycle % RESPONSE_CHECK_INTERVAL === 0) { + logger.debug('Checking for responses to sent messages...'); + const responses = await responseChecker.checkForResponses(); + + if (responses.some((r) => r.gotResponse)) { + // Reload successful examples + const updatedExamples = responseChecker.getSuccessfulExamples(3); + processorService.setSuccessfulExamples(updatedExamples); + } + } + + // Get unprocessed posts + const posts = await monitorService.getUnprocessedPosts(); + + if (posts.length > 0) { + logger.info(`Found ${posts.length} unprocessed posts`); + } + + for (const post of posts) { + if (isShuttingDown) break; + + try { + // Mark as processed immediately to avoid duplicates on restart + postsRepo.insert({ + id: post.id, + subreddit: post.subreddit, + author: post.author, + title: post.title, + imageUrl: redditApi.getImageUrl(post), + processedAt: Date.now(), + }); + + // Process post (download image, analyze, generate message) + const processed = await withRetry( + () => processorService.processPost(post), + { logger, retries: 2, operationName: 'Post processing' } + ); + + if (!processed) { + logger.debug(`Skipping post ${post.id} - processing returned null`); + webServer?.logActivity('skipped', `Skipped post by u/${post.author}`, post.author); + continue; + } + + // Update database with analysis + postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage); + + // Create subject line + const subject = `Re: ${post.title.slice(0, 50)}${post.title.length > 50 ? '...' : ''}`; + + // Send message with rate limiting + await rateLimiter.throttle(async () => { + logger.info(`Sending message to u/${post.author}...`); + + const success = await messagePage.sendMessage( + post.author, + subject, + processed.generatedMessage + ); + + if (success) { + postsRepo.markMessageSent(post.id); + messagesRepo.insert({ + postId: post.id, + recipient: post.author, + subject, + message: processed.generatedMessage, + sentAt: Date.now(), + }); + + const stats = rateLimiter.getStats(); + logger.info( + `Message sent! (${stats.messagesThisHour}/${config.rateLimit.maxMessagesPerHour} this hour)` + ); + webServer?.logActivity('sent', `Sent message to u/${post.author}`, post.author); + webServer?.updateStats(stats.messagesThisHour); + } else { + postsRepo.setError(post.id, 'Failed to send message'); + webServer?.logActivity('error', `Failed to send to u/${post.author}`, post.author); + } + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Error processing post ${post.id}:`, error); + postsRepo.setError(post.id, errorMessage); + } + } + + if (!isShuttingDown) { + // Wait before next poll + logger.debug(`Waiting ${config.reddit.pollIntervalMs / 1000} seconds before next poll...`); + await sleep(config.reddit.pollIntervalMs); + } + } catch (error) { + logger.error('Error in main loop:', error); + // Wait a minute on error before retrying + await sleep(60000); + } + } +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/src/reddit/api.ts b/src/reddit/api.ts new file mode 100644 index 0000000..3744893 --- /dev/null +++ b/src/reddit/api.ts @@ -0,0 +1,186 @@ +import axios, { AxiosInstance } from 'axios'; +import type { BrowserContext } from 'playwright'; +import type { Config } from '../config/index.js'; +import type { Logger } from '../utils/logger.js'; + +export interface RedditPost { + id: string; + name: string; // fullname like "t3_xxxxx" + subreddit: string; + author: string; + title: string; + url: string; + permalink: string; + thumbnail: string; + is_self: boolean; + post_hint?: string; + preview?: { + images: Array<{ + source: { url: string; width: number; height: number }; + resolutions: Array<{ url: string; width: number; height: number }>; + }>; + }; + created_utc: number; + over_18: boolean; + spoiler: boolean; +} + +export class RedditApiClient { + private client: AxiosInstance; + private browserContext: BrowserContext | null = null; + + constructor( + config: Config, + private logger: Logger + ) { + this.client = axios.create({ + baseURL: 'https://www.reddit.com', + headers: { + 'User-Agent': config.reddit.userAgent, + Accept: 'application/json', + }, + timeout: 30000, + }); + } + + setBrowserContext(context: BrowserContext): void { + this.browserContext = context; + this.logger.debug('Browser context set for authenticated API requests'); + } + + private async getCookieHeader(): Promise { + if (!this.browserContext) { + return ''; + } + + try { + const cookies = await this.browserContext.cookies('https://www.reddit.com'); + return cookies.map((c) => `${c.name}=${c.value}`).join('; '); + } catch (error) { + this.logger.warn('Failed to get cookies from browser context:', error); + return ''; + } + } + + async getNewPosts(subreddit: string, limit = 25): Promise { + try { + const cookieHeader = await this.getCookieHeader(); + const headers: Record = {}; + if (cookieHeader) { + headers['Cookie'] = cookieHeader; + } + + const response = await this.client.get(`/r/${subreddit}/new.json`, { + params: { limit, raw_json: 1 }, + headers, + }); + + const posts: RedditPost[] = response.data.data.children + .map((child: { data: RedditPost }) => child.data) + .filter((post: RedditPost) => this.isValidPost(post)); + + this.logger.debug(`Fetched ${posts.length} valid image posts from r/${subreddit}`); + return posts; + } catch (error) { + if (axios.isAxiosError(error)) { + if (error.response?.status === 404) { + this.logger.warn(`Subreddit r/${subreddit} not found`); + return []; + } + if (error.response?.status === 403) { + this.logger.warn(`Subreddit r/${subreddit} is private or quarantined`); + return []; + } + if (error.response?.status === 429) { + this.logger.warn(`Rate limited by Reddit, waiting...`); + throw error; + } + } + this.logger.error(`Error fetching posts from r/${subreddit}:`, error); + throw error; + } + } + + private isValidPost(post: RedditPost): boolean { + // Skip self posts (text only) + if (post.is_self) return false; + + // Skip deleted/removed authors + if (post.author === '[deleted]' || post.author === 'AutoModerator') return false; + + // Skip NSFW if we want to be safe (configurable later) + // if (post.over_18) return false; + + // Check if it's an image post + return this.hasImage(post); + } + + private hasImage(post: RedditPost): boolean { + // Check post_hint + if (post.post_hint === 'image') return true; + + // Check for preview images + if (post.preview?.images?.length) return true; + + // Check URL patterns for direct image links + const url = post.url.toLowerCase(); + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + + if (imageExtensions.some((ext) => url.includes(ext))) return true; + + // Check for known image hosts + const imageHosts = ['i.redd.it', 'i.imgur.com', 'imgur.com']; + if (imageHosts.some((host) => url.includes(host))) return true; + + return false; + } + + getImageUrl(post: RedditPost): string | null { + // Prefer high-quality preview images (already processed by Reddit) + if (post.preview?.images?.[0]?.source?.url) { + // Reddit encodes URLs in preview, need to decode HTML entities + return this.decodeRedditUrl(post.preview.images[0].source.url); + } + + // Direct i.redd.it links + if (post.url.includes('i.redd.it')) { + return post.url; + } + + // Direct imgur links + if (post.url.includes('i.imgur.com')) { + return post.url; + } + + // Imgur album/page links - try to get direct image + if (post.url.includes('imgur.com') && !post.url.includes('i.imgur.com')) { + // Try adding .jpg extension + const imgurId = post.url.split('/').pop()?.split('.')[0]; + if (imgurId) { + return `https://i.imgur.com/${imgurId}.jpg`; + } + } + + // Check if URL has image extension + const url = post.url.toLowerCase(); + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + if (imageExtensions.some((ext) => url.includes(ext))) { + return post.url; + } + + // Use thumbnail as last resort if it's a valid URL + if (post.thumbnail && post.thumbnail.startsWith('http')) { + return post.thumbnail; + } + + return null; + } + + private decodeRedditUrl(url: string): string { + return url.replace(/&/g, '&'); + } + + getPostUrl(post: RedditPost): string { + return `https://www.reddit.com${post.permalink}`; + } +} diff --git a/src/services/monitor.ts b/src/services/monitor.ts new file mode 100644 index 0000000..58cdc98 --- /dev/null +++ b/src/services/monitor.ts @@ -0,0 +1,50 @@ +import type { RedditApiClient, RedditPost } from '../reddit/api.js'; +import type { PostsRepository } from '../database/repositories.js'; +import type { Config } from '../config/index.js'; +import type { Logger } from '../utils/logger.js'; + +export class MonitorService { + constructor( + private redditApi: RedditApiClient, + private postsRepo: PostsRepository, + private config: Config, + private logger: Logger + ) {} + + async getUnprocessedPosts(): Promise { + const unprocessedPosts: RedditPost[] = []; + + for (const subreddit of this.config.reddit.subreddits) { + try { + this.logger.debug(`Checking r/${subreddit} for new posts...`); + const posts = await this.redditApi.getNewPosts(subreddit); + + let newCount = 0; + for (const post of posts) { + if (!this.postsRepo.isProcessed(post.id)) { + unprocessedPosts.push(post); + newCount++; + } + } + + if (newCount > 0) { + this.logger.info(`Found ${newCount} new posts in r/${subreddit}`); + } + } catch (error) { + this.logger.error(`Failed to fetch posts from r/${subreddit}:`, error); + } + + // Small delay between subreddit fetches to be nice to Reddit + await this.sleep(1000); + } + + // Sort by creation time (oldest first, so we process in order) + unprocessedPosts.sort((a, b) => a.created_utc - b.created_utc); + + return unprocessedPosts; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/services/processor.ts b/src/services/processor.ts new file mode 100644 index 0000000..a2481bd --- /dev/null +++ b/src/services/processor.ts @@ -0,0 +1,101 @@ +import type { RedditApiClient, RedditPost } from '../reddit/api.js'; +import type { OpenRouterClient, DetectedGender } from '../ai/openrouter.js'; +import type { Logger } from '../utils/logger.js'; +import type { Config } from '../config/index.js'; +import { downloadImage, bufferToBase64, isValidImageMimeType } from '../utils/image.js'; + +export interface ProcessedPost { + post: RedditPost; + imageAnalysis: string; + detectedGender: DetectedGender; + generatedMessage: string; +} + +export class ProcessorService { + private successfulExamples: string[] = []; + + constructor( + private redditApi: RedditApiClient, + private aiClient: OpenRouterClient, + private config: Config, + private logger: Logger + ) {} + + setSuccessfulExamples(examples: string[]): void { + this.successfulExamples = examples; + if (examples.length > 0) { + this.logger.info(`Loaded ${examples.length} successful message examples for learning`); + } + } + + async processPost(post: RedditPost): Promise { + const imageUrl = this.redditApi.getImageUrl(post); + + if (!imageUrl) { + this.logger.warn(`No image URL found for post ${post.id}`); + return null; + } + + this.logger.info(`Processing post: "${post.title.slice(0, 50)}..." by u/${post.author}`); + + // Download image + let imageData; + try { + imageData = await downloadImage(imageUrl, this.config.reddit.userAgent); + } catch (error) { + this.logger.error(`Failed to download image from ${imageUrl}:`, error); + return null; + } + + // Validate image type + if (!isValidImageMimeType(imageData.mimeType)) { + this.logger.warn(`Invalid image type ${imageData.mimeType} for post ${post.id}`); + return null; + } + + // Check image size (skip very small images like thumbnails) + if (imageData.buffer.length < 5000) { + this.logger.warn(`Image too small (${imageData.buffer.length} bytes) for post ${post.id}`); + return null; + } + + // Convert to base64 + const base64 = bufferToBase64(imageData.buffer); + + // Quick gender check first (saves API costs on non-matching posts) + const targetGenders = this.config.reddit.targetGenders; + if (targetGenders.length > 0) { + const detectedGender = await this.aiClient.detectGenderOnly(base64, imageData.mimeType); + + if (!targetGenders.includes(detectedGender)) { + this.logger.info( + `Skipping post ${post.id} - detected gender "${detectedGender}" not in target list [${targetGenders.join(', ')}]` + ); + return null; + } + + this.logger.debug(`Gender check passed: ${detectedGender}`); + } + + // Full analysis and message generation (only for matching posts) + const { analysis, message } = await this.aiClient.analyzeAndGenerateMessage( + base64, + imageData.mimeType, + post.title, + post.author, + this.successfulExamples + ); + + if (!message || message.length < 10) { + this.logger.warn(`Generated message too short or empty for post ${post.id}`); + return null; + } + + return { + post, + imageAnalysis: analysis.description, + detectedGender: analysis.detectedGender, + generatedMessage: message, + }; + } +} diff --git a/src/services/response-checker.ts b/src/services/response-checker.ts new file mode 100644 index 0000000..33837a0 --- /dev/null +++ b/src/services/response-checker.ts @@ -0,0 +1,164 @@ +import type { Page } from 'playwright'; +import type { Logger } from '../utils/logger.js'; +import type { MessagesRepository, SuccessfulMessagesRepository, SentMessageRecord } from '../database/repositories.js'; +import type { PostsRepository } from '../database/repositories.js'; + +export interface ResponseCheckResult { + messageId: number; + recipient: string; + gotResponse: boolean; + responseSnippet?: string; +} + +export class ResponseCheckerService { + constructor( + private page: Page, + private messagesRepo: MessagesRepository, + private successfulMessagesRepo: SuccessfulMessagesRepository, + private postsRepo: PostsRepository, + private logger: Logger + ) {} + + async checkForResponses(): Promise { + const results: ResponseCheckResult[] = []; + + // Get messages that haven't been checked recently (at least 1 hour old, recheck every 6 hours) + const minAgeMs = 60 * 60 * 1000; // 1 hour + const recheckAfterMs = 6 * 60 * 60 * 1000; // 6 hours + const uncheckedMessages = this.messagesRepo.getUncheckedMessages(minAgeMs, recheckAfterMs, 5); + + if (uncheckedMessages.length === 0) { + this.logger.debug('No messages to check for responses'); + return results; + } + + this.logger.info(`Checking ${uncheckedMessages.length} messages for responses...`); + + // Navigate to inbox + try { + await this.page.goto('https://www.reddit.com/message/inbox/', { + waitUntil: 'networkidle', + timeout: 30000, + }); + + await this.page.waitForTimeout(2000); + + // Get all message threads/conversations + const conversations = await this.getInboxConversations(); + + for (const message of uncheckedMessages) { + const result = await this.checkMessageResponse(message, conversations); + results.push(result); + + if (result.gotResponse) { + // Mark as got response + this.messagesRepo.markGotResponse(message.id); + + // Get the original post info + const post = this.postsRepo.getById(message.postId); + + // Store as successful message for learning + this.successfulMessagesRepo.insert({ + sentMessageId: message.id, + originalMessage: message.message, + imageAnalysis: post?.imageAnalysis || null, + postTitle: post?.title || null, + responseSnippet: result.responseSnippet || null, + }); + + this.logger.info(`Got response from u/${message.recipient}! Stored for learning.`); + } else { + // Just mark as checked + this.messagesRepo.markChecked(message.id); + } + } + } catch (error) { + this.logger.error('Failed to check inbox for responses:', error); + } + + return results; + } + + private async getInboxConversations(): Promise> { + const conversations = new Map(); + + try { + // Try to get conversation items from inbox + const messageItems = await this.page.$$('.message, [data-testid="message"], .thing.message'); + + for (const item of messageItems) { + try { + // Get the author/recipient of the conversation + const authorEl = await item.$('.author, [data-testid="message-author"], .sender a'); + const author = authorEl ? await authorEl.textContent() : null; + + if (!author) continue; + + const cleanAuthor = author.replace(/^u\//, '').replace(/^\/u\//, '').trim(); + + // Check if this is a reply (not from us) + const isReply = await item.evaluate((el) => { + // Check various indicators that this is a received message, not sent + return !el.classList.contains('sent') && !el.querySelector('.sent'); + }); + + // Get message snippet + const bodyEl = await item.$('.md, .message-body, [data-testid="message-body"]'); + const snippet = bodyEl ? (await bodyEl.textContent())?.slice(0, 200) || '' : ''; + + if (isReply && cleanAuthor) { + conversations.set(cleanAuthor.toLowerCase(), { + hasReply: true, + snippet: snippet.trim(), + }); + } + } catch { + // Skip problematic items + } + } + } catch (error) { + this.logger.warn('Failed to parse inbox conversations:', error); + } + + return conversations; + } + + private async checkMessageResponse( + message: SentMessageRecord, + conversations: Map + ): Promise { + const recipientLower = message.recipient.toLowerCase(); + const conversation = conversations.get(recipientLower); + + if (conversation?.hasReply) { + return { + messageId: message.id, + recipient: message.recipient, + gotResponse: true, + responseSnippet: conversation.snippet, + }; + } + + return { + messageId: message.id, + recipient: message.recipient, + gotResponse: false, + }; + } + + getSuccessfulExamples(limit: number = 3): string[] { + const successful = this.successfulMessagesRepo.getRecent(limit); + + return successful.map((s) => { + let example = `Message: "${s.originalMessage}"`; + if (s.postTitle) { + example = `Post: "${s.postTitle}"\n${example}`; + } + return example; + }); + } + + getSuccessCount(): number { + return this.successfulMessagesRepo.count(); + } +} diff --git a/src/utils/image.ts b/src/utils/image.ts new file mode 100644 index 0000000..cd6efc9 --- /dev/null +++ b/src/utils/image.ts @@ -0,0 +1,34 @@ +import axios from 'axios'; + +export interface DownloadedImage { + buffer: Buffer; + mimeType: string; +} + +export async function downloadImage(url: string, userAgent: string): Promise { + const response = await axios.get(url, { + responseType: 'arraybuffer', + timeout: 30000, + headers: { + 'User-Agent': userAgent, + Accept: 'image/*', + }, + maxRedirects: 5, + }); + + const contentType = response.headers['content-type'] || 'image/jpeg'; + + return { + buffer: Buffer.from(response.data), + mimeType: contentType.split(';')[0].trim(), + }; +} + +export function bufferToBase64(buffer: Buffer): string { + return buffer.toString('base64'); +} + +export function isValidImageMimeType(mimeType: string): boolean { + const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + return validTypes.includes(mimeType); +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..9300bc1 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,32 @@ +import winston from 'winston'; + +export type Logger = winston.Logger; + +export function createLogger(): Logger { + return winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.json() + ), + transports: [ + new winston.transports.Console({ + format: winston.format.combine( + winston.format.colorize(), + winston.format.printf(({ level, message, timestamp, ...meta }) => { + const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ''; + return `${timestamp} [${level}]: ${message}${metaStr}`; + }) + ), + }), + new winston.transports.File({ + filename: 'logs/error.log', + level: 'error', + }), + new winston.transports.File({ + filename: 'logs/combined.log', + }), + ], + }); +} diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts new file mode 100644 index 0000000..e85a458 --- /dev/null +++ b/src/utils/rate-limiter.ts @@ -0,0 +1,66 @@ +import PQueue from 'p-queue'; +import type { Config } from '../config/index.js'; +import type { Logger } from './logger.js'; + +export class RateLimiter { + private queue: PQueue; + private lastMessageTime: number = 0; + private messagesThisHour: number = 0; + private hourStartTime: number = Date.now(); + + constructor( + private config: Config, + private logger: Logger + ) { + this.queue = new PQueue({ concurrency: 1 }); + } + + async throttle(fn: () => Promise): Promise { + return this.queue.add(async () => { + const now = Date.now(); + + // Reset hourly counter if needed + if (now - this.hourStartTime > 3600000) { + this.messagesThisHour = 0; + this.hourStartTime = now; + this.logger.debug('Hourly rate limit counter reset'); + } + + // Check hourly limit + if (this.messagesThisHour >= this.config.rateLimit.maxMessagesPerHour) { + const waitTime = 3600000 - (now - this.hourStartTime); + this.logger.info(`Hourly limit reached, waiting ${Math.round(waitTime / 60000)} minutes`); + await this.sleep(waitTime); + this.messagesThisHour = 0; + this.hourStartTime = Date.now(); + } + + // Enforce minimum delay with jitter + const timeSinceLastMessage = now - this.lastMessageTime; + const minDelay = this.config.rateLimit.minDelayBetweenMessagesMs; + const jitter = Math.random() * this.config.rateLimit.jitterMs; + + if (this.lastMessageTime > 0 && timeSinceLastMessage < minDelay) { + const waitTime = minDelay - timeSinceLastMessage + jitter; + this.logger.debug(`Rate limiting: waiting ${Math.round(waitTime / 1000)} seconds`); + await this.sleep(waitTime); + } + + this.lastMessageTime = Date.now(); + this.messagesThisHour++; + + return fn(); + }) as Promise; + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + getStats(): { messagesThisHour: number; minutesSinceLastMessage: number } { + return { + messagesThisHour: this.messagesThisHour, + minutesSinceLastMessage: Math.round((Date.now() - this.lastMessageTime) / 60000), + }; + } +} diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..3c45dee --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,28 @@ +import pRetry from 'p-retry'; +import type { Logger } from './logger.js'; + +export async function withRetry( + fn: () => Promise, + options: { + retries?: number; + logger?: Logger; + operationName?: string; + } = {} +): Promise { + const { retries = 3, logger, operationName = 'operation' } = options; + + return pRetry(fn, { + retries, + minTimeout: 5000, + maxTimeout: 60000, + factor: 2, + randomize: true, + onFailedAttempt: (context) => { + const errorMessage = context.error instanceof Error ? context.error.message : String(context.error); + logger?.warn( + `${operationName} attempt ${context.attemptNumber} failed: ${errorMessage}. ` + + `${context.retriesLeft} retries left.` + ); + }, + }); +} diff --git a/src/web/server.ts b/src/web/server.ts new file mode 100644 index 0000000..ce73d58 --- /dev/null +++ b/src/web/server.ts @@ -0,0 +1,140 @@ +import express from 'express'; +import path from 'path'; +import type { Server } from 'http'; +import type { Logger } from '../utils/logger.js'; +import type { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from '../database/repositories.js'; +import type { Config } from '../config/index.js'; + +export interface BotStats { + isRunning: boolean; + dryRunMode: boolean; + totalProcessed: number; + totalMessagesSent: number; + totalResponses: number; + responseRate: number; + messagesThisHour: number; + maxMessagesPerHour: number; + subreddits: string[]; + targetGenders: string[]; + recentActivity: ActivityItem[]; +} + +export interface ActivityItem { + timestamp: number; + type: 'sent' | 'skipped' | 'response' | 'error'; + message: string; + recipient?: string; +} + +export class WebServer { + private app: express.Application; + private server: Server | null = null; + private activityLog: ActivityItem[] = []; + private botStats = { + isRunning: true, + messagesThisHour: 0, + }; + + constructor( + private port: number, + private config: Config, + private postsRepo: PostsRepository, + private messagesRepo: MessagesRepository, + private successfulMessagesRepo: SuccessfulMessagesRepository, + private logger: Logger + ) { + this.app = express(); + this.setupRoutes(); + } + + private setupRoutes(): void { + // Serve static files + this.app.use(express.static(path.join(process.cwd(), 'public'))); + this.app.use(express.json()); + + // API: Get stats + this.app.get('/api/stats', (req, res) => { + const stats = this.getStats(); + res.json(stats); + }); + + // API: Get recent activity + this.app.get('/api/activity', (req, res) => { + res.json(this.activityLog.slice(-50).reverse()); + }); + + // API: Get recent messages + this.app.get('/api/messages', (req, res) => { + const messages = this.messagesRepo.getRecent(20); + res.json(messages); + }); + + // API: Get successful messages (ones that got responses) + this.app.get('/api/successful', (req, res) => { + const successful = this.successfulMessagesRepo.getRecent(10); + res.json(successful); + }); + + // Serve dashboard for all other routes + this.app.get('*', (req, res) => { + res.sendFile(path.join(process.cwd(), 'public', 'index.html')); + }); + } + + private getStats(): BotStats { + const messages = this.messagesRepo.getRecent(1000); + const totalSent = messages.length; + const totalResponses = messages.filter((m) => m.gotResponse).length; + + return { + isRunning: this.botStats.isRunning, + dryRunMode: this.config.browser.dryRun, + totalProcessed: this.getTotalProcessed(), + totalMessagesSent: totalSent, + totalResponses, + responseRate: totalSent > 0 ? Math.round((totalResponses / totalSent) * 100) : 0, + messagesThisHour: this.botStats.messagesThisHour, + maxMessagesPerHour: this.config.rateLimit.maxMessagesPerHour, + subreddits: this.config.reddit.subreddits, + targetGenders: this.config.reddit.targetGenders, + recentActivity: this.activityLog.slice(-10).reverse(), + }; + } + + private getTotalProcessed(): number { + // This is a simple count - in production you'd query the DB + return this.activityLog.filter((a) => a.type === 'sent' || a.type === 'skipped').length; + } + + logActivity(type: ActivityItem['type'], message: string, recipient?: string): void { + const item: ActivityItem = { + timestamp: Date.now(), + type, + message, + recipient, + }; + this.activityLog.push(item); + + // Keep only last 200 items + if (this.activityLog.length > 200) { + this.activityLog = this.activityLog.slice(-200); + } + } + + updateStats(messagesThisHour: number): void { + this.botStats.messagesThisHour = messagesThisHour; + } + + start(): void { + this.server = this.app.listen(this.port, () => { + this.logger.info(`Web dashboard running at http://localhost:${this.port}`); + }); + } + + stop(): void { + if (this.server) { + this.server.close(); + this.server = null; + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2bf9caa --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "resolveJsonModule": true, + "noEmit": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}