Fix NSFW reply refusals and media offers
This commit is contained in:
1 parent
e9a4c965ee
commit
6b2e1bc380
11 files changed
+161
-22
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user