feat: automate bounded image-sharing conversations

This commit is contained in:
jjbrunton committed 2026-08-08 23:10:52 +01:00
1 parent dcf57273e2
commit 3beb8aada2
15 files changed
+477 -19

No files matched your search

+18 -11
View File
@@ -320,30 +320,37 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
.map(msg => `${msg.isSelf ? 'You' : msg.sender}: ${msg.text}`)
.join('\n');
const turnNumber = Math.min(3, (state?.automation.turnsUsed ?? 0) + 1);
const turnGuidance = turnNumber === 1
? 'Make this a light, optional invitation that follows naturally from what they just said.'
: turnNumber === 2
? 'Be a little clearer that you would enjoy seeing them, while keeping the invitation easy to decline.'
: 'Make one final, direct but low-pressure invitation. Do not imply that you will ask again.';
const legacyPrompt = `You are helping continue a Reddit chat conversation. Here is the conversation so far:
${historyText}
Write the next message to send to ${username}. The goal of THIS message is to playfully nudge them into sending a pic or video OF THEMSELVES — their body, face, outfit, what they look like right now, a selfie. NOT a pic of an object, place, food, pet, or scenery. Keep the same tone and style as the previous messages from "You" — casual, flirty, direct, never robotic. Keep it to 1-3 sentences. You can reference something they just said as a hook, but the ask itself must be about seeing THEM.
Write the next message to send to ${username}. This is automated conversation turn ${turnNumber} of 3. Acknowledge or answer their latest message first, then naturally invite them to share an image of themselves if they are comfortable. Keep the same vocabulary, punctuation, message length, and energy as the previous messages from "You". Keep it to 1-3 short sentences.
Pick ONE technique that fits the current vibe — don't combine them, don't telegraph that you're using one:
- Playful dare ("bet you won't send a selfie right now")
- Direct ask for them ("send me a pic", "lemme see you", "selfie?")
- Tease about how they look / sound ("now I have to see what you look like")
- Curiosity hook about their appearance ("ok I need a face to picture here")
- Compliment + ask ("you sound hot, prove it 😏", "show me that smile")
- Outfit / "what are you wearing" angle ("send a pic of the fit")
TURN GUIDANCE: ${turnGuidance}
Use one natural angle that fits the conversation: a selfie, a pic or video of them, their smile, their outfit, or what they look like right now. Vary the wording from any earlier image invitation.
DO NOT:
- Sound like an AI or use formal language
- Use words like "captivating", "alluring", "exquisite", "radiant"
- Beg, sound desperate, or repeat an ask they already declined
- Beg, pressure, guilt, challenge, dare, demand proof, or repeat an ask they declined
- Ask for nudity or explicit content
- Write long elaborate sentences
- Mention OnlyFans, paying, or anything transactional
Just write the message, nothing else.`;
const prompt = this.buildConversationPrompt(conversationHistory, username, state, intent);
const effectiveIntent = intent ?? {
kind: 'media-ask' as const,
reason: `image-sharing turn ${turnNumber} of 3`,
};
const prompt = this.buildConversationPrompt(conversationHistory, username, state, effectiveIntent);
const message = await this.generateTextWithFallback('generateMediaAsk', {
system: prompt.system,
user: `${prompt.user}\n\nMEDIA-ASK REQUIREMENTS (apply only if the intent is media-ask):\n${legacyPrompt}`,
@@ -443,7 +450,7 @@ Just write the message, nothing else.`;
? `Age: ${state.persona.age ?? 'not established'}\nLocation: ${state.persona.location ?? 'not established'}\nRelationship status: ${state.persona.relationshipStatus}`
: 'No durable persona facts have been established.';
const memory = state
? `Boundaries: ${state.boundaries.join('; ') || 'none recorded'}\nPrevious media ask: ${state.askedForMedia ? 'yes' : 'no'}\nTopics: ${state.topics.join(', ') || 'none recorded'}`
? `Boundaries: ${state.boundaries.join('; ') || 'none recorded'}\nPrevious media ask: ${state.askedForMedia ? 'yes' : 'no'}\nAutomated turns used: ${state.automation.turnsUsed} of 3\nTopics: ${state.topics.join(', ') || 'none recorded'}`
: 'No durable conversation state has been established.';
const selectedIntent = intent ?? { kind: 'reciprocate', reason: 'acknowledge their message before advancing the conversation' };
return {
+148
View File
@@ -0,0 +1,148 @@
import type { OpenRouterClient } from '../ai/openrouter.js';
import type { ConversationStateRepository } from '../database/repositories.js';
import type { Logger } from '../utils/logger.js';
import type { RateLimiter } from '../utils/rate-limiter.js';
import type { ActiveChat, ChatMessage } from './chat/types.js';
import type { ChatService } from './chat/service.js';
import {
buildConversationState,
chooseConversationIntent,
MAX_AUTOMATED_CONVERSATION_TURNS,
mergeConversationState,
remainingAutomationTurns,
type ConversationState,
} from './conversation-state.js';
export type ConversationAutomationResultKind =
| 'sent'
| 'human-required'
| 'boundary'
| 'image-received';
export interface ConversationAutomationResult {
conversationId: string;
username: string;
kind: ConversationAutomationResultKind;
remainingTurns: number;
}
/**
* 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.
*/
export class ConversationAutomationService {
constructor(
private readonly chat: ChatService,
private readonly ai: OpenRouterClient,
private readonly states: ConversationStateRepository,
private readonly rateLimiter: RateLimiter,
private readonly logger: Logger,
private readonly dryRun = false,
) {}
async processUnreadConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
await this.chat.refreshTopChats(5);
const conversations = await this.chat.getCachedConversations();
const results: ConversationAutomationResult[] = [];
for (const conversation of conversations) {
const result = await this.processConversation(conversation, signal);
if (result) results.push(result);
}
return results;
}
async processConversation(
conversation: ActiveChat,
signal?: AbortSignal,
): Promise<ConversationAutomationResult | null> {
if (!conversation.unread || conversation.isFromUs || !conversation.roomId || !conversation.url) return null;
const messages = conversation.messages ?? [];
const latest = messages.at(-1);
if (!latest || latest.isSelf) return null;
const conversationId = conversation.roomId;
const mapped = messages.map(({ sender, text, isSelf }) => ({ sender, text, isSelf }));
const state = mergeConversationState(
this.states.get(conversationId),
buildConversationState(mapped),
);
const incomingKey = messageKey(latest);
if (state.automation.lastIncomingKey === incomingKey) return null;
if (state.automation.status !== 'active') return null;
if (hasReceivedImage(messages)) {
return this.finish(conversation, conversationId, state, incomingKey, 'image-received', 'The user shared an image.');
}
if (state.boundaries.length > 0) {
return this.finish(conversation, conversationId, state, incomingKey, 'boundary', state.boundaries.join('; '));
}
if (remainingAutomationTurns(state) === 0) {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', 'Three automated turns have been used.');
}
const intent = chooseConversationIntent(mapped, state, 'media-ask');
if (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);
if (this.dryRun) {
this.logger.info(`[automation] Dry run prepared turn ${state.automation.turnsUsed + 1} for u/${conversation.username}`);
return null;
}
const sent = await this.rateLimiter.throttle(
() => this.chat.sendChatMessage(conversation.url!, reply),
(success) => success,
signal,
);
if (!sent) return null;
state.askedForMedia = true;
state.automation.turnsUsed += 1;
state.automation.lastIncomingKey = incomingKey;
const exhausted = state.automation.turnsUsed >= MAX_AUTOMATED_CONVERSATION_TURNS;
if (exhausted) {
state.automation.status = 'human-required';
state.automation.handoffReason = 'Three automated turns have been used.';
}
this.states.save(conversationId, state);
this.logger.info(
`[automation] Sent turn ${state.automation.turnsUsed}/${MAX_AUTOMATED_CONVERSATION_TURNS} to u/${conversation.username}`,
);
return {
conversationId,
username: conversation.username,
kind: exhausted ? 'human-required' : 'sent',
remainingTurns: remainingAutomationTurns(state),
};
}
private finish(
conversation: ActiveChat,
conversationId: string,
state: ConversationState,
incomingKey: string,
status: 'image-received' | 'human-required' | 'boundary',
reason: string,
): ConversationAutomationResult {
state.automation.status = status;
state.automation.lastIncomingKey = incomingKey;
state.automation.handoffReason = reason;
this.states.save(conversationId, state);
return {
conversationId,
username: conversation.username,
kind: status,
remainingTurns: remainingAutomationTurns(state),
};
}
}
function hasReceivedImage(messages: ChatMessage[]): boolean {
return messages.some((message) => !message.isSelf && (message.media?.length ?? 0) > 0);
}
function messageKey(message: ChatMessage): string {
return message.eventId ?? `${message.timestamp}:${message.sender}:${message.text}`;
}
+59 -3
View File
@@ -25,8 +25,40 @@ export interface ConversationPersona {
export interface ConversationState {
persona: ConversationPersona;
boundaries: string[];
/** Kept for backwards compatibility with state written before automation. */
askedForMedia: boolean;
topics: string[];
automation: ConversationAutomationState;
}
export const MAX_AUTOMATED_CONVERSATION_TURNS = 3;
export type ConversationAutomationStatus =
| 'active'
| 'image-received'
| 'human-required'
| 'boundary'
| 'user-overridden';
export interface ConversationAutomationState {
turnsUsed: number;
status: ConversationAutomationStatus;
lastIncomingKey: string | null;
handoffReason: string | null;
}
export function createConversationAutomationState(): ConversationAutomationState {
return {
turnsUsed: 0,
status: 'active',
lastIncomingKey: null,
handoffReason: null,
};
}
export function remainingAutomationTurns(state: ConversationState): number {
if (state.automation.status !== 'active') return 0;
return Math.max(0, MAX_AUTOMATED_CONVERSATION_TURNS - state.automation.turnsUsed);
}
const EMPTY_PERSONA: ConversationPersona = {
@@ -67,6 +99,7 @@ export function buildConversationState(messages: ConversationMessage[]): Convers
boundaries,
askedForMedia: /\b(send|show|share)\b[^.\n]{0,40}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b/i.test(ownText),
topics,
automation: createConversationAutomationState(),
};
}
@@ -79,10 +112,13 @@ export function chooseConversationIntent(
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.askedForMedia) {
return { kind: 'follow-up', reason: 'a media ask was already made, so do not repeat it' };
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: 'a media ask was explicitly requested and no boundary is recorded' };
return {
kind: 'media-ask',
reason: `image-sharing turn ${state.automation.turnsUsed + 1} of ${MAX_AUTOMATED_CONVERSATION_TURNS}`,
};
}
if (state.boundaries.length > 0) {
@@ -116,6 +152,7 @@ export function mergeConversationState(
boundaries: [...new Set([...saved.boundaries, ...observed.boundaries])],
askedForMedia: saved.askedForMedia || observed.askedForMedia,
topics: [...new Set([...saved.topics, ...observed.topics])],
automation: saved.automation,
};
}
@@ -152,6 +189,7 @@ export function parseConversationState(value: string | null | undefined): Conver
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,
@@ -163,12 +201,30 @@ export function parseConversationState(value: string | null | undefined): Conver
boundaries: parsed.boundaries.filter((value): value is string => typeof value === 'string'),
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 status: ConversationAutomationStatus =
parsed.status === 'image-received' || parsed.status === 'human-required' || parsed.status === 'boundary' || parsed.status === 'user-overridden'
? parsed.status
: 'active';
return {
turnsUsed: typeof parsed.turnsUsed === 'number'
? Math.max(0, Math.min(MAX_AUTOMATED_CONVERSATION_TURNS, Math.trunc(parsed.turnsUsed)))
: 0,
status,
lastIncomingKey: typeof parsed.lastIncomingKey === 'string' ? parsed.lastIncomingKey : null,
handoffReason: typeof parsed.handoffReason === 'string' ? parsed.handoffReason : null,
};
}
function extractTopics(text: string): string[] {
const topics: string[] = [];
for (const topic of ['music', 'films', 'movies', 'gaming', 'work', 'travel', 'fitness']) {
+24 -1
View File
@@ -41,6 +41,7 @@ import {
import { RecipientDeduplicator } from './recipient-deduplicator.js';
import { ConfigStore } from '../config/persistence.js';
import { createConfigBackedSubredditPollingControl } from './subreddit-polling-control.js';
import { ConversationAutomationService } from './conversation-automation.js';
export interface ConcreteRuntimeFactoryOptions {
config: Config;
@@ -153,13 +154,21 @@ async function buildRuntime(
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
});
await rateLimiter.initialize();
const conversationAutomation = new ConversationAutomationService(
chatService,
aiClient,
conversationStateRepo,
rateLimiter,
logger,
accountConfig.browser.dryRun,
);
const resources: RedditAccountRuntimeResources = {
browserProfileDir, browserClient, accountAuth, matrixClient, chatCache, redditApi,
postsRepo, messagesRepo, successfulMessagesRepo, deliveryOutboxRepo,
conversationStateRepo, conversationOutcomesRepo, redditUsersRepo, accountContentRepo,
activityLogRepo,
monitorService, processorService, responseChecker, chatService, mediaService,
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
mediaSyncService, rateLimiter,
};
const lifecycle = new AccountRuntimeLoop(
@@ -232,6 +241,20 @@ class AccountRuntimeLoop {
if (Date.now() >= nextResponseCheckAt) {
nextResponseCheckAt = Date.now() + responseInterval;
await this.resources.responseChecker.checkForResponses();
const automationResults = await this.resources.conversationAutomation.processUnreadConversations(signal);
for (const result of automationResults) {
if (result.kind === 'sent') continue;
this.resources.activityLogRepo.insert({
timestamp: Date.now(),
type: 'response',
message: result.kind === 'human-required'
? `Human follow-up required for u/${result.username}`
: result.kind === 'image-received'
? `Image received from u/${result.username}`
: `Automation stopped for u/${result.username} after a boundary`,
recipient: result.username,
});
}
}
await delay(this.config.reddit.pollIntervalMs, signal);
} catch (error) {
+2
View File
@@ -30,6 +30,7 @@ import type { MonitorService } from './monitor.js';
import type { ProcessorService } from './processor.js';
import type { ResponseCheckerService } from './response-checker.js';
import type { RedditPost } from '../reddit/api.js';
import type { ConversationAutomationService } from './conversation-automation.js';
/** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */
export interface VerifiedAccountSession {
@@ -70,6 +71,7 @@ export interface RedditAccountRuntimeResources {
monitorService: MonitorService;
processorService: ProcessorService;
responseChecker: ResponseCheckerService;
conversationAutomation: ConversationAutomationService;
chatService: ChatService;
mediaService: MediaService;
mediaSyncService: MediaSyncService;
+23
View File
@@ -5,6 +5,7 @@ import {
buildConversationState,
chooseConversationIntent,
mergeConversationState,
remainingAutomationTurns,
} from '../../services/conversation-state.js';
type OwnedActiveChat = ActiveChat & { owner?: { accountId: string; redditUsername: string } };
@@ -191,12 +192,32 @@ export function createConversationsRouter(ctx: WebContext): Router {
ctx.logger.warn(`Failed to look up triggering post for ${conversation.username}:`, err);
}
const stateRepo = resolved.runtime?.resources.conversationStateRepo ?? ctx.services.conversationState;
const intelligence = getConversationIntelligence(conversation, stateRepo);
const receivedImage = (conversation.messages ?? []).some(
(message) => !message.isSelf && (message.media?.length ?? 0) > 0,
);
if (intelligence.state.automation.status === 'active' && receivedImage) {
intelligence.state.automation.status = 'image-received';
intelligence.state.automation.handoffReason = 'The user shared an image.';
} else if (intelligence.state.automation.status === 'active' && intelligence.state.boundaries.length > 0) {
intelligence.state.automation.status = 'boundary';
intelligence.state.automation.handoffReason = intelligence.state.boundaries.join('; ');
}
if (conversation.roomId) stateRepo?.save(conversation.roomId, intelligence.state);
res.json({
...conversation,
owner: resolved.runtime
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
: currentOwner(),
triggeringPost,
automation: {
turnsUsed: intelligence.state.automation.turnsUsed,
remainingTurns: remainingAutomationTurns(intelligence.state),
status: intelligence.state.automation.status,
handoffReason: intelligence.state.automation.handoffReason,
},
syncInProgress: syncInFlight
? {
kind: syncStatus.syncingKind,
@@ -331,6 +352,8 @@ export function createConversationsRouter(ctx: WebContext): Router {
saved,
buildConversationState([...currentMessages, { sender: 'Me', text: message.trim(), isSelf: true }]),
);
updatedState.automation.status = 'user-overridden';
updatedState.automation.handoffReason = 'A manual reply was sent in this conversation.';
stateRepo?.save(conversation.roomId, updatedState);
}
if (resolved.runtime) {