Files
RedditBot/src/services/conversation-state.ts
T
Jamie BruntonandClaude Opus 5 827eb3a1e0 feat: force a seller scan over active conversations from the inbox
Adds a "Scan for sellers" button to the conversations header, backed by
POST /api/conversations/seller-scan.

The sweep reuses the existing seller detection rather than inventing a
second one: the configured keyword/domain list, the seller-link funnel
patterns, and the aiReplyScreening confidences all decide the verdict, so
a swept conversation lands in the state the live reply loop would have
given it. Being operator-forced, it deliberately ignores filterSellers
and aiReplyScreening.enabled.

AI is the last resort. Conversations are skipped when already flagged or
killed, settled for free by the deterministic rules, or skipped against a
durable sellerScreenedKey watermark when nothing new has arrived since
the last clean sweep. Only the remainder reaches the model, batched eight
at a time through a new screenConversationsForSellers, so the system
prompt and round trip are paid per batch instead of per conversation. A
classifier outage degrades the sweep instead of failing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:42:55 +01:00

353 lines
14 KiB
TypeScript

import { createHash } from 'node:crypto';
import { isSafeOutboundMessage } from './outbound-message-safety.js';
import { containsMediaRequest, detectConversationBoundaries } from './conversation-boundaries.js';
export interface ConversationMessage {
sender: string;
text: string;
isSelf: boolean;
}
export type ConversationIntentKind =
| 'answer-question'
| 'respect-boundary'
| 'reciprocate'
| 'follow-up'
| 'media-ask';
export interface ConversationIntent {
kind: ConversationIntentKind;
reason: string;
}
export interface ConversationPersona {
age: number | null;
location: string | null;
relationshipStatus: 'married' | 'single' | 'unspecified';
}
export interface ConversationState {
persona: ConversationPersona;
boundaries: string[];
/** Durable marker set when configured seller or upsell signals are detected. */
sellerDetected: boolean;
/**
* Watermark of the last inbound message an operator-forced seller sweep has
* already screened and cleared. Re-running the sweep skips a conversation
* whose watermark is unchanged, so repeated presses cost no AI calls.
*/
sellerScreenedKey?: string;
/** Kept for backwards compatibility with state written before automation. */
askedForMedia: boolean;
topics: string[];
automation: ConversationAutomationState;
}
export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3;
export const DEFAULT_HYBRID_RAPPORT_TURNS = 3;
export const CONVERSATION_POLICY_VERSION = 3;
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual';
export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask';
/**
* `killed` is terminal and distinct from `human-required`: the conversation was
* ended because the other party is selling or pulling the conversation off
* Reddit, so there is nothing for an operator to pick up. Everything else is a
* handoff that still expects a human.
*/
export type ConversationAutomationStatus =
| 'active'
| 'image-received'
| 'human-required'
| 'boundary'
| 'user-overridden'
| 'killed';
export interface ConversationAutomationState {
policyMode: ConversationPolicyMode;
policyVersion: number;
maxAutomatedTurns: number;
rapportTurnsBeforeMediaAsk: number;
hybridPhase: HybridConversationPhase | null;
turnsUsed: number;
status: ConversationAutomationStatus;
lastIncomingKey: string | null;
handoffReason: string | null;
/** SHA-256 fingerprints avoid storing a second copy of generated message text. */
aiMessageFingerprints: string[];
}
export function createConversationAutomationState(
policyMode: ConversationPolicyMode = 'legacy',
maxAutomatedTurns = LEGACY_AUTOMATED_CONVERSATION_TURNS,
rapportTurnsBeforeMediaAsk = DEFAULT_HYBRID_RAPPORT_TURNS,
): ConversationAutomationState {
return {
policyMode,
policyVersion: CONVERSATION_POLICY_VERSION,
maxAutomatedTurns,
rapportTurnsBeforeMediaAsk,
hybridPhase: policyMode === 'r4r-media' ? 'rapport' : null,
turnsUsed: 0,
status: 'active',
lastIncomingKey: null,
handoffReason: null,
aiMessageFingerprints: [],
};
}
export function conversationMessageFingerprint(message: string): string {
return createHash('sha256').update(message.trim()).digest('hex');
}
export function remainingAutomationTurns(state: ConversationState): number {
return Math.max(0, state.automation.maxAutomatedTurns - state.automation.turnsUsed);
}
const EMPTY_PERSONA: ConversationPersona = {
age: null,
location: null,
relationshipStatus: 'unspecified',
};
export function buildConversationState(messages: ConversationMessage[]): ConversationState {
const firstSelfMessage = messages.find((message) => message.isSelf)?.text ?? '';
const theirText = messages
.filter((message) => !message.isSelf)
.map((message) => message.text)
.join('\n');
const ageMatch = firstSelfMessage.match(/\b(3[0-5])\s*[mM]\b/);
const persona: ConversationPersona = {
age: ageMatch ? Number(ageMatch[1]) : EMPTY_PERSONA.age,
location: /\bUK\b/i.test(firstSelfMessage) ? 'UK' : EMPTY_PERSONA.location,
relationshipStatus: /\bmarried\b|\bwife\b/i.test(firstSelfMessage) ? 'married' : EMPTY_PERSONA.relationshipStatus,
};
const boundaries = detectConversationBoundaries(messages);
const topics = extractTopics(theirText);
return {
persona,
boundaries,
sellerDetected: false,
askedForMedia: messages.some((message) => message.isSelf && containsMediaRequest(message.text)),
topics,
automation: createConversationAutomationState(),
};
}
export function chooseConversationIntent(
messages: ConversationMessage[],
state: ConversationState,
requestedIntent?: 'media-ask',
): ConversationIntent {
if (requestedIntent === 'media-ask') {
if (state.boundaries.includes('does not want to send photos or videos')) {
return { kind: 'respect-boundary', reason: 'they have declined or set a boundary around photos or videos' };
}
if (state.automation.status !== 'active' || remainingAutomationTurns(state) === 0) {
return { kind: 'follow-up', reason: 'the automated conversation has reached human handoff' };
}
return {
kind: 'media-ask',
reason: `image-sharing turn ${state.automation.turnsUsed + 1} of ${state.automation.maxAutomatedTurns}`,
};
}
if (state.boundaries.length > 0) {
return { kind: 'respect-boundary', reason: 'their conversation contains a stated boundary' };
}
const latestOtherMessage = [...messages].reverse().find((message) => !message.isSelf)?.text ?? '';
if (/\?\s*$|\b(what|why|where|when|who|how|are you|do you|would you|can you)\b/i.test(latestOtherMessage)) {
return { kind: 'answer-question', reason: 'their latest message asks a direct question' };
}
if (latestOtherMessage.trim().length > 0) {
return { kind: 'reciprocate', reason: 'they shared something to acknowledge before advancing the conversation' };
}
return { kind: 'follow-up', reason: 'there is no substantive reply to build on' };
}
/** Merge durable memory with observations from the currently loaded transcript. */
export function mergeConversationState(
saved: ConversationState | undefined,
observed: ConversationState,
): ConversationState {
if (!saved) return observed;
return {
persona: {
age: observed.persona.age ?? saved.persona.age,
location: observed.persona.location ?? saved.persona.location,
relationshipStatus: observed.persona.relationshipStatus !== 'unspecified'
? observed.persona.relationshipStatus
: saved.persona.relationshipStatus,
},
boundaries: [...new Set([...saved.boundaries, ...observed.boundaries])],
sellerDetected: saved.sellerDetected || observed.sellerDetected,
...(saved.sellerScreenedKey ? { sellerScreenedKey: saved.sellerScreenedKey } : {}),
askedForMedia: saved.askedForMedia || observed.askedForMedia,
topics: [...new Set([...saved.topics, ...observed.topics])],
automation: saved.automation,
};
}
/**
* Conversation text is deliberately delimited as data. It must never override
* the system instruction, even if a participant includes instruction-like text.
*/
export function formatConversationAsUntrustedData(messages: ConversationMessage[], maxMessages = 24): string {
const recentMessages = messages.slice(-maxMessages);
const rows = recentMessages.map((message) => {
const speaker = message.isSelf ? 'You' : 'Other person';
return `${speaker}: ${message.text}`;
});
return `BEGIN UNTRUSTED CONVERSATION DATA\n${rows.join('\n')}\nEND UNTRUSTED CONVERSATION DATA`;
}
export type ConversationReplySafetyFailure =
| 'empty reply'
| 'reply exceeds 600 characters'
| 'reply contains instruction or prompt leakage'
| 'reply implies shared location or an in-person meeting'
| 'reply exceeds the three-sentence limit'
| 'reply solicits media outside the configured media-ask phase'
| 'media ask does not contain a recognizable image or video request'
| 'media ask requests explicit or sexual content'
| 'media ask uses coercive or pressuring language';
/** Explain why generated text violates the shared autonomous reply contract. */
export function conversationReplySafetyFailure(message: string): ConversationReplySafetyFailure | null {
const trimmed = message.trim();
if (!trimmed) return 'empty reply';
if (trimmed.length > 600) return 'reply exceeds 600 characters';
if (/\b(as an ai|system prompt|begin untrusted|end untrusted|ignore (all|previous) instructions)\b/i.test(trimmed)) {
return 'reply contains instruction or prompt leakage';
}
if (!isSafeOutboundMessage(trimmed)) return 'reply implies shared location or an in-person meeting';
const sentences = trimmed.match(/[^.!?]+[.!?]+/g) ?? [];
if (sentences.length > 3) return 'reply exceeds the three-sentence limit';
return null;
}
/** Reject obvious instruction leakage and outputs that disregard the reply length contract. */
export function isSafeConversationReply(message: string): boolean {
return conversationReplySafetyFailure(message) === null;
}
export function r4rReplySafetyFailure(message: string): ConversationReplySafetyFailure | null {
const sharedFailure = conversationReplySafetyFailure(message);
if (sharedFailure) return sharedFailure;
return containsMediaRequest(message)
? 'reply solicits media outside the configured media-ask phase'
: null;
}
/** R4R automation may converse and flirt, but it must never solicit sexual media. */
export function isSafeR4RReply(message: string): boolean {
return r4rReplySafetyFailure(message) === null;
}
export function hybridMediaAskSafetyFailure(message: string): ConversationReplySafetyFailure | null {
const sharedFailure = conversationReplySafetyFailure(message);
if (sharedFailure) return sharedFailure;
if (!containsMediaRequest(message)) return 'media ask does not contain a recognizable image or video request';
if (/\b(nude|nudes|naked|explicit|nsfw|sexy pic|sexual)\b/i.test(message)) {
return 'media ask requests explicit or sexual content';
}
if (/\b(must|prove|dare|owe|have to|need to)\b/i.test(message)) {
return 'media ask uses coercive or pressuring language';
}
return null;
}
/** The hybrid policy gets one optional, non-explicit image request after rapport. */
export function isSafeHybridMediaAsk(message: string): boolean {
return hybridMediaAskSafetyFailure(message) === null;
}
export function serializeConversationState(state: ConversationState): string {
return JSON.stringify(state);
}
export function parseConversationState(value: string | null | undefined): ConversationState | null {
if (!value) return null;
try {
const parsed = JSON.parse(value) as Partial<ConversationState>;
if (!parsed.persona || !Array.isArray(parsed.boundaries) || !Array.isArray(parsed.topics) || typeof parsed.askedForMedia !== 'boolean') {
return null;
}
const automation = parseAutomationState(parsed.automation);
return {
persona: {
age: typeof parsed.persona.age === 'number' ? parsed.persona.age : null,
location: typeof parsed.persona.location === 'string' ? parsed.persona.location : null,
relationshipStatus: parsed.persona.relationshipStatus === 'married' || parsed.persona.relationshipStatus === 'single'
? parsed.persona.relationshipStatus
: 'unspecified',
},
boundaries: parsed.boundaries.filter((value): value is string => typeof value === 'string'),
sellerDetected: typeof parsed.sellerDetected === 'boolean'
? parsed.sellerDetected
: automation.handoffReason?.startsWith('Potential upsell detected') === true,
...(typeof parsed.sellerScreenedKey === 'string' ? { sellerScreenedKey: parsed.sellerScreenedKey } : {}),
askedForMedia: parsed.askedForMedia,
topics: parsed.topics.filter((value): value is string => typeof value === 'string'),
automation,
};
} catch {
return null;
}
}
function parseAutomationState(value: unknown): ConversationAutomationState {
if (!value || typeof value !== 'object') return createConversationAutomationState();
const parsed = value as Partial<ConversationAutomationState>;
const policyMode: ConversationPolicyMode = parsed.policyMode === 'r4r'
|| parsed.policyMode === 'r4r-media'
|| parsed.policyMode === 'manual'
? parsed.policyMode
: 'legacy';
const maxAutomatedTurns = typeof parsed.maxAutomatedTurns === 'number'
? Math.max(1, Math.min(20, Math.trunc(parsed.maxAutomatedTurns)))
: LEGACY_AUTOMATED_CONVERSATION_TURNS;
const status: ConversationAutomationStatus =
parsed.status === 'image-received' || parsed.status === 'human-required' || parsed.status === 'boundary'
|| parsed.status === 'user-overridden' || parsed.status === 'killed'
? parsed.status
: 'active';
const rapportTurnsBeforeMediaAsk = typeof parsed.rapportTurnsBeforeMediaAsk === 'number'
? Math.max(1, Math.min(19, Math.trunc(parsed.rapportTurnsBeforeMediaAsk)))
: DEFAULT_HYBRID_RAPPORT_TURNS;
const hybridPhase: HybridConversationPhase | null = policyMode !== 'r4r-media'
? null
: parsed.hybridPhase === 'media-ask' || parsed.hybridPhase === 'post-media-ask'
? parsed.hybridPhase
: 'rapport';
return {
policyMode,
policyVersion: typeof parsed.policyVersion === 'number' && parsed.policyVersion > 0
? Math.trunc(parsed.policyVersion)
: CONVERSATION_POLICY_VERSION,
maxAutomatedTurns,
rapportTurnsBeforeMediaAsk,
hybridPhase,
turnsUsed: typeof parsed.turnsUsed === 'number'
? Math.max(0, Math.min(maxAutomatedTurns, Math.trunc(parsed.turnsUsed)))
: 0,
status,
lastIncomingKey: typeof parsed.lastIncomingKey === 'string' ? parsed.lastIncomingKey : null,
handoffReason: typeof parsed.handoffReason === 'string' ? parsed.handoffReason : null,
aiMessageFingerprints: Array.isArray(parsed.aiMessageFingerprints)
? parsed.aiMessageFingerprints.filter((item): item is string => typeof item === 'string')
: [],
};
}
function extractTopics(text: string): string[] {
const topics: string[] = [];
for (const topic of ['music', 'films', 'movies', 'gaming', 'work', 'travel', 'fitness']) {
if (new RegExp(`\\b${topic}\\b`, 'i').test(text)) topics.push(topic);
}
return topics;
}