Adds a Notifications tab to Settings so an operator can pick which events reach their phone, published to a single self-hosted ntfy topic. The notifier subscribes to the realtime event hub the dashboard already consumes, so every notifiable event keeps exactly one publisher. Inbound media had no event, so `media.received` is added and published from the existing durable `gotImage` gate in the chat service — one notification per conversation, never repeated when the room re-syncs. It feeds the in-app notification bell as well. - Best-effort delivery: a rejecting or unreachable ntfy server is logged and never propagated into the path that produced the event. - Events buffered before the notifier attaches are skipped, so a restart cannot replay a backlog to somebody's phone. - Settings are applied field by field onto the live config object, so a save takes effect without a restart. - The access token is environment-owned (NTFY_AUTH_TOKEN) like the other secrets: stripped before the settings record is written, never returned by the API, which reports only `hasAuthToken`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
import { z } from 'zod';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import YAML from 'yaml';
|
|
import dotenv from 'dotenv';
|
|
import { TONE_KEYS } from './tone-profiles.js';
|
|
|
|
dotenv.config();
|
|
|
|
export const MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS = 20;
|
|
|
|
export const ConversationBehaviourSchema = z.object({
|
|
mode: z.enum(['legacy', 'r4r', 'r4r-media', 'manual']).default('legacy'),
|
|
maxAutomatedTurns: z.number().int().min(1).max(MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS).default(3),
|
|
rapportTurnsBeforeMediaAsk: z.number().int().min(1).max(MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS - 1).default(3),
|
|
}).superRefine((behaviour, context) => {
|
|
if (behaviour.mode === 'r4r-media' && behaviour.rapportTurnsBeforeMediaAsk >= behaviour.maxAutomatedTurns) {
|
|
context.addIssue({
|
|
code: 'custom',
|
|
path: ['rapportTurnsBeforeMediaAsk'],
|
|
message: 'Rapport turns must be lower than the maximum automated turns.',
|
|
});
|
|
}
|
|
}).default({ mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 });
|
|
|
|
export type ConversationBehaviour = z.infer<typeof ConversationBehaviourSchema>;
|
|
|
|
export const SubredditConfigSchema = z.object({
|
|
name: z.string(),
|
|
enabled: z.boolean().default(true),
|
|
type: z.enum(['image', 'chat']).default('image'),
|
|
weight: z.number().positive().optional(),
|
|
tone: z.enum(TONE_KEYS).default('default'),
|
|
scripted: z.string().optional(),
|
|
conversation: ConversationBehaviourSchema,
|
|
});
|
|
|
|
export type SubredditConfig = z.infer<typeof SubredditConfigSchema>;
|
|
|
|
const SubredditEntrySchema = z
|
|
.union([z.string().transform((s) => ({ name: s })), z.object({}).passthrough()])
|
|
.pipe(SubredditConfigSchema);
|
|
|
|
export const ConfigSchema = z.object({
|
|
reddit: z.object({
|
|
subreddits: z.array(SubredditEntrySchema).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']),
|
|
filterSellers: z.boolean().default(true),
|
|
prioritizeRecentPosts: z.boolean().default(false),
|
|
recentWindowMinutes: z.number().positive().default(60),
|
|
maxReadyCandidates: z.number().int().min(1).max(500).default(25),
|
|
sellerKeywords: z
|
|
.array(z.string())
|
|
.default([
|
|
'onlyfans',
|
|
'only fans',
|
|
'fansly',
|
|
'manyvids',
|
|
'linktree',
|
|
'linktr.ee',
|
|
'allmylinks',
|
|
'beacons.ai',
|
|
'cash.app',
|
|
'cashapp',
|
|
'venmo',
|
|
'paypal',
|
|
'telegram',
|
|
'subscribe',
|
|
'premium snap',
|
|
'premiumsnap',
|
|
'fan.ly',
|
|
'fanvue',
|
|
'loyalfans',
|
|
'pornhub.com',
|
|
'xvideos.com',
|
|
'chaturbate',
|
|
'myfreecams',
|
|
'cam girl',
|
|
'camgirl',
|
|
'buy my',
|
|
'custom content',
|
|
'custom vid',
|
|
'selling content',
|
|
'paid content',
|
|
'tip me',
|
|
'spoil me',
|
|
'findom',
|
|
'send tribute',
|
|
'wishlist',
|
|
'throne.com',
|
|
]),
|
|
sellerDomains: z
|
|
.array(z.string())
|
|
.default([
|
|
'onlyfans.com',
|
|
'fansly.com',
|
|
'fanvue.com',
|
|
'loyalfans.com',
|
|
'manyvids.com',
|
|
'linktr.ee',
|
|
'allmylinks.com',
|
|
'beacons.ai',
|
|
'beacons.page',
|
|
't.me',
|
|
'telegram.me',
|
|
'throne.com',
|
|
'throne.me',
|
|
'fan.ly',
|
|
'chaturbate.com',
|
|
'myfreecams.com',
|
|
'pornhub.com',
|
|
'xvideos.com',
|
|
'cash.app',
|
|
'cashapp.com',
|
|
'venmo.com',
|
|
'paypal.me',
|
|
'paypal.com',
|
|
'admireme.vip',
|
|
'fancentro.com',
|
|
'justforfans.app',
|
|
'justfor.fans',
|
|
]),
|
|
}),
|
|
conversations: z.object({
|
|
/**
|
|
* Ends a conversation outright when the other party pushes it to another
|
|
* app, to contact details, or to a payment rail. This is a safety control
|
|
* rather than a lead-quality filter, so it is independent of
|
|
* `reddit.filterSellers`.
|
|
*/
|
|
offPlatformKill: z.boolean().default(true),
|
|
/** Whether a configured seller keyword or domain also ends the conversation. */
|
|
killOnSellerKeyword: z.boolean().default(true),
|
|
/**
|
|
* Second-stage AI screening of inbound replies. Runs only when the
|
|
* deterministic rules come up clean, and fails open.
|
|
*/
|
|
aiReplyScreening: z.object({
|
|
enabled: z.boolean().default(false),
|
|
killConfidence: z.number().min(0).max(1).default(0.8),
|
|
flagConfidence: z.number().min(0).max(1).default(0.6),
|
|
}).default({ enabled: false, killConfidence: 0.8, flagConfidence: 0.6 }),
|
|
}).default({
|
|
offPlatformKill: true,
|
|
killOnSellerKeyword: true,
|
|
aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
|
|
}),
|
|
pushNotifications: z.object({
|
|
enabled: z.boolean().default(false),
|
|
/** Base URL of the ntfy server, e.g. https://ntfy.example.com. */
|
|
serverUrl: z.string().default('https://ntfy.sh'),
|
|
/** Single ntfy topic every selected event is published to. */
|
|
topic: z.string().default(''),
|
|
/**
|
|
* Bearer token for a protected topic. Environment-owned (NTFY_AUTH_TOKEN)
|
|
* like the other secrets, so it is never written to the settings record.
|
|
*/
|
|
authToken: z.string().optional(),
|
|
priority: z.enum(['min', 'low', 'default', 'high', 'max']).default('default'),
|
|
events: z.object({
|
|
imageReceived: z.boolean().default(true),
|
|
replyReceived: z.boolean().default(false),
|
|
messageFailed: z.boolean().default(false),
|
|
botStopped: z.boolean().default(false),
|
|
rateLimited: z.boolean().default(false),
|
|
sessionExpired: z.boolean().default(true),
|
|
syncFailed: z.boolean().default(false),
|
|
}).default({
|
|
imageReceived: true,
|
|
replyReceived: false,
|
|
messageFailed: false,
|
|
botStopped: false,
|
|
rateLimited: false,
|
|
sessionExpired: true,
|
|
syncFailed: false,
|
|
}),
|
|
}).default({
|
|
enabled: false,
|
|
serverUrl: 'https://ntfy.sh',
|
|
topic: '',
|
|
priority: 'default',
|
|
events: {
|
|
imageReceived: true,
|
|
replyReceived: false,
|
|
messageFailed: false,
|
|
botStopped: false,
|
|
rateLimited: false,
|
|
sessionExpired: true,
|
|
syncFailed: false,
|
|
},
|
|
}),
|
|
openrouter: z.object({
|
|
apiKey: z.string(),
|
|
imageAnalysisModel: z.string().default('google/gemini-3-flash-preview'),
|
|
imageAnalysisFallbackModels: z.array(z.string()).default([]),
|
|
chatModel: z.string().default('google/gemini-3-flash-preview'),
|
|
chatFallbackModels: z.array(z.string()).default([]),
|
|
imageAnalysisPrompt: z.string(),
|
|
messageGenerationPrompt: z.string(),
|
|
chatOpenerPrompt: z.string().default(
|
|
'Write a truthful, respectful opener to an adult Reddit user who explicitly asked to chat.\n\nPost title: {title}\nPost body: {selftext}\nAuthor: u/{author}\nSelf-declared gender: {gender}\n\nUse British English where natural. Reference something specific from the post, keep it to 1-2 sentences, and end with an easy invitation to reply. Do not invent age, relationship status, location, identity, shared interests, or personal history. Avoid pressure, sexual escalation, clinical language, and generic compliments. Just write the message.',
|
|
),
|
|
}),
|
|
rateLimit: z.object({
|
|
minDelayBetweenMessagesMs: z.number().default(300000),
|
|
maxMessagesPerHour: z.number().default(8),
|
|
jitterMs: z.number().default(60000),
|
|
}).default({ minDelayBetweenMessagesMs: 300000, maxMessagesPerHour: 8, jitterMs: 60000 }),
|
|
browser: z.object({
|
|
sessionDir: z.string().default('./data/browser-session'),
|
|
dryRun: z.boolean().default(false),
|
|
}).default({ sessionDir: './data/browser-session', dryRun: false }),
|
|
database: z.object({
|
|
path: z.string().default('./data/reddit-bot.db'),
|
|
}).default({ path: './data/reddit-bot.db' }),
|
|
media: z.object({
|
|
syncIntervalMs: z.number().default(1800000), // 30 minutes
|
|
syncEnabled: z.boolean().default(true),
|
|
}).default({ syncIntervalMs: 1800000, syncEnabled: true }),
|
|
strategies: z.object({
|
|
explorationRate: z.number().min(0).max(1).default(0.2),
|
|
minSamples: z.number().int().positive().transform((value) => Math.max(20, value)).default(20),
|
|
responseWindowHours: z.number().positive().default(12),
|
|
variants: z.array(z.object({
|
|
id: z.string(),
|
|
weight: z.number().default(1),
|
|
promptOverride: z.string(),
|
|
})).default([]),
|
|
}).default({ explorationRate: 0.2, minSamples: 20, responseWindowHours: 12, variants: [] }),
|
|
chatStrategies: z.object({
|
|
explorationRate: z.number().min(0).max(1).default(0.2),
|
|
minSamples: z.number().int().positive().transform((value) => Math.max(20, value)).default(20),
|
|
responseWindowHours: z.number().positive().default(12),
|
|
variants: z.array(z.object({
|
|
id: z.string(),
|
|
weight: z.number().default(1),
|
|
promptOverride: z.string(),
|
|
})).default([]),
|
|
}).default({ explorationRate: 0.2, minSamples: 20, responseWindowHours: 12, variants: [] }),
|
|
web: z.object({
|
|
enabled: z.boolean().default(true),
|
|
port: z.number().default(3000),
|
|
/** Prefer WEB_DASHBOARD_TOKEN so secrets do not live in config.yaml. */
|
|
authToken: z.string().min(8).optional(),
|
|
/** Optional least-privilege bearer token for GET/SSE agent access. */
|
|
agentReadToken: z.string().min(8).optional(),
|
|
/** Optional bearer token for /api/v1 controls and jobs as well as reads. */
|
|
agentControlToken: z.string().min(8).optional(),
|
|
/** Dangerous diagnostic endpoints are disabled unless explicitly enabled. */
|
|
debugEnabled: z.boolean().default(false),
|
|
}).default({ enabled: true, port: 3000, debugEnabled: false }),
|
|
});
|
|
|
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
|
|
export interface ConfigPersistence {
|
|
load(environment?: NodeJS.ProcessEnv): Config | null;
|
|
save(config: Config): void;
|
|
}
|
|
|
|
/**
|
|
* Loads the database-backed configuration. A legacy YAML file is imported
|
|
* exactly once when the database has not yet been seeded, which makes the
|
|
* migration safe for existing installations.
|
|
*/
|
|
export function loadConfig(store?: ConfigPersistence): Config {
|
|
const storedConfig = store?.load(process.env);
|
|
if (storedConfig) return storedConfig;
|
|
|
|
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.database) rawConfig.database = {};
|
|
// The database must be located before application settings can be read, so
|
|
// its path is an environment-owned bootstrap value rather than app config.
|
|
rawConfig.database.path = process.env.DATABASE_PATH ?? './data/reddit-bot.db';
|
|
|
|
if (process.env.NTFY_AUTH_TOKEN?.trim()) {
|
|
if (!rawConfig.pushNotifications) rawConfig.pushNotifications = {};
|
|
rawConfig.pushNotifications.authToken = process.env.NTFY_AUTH_TOKEN;
|
|
}
|
|
|
|
if (process.env.WEB_DASHBOARD_TOKEN !== undefined) {
|
|
if (!rawConfig.web) rawConfig.web = {};
|
|
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN;
|
|
}
|
|
if (process.env.REDDIT_BOT_AGENT_READ_TOKEN?.trim()) {
|
|
if (!rawConfig.web) rawConfig.web = {};
|
|
rawConfig.web.agentReadToken = process.env.REDDIT_BOT_AGENT_READ_TOKEN;
|
|
}
|
|
if (process.env.REDDIT_BOT_AGENT_CONTROL_TOKEN?.trim()) {
|
|
if (!rawConfig.web) rawConfig.web = {};
|
|
rawConfig.web.agentControlToken = process.env.REDDIT_BOT_AGENT_CONTROL_TOKEN;
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
|
|
if (result.data.web.enabled && !result.data.web.authToken) {
|
|
throw new Error('WEB_DASHBOARD_TOKEN environment variable or web.authToken configuration is required when the dashboard is enabled');
|
|
}
|
|
|
|
store?.save(result.data);
|
|
return result.data;
|
|
}
|