326 lines
14 KiB
TypeScript
326 lines
14 KiB
TypeScript
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<boolean>;
|
||
sendChatMessageDetailed?(roomId: string, message: string, txnId?: string): Promise<{ eventId: string } | null>;
|
||
}
|
||
|
||
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<MediaExchangeResult> {
|
||
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<MediaExchangeResult> {
|
||
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<MediaExchangeResult> {
|
||
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', 'human') ?? 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<MediaExchangeResult> {
|
||
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 textReceipt = send.matrixTextEventId
|
||
? { eventId: send.matrixTextEventId }
|
||
: this.chat.sendChatMessageDetailed
|
||
? await this.chat.sendChatMessageDetailed(
|
||
conversationId,
|
||
RECIPROCAL_MESSAGE,
|
||
`rbot-library-reciprocal-${send.id}`,
|
||
)
|
||
: await this.chat.sendChatMessage(
|
||
conversationId,
|
||
RECIPROCAL_MESSAGE,
|
||
`rbot-library-reciprocal-${send.id}`,
|
||
) ? { eventId: `rbot-library-reciprocal-${send.id}` } : null;
|
||
const textSent = textReceipt !== null;
|
||
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, textReceipt!.eventId);
|
||
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<string>,
|
||
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<T>(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);
|
||
}
|