diff --git a/docs/configuration.md b/docs/configuration.md index 6e516b1..03f6646 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -37,7 +37,7 @@ Never document real values. `.env` is ignored and may contain production credent - `filterSellers`, `sellerKeywords`, `sellerDomains`: seller policy inputs; see also the `conversations` section, whose off-platform kill is independent of this flag. The defaults treat Telegram references, including `t.me` and `telegram.me` links, as seller signals alongside paid-content platforms and payment prompts. - `prioritizeRecentPosts`, `recentWindowMinutes`: recency/fallback behavior. - `maxReadyCandidates`: hard ceiling for the active candidate target; valid range 1–500. Runtime lowers the actual target to what authenticated running accounts can send within `recentWindowMinutes` under current rolling-hour and message-spacing limits. -- `physiqueTargeting`: optional image-candidate filter with `enabled`, inclusive `minScore`, and inclusive `maxScore` values from 1 to 10. It is disabled by default. When enabled, a model score outside the range or an image without enough visible body evidence to score is rejected before opener generation. The setting is hot-read by existing account runtimes; chat sources are unaffected. +- `physiqueTargeting`: optional image-candidate filter with `enabled`, inclusive `minScore`, and inclusive `maxScore` values from 1 to 10. It is disabled by default. When enabled, a model score outside the range or an image without enough visible body evidence to score is rejected before opener generation. Saving the setting also reconciles prepared image candidates in the ready and delivery-retry stages: candidates that no longer qualify are rejected, while retained candidates receive the newly applied range snapshot shown in the funnel. A candidate already leased for sending is not changed mid-send. The setting is hot-read by existing account runtimes; chat sources are unaffected. Subreddit `type` is `image` or `chat`. Conversation policy contains `mode`, `maxAutomatedTurns`, and `rapportTurnsBeforeMediaAsk`; hybrid rapport turns must be lower than the maximum. diff --git a/docs/data-flow.md b/docs/data-flow.md index a0f56b9..06e43a9 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -33,12 +33,12 @@ A screening worker leases one discovered candidate. `MonitorService.screenCandid 1. Global processed-post and recipient-history checks. 2. For chat sources, self-declared gender and adult age parsing. -3. Seller evidence from the post, profile/about, and recent activity. +3. Seller evidence from the post, profile/about (including structured social links when exposed and narrow NSFW link-funnel language), and recent activity. 4. Source-specific preparation in `ProcessorService`. For image posts, preparation downloads and validates the image, detects target gender, and performs model analysis. The analysis includes a separate 1–10 visible-physique score only when enough of the body is visible. If physique targeting is enabled, an unknown score or a score outside the configured inclusive range becomes an explicit rejection before strategy selection and opener generation. Passing image posts then select an image strategy and generate an opener. Chat posts do not use physique targeting; they select a chat strategy and generate from the post text. Both paths apply outbound safety checks and return either a prepared immutable result or an explicit skip reason. -Terminal results become rejected candidates. Rate limits and transient failures become retryable candidates. Prepared candidates become ready; the persisted snapshot includes the opener, grounded image analysis, attractiveness and physique scores, the physique range used, source-image URL, and experiment/model/tone metadata. This lets a different account deliver without repeating AI work and lets the authenticated operator inspect the exact preparation context. +Terminal results become rejected candidates. Rate limits and transient failures become retryable candidates. Prepared candidates become ready; the persisted snapshot includes the opener, grounded image analysis, attractiveness and physique scores, the physique range used, source-image URL, and experiment/model/tone metadata. This lets a different account deliver without repeating AI work and lets the authenticated operator inspect the exact preparation context. Saving a physique-range change transactionally re-evaluates prepared image candidates in ready and delivery-retry stages, rejects those outside the new range, and refreshes the displayed range snapshot for retained candidates. A candidate already leased for sending is left unchanged until that send resolves. ### 3. Dispatch diff --git a/docs/product-and-safety.md b/docs/product-and-safety.md index 9348b26..b00a14b 100644 --- a/docs/product-and-safety.md +++ b/docs/product-and-safety.md @@ -32,7 +32,7 @@ Configured subreddits have a source type: Image analysis may produce a subjective 1–10 visible-physique score. It is not a medical assessment and remains unknown when the image does not show enough of the body for a grounded judgment. Physique targeting is disabled by default. When an operator enables an inclusive minimum/maximum range, image candidates outside that range—and unscorable image candidates—are rejected before opener generation; chat candidates are unaffected. -Seller filtering combines the candidate post with Reddit profile/about and recent activity evidence. The default policy treats Telegram names and links as seller signals alongside paid-content platforms and payment prompts. The configured keyword/domain lists are policy inputs, not a substitute for the surrounding screening flow. A profile lookup failure is not itself proof that a person is a seller; network and rate-limit failures must remain distinguishable from a rejection. +Seller filtering combines the candidate post with Reddit profile/about, structured social links when Reddit exposes them, and recent activity evidence. Because Reddit's legacy profile response can omit a social-link button that is visible in its UI, an NSFW bio is also rejected when it combines an explicit link call-to-action with a promise of more or exclusive personal content. The default policy treats Telegram names and links as seller signals alongside paid-content platforms and payment prompts. The configured keyword/domain lists are policy inputs, not a substitute for the surrounding screening flow. A profile lookup failure is not itself proof that a person is a seller; network and rate-limit failures must remain distinguishable from a rejection. The system must not manufacture personal facts. Generated messages must not claim shared location or proximity, invite an in-person meeting, invent identity/history, or ignore a conversation boundary. The final outbound safety check is intentionally separate from prompt wording. diff --git a/src/database/candidate-queue-repository.ts b/src/database/candidate-queue-repository.ts index 0a8987c..8d6107a 100644 --- a/src/database/candidate-queue-repository.ts +++ b/src/database/candidate-queue-repository.ts @@ -1,5 +1,7 @@ import type Database from 'better-sqlite3'; +import type { PhysiqueTargeting } from '../config/index.js'; import type { RedditPost } from '../reddit/api.js'; +import { evaluatePhysiqueTargeting } from '../services/physique-targeting.js'; import type { ProcessedPost } from '../services/processor.js'; export type CandidateStatus = @@ -47,6 +49,11 @@ export interface CandidateEnqueueResult { deferredAtCapacity: number; } +export interface PhysiqueTargetingReconciliation { + rejected: number; + retained: number; +} + const ACTIVE_STATUSES: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable']; /** Global, durable funnel from Reddit listing discovery through outbound delivery. */ @@ -243,6 +250,54 @@ export class CandidateQueueRepository { `).run(now, now + sendingDelayMs, now).changes; } + /** Re-evaluates prepared image candidates that have not yet been leased for sending. */ + reconcilePhysiqueTargeting( + targeting: PhysiqueTargeting, + now: number = Date.now(), + ): PhysiqueTargetingReconciliation { + const rows = this.db.prepare(` + SELECT post_id, prepared_json FROM candidate_queue + WHERE source_type = 'image' + AND status IN ('ready', 'retryable') + AND prepared_json IS NOT NULL + `).all() as Array<{ post_id: string; prepared_json: string }>; + const preparedRows = rows.map((row) => ({ + postId: row.post_id, + prepared: JSON.parse(row.prepared_json) as ProcessedPost, + })); + const reject = this.db.prepare(` + UPDATE candidate_queue + SET status = 'rejected', last_error = ?, updated_at = ?, + lease_owner = NULL, lease_expires_at = NULL, assigned_account_id = NULL + WHERE post_id = ? AND status IN ('ready', 'retryable') + `); + const retain = this.db.prepare(` + UPDATE candidate_queue + SET prepared_json = ?, updated_at = ? + WHERE post_id = ? AND status IN ('ready', 'retryable') + `); + + return this.db.transaction(() => { + const result: PhysiqueTargetingReconciliation = { rejected: 0, retained: 0 }; + for (const row of preparedRows) { + const score = typeof row.prepared.physiqueScore === 'number' + ? row.prepared.physiqueScore + : null; + const decision = evaluatePhysiqueTargeting(score, targeting); + if (!decision.accepted) { + result.rejected += reject.run(decision.reason, now, row.postId).changes; + continue; + } + const refreshed: ProcessedPost = { + ...row.prepared, + physiqueTargeting: { ...targeting }, + }; + result.retained += retain.run(JSON.stringify(refreshed), now, row.postId).changes; + } + return result; + })(); + } + expireStale(cutoff: number, now: number = Date.now()): number { return this.db.prepare(` UPDATE candidate_queue diff --git a/src/main-loop.ts b/src/main-loop.ts index d83d131..3cc1ce5 100644 --- a/src/main-loop.ts +++ b/src/main-loop.ts @@ -453,11 +453,20 @@ async function runConcurrentAccountRuntimes(app: AppContainer): Promise { const workerInstance = `candidate-funnel-${process.pid}-${Date.now()}`; const leaseMs = 5 * 60_000; const recovered = candidateQueueRepo.recoverAllLeases(); + const physiqueReconciliation = candidateQueueRepo.reconcilePhysiqueTargeting( + config.reddit.physiqueTargeting, + ); app.recipientDeduplicator.recoverStaleReservations(Date.now() - 10 * 60_000); logger.info( `Running ${accountRuntimeManager.list().length} account runtime(s) with durable candidate funnel` + (recovered > 0 ? `; recovered ${recovered} interrupted candidate(s)` : ''), ); + if (physiqueReconciliation.rejected > 0 || physiqueReconciliation.retained > 0) { + logger.info( + `Reconciled prepared image candidates against physique targeting: ` + + `${physiqueReconciliation.retained} retained, ${physiqueReconciliation.rejected} rejected`, + ); + } await new Promise((resolve) => { let shuttingDown = false; const controller = new AbortController(); diff --git a/src/reddit/api.ts b/src/reddit/api.ts index 563fc29..06202df 100644 --- a/src/reddit/api.ts +++ b/src/reddit/api.ts @@ -106,6 +106,52 @@ function joinText(values: unknown[]): string { return values.filter((value): value is string => typeof value === 'string').join(' '); } +function recordValue(value: unknown, key: string): unknown { + return value && typeof value === 'object' ? (value as Record)[key] : undefined; +} + +/** Flatten Reddit's structured profile-link payloads across legacy/current field naming. */ +function socialLinkText(userData: unknown): string { + const subreddit = recordValue(userData, 'subreddit'); + const candidates = [ + recordValue(userData, 'social_links'), + recordValue(userData, 'socialLinks'), + recordValue(subreddit, 'social_links'), + recordValue(subreddit, 'socialLinks'), + ]; + const values: unknown[] = []; + for (const candidate of candidates) { + if (!Array.isArray(candidate)) continue; + for (const link of candidate) { + values.push( + recordValue(link, 'title'), + recordValue(link, 'handle'), + recordValue(link, 'type'), + recordValue(link, 'url'), + recordValue(link, 'outbound_url'), + recordValue(link, 'outboundUrl'), + ); + } + } + return joinText(values); +} + +/** + * Catch a narrow paid-content funnel that deliberately keeps the platform name + * out of the bio. Requiring an NSFW profile, an explicit link CTA, and a + * promise of more/exclusive personal content avoids treating ordinary links as + * seller evidence. + */ +function findProfileFunnel(text: string, isNsfw: boolean): string | null { + if (!isNsfw) return null; + const normalized = text.toLocaleLowerCase().replace(/[\u2018\u2019]/g, "'"); + const linkCallToAction = /\b(?:hit|check|tap|click|visit|follow)\s+(?:the|my)\s+link\b/; + const contentPromise = /\b(?:see|get|show(?:ing)?|watch)\s+(?:a\s+)?(?:lot\s+)?more(?:\s+of\s+me)?\b|\bexclusive\s+(?:content|pics?|photos?|videos?)\b/; + return linkCallToAction.test(normalized) && contentPromise.test(normalized) + ? 'NSFW profile links to more or exclusive personal content' + : null; +} + /** Match configured seller signals in user-controlled text. */ export function findSellerSignal( text: string, @@ -422,10 +468,12 @@ export class RedditApiClient { const userData = response.data?.data; if (userData) { + const subreddit = userData.subreddit; const profileText = joinText([ - userData.subreddit?.public_description || '', - userData.subreddit?.title || '', - userData.subreddit?.display_name_prefixed || '', + subreddit?.public_description || '', + subreddit?.title || '', + subreddit?.display_name_prefixed || '', + socialLinkText(userData), ]); const profileSignal = findSellerSignal(profileText, sellerKeywords, sellerDomains); if (profileSignal) { @@ -434,6 +482,11 @@ export class RedditApiClient { ); return true; } + const funnel = findProfileFunnel(profileText, subreddit?.over_18 === true); + if (funnel) { + this.logger.info(`u/${username} flagged as seller profile (matched funnel: "${funnel}")`); + return true; + } } } catch (error) { const rateLimitError = this.toRateLimitError(error); diff --git a/src/web/routes/config.ts b/src/web/routes/config.ts index 993aa7d..4a14c1b 100644 --- a/src/web/routes/config.ts +++ b/src/web/routes/config.ts @@ -175,6 +175,17 @@ export function createConfigRouter(ctx: WebContext): Router { configStore.save(ctx.config); + if (parsedPhysiqueTargeting?.success && ctx.services.candidateQueue) { + const reconciliation = ctx.services.candidateQueue.reconcilePhysiqueTargeting( + parsedPhysiqueTargeting.data, + ); + ctx.activity.push( + 'info', + `Physique targeting reconciled ${reconciliation.retained} prepared image candidate(s)` + + ` and rejected ${reconciliation.rejected}`, + ); + } + ctx.getOnConfigChange()?.(ctx.runtimeConfig); ctx.activity.push('info', 'Config updated via dashboard'); diff --git a/tests/candidate-queue.test.ts b/tests/candidate-queue.test.ts index 5c471ec..9d7cb07 100644 --- a/tests/candidate-queue.test.ts +++ b/tests/candidate-queue.test.ts @@ -26,13 +26,13 @@ function post(id: string, author: string, created = 100): RedditPost { }; } -function prepared(candidate: RedditPost): ProcessedPost { +function prepared(candidate: RedditPost, physiqueScore: number | null = 5): ProcessedPost { return { post: candidate, imageAnalysis: 'analysis', detectedGender: 'female', attractivenessScore: 64, - physiqueScore: 5, + physiqueScore, physiqueTargeting: { enabled: true, minScore: 3, maxScore: 7 }, sourceImageUrl: 'https://i.redd.it/example.jpg', generatedMessage: 'prepared message', @@ -180,6 +180,56 @@ describe('CandidateQueueRepository', () => { }); }); + it('reconciles ready and retryable images when the physique range changes', () => { + queue.enqueue([ + post('inside', 'alice', 300), + post('outside', 'bob', 200), + post('retry', 'carol', 100), + ], 'image', 1, 1_000); + + for (const worker of ['screen-1', 'screen-2', 'screen-3']) { + const candidate = queue.claimForScreening(worker, 100, 1_010)!; + const score = candidate.postId === 'outside' ? 7 : 4; + queue.markReady(candidate.postId, worker, prepared(candidate.post, score), 1_011); + } + const retry = queue.claimReady('account-a', 'send', 100, 1_012)!; + expect(retry.postId).toBe('inside'); + queue.deferDelivery(retry.postId, 'send', 'rate limited', 2_000, 1_013); + + expect(queue.reconcilePhysiqueTargeting( + { enabled: true, minScore: 1, maxScore: 6 }, + 1_020, + )).toEqual({ retained: 2, rejected: 1 }); + + const active = queue.listActive(); + expect(active.map((candidate) => candidate.postId)).toEqual(['inside', 'retry']); + expect(active.every((candidate) => ( + candidate.prepared?.physiqueTargeting?.enabled === true + && candidate.prepared.physiqueTargeting.maxScore === 6 + ))).toBe(true); + expect(queue.getStats().byStatus.rejected).toBe(1); + }); + + it('fails closed for an unscored prepared image but does not touch a leased send', () => { + queue.enqueue([ + post('unscored', 'alice', 200), + post('sending', 'bob', 100), + ], 'image', 1, 1_000); + for (const worker of ['screen-1', 'screen-2']) { + const candidate = queue.claimForScreening(worker, 100, 1_010)!; + queue.markReady(candidate.postId, worker, prepared(candidate.post, null), 1_011); + } + const sending = queue.claimReady('account-a', 'send', 100, 1_012)!; + + expect(queue.reconcilePhysiqueTargeting( + { enabled: true, minScore: 1, maxScore: 6 }, + 1_020, + )).toEqual({ retained: 0, rejected: 1 }); + expect(queue.listActive()).toEqual([ + expect.objectContaining({ postId: sending.postId, status: 'sending' }), + ]); + }); + it('lists only active candidates in newest-post order across stages', () => { queue.enqueue([ post('waiting', 'alice', 100), diff --git a/tests/reddit-api.test.ts b/tests/reddit-api.test.ts index b17be73..521c194 100644 --- a/tests/reddit-api.test.ts +++ b/tests/reddit-api.test.ts @@ -357,6 +357,58 @@ describe('RedditApiClient', () => { expect(result).toBe(true); }); + it('detects a configured seller platform in structured social links', async () => { + const get = vi.fn().mockResolvedValue({ + data: { + data: { + subreddit: { public_description: 'Just me', over_18: true }, + social_links: [{ title: 'My OnlyFans', outbound_url: 'https://onlyfans.com/example' }], + }, + }, + }); + vi.mocked(axios.create).mockReturnValue({ get } as any); + const client2 = new RedditApiClient(getTestConfig(), logger); + + await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com'])) + .resolves.toBe(true); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('detects a narrow NSFW link-to-more funnel when Reddit omits the social link', async () => { + const get = vi.fn().mockResolvedValue({ + data: { + data: { + subreddit: { + public_description: "Hit the link to see more of me, we both know why you're here", + title: '', + display_name_prefixed: 'u/testuser', + over_18: true, + }, + }, + }, + }); + vi.mocked(axios.create).mockReturnValue({ get } as any); + const client2 = new RedditApiClient(getTestConfig(), logger); + + await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com'])) + .resolves.toBe(true); + expect(get).toHaveBeenCalledTimes(1); + }); + + it('does not treat an ordinary or non-NSFW profile link as a seller funnel', async () => { + const get = vi.fn() + .mockResolvedValueOnce({ + data: { data: { subreddit: { public_description: 'Hit the link to see more of my art', over_18: false } } }, + }) + .mockResolvedValueOnce({ data: { data: { children: [] } } }); + vi.mocked(axios.create).mockReturnValue({ get } as any); + const client2 = new RedditApiClient(getTestConfig(), logger); + + await expect(client2.isSellerProfile('testuser', ['onlyfans'], ['onlyfans.com'])) + .resolves.toBe(false); + expect(get).toHaveBeenCalledTimes(2); + }); + it('returns false when no keywords match', async () => { const mockResponse = { data: { diff --git a/tests/web/config-route.test.ts b/tests/web/config-route.test.ts index f347e70..c9a5e34 100644 --- a/tests/web/config-route.test.ts +++ b/tests/web/config-route.test.ts @@ -25,10 +25,11 @@ function harness() { // harnesses would otherwise share the same `conversations` instance. const config = structuredClone(ConfigSchema.parse(getTestConfig())) as Config; const saved: Config[] = []; + const candidateQueue = { reconcilePhysiqueTargeting: vi.fn(() => ({ retained: 2, rejected: 1 })) }; const ctx = { config, runtimeConfig: config, - services: { configStore: { save: (next: Config) => saved.push(next) } }, + services: { configStore: { save: (next: Config) => saved.push(next) }, candidateQueue }, getOnConfigChange: () => undefined, activity: { push: vi.fn() }, broadcaster: { push: vi.fn() }, @@ -37,7 +38,7 @@ function harness() { const app = express(); app.use(express.json()); app.use('/api', createConfigRouter(ctx)); - return { app, config, saved }; + return { app, config, saved, candidateQueue, ctx }; } describe('config route conversation kill controls', () => { @@ -112,7 +113,7 @@ describe('config route conversation kill controls', () => { describe('config route physique targeting', () => { it('hot-updates and persists the image-candidate score range', async () => { - const { app, config, saved } = harness(); + const { app, config, saved, candidateQueue, ctx } = harness(); const targetingRef = config.reddit.physiqueTargeting; const response = await request(app, '/api/config', { @@ -129,6 +130,13 @@ describe('config route physique targeting', () => { expect(config.reddit.physiqueTargeting).toBe(targetingRef); expect(config.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 }); expect(saved.at(-1)?.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 }); + expect(candidateQueue.reconcilePhysiqueTargeting).toHaveBeenCalledWith({ + enabled: true, minScore: 3, maxScore: 7, + }); + expect(ctx.activity.push).toHaveBeenCalledWith( + 'info', + 'Physique targeting reconciled 2 prepared image candidate(s) and rejected 1', + ); }); it('rejects an inverted physique range without mutating configuration', async () => {