Fix NSFW reply refusals and media offers

This commit is contained in:
Jamie Brunton committed 2026-08-14 08:59:07 +01:00
1 parent e9a4c965ee
commit 6b2e1bc380
11 files changed
+161 -22

No files matched your search

+22 -2
View File
@@ -354,7 +354,8 @@ export class ConversationAutomationService {
if (state.boundaries.length > 0) {
return this.finish(conversation, conversationId, state, incomingKey, 'boundary', state.boundaries.join('; '));
}
if (this.mediaExchange?.detect(conversationId, latest.text)) {
const previousSelfText = previousSelfMessageBefore(messages, latest)?.text;
if (this.mediaExchange?.detect(conversationId, latest.text, previousSelfText)) {
const exchangeAttempt = await this.rateLimiter.throttle(
async () => {
const refreshed = await this.loadConversationBeforeSend(conversationId, conversation);
@@ -382,7 +383,16 @@ export class ConversationAutomationService {
return { kind: 'killed' as const, reason: 'Potential upsell appeared before the image could be sent.' };
}
if (messageKey(refreshedLatest) !== incomingKey) return { kind: 'superseded' as const };
return this.mediaExchange!.handle(conversationId, incomingKey, refreshedLatest.text);
const refreshedPreviousSelfText = previousSelfMessageBefore(
refreshed.messages ?? [],
refreshedLatest,
)?.text;
return this.mediaExchange!.handle(
conversationId,
incomingKey,
refreshedLatest.text,
refreshedPreviousSelfText,
);
},
(result) => result.kind === 'sent' || (result.kind === 'human-required' && result.remoteSent === true),
signal,
@@ -862,6 +872,16 @@ function latestInboundMessage(messages: ChatMessage[]): ChatMessage | null {
return latest && !latest.isSelf ? latest : null;
}
/** The latest outbound turn before a specific inbound event, used to interpret acceptance of a media offer. */
function previousSelfMessageBefore(
messages: readonly ChatMessage[],
inbound: ChatMessage,
): ChatMessage | undefined {
const inboundIndex = messages.lastIndexOf(inbound);
const earlier = inboundIndex >= 0 ? messages.slice(0, inboundIndex) : messages;
return [...earlier].reverse().find((message) => message.isSelf);
}
function hasReceivedImage(messages: ChatMessage[]): boolean {
return messages.some((message) => !message.isSelf && (message.media?.length ?? 0) > 0);
}
+13 -2
View File
@@ -30,11 +30,22 @@ const FULL_STOP_PATTERNS = [
];
export function containsMediaRequest(text: string): boolean {
return new RegExp(String.raw`\b${MEDIA_VERB}\b[^.!?\n]{0,60}\b${MEDIA_NOUN}\b`, 'i').test(text)
|| new RegExp(String.raw`\b(?:can|could|would|will) you\b[^.!?\n]{0,60}\b(?:${MEDIA_VERB}\b[^.!?\n]{0,30})?\b${MEDIA_NOUN}\b`, 'i').test(text)
return new RegExp(String.raw`\b(?:can|could|would|will) you\b[^.!?\n]{0,60}\b(?:${MEDIA_VERB}\b[^.!?\n]{0,30})?\b${MEDIA_NOUN}\b`, 'i').test(text)
|| new RegExp(String.raw`\byou (?:can|could|may)\b[^.!?\n]{0,40}\b${MEDIA_VERB}\b[^.!?\n]{0,40}\b${MEDIA_NOUN}\b`, 'i').test(text)
|| new RegExp(String.raw`\b(?:send|share|show|give|drop) me\b[^.!?\n]{0,40}\b${MEDIA_NOUN}\b`, 'i').test(text)
|| new RegExp(String.raw`(?:^|[.!?]\s+|,\s+)(?:please\s+)?${MEDIA_VERB}\b[^.!?\n]{0,60}\b${MEDIA_NOUN}\b`, 'i').test(text)
|| new RegExp(String.raw`\bgot any (?:more )?${MEDIA_NOUN}\b`, 'i').test(text);
}
/** A generic promise or invitation to share our own approved media, not a request for theirs. */
export function containsMediaOffer(text: string): boolean {
return new RegExp(
String.raw`\b(?:i (?:can|could|would|will|might)|i['’]ll|want me to|how about i)\b[^.!?\n]{0,40}\b${MEDIA_VERB}\b[^.!?\n]{0,40}\b${MEDIA_NOUN}\b`,
'i',
).test(text)
|| new RegExp(String.raw`\bi(?:['’]ve| have) got\b[^.!?\n]{0,40}\b${MEDIA_NOUN}\b[^.!?\n]{0,30}\b(?:if you want|to share|to show you)\b`, 'i').test(text);
}
export function isFullConversationStop(text: string): boolean {
return FULL_STOP_PATTERNS.some((pattern) => pattern.test(text));
}
+1 -1
View File
@@ -45,7 +45,7 @@ export interface ConversationState {
export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3;
export const DEFAULT_HYBRID_RAPPORT_TURNS = 3;
export const CONVERSATION_POLICY_VERSION = 3;
export const CONVERSATION_POLICY_VERSION = 4;
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual';
export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask';
+16 -5
View File
@@ -3,6 +3,7 @@ import path from 'node:path';
import type { MediaLibraryRepository } from '../database/media-library-repository.js';
import type { Logger } from '../utils/logger.js';
import type { MediaLibraryAsset, MediaLibrarySend, MediaLibraryTag, MediaRequestKind } from './media-library-types.js';
import { containsMediaOffer } from './conversation-boundaries.js';
export interface MediaExchangeChat {
sendChatImage(
@@ -51,10 +52,11 @@ export class MediaLibraryExchangeService {
private readonly random: () => number = Math.random,
) {}
detect(conversationId: string, incomingText: string): MediaRequestKind | null {
detect(conversationId: string, incomingText: string, previousSelfText?: string): MediaRequestKind | null {
return detectMediaRequest(
incomingText,
this.repository.listConversationSends(this.accountId, conversationId),
previousSelfText,
);
}
@@ -70,8 +72,9 @@ export class MediaLibraryExchangeService {
conversationId: string,
incomingKey: string,
incomingText: string,
previousSelfText?: string,
): Promise<MediaExchangeResult> {
return this.handleRequest(conversationId, incomingKey, incomingText);
return this.handleRequest(conversationId, incomingKey, incomingText, undefined, previousSelfText);
}
listShareable(conversationId: string, incomingText: string): ShareableMediaLibraryAssets {
@@ -157,9 +160,10 @@ export class MediaLibraryExchangeService {
incomingKey: string,
incomingText: string,
selectedAssetId?: string,
previousSelfText?: string,
): Promise<MediaExchangeResult> {
const prior = this.repository.listConversationSends(this.accountId, conversationId);
const requestKind = detectMediaRequest(incomingText, prior);
const requestKind = detectMediaRequest(incomingText, prior, previousSelfText);
if (!requestKind) return { kind: 'not-requested' };
if (this.dryRun) return { kind: 'dry-run', requestKind };
@@ -282,7 +286,11 @@ export class MediaLibraryExchangeService {
}
}
export function detectMediaRequest(text: string, prior: readonly MediaLibrarySend[] = []): MediaRequestKind | null {
export function detectMediaRequest(
text: string,
prior: readonly MediaLibrarySend[] = [],
previousSelfText?: string,
): MediaRequestKind | null {
const normalized = text.toLowerCase().replace(/[’]/g, "'");
if (/\b(?:penis|dick|cock|schlong)\b/.test(normalized)) return 'penis';
if (/\b(?:topless|nude|nudes|boobs?|tits?)\b/.test(normalized)) return 'topless';
@@ -292,7 +300,10 @@ export function detectMediaRequest(text: string, prior: readonly MediaLibrarySen
const generic = /\b(?:pic|pics|picture|pictures|photo|photos|image|images)\b/.test(normalized)
|| /\b(?:show me (?:you|u|yourself)|let me see you|what do you look like)\b/.test(normalized)
|| (prior.length > 0 && /^(?:more|another|again|one more|send (?:me )?one)[.!?\s]*$/.test(normalized.trim()));
|| (prior.length > 0 && /^(?:more|another|again|one more|send (?:me )?one)[.!?\s]*$/.test(normalized.trim()))
|| (previousSelfText !== undefined
&& containsMediaOffer(previousSelfText)
&& /^(?:yes(?: please)?|yeah(?: please)?|yep(?: please)?|sure|please|go on|okay|ok|definitely|i(?:'d| would) (?:like|love) that|show me|send it|let me see)(?:[.!?\s\p{Extended_Pictographic}\uFE0F]*)$/iu.test(normalized.trim()));
return generic ? 'generic' : null;
}