Add consent-gated media exchange library

This commit is contained in:
jjbrunton committed 2026-08-09 19:24:38 +01:00
1 parent b8e4a1bb88
commit bf474dfe10
42 files changed
+2533 -9

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
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>;
}
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 };
/** 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),
);
}
async handle(
conversationId: string,
incomingKey: string,
incomingText: 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?.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);
for (const asset of shuffled(eligible, this.random)) {
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 {
if (!send.matrixMediaEventId) {
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 { kind: 'human-required', reason: 'Matrix did not confirm the requested image send.' };
}
this.repository.markMediaSent(send.id, imageResult.mxcUri, imageResult.eventId);
send = this.repository.getSend(this.accountId, conversationId, incomingKey)!;
}
const textSent = send.matrixTextEventId
? true
: await this.chat.sendChatMessage(
conversationId,
'Your turn — send me one back if you’re comfortable.',
`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.' };
}
}
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);
}