import fs from 'node:fs/promises'; 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'; export interface MediaExchangeChat { sendChatImage( roomId: string, image: { bytes: Buffer; mimeType: string; fileName: string; width: number; height: number }, txnId: string, existingMxcUri?: string | null, ): Promise<{ eventId: string; mxcUri: string } | null>; sendChatMessage(roomId: string, message: string, txnId?: string): Promise; } export interface MediaExchangeOutboundAttribution { eventIds: string[]; texts: string[]; } const RECIPROCAL_MESSAGE = 'Your turn — send me one back if you’re comfortable.'; /** Ledger `incoming_key` prefix for operator-initiated manual shares (no inbound request event). */ const MANUAL_INCOMING_KEY_PREFIX = 'manual:'; /** Sentinel stored in `matrix_text_event_id` for a manual share, which sends no reciprocal text. */ const MANUAL_NO_RECIPROCAL_MARKER = 'manual-no-reciprocal'; export type MediaExchangeResult = | { kind: 'not-requested' } | { kind: 'dry-run'; requestKind: MediaRequestKind } | { kind: 'sent'; assetId: string; requestKind: MediaRequestKind } | { kind: 'retryable'; assetId: string; requestKind: MediaRequestKind } | { kind: 'human-required'; reason: string; remoteSent?: boolean }; export interface ShareableMediaLibraryAssets { requestKind: MediaRequestKind | null; assets: MediaLibraryAsset[]; } /** Detects inbound requests, reserves an unused asset, then sends image before reciprocal text. */ export class MediaLibraryExchangeService { constructor( private readonly repository: MediaLibraryRepository, private readonly chat: MediaExchangeChat, private readonly logger: Logger, private readonly accountId: string, private readonly rootDirectory = path.join(process.cwd(), 'data', 'media-library'), private readonly dryRun = false, private readonly random: () => number = Math.random, ) {} detect(conversationId: string, incomingText: string): MediaRequestKind | null { return detectMediaRequest( incomingText, this.repository.listConversationSends(this.accountId, conversationId), ); } getOutboundAttribution(conversationId: string): MediaExchangeOutboundAttribution { const sends = this.repository.listConversationSends(this.accountId, conversationId); return { eventIds: sends.flatMap((send) => send.matrixMediaEventId ? [send.matrixMediaEventId] : []), texts: sends.flatMap((send) => send.matrixTextEventId ? [RECIPROCAL_MESSAGE] : []), }; } async handle( conversationId: string, incomingKey: string, incomingText: string, ): Promise { return this.handleRequest(conversationId, incomingKey, incomingText); } listShareable(conversationId: string, incomingText: string): ShareableMediaLibraryAssets { const prior = this.repository.listConversationSends(this.accountId, conversationId); const requestKind = detectMediaRequest(incomingText, prior); if (!requestKind) return { requestKind: null, assets: [] }; const used = new Set(prior.map((item) => item.assetId)); return { requestKind, assets: this.pickEligible(this.repository.listEligible(), used, requestKind, prior), }; } async handleSelected( conversationId: string, incomingKey: string, incomingText: string, assetId: string, ): Promise { return this.handleRequest(conversationId, incomingKey, incomingText, assetId); } /** * Every enabled library image not yet shared in this conversation. Unlike * {@link listShareable}, this ignores whether the latest message requested an * image and does not filter by request kind or safety tier — the operator * chooses. Used by the dashboard's manual "Share a saved image" panel. */ listShareableManual(conversationId: string): MediaLibraryAsset[] { const used = new Set( this.repository.listConversationSends(this.accountId, conversationId).map((item) => item.assetId), ); return this.repository.listEligible().filter((asset) => !used.has(asset.id)); } /** * Operator-initiated share of a specific library image, with none of the * automated exchange's gating: no request detection, no tier/boundary/seller * checks, and no reciprocal "your turn" text. The human made the call. */ async shareManual(conversationId: string, assetId: string): Promise { if (this.dryRun) return { kind: 'dry-run', requestKind: 'generic' }; const incomingKey = `${MANUAL_INCOMING_KEY_PREFIX}${assetId}`; let send = this.repository.getSend(this.accountId, conversationId, incomingKey); if (send?.status === 'sent') return { kind: 'sent', assetId: send.assetId, requestKind: send.requestKind }; if (send?.status === 'failed' || send?.status === 'superseded') { return { kind: 'human-required', reason: 'A previous manual share of this image failed or became uncertain.' }; } if (!send) { const chosen = this.repository.get(assetId); if (!chosen || !chosen.enabled) { return { kind: 'human-required', reason: 'That library image is disabled or unavailable.' }; } send = this.repository.tryReserve(assetId, this.accountId, conversationId, incomingKey, 'generic') ?? undefined; if (!send) { return { kind: 'human-required', reason: 'That library image has already been shared in this conversation.' }; } } const asset = this.repository.get(send.assetId); if (!asset || !asset.enabled) { this.repository.markFailed(send.id, 'Reserved library image is missing or disabled'); return { kind: 'human-required', reason: 'The reserved library image is no longer available.' }; } try { const delivered = await this.deliverReservedImage(conversationId, send, asset); if ('failure' in delivered) return delivered.failure; send = delivered.send; if (!send.matrixTextEventId) this.repository.markSent(send.id, MANUAL_NO_RECIPROCAL_MARKER); this.logger.info(`[media-library] Manually shared asset ${asset.id} in ${conversationId}`); return { kind: 'sent', assetId: asset.id, requestKind: 'generic' }; } catch (error) { this.repository.markFailed(send.id, safeError(error)); this.logger.warn(`[media-library] Manual share failed in ${conversationId}: ${safeError(error)}`); return { kind: 'human-required', reason: 'The requested library image could not be read or sent.' }; } } private async handleRequest( conversationId: string, incomingKey: string, incomingText: string, selectedAssetId?: string, ): Promise { const prior = this.repository.listConversationSends(this.accountId, conversationId); const requestKind = detectMediaRequest(incomingText, prior); if (!requestKind) return { kind: 'not-requested' }; if (this.dryRun) return { kind: 'dry-run', requestKind }; let send = this.repository.getSend(this.accountId, conversationId, incomingKey); if (send && selectedAssetId && send.assetId !== selectedAssetId) { return { kind: 'human-required', reason: 'A different library image is already reserved for this request.' }; } if (send?.status === 'sent' && send.matrixTextEventId === 'unconfirmed') { return { kind: 'human-required', reason: 'The image was sent, but the reciprocal text was not confirmed.', remoteSent: true }; } if (send?.status === 'sent') return { kind: 'sent', assetId: send.assetId, requestKind: send.requestKind }; if (send?.status === 'failed' || send?.status === 'superseded') { return { kind: 'human-required', reason: 'A previous media send for this request failed or became uncertain.' }; } if (!send) { const used = new Set(prior.map((item) => item.assetId)); const eligible = this.pickEligible(this.repository.listEligible(), used, requestKind, prior); const candidates = selectedAssetId ? eligible.filter((asset) => asset.id === selectedAssetId) : shuffled(eligible, this.random); if (selectedAssetId && candidates.length === 0) { return { kind: 'human-required', reason: 'That library image is disabled, already used, or does not match the current request.' }; } for (const asset of candidates) { send = this.repository.tryReserve(asset.id, this.accountId, conversationId, incomingKey, requestKind) ?? undefined; if (send) break; } if (!send) { return { kind: 'human-required', reason: `No unused enabled library image matches the ${requestKind === 'generic' ? 'current safe escalation tier' : requestKind} request.`, }; } } const asset = this.repository.get(send.assetId); if (!asset || !asset.enabled) { this.repository.markFailed(send.id, 'Reserved library image is missing or disabled'); return { kind: 'human-required', reason: 'The reserved library image is no longer available.' }; } try { const delivered = await this.deliverReservedImage(conversationId, send, asset); if ('failure' in delivered) return delivered.failure; send = delivered.send; const textSent = send.matrixTextEventId ? true : await this.chat.sendChatMessage( conversationId, RECIPROCAL_MESSAGE, `rbot-library-reciprocal-${send.id}`, ); if (!textSent) { // The image event is already confirmed. Consume the asset and surface // a handoff rather than risking a duplicate image on a later retry. this.repository.markSent(send.id, 'unconfirmed'); return { kind: 'human-required', reason: 'The image was sent, but the reciprocal text was not confirmed.', remoteSent: true }; } if (!send.matrixTextEventId) this.repository.markSent(send.id, `rbot-library-reciprocal-${send.id}`); this.logger.info(`[media-library] Sent ${requestKind} asset ${asset.id} in ${conversationId}`); return { kind: 'sent', assetId: asset.id, requestKind }; } catch (error) { this.repository.markFailed(send.id, safeError(error)); this.logger.warn(`[media-library] Exchange failed in ${conversationId}: ${safeError(error)}`); return { kind: 'human-required', reason: 'The requested library image could not be read or sent.' }; } } /** * Sends the reserved asset's bytes if the image event isn't already * confirmed, then returns the refreshed send row. Callers own the reciprocal * text (or lack of it) and the surrounding try/catch. */ private async deliverReservedImage( conversationId: string, send: MediaLibrarySend, asset: MediaLibraryAsset, ): Promise<{ send: MediaLibrarySend } | { failure: MediaExchangeResult }> { if (send.matrixMediaEventId) return { send }; const bytes = await fs.readFile(resolveInside(this.rootDirectory, asset.relativePath)); const imageResult = await this.chat.sendChatImage( conversationId, { bytes, mimeType: asset.mimeType, fileName: asset.fileName, width: asset.width, height: asset.height, }, `rbot-library-media-${send.id}`, send.mxcUri, ); if (!imageResult) { this.repository.markFailed(send.id, 'Matrix did not confirm the image event'); return { failure: { kind: 'human-required', reason: 'Matrix did not confirm the requested image send.' } }; } this.repository.markMediaSent(send.id, imageResult.mxcUri, imageResult.eventId); return { send: this.repository.getSend(this.accountId, conversationId, send.incomingKey)! }; } private pickEligible( assets: MediaLibraryAsset[], used: Set, requestKind: MediaRequestKind, prior: MediaLibrarySend[], ): MediaLibraryAsset[] { const unused = assets.filter((asset) => !used.has(asset.id)); if (requestKind !== 'generic') return unused.filter((asset) => asset.tags.includes(requestKind)); const completed = prior.filter((item) => item.status === 'sent').length; const maxTier = Math.min(2, completed); return unused.filter((asset) => assetTier(asset) <= maxTier); } } export function detectMediaRequest(text: string, prior: readonly MediaLibrarySend[] = []): 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'; if (/\b(?:feet|foot|toes)\b/.test(normalized)) return 'feet'; if (/\b(?:selfie|face pic|picture of (?:your|ur) face)\b/.test(normalized)) return 'selfie'; if (/\b(?:chest|torso)\b/.test(normalized)) return 'chest'; 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())); return generic ? 'generic' : null; } function assetTier(asset: MediaLibraryAsset): number { if (asset.tags.includes('penis')) return 2; if (asset.tags.includes('topless') || asset.safety === 'nsfw') return 1; return 0; } function shuffled(items: readonly T[], random: () => number): T[] { const copy = [...items]; for (let index = copy.length - 1; index > 0; index -= 1) { const swap = Math.floor(random() * (index + 1)); [copy[index], copy[swap]] = [copy[swap], copy[index]]; } return copy; } function resolveInside(root: string, relativePath: string): string { const resolvedRoot = path.resolve(root); const resolved = path.resolve(resolvedRoot, relativePath); if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) { throw new Error('Unsafe media-library path'); } return resolved; } function safeError(error: unknown): string { return error instanceof Error ? error.message : String(error); }