Files
RedditBot/src/services/media-library-exchange.ts
T
Jamie BruntonandClaude Opus 4.8 c8689af04d feat: decouple manual image sharing from automation gating
The "Share a saved image" panel was wired to the automated exchange
engine, so an operator could only share an image right after an inbound
image request, limited to assets the auto-exchanger would itself pick.

Add a manual path that offers any enabled, unused library image at any
point (no request detection, tier, seller, or boundary gating) and sends
it with no reciprocal "your turn" text. Safety flags now surface as a
non-blocking warning instead of disabling the panel. The automated
auto-send path keeps all its guardrails; the shared image-delivery step
is extracted into a helper used by both paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 09:18:37 +01:00

318 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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') ?? 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 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<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);
}