Files
RedditBot/src/web/routes/outreach.ts
T

198 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Router } from 'express';
import { z } from 'zod';
import type { CandidateStatus } from '../../database/candidate-queue-repository.js';
import { isSafeOutboundMessage } from '../../services/outbound-message-safety.js';
import type { WebContext } from '../context.js';
const ACTIVE_STATUSES = ['discovered', 'screening', 'ready', 'sending', 'retryable'] as const;
const MessageUpdateSchema = z.strictObject({ message: z.string().trim().min(1).max(2000) });
type ActiveCandidateStatus = Exclude<CandidateStatus, 'sent' | 'rejected'>;
interface OutreachCandidateView {
postId: string;
subreddit: string;
author: string;
title: string;
postUrl: string;
targetComment: { body: string; permalink: string } | null;
sourceType: 'image' | 'chat';
status: ActiveCandidateStatus;
priority: number;
postCreatedAt: number;
discoveredAt: number;
updatedAt: number;
availableAt: number;
attemptCount: number;
hasPreparedMessage: boolean;
physiqueScore: number | null;
preparation: {
message: string;
imageAnalysis: string;
detectedGender: 'male' | 'female' | 'other' | 'unknown';
attractivenessScore: number | null;
physiqueScore: number | null;
physiqueTargeting: { enabled: boolean; minScore: number; maxScore: number } | null;
sourceImageUrl: string | null;
strategyId: string | null;
experimentId: string | null;
promptVersion: string | null;
selectionReason: 'explore' | 'exploit' | null;
modelId: string;
toneId: string;
scripted: boolean;
} | null;
note: string | null;
owner: { accountId: string; redditUsername: string } | null;
}
function redditPostUrl(permalink: string): string {
try {
return new URL(permalink, 'https://www.reddit.com').toString();
} catch {
return 'https://www.reddit.com';
}
}
export function createOutreachRouter(ctx: WebContext): Router {
const router = Router();
router.get('/outreach', (_req, res) => {
const queue = ctx.services.candidateQueue;
if (!queue) {
res.status(503).json({ error: 'Outreach queue not initialized' });
return;
}
const stats = queue.getStats();
const records = queue.listActive();
const accountById = new Map((ctx.services.redditAccounts?.list() ?? [])
.map((account) => [account.accountId, account.redditUsername] as const));
const candidates: OutreachCandidateView[] = records.map((candidate) => ({
postId: candidate.postId,
subreddit: candidate.subreddit,
author: candidate.author,
title: candidate.post.title,
postUrl: redditPostUrl(candidate.post.permalink),
targetComment: candidate.post.targetComment
? {
body: candidate.post.targetComment.body,
permalink: redditPostUrl(candidate.post.targetComment.permalink),
}
: null,
sourceType: candidate.sourceType,
status: candidate.status as ActiveCandidateStatus,
priority: candidate.priority,
postCreatedAt: candidate.post.created_utc * 1_000,
discoveredAt: candidate.discoveredAt,
updatedAt: candidate.updatedAt,
availableAt: candidate.availableAt,
attemptCount: candidate.attemptCount,
hasPreparedMessage: candidate.prepared !== null,
physiqueScore: candidate.prepared?.physiqueScore ?? null,
preparation: candidate.prepared ? {
message: candidate.prepared.generatedMessage,
imageAnalysis: candidate.prepared.imageAnalysis,
detectedGender: candidate.prepared.detectedGender,
attractivenessScore: candidate.prepared.attractivenessScore ?? null,
physiqueScore: candidate.prepared.physiqueScore ?? null,
physiqueTargeting: candidate.sourceType === 'image'
? { ...ctx.runtimeConfig.reddit.physiqueTargeting }
: null,
sourceImageUrl: candidate.prepared.sourceImageUrl ?? null,
strategyId: candidate.prepared.strategyId,
experimentId: candidate.prepared.experimentId,
promptVersion: candidate.prepared.promptVersion,
selectionReason: candidate.prepared.selectionReason,
modelId: candidate.prepared.modelId,
toneId: candidate.prepared.toneId,
scripted: candidate.prepared.scripted,
operatorEdited: candidate.prepared.operatorEdited ?? false,
} : null,
note: candidate.lastError,
owner: candidate.assignedAccountId
? {
accountId: candidate.assignedAccountId,
redditUsername: accountById.get(candidate.assignedAccountId) ?? candidate.assignedAccountId,
}
: null,
}));
const stages = {
discovered: stats.byStatus.discovered,
screening: stats.byStatus.screening,
ready: stats.byStatus.ready,
sending: stats.byStatus.sending,
retryable: stats.byStatus.retryable,
};
const response = {
generatedAt: Date.now(),
activeCount: ACTIVE_STATUSES.reduce((total, status) => total + stats.byStatus[status], 0),
stages,
outcomes: { sent: stats.byStatus.sent, rejected: stats.byStatus.rejected },
rejectionReasons: stats.rejectionReasons,
oldestActiveAt: queue.getOldestActiveAt(),
candidates,
};
res.json(response);
});
router.delete('/outreach', (_req, res) => {
const queue = ctx.services.candidateQueue;
if (!queue) {
res.status(503).json({ error: 'Outreach queue not initialized' });
return;
}
const result = queue.clearUnsent();
const message = `Cleared ${result.total} unsent outreach candidate(s)`
+ (result.retainedSending > 0 ? `; retained ${result.retainedSending} in-flight send(s)` : '');
ctx.activity.push('info', message);
ctx.broadcaster.push('warn', message, result);
res.json({
success: true,
cleared: {
discovered: result.discovered,
screening: result.screening,
ready: result.ready,
retryable: result.retryable,
total: result.total,
},
retainedSending: result.retainedSending,
});
});
router.patch('/outreach/:postId/message', (req, res) => {
const queue = ctx.services.candidateQueue;
if (!queue) return res.status(503).json({ error: 'Outreach queue not initialized' });
const parsed = MessageUpdateSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Message must contain 1–2000 characters' });
if (!isSafeOutboundMessage(parsed.data.message)) {
return res.status(400).json({ error: 'Message fails location or in-person meeting safeguards' });
}
const result = queue.updatePreparedMessage(req.params.postId, parsed.data.message);
if (result === 'not-found') return res.status(404).json({ error: 'Outreach candidate not found' });
if (result === 'not-editable') {
return res.status(409).json({ error: 'Only ready or retrying messages can be edited' });
}
const message = `Edited prepared outreach message for candidate ${req.params.postId}`;
ctx.activity.push('info', message);
ctx.broadcaster.push('info', message);
return res.json({ success: true, postId: req.params.postId, message: parsed.data.message });
});
router.delete('/outreach/:postId', (req, res) => {
const queue = ctx.services.candidateQueue;
if (!queue) return res.status(503).json({ error: 'Outreach queue not initialized' });
const result = queue.cancelUnsent(req.params.postId);
if (result === 'not-found') return res.status(404).json({ error: 'Outreach candidate not found' });
if (result === 'not-cancellable') {
return res.status(409).json({ error: 'Candidate is already sending or has finished' });
}
const message = `Removed outreach candidate ${req.params.postId} from the funnel`;
ctx.activity.push('info', message);
ctx.broadcaster.push('warn', message);
return res.json({ success: true, postId: req.params.postId });
});
return router;
}