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

+81
View File
@@ -528,6 +528,87 @@ export class MatrixClient {
return res.event_id;
}
/** Upload an image to the Matrix content repository and return its mxc URI. */
async uploadImage(bytes: Buffer, mimeType: string, fileName: string): Promise<string> {
const candidates = [
'/_matrix/media/v3/upload',
'/_matrix/client/v1/media/upload',
];
for (const endpoint of candidates) {
const url = new URL(this.creds.homeserver + endpoint);
url.searchParams.set('filename', fileName);
await this.acquireRequestSlot();
const response = await this.timedFetch(url.toString(), {
method: 'POST',
headers: {
Authorization: `Bearer ${this.creds.accessToken}`,
'Content-Type': mimeType,
},
body: Uint8Array.from(bytes).buffer,
});
if (response === null) throw new Error('Matrix media upload timed out');
if (response.status === 401 || response.status === 403) {
const failure = await readMatrixFailure(response);
if (isMatrixAuthenticationFailure(response.status, failure.errcode)) {
throw new MatrixAuthError(`Matrix media upload → ${response.status} (token rejected)`);
}
throw new Error(`Matrix media upload → ${response.status}: ${failure.text.slice(0, 200)}`);
}
if (response.status === 404 || response.status === 405) continue;
if (response.status === 429) {
const failure = await response.json().catch(() => ({})) as {
retry_after_ms?: unknown;
errcode?: unknown;
['com.reddit.error.code']?: unknown;
};
throw new MatrixRateLimitError(
'Matrix media upload rate limited',
typeof failure.retry_after_ms === 'number' ? failure.retry_after_ms : 500,
typeof failure.errcode === 'string' ? failure.errcode : null,
typeof failure['com.reddit.error.code'] === 'string' ? failure['com.reddit.error.code'] : null,
);
}
if (!response.ok) {
const failure = await response.text().catch(() => '');
throw new Error(`Matrix media upload → ${response.status}: ${failure.slice(0, 200)}`);
}
const payload = await response.json() as { content_uri?: unknown };
if (typeof payload.content_uri !== 'string' || !payload.content_uri.startsWith('mxc://')) {
throw new Error('Matrix media upload returned no content URI');
}
return payload.content_uri;
}
throw new Error('Matrix homeserver does not expose a supported media upload endpoint');
}
async sendImageMessage(
roomId: string,
body: string,
mxcUri: string,
info: { mimeType: string; size: number; width: number; height: number },
txnId?: string,
): Promise<string> {
const id = txnId ?? `rbot-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const response = await this.request<{ event_id: string }>(
'PUT',
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(id)}`,
{
body: {
msgtype: 'm.image',
body,
url: mxcUri,
info: {
mimetype: info.mimeType,
size: info.size,
w: info.width,
h: info.height,
},
},
},
);
return response.event_id;
}
/**
* Convert an mxc:// URI to an HTTPS download URL on this homeserver.
* Reddit's media is served from the same Matrix server.
+43 -2
View File
@@ -582,7 +582,7 @@ export class ChatService {
* `https://chat.reddit.com/room/<roomId>` URL (web UI compatibility) or
* a raw Matrix room ID like `!abc:reddit.com`.
*/
async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> {
async sendChatMessage(chatUrlOrRoomId: string, message: string, txnId?: string): Promise<boolean> {
if (!this.accountAuth?.isFullyAuthenticated()) return false;
const active = this.getInvitationCooldown();
if (active && this.blocksExistingRoomSends(active)) {
@@ -599,7 +599,7 @@ export class ChatService {
this.logger.error(`[chat] cannot extract room ID from "${chatUrlOrRoomId}"`);
return false;
}
const eventId = await this.withAuth(() => this.matrix.sendTextMessage(roomId, message));
const eventId = await this.withAuth(() => this.matrix.sendTextMessage(roomId, message, txnId));
this.logger.info(`[chat] sent message to ${roomId} (event_id=${eventId})`);
const chat = this.cache.get().find((c) => c.roomId === roomId);
if (chat) {
@@ -619,6 +619,47 @@ export class ChatService {
});
}
async sendChatImage(
chatUrlOrRoomId: string,
image: { bytes: Buffer; mimeType: string; fileName: string; width: number; height: number },
txnId: string,
existingMxcUri?: string | null,
): Promise<{ eventId: string; mxcUri: string } | null> {
if (!this.accountAuth?.isFullyAuthenticated()) return null;
const active = this.getInvitationCooldown();
if (active && this.blocksExistingRoomSends(active)) return null;
return this.enqueue(async () => {
try {
const roomId = ChatService.extractRoomId(chatUrlOrRoomId);
if (!roomId) return null;
const mxcUri = existingMxcUri
?? await this.withAuth(() => this.matrix.uploadImage(image.bytes, image.mimeType, image.fileName));
const eventId = await this.withAuth(() => this.matrix.sendImageMessage(
roomId,
image.fileName,
mxcUri,
{ mimeType: image.mimeType, size: image.bytes.length, width: image.width, height: image.height },
txnId,
));
this.logger.info(`[chat] sent image to ${roomId} (event_id=${eventId})`);
const chat = this.cache.get().find((item) => item.roomId === roomId);
if (chat) {
chat.lastMessageSnippet = '[Image]';
chat.lastMessageTime = Date.now();
chat.isFromUs = true;
}
return { eventId, mxcUri };
} catch (error) {
if (error instanceof MatrixRateLimitError) {
this.setInvitationCooldown(error, 'all-sends');
return null;
}
this.logger.error('[chat] sendChatImage failed:', error);
return null;
}
});
}
/**
* Start a brand-new DM with the given Reddit user and send the opening
* line. Returns the new chat (or null on failure).
+48
View File
@@ -20,6 +20,7 @@ import {
type ConversationState,
} from './conversation-state.js';
import { containsMediaRequest } from './conversation-boundaries.js';
import type { MediaLibraryExchangeService } from './media-library-exchange.js';
export type ConversationAutomationResultKind =
| 'sent'
@@ -94,6 +95,7 @@ export class ConversationAutomationService {
private readonly now: () => number = Date.now,
private readonly pendingReplyRetryDelayMs = PENDING_REPLY_RETRY_DELAY_MS,
private readonly beforeSendLoader?: ConversationBeforeSendLoader,
private readonly mediaExchange?: MediaLibraryExchangeService,
) {}
async processPendingConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
@@ -260,6 +262,52 @@ export class ConversationAutomationService {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', safetyReason);
}
}
if (this.mediaExchange?.detect(conversationId, latest.text)) {
const exchangeAttempt = await this.rateLimiter.throttle(
async () => {
const refreshed = await this.loadConversationBeforeSend(conversationId, conversation);
const refreshedLatest = refreshed ? latestInboundMessage(refreshed.messages ?? []) : null;
if (!refreshed || !refreshedLatest) return { kind: 'superseded' as const };
const refreshedState = mergeConversationState(
this.states.get(conversationId),
buildConversationState((refreshed.messages ?? []).map(({ sender, text, isSelf }) => ({ sender, text, isSelf }))),
);
if (refreshedState.boundaries.length > 0 || hasReceivedImage(refreshed.messages ?? [])) {
return { kind: 'human-required' as const, reason: 'The conversation became ineligible before the image could be sent.' };
}
const refreshedSellerSignal = findSellerSignal(
refreshedLatest.text,
[...this.sellerKeywords],
[...this.sellerDomains],
);
if (refreshedSellerSignal) {
return { kind: 'human-required' 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);
},
(result) => result.kind === 'sent' || (result.kind === 'human-required' && result.remoteSent === true),
signal,
REPLY_SEND_PRIORITY,
);
const exchange = exchangeAttempt;
if (exchange.kind === 'sent') {
state.askedForMedia = true;
state.automation.lastIncomingKey = incomingKey;
this.states.save(conversationId, state);
return {
conversationId,
username: conversation.username,
kind: 'sent',
remainingTurns: remainingAutomationTurns(state),
};
}
if (exchange.kind === 'human-required') {
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', exchange.reason);
}
if (exchange.kind === 'retryable' || exchange.kind === 'dry-run') return null;
if (exchange.kind === 'superseded') return null;
}
if (remainingAutomationTurns(state) === 0) {
return this.finish(
conversation,
+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);
}
+51
View File
@@ -0,0 +1,51 @@
export const MEDIA_LIBRARY_TAGS = ['penis', 'chest', 'selfie', 'topless', 'feet'] as const;
export type MediaLibraryTag = (typeof MEDIA_LIBRARY_TAGS)[number];
export const MEDIA_LIBRARY_SAFETY = ['sfw', 'nsfw', 'unknown'] as const;
export type MediaLibrarySafety = (typeof MEDIA_LIBRARY_SAFETY)[number];
export interface MediaLibraryClassification {
tags: MediaLibraryTag[];
safety: Exclude<MediaLibrarySafety, 'unknown'>;
description: string;
confidence: number;
}
export interface MediaLibraryAsset {
id: string;
tags: MediaLibraryTag[];
safety: MediaLibrarySafety;
classificationStatus: 'ready' | 'failed';
description: string | null;
confidence: number | null;
fileName: string;
relativePath: string;
mimeType: string;
sizeBytes: number;
width: number;
height: number;
enabled: boolean;
consentConfirmedAt: number;
createdAt: number;
updatedAt: number;
sendCount: number;
lastSentAt: number | null;
}
export type MediaRequestKind = MediaLibraryTag | 'generic';
export interface MediaLibrarySend {
id: number;
assetId: string;
accountId: string;
conversationId: string;
incomingKey: string;
requestKind: MediaRequestKind;
status: 'pending' | 'sent' | 'failed' | 'superseded';
requestedAt: number;
sentAt: number | null;
matrixMediaEventId: string | null;
matrixTextEventId: string | null;
mxcUri: string | null;
error: string | null;
}
+222
View File
@@ -0,0 +1,222 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import type { Logger } from '../utils/logger.js';
import type { MediaLibraryRepository } from '../database/media-library-repository.js';
import {
MEDIA_LIBRARY_TAGS,
type MediaLibraryAsset,
type MediaLibraryClassification,
type MediaLibrarySafety,
type MediaLibraryTag,
} from './media-library-types.js';
export interface MediaLibraryClassifier {
classifyLibraryImage(imageBase64: string, mimeType: string): Promise<MediaLibraryClassification>;
}
export interface MediaLibraryUpload {
bytes: Buffer;
contentType: string;
originalFileName: string;
consentConfirmed: boolean;
}
export interface MediaLibraryUpdate {
tags: MediaLibraryTag[];
safety: Exclude<MediaLibrarySafety, 'unknown'>;
enabled: boolean;
}
const MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
const MAX_INPUT_PIXELS = 40_000_000;
/** Owns validation, metadata removal, AI classification, and private files. */
export class MediaLibraryService {
readonly rootDirectory: string;
constructor(
private readonly repository: MediaLibraryRepository,
private readonly classifier: MediaLibraryClassifier,
private readonly logger: Logger,
rootDirectory = path.join(process.cwd(), 'data', 'media-library'),
private readonly now: () => number = Date.now,
) {
this.rootDirectory = rootDirectory;
}
list(): MediaLibraryAsset[] {
return this.repository.list();
}
get(id: string): MediaLibraryAsset | undefined {
return this.repository.get(id);
}
async upload(input: MediaLibraryUpload): Promise<MediaLibraryAsset> {
if (!input.consentConfirmed) {
throw new MediaLibraryInputError('Confirm that every depicted adult consented to this use');
}
if (input.bytes.length === 0 || input.bytes.length > MAX_UPLOAD_BYTES) {
throw new MediaLibraryInputError('Image must be between 1 byte and 15 MB');
}
const declaredMimeType = input.contentType.split(';', 1)[0].trim().toLowerCase();
if (!['image/jpeg', 'image/png', 'image/webp'].includes(declaredMimeType)) {
throw new MediaLibraryInputError('Only JPEG, PNG, and WebP images are supported');
}
const cleaned = await stripImageMetadata(input.bytes, declaredMimeType);
const sha256 = crypto.createHash('sha256').update(cleaned.bytes).digest('hex');
const duplicate = this.repository.getBySha256(sha256);
if (duplicate) throw new MediaLibraryConflictError('This image is already in the library');
const id = crypto.randomUUID();
const extension = extensionForMime(cleaned.mimeType);
const fileName = `${id}${extension}`;
const relativePath = fileName;
const fullPath = path.join(this.rootDirectory, relativePath);
await fs.mkdir(this.rootDirectory, { recursive: true });
await fs.writeFile(fullPath, cleaned.bytes, { flag: 'wx' });
let classification: MediaLibraryClassification | null = null;
try {
classification = await this.classifier.classifyLibraryImage(
cleaned.bytes.toString('base64'),
cleaned.mimeType,
);
} catch (error) {
this.logger.warn(`Media library classification failed for ${id}: ${safeError(error)}`);
}
const createdAt = this.now();
try {
return this.repository.create({
id,
tags: classification?.tags ?? [],
safety: classification?.safety ?? 'unknown',
classificationStatus: classification ? 'ready' : 'failed',
description: classification?.description ?? null,
confidence: classification?.confidence ?? null,
fileName: sanitizeDisplayFileName(input.originalFileName, extension),
relativePath,
mimeType: cleaned.mimeType,
sizeBytes: cleaned.bytes.length,
width: cleaned.width,
height: cleaned.height,
sha256,
// Low-confidence results remain disabled until the operator reviews them.
enabled: Boolean(classification && classification.confidence >= 0.7),
consentConfirmedAt: createdAt,
createdAt,
});
} catch (error) {
await fs.unlink(fullPath).catch(() => undefined);
throw error;
}
}
update(id: string, input: MediaLibraryUpdate): MediaLibraryAsset {
const tags = [...new Set(input.tags)];
if (tags.length === 0 || tags.some((tag) => !(MEDIA_LIBRARY_TAGS as readonly string[]).includes(tag))) {
throw new MediaLibraryInputError('Select at least one valid anatomy tag');
}
const updated = this.repository.update(id, tags, input.safety, input.enabled, this.now());
if (!updated) throw new MediaLibraryNotFoundError('Library image not found');
return updated;
}
async delete(id: string): Promise<boolean> {
const asset = this.repository.get(id);
if (!asset) return false;
if (!this.repository.softDelete(id, this.now())) return false;
const fullPath = resolveInside(this.rootDirectory, asset.relativePath);
await fs.unlink(fullPath).catch((error: unknown) => {
if (!isMissingFile(error)) this.logger.warn(`Failed to remove library file ${id}: ${safeError(error)}`);
});
return true;
}
resolvePath(asset: MediaLibraryAsset): string {
return resolveInside(this.rootDirectory, asset.relativePath);
}
}
export class MediaLibraryInputError extends Error {}
export class MediaLibraryConflictError extends Error {}
export class MediaLibraryNotFoundError extends Error {}
async function stripImageMetadata(bytes: Buffer, declaredMime: string): Promise<{
bytes: Buffer;
mimeType: string;
width: number;
height: number;
}> {
try {
const image = sharp(bytes, { limitInputPixels: MAX_INPUT_PIXELS, animated: false });
const metadata = await image.metadata();
if (!metadata.width || !metadata.height || !metadata.format) {
throw new MediaLibraryInputError('The upload is not a readable image');
}
if ((metadata.pages ?? 1) > 1) {
throw new MediaLibraryInputError('Animated images are not supported');
}
const actualMime = mimeForFormat(metadata.format);
if (!actualMime || actualMime !== declaredMime.toLowerCase()) {
throw new MediaLibraryInputError('The image contents do not match its declared file type');
}
const oriented = image.rotate();
const output = actualMime === 'image/jpeg'
? oriented.jpeg({ quality: 92, mozjpeg: true })
: actualMime === 'image/png'
? oriented.png({ compressionLevel: 9 })
: oriented.webp({ quality: 92 });
const cleaned = await output.toBuffer({ resolveWithObject: true });
return {
bytes: cleaned.data,
mimeType: actualMime,
width: cleaned.info.width,
height: cleaned.info.height,
};
} catch (error) {
if (error instanceof MediaLibraryInputError) throw error;
throw new MediaLibraryInputError(`Unable to process image: ${safeError(error)}`);
}
}
function mimeForFormat(format: string): string | null {
if (format === 'jpeg') return 'image/jpeg';
if (format === 'png') return 'image/png';
if (format === 'webp') return 'image/webp';
return null;
}
function extensionForMime(mimeType: string): string {
if (mimeType === 'image/jpeg') return '.jpg';
if (mimeType === 'image/png') return '.png';
return '.webp';
}
function sanitizeDisplayFileName(original: string, extension: string): string {
const base = path.basename(original || `library-image${extension}`)
.replace(/[^a-zA-Z0-9._ -]/g, '_')
.slice(0, 120);
return base || `library-image${extension}`;
}
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);
}
function isMissingFile(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
}
@@ -44,6 +44,8 @@ import { ConfigStore } from '../config/persistence.js';
import { createConfigBackedSubredditPollingControl } from './subreddit-polling-control.js';
import { ConversationAutomationService } from './conversation-automation.js';
import { AiUsageRepository } from '../database/ai-usage-repository.js';
import { MediaLibraryRepository } from '../database/media-library-repository.js';
import { MediaLibraryExchangeService } from './media-library-exchange.js';
export interface ConcreteRuntimeFactoryOptions {
config: Config;
@@ -193,6 +195,14 @@ async function buildRuntime(
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
});
await rateLimiter.initialize();
const mediaExchange = new MediaLibraryExchangeService(
new MediaLibraryRepository(db),
chatService,
logger,
session.accountId,
undefined,
accountConfig.browser.dryRun,
);
const conversationAutomation = new ConversationAutomationService(
chatService,
aiClient,
@@ -205,6 +215,10 @@ async function buildRuntime(
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerKeywords : [],
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerDomains : [],
() => accountConfig.reddit.subreddits,
Date.now,
undefined,
undefined,
mediaExchange,
);
const resources: RedditAccountRuntimeResources = {