Add per-subreddit R4R conversation modes

This commit is contained in:
jjbrunton committed 2026-08-09 11:30:32 +01:00
1 parent 23d4c3a320
commit 0f76bcf59a
14 files changed
+343 -24

No files matched your search

+83 -12
View File
@@ -1,4 +1,5 @@
import type { OpenRouterClient } from '../ai/openrouter.js';
import type { SubredditConfig } from '../config/index.js';
import type { ConversationStateRepository, SentMessageRecord } from '../database/repositories.js';
import type { Logger } from '../utils/logger.js';
import type { RateLimiter } from '../utils/rate-limiter.js';
@@ -9,7 +10,9 @@ import {
buildConversationState,
chooseConversationIntent,
conversationMessageFingerprint,
MAX_AUTOMATED_CONVERSATION_TURNS,
createConversationAutomationState,
isSafeR4RReply,
LEGACY_AUTOMATED_CONVERSATION_TURNS,
mergeConversationState,
remainingAutomationTurns,
type ConversationState,
@@ -39,8 +42,8 @@ export interface ConversationAutomationClaims {
/**
* Sends at most one reply for each newly received user message. Durable state
* makes retries and restarts idempotent, and every conversation stops after
* three automated turns or immediately when consent is withdrawn.
* makes retries and restarts idempotent. The opener subreddit selects a
* snapshotted legacy, autonomous R4R, or manual policy for the conversation.
*/
export class ConversationAutomationService {
constructor(
@@ -54,6 +57,7 @@ export class ConversationAutomationService {
private readonly dryRun = false,
private readonly sellerKeywords: readonly string[] = [],
private readonly sellerDomains: readonly string[] = [],
private readonly configuredSubreddits: () => readonly SubredditConfig[] = () => [],
) {}
async processUnreadConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
@@ -85,14 +89,36 @@ export class ConversationAutomationService {
return null;
}
const mapped = messages.map(({ sender, text, isSelf }) => ({ sender, text, isSelf }));
const savedState = this.states.get(conversationId);
const state = mergeConversationState(
this.states.get(conversationId),
savedState,
buildConversationState(mapped),
);
if (!savedState) {
const behaviour = this.resolveConversationBehaviour(opener.subreddit);
const maxTurns = behaviour.mode === 'r4r'
? behaviour.maxAutomatedTurns
: LEGACY_AUTOMATED_CONVERSATION_TURNS;
state.automation = createConversationAutomationState(behaviour.mode, maxTurns);
// Snapshot before generation so retries, restarts, dry runs, and later
// configuration edits cannot change the policy mid-conversation.
this.states.save(conversationId, state);
}
const incomingKey = messageKey(latest);
if (state.automation.lastIncomingKey === incomingKey) return null;
if (state.automation.status !== 'active') return null;
if (state.automation.policyMode === 'manual') {
return this.finish(
conversation,
conversationId,
state,
incomingKey,
'human-required',
'This subreddit is configured for manual conversation handling.',
);
}
const sellerSignal = findSellerSignal(latest.text, [...this.sellerKeywords], [...this.sellerDomains]);
if (sellerSignal) {
return this.finish(
@@ -110,15 +136,41 @@ export class ConversationAutomationService {
if (state.boundaries.length > 0) {
return this.finish(conversation, conversationId, state, incomingKey, 'boundary', state.boundaries.join('; '));
}
if (state.automation.policyMode === 'r4r') {
const safetyReason = r4rSafetyHandoffReason(latest.text);
if (safetyReason) {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', safetyReason);
}
}
if (remainingAutomationTurns(state) === 0) {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', 'Three automated turns have been used.');
return this.finish(
conversation,
conversationId,
state,
incomingKey,
'human-required',
`${state.automation.maxAutomatedTurns} automated turns have been used.`,
);
}
const intent = chooseConversationIntent(mapped, state, 'media-ask');
if (intent.kind !== 'media-ask') {
const isLegacy = state.automation.policyMode === 'legacy';
const intent = chooseConversationIntent(mapped, state, isLegacy ? 'media-ask' : undefined);
if (isLegacy && intent.kind !== 'media-ask') {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', intent.reason);
}
const reply = await this.ai.generateMediaAsk(mapped, conversation.username, state, intent);
const reply = isLegacy
? await this.ai.generateMediaAsk(mapped, conversation.username, state, intent)
: await this.ai.generateReply(mapped, conversation.username, state, intent);
if (!isLegacy && !isSafeR4RReply(reply)) {
return this.finish(
conversation,
conversationId,
state,
incomingKey,
'human-required',
'The generated R4R reply failed autonomous safety checks.',
);
}
if (this.dryRun) {
this.logger.info(`[automation] Dry run prepared turn ${state.automation.turnsUsed + 1} for u/${conversation.username}`);
return null;
@@ -129,14 +181,14 @@ export class ConversationAutomationService {
// Persist the full intended state before the remote send. If the process
// dies after Matrix accepts the message, both this state and the durable
// event claim make the retry fail closed instead of sending twice.
state.askedForMedia = true;
if (isLegacy) state.askedForMedia = true;
state.automation.turnsUsed += 1;
state.automation.lastIncomingKey = incomingKey;
state.automation.aiMessageFingerprints.push(conversationMessageFingerprint(reply));
const exhausted = state.automation.turnsUsed >= MAX_AUTOMATED_CONVERSATION_TURNS;
const exhausted = state.automation.turnsUsed >= state.automation.maxAutomatedTurns;
if (exhausted) {
state.automation.status = 'human-required';
state.automation.handoffReason = 'Three automated turns have been used.';
state.automation.handoffReason = `${state.automation.maxAutomatedTurns} automated turns have been used.`;
}
this.states.save(conversationId, state);
@@ -148,7 +200,7 @@ export class ConversationAutomationService {
if (!sent) return null;
this.claims.markSent(conversationId, incomingKey);
this.logger.info(
`[automation] Sent turn ${state.automation.turnsUsed}/${MAX_AUTOMATED_CONVERSATION_TURNS} to u/${conversation.username}`,
`[automation] Sent ${state.automation.policyMode} turn ${state.automation.turnsUsed}/${state.automation.maxAutomatedTurns} to u/${conversation.username}`,
);
return {
conversationId,
@@ -158,6 +210,13 @@ export class ConversationAutomationService {
};
}
private resolveConversationBehaviour(subreddit: string | undefined): SubredditConfig['conversation'] {
const configured = subreddit
? this.configuredSubreddits().find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase())
: undefined;
return configured?.conversation ?? { mode: 'legacy', maxAutomatedTurns: LEGACY_AUTOMATED_CONVERSATION_TURNS };
}
private finish(
conversation: ActiveChat,
conversationId: string,
@@ -186,3 +245,15 @@ function hasReceivedImage(messages: ChatMessage[]): boolean {
function messageKey(message: ChatMessage): string {
return message.eventId ?? `${message.timestamp}:${message.sender}:${message.text}`;
}
function r4rSafetyHandoffReason(message: string): string | null {
if (/\b(?:i(?:'m| am)|im|age(?:d)?|turning)\s*(?:1[0-7]|[0-9])\b/i.test(message)
|| /\b(?:1[0-7]|[0-9])\s*[fm]\b/i.test(message)
|| /\b(?:minor|underage)\b/i.test(message)) {
return 'The participant may be under 18.';
}
if (/\b(?:meet|meetup|meet up|come over|address|phone number|whatsapp|telegram|snapchat)\b/i.test(message)) {
return 'The conversation reached contact details or an in-person meeting.';
}
return null;
}
+34 -5
View File
@@ -33,7 +33,9 @@ export interface ConversationState {
automation: ConversationAutomationState;
}
export const MAX_AUTOMATED_CONVERSATION_TURNS = 3;
export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3;
export const CONVERSATION_POLICY_VERSION = 1;
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'manual';
export type ConversationAutomationStatus =
| 'active'
@@ -43,6 +45,9 @@ export type ConversationAutomationStatus =
| 'user-overridden';
export interface ConversationAutomationState {
policyMode: ConversationPolicyMode;
policyVersion: number;
maxAutomatedTurns: number;
turnsUsed: number;
status: ConversationAutomationStatus;
lastIncomingKey: string | null;
@@ -51,8 +56,14 @@ export interface ConversationAutomationState {
aiMessageFingerprints: string[];
}
export function createConversationAutomationState(): ConversationAutomationState {
export function createConversationAutomationState(
policyMode: ConversationPolicyMode = 'legacy',
maxAutomatedTurns = LEGACY_AUTOMATED_CONVERSATION_TURNS,
): ConversationAutomationState {
return {
policyMode,
policyVersion: CONVERSATION_POLICY_VERSION,
maxAutomatedTurns,
turnsUsed: 0,
status: 'active',
lastIncomingKey: null,
@@ -67,7 +78,7 @@ export function conversationMessageFingerprint(message: string): string {
export function remainingAutomationTurns(state: ConversationState): number {
if (state.automation.status !== 'active') return 0;
return Math.max(0, MAX_AUTOMATED_CONVERSATION_TURNS - state.automation.turnsUsed);
return Math.max(0, state.automation.maxAutomatedTurns - state.automation.turnsUsed);
}
const EMPTY_PERSONA: ConversationPersona = {
@@ -126,7 +137,7 @@ export function chooseConversationIntent(
}
return {
kind: 'media-ask',
reason: `image-sharing turn ${state.automation.turnsUsed + 1} of ${MAX_AUTOMATED_CONVERSATION_TURNS}`,
reason: `image-sharing turn ${state.automation.turnsUsed + 1} of ${state.automation.maxAutomatedTurns}`,
};
}
@@ -187,6 +198,13 @@ export function isSafeConversationReply(message: string): boolean {
return sentences.length <= 3;
}
/** R4R automation may converse and flirt, but it must never solicit sexual media. */
export function isSafeR4RReply(message: string): boolean {
if (!isSafeConversationReply(message)) return false;
return !/\b(send|show|share|swap|trade|drop|give|dm)\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes|content)\b/i.test(message)
&& !/\b(can|could|would|will) you\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes)\b/i.test(message);
}
export function serializeConversationState(state: ConversationState): string {
return JSON.stringify(state);
}
@@ -220,13 +238,24 @@ export function parseConversationState(value: string | null | undefined): Conver
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 === '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
: 'active';
return {
policyMode,
policyVersion: typeof parsed.policyVersion === 'number' && parsed.policyVersion > 0
? Math.trunc(parsed.policyVersion)
: CONVERSATION_POLICY_VERSION,
maxAutomatedTurns,
turnsUsed: typeof parsed.turnsUsed === 'number'
? Math.max(0, Math.min(MAX_AUTOMATED_CONVERSATION_TURNS, Math.trunc(parsed.turnsUsed)))
? Math.max(0, Math.min(maxAutomatedTurns, Math.trunc(parsed.turnsUsed)))
: 0,
status,
lastIncomingKey: typeof parsed.lastIncomingKey === 'string' ? parsed.lastIncomingKey : null,
@@ -181,6 +181,7 @@ async function buildRuntime(
accountConfig.browser.dryRun,
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerKeywords : [],
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerDomains : [],
() => accountConfig.reddit.subreddits,
);
const resources: RedditAccountRuntimeResources = {