Add consent-gated media exchange library
This commit is contained in:
1 parent
b8e4a1bb88
commit
bf474dfe10
42 files changed
+2533
-9
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import OpenAI from 'openai';
|
||||
import { z } from 'zod';
|
||||
import type { Config } from '../config/index.js';
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import type { AiUsageRecordInput, AiUsageOutcome } from '../database/ai-usage-repository.js';
|
||||
@@ -10,6 +11,10 @@ import {
|
||||
type ConversationState,
|
||||
} from '../services/conversation-state.js';
|
||||
import { isSafeOutboundMessage } from '../services/outbound-message-safety.js';
|
||||
import {
|
||||
MEDIA_LIBRARY_TAGS,
|
||||
type MediaLibraryClassification,
|
||||
} from '../services/media-library-types.js';
|
||||
|
||||
export type DetectedGender = 'male' | 'female' | 'other' | 'unknown';
|
||||
|
||||
@@ -142,6 +147,61 @@ export class OpenRouterClient {
|
||||
throw new Error('AI_REFUSAL: All models refused to analyze image');
|
||||
}
|
||||
|
||||
async classifyLibraryImage(imageBase64: string, mimeType: string): Promise<MediaLibraryClassification> {
|
||||
const models = [
|
||||
this.config.openrouter.imageAnalysisModel,
|
||||
...this.config.openrouter.imageAnalysisFallbackModels,
|
||||
];
|
||||
let lastError: unknown = null;
|
||||
for (const model of models) {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const response = await this.client.chat.completions.create({
|
||||
model,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Classify this operator-owned adult image for a private media library. Return JSON only with this exact shape:
|
||||
{"tags":["selfie"],"safety":"sfw","description":"short neutral description","confidence":0.95}
|
||||
|
||||
Allowed tags: ${MEDIA_LIBRARY_TAGS.join(', ')}.
|
||||
- selfie: a face-led or self-portrait style image
|
||||
- chest: a clothed chest or upper torso
|
||||
- topless: a bare chest or bare upper torso
|
||||
- penis: a visible penis
|
||||
- feet: feet are a material focus
|
||||
|
||||
Use every applicable tag; tags may overlap. Set safety to "nsfw" when nudity, exposed genitals, or clearly sexual presentation is visible, otherwise "sfw". Confidence must be between 0 and 1. Do not identify the person, infer age, or add attributes outside this taxonomy.`,
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:${mimeType};base64,${imageBase64}` },
|
||||
},
|
||||
],
|
||||
}],
|
||||
max_tokens: 180,
|
||||
temperature: 0,
|
||||
});
|
||||
const content = response.choices[0]?.message?.content ?? '';
|
||||
const parsed = MediaLibraryClassificationSchema.parse(parseJsonObject(content));
|
||||
this.recordUsage('classifyLibraryImage', model, startedAt, response, 'success');
|
||||
return {
|
||||
tags: [...new Set(parsed.tags)],
|
||||
safety: parsed.safety,
|
||||
description: parsed.description.trim().slice(0, 300),
|
||||
confidence: parsed.confidence,
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
this.recordError('classifyLibraryImage', model, startedAt, error);
|
||||
this.logger.warn(`[AI] classifyLibraryImage: ${model} failed${model !== models.at(-1) ? ', trying fallback...' : ''}`);
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error('Library image classification failed');
|
||||
}
|
||||
|
||||
private async tryAnalyzeWithModel(model: string, imageBase64: string, mimeType: string): Promise<ImageAnalysisResult | null> {
|
||||
const startedAt = Date.now();
|
||||
const genderPrompt = `First, identify the gender of the person in this image. Respond with EXACTLY one of these on the first line: GENDER:male, GENDER:female, GENDER:other, or GENDER:unknown
|
||||
@@ -684,3 +744,18 @@ Just write the message, nothing else.`;
|
||||
function numericUsage(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
||||
}
|
||||
|
||||
const MediaLibraryClassificationSchema = z.strictObject({
|
||||
tags: z.array(z.enum(MEDIA_LIBRARY_TAGS)).min(1),
|
||||
safety: z.enum(['sfw', 'nsfw']),
|
||||
description: z.string().min(1).max(500),
|
||||
confidence: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
function parseJsonObject(text: string): unknown {
|
||||
const cleaned = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
||||
const start = cleaned.indexOf('{');
|
||||
const end = cleaned.lastIndexOf('}');
|
||||
if (start < 0 || end < start) throw new Error('Classifier returned no JSON object');
|
||||
return JSON.parse(cleaned.slice(start, end + 1));
|
||||
}
|
||||
@@ -52,6 +52,8 @@ import { importLegacyAccountContent } from './database/legacy-content-import.js'
|
||||
import { RecipientDeduplicator } from './services/recipient-deduplicator.js';
|
||||
import { createConfigBackedSubredditPollingControl } from './services/subreddit-polling-control.js';
|
||||
import { CandidateQueueRepository, RuntimeStateRepository } from './database/candidate-queue-repository.js';
|
||||
import { MediaLibraryRepository } from './database/media-library-repository.js';
|
||||
import { MediaLibraryService } from './services/media-library.js';
|
||||
|
||||
export interface AppContainer {
|
||||
config: Config;
|
||||
@@ -82,6 +84,7 @@ export interface AppContainer {
|
||||
mediaService: MediaService;
|
||||
chatService: ChatService;
|
||||
mediaSyncService: MediaSyncService;
|
||||
mediaLibraryService: MediaLibraryService;
|
||||
rateLimiter: RateLimiter;
|
||||
|
||||
webServer: WebServer | null;
|
||||
@@ -178,6 +181,11 @@ export async function bootstrap(
|
||||
const aiClient = new OpenRouterClient(config, logger, undefined, (usage) => {
|
||||
aiUsageRepo.record({ ...usage, accountId: accountAuth.getSnapshot().accountId });
|
||||
});
|
||||
const mediaLibraryService = new MediaLibraryService(
|
||||
new MediaLibraryRepository(db),
|
||||
aiClient,
|
||||
logger,
|
||||
);
|
||||
const monitorService = new MonitorService(
|
||||
redditApi, postsRepo, recipientDeduplicator, config, logger, 1000,
|
||||
createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
|
||||
@@ -427,6 +435,7 @@ export async function bootstrap(
|
||||
});
|
||||
webServer.setMediaService(mediaService);
|
||||
webServer.setMediaSyncService(mediaSyncService);
|
||||
webServer.setMediaLibraryService(mediaLibraryService);
|
||||
webServer.setResponseChecker(responseChecker);
|
||||
webServer.setRateLimiter(rateLimiter);
|
||||
webServer.setAiClient(aiClient);
|
||||
@@ -483,6 +492,7 @@ export async function bootstrap(
|
||||
mediaService,
|
||||
chatService,
|
||||
mediaSyncService,
|
||||
mediaLibraryService,
|
||||
rateLimiter,
|
||||
webServer,
|
||||
};
|
||||
|
||||
@@ -364,6 +364,54 @@ export function runMigrations(db: Database.Database): void {
|
||||
CREATE INDEX idx_conversation_automation_claims_status
|
||||
ON conversation_automation_claims(account_id, status, claimed_at);
|
||||
`),
|
||||
() => db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS media_library_assets (
|
||||
asset_id TEXT PRIMARY KEY,
|
||||
tags_json TEXT NOT NULL,
|
||||
safety TEXT NOT NULL CHECK(safety IN ('sfw', 'nsfw', 'unknown')),
|
||||
classification_status TEXT NOT NULL CHECK(classification_status IN ('ready', 'failed')),
|
||||
description TEXT,
|
||||
confidence REAL,
|
||||
file_name TEXT NOT NULL,
|
||||
relative_path TEXT NOT NULL UNIQUE,
|
||||
mime_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
sha256 TEXT NOT NULL UNIQUE,
|
||||
enabled INTEGER NOT NULL DEFAULT 0 CHECK(enabled IN (0, 1)),
|
||||
consent_confirmed_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_library_assets_enabled
|
||||
ON media_library_assets(enabled, classification_status, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media_library_sends (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
asset_id TEXT NOT NULL,
|
||||
asset_sha256 TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
incoming_key TEXT NOT NULL,
|
||||
request_kind TEXT NOT NULL CHECK(request_kind IN ('generic', 'penis', 'chest', 'selfie', 'topless', 'feet')),
|
||||
status TEXT NOT NULL CHECK(status IN ('pending', 'sent', 'failed', 'superseded')),
|
||||
requested_at INTEGER NOT NULL,
|
||||
sent_at INTEGER,
|
||||
matrix_media_event_id TEXT,
|
||||
matrix_text_event_id TEXT,
|
||||
mxc_uri TEXT,
|
||||
error TEXT,
|
||||
UNIQUE(account_id, conversation_id, incoming_key),
|
||||
UNIQUE(account_id, conversation_id, asset_id),
|
||||
UNIQUE(account_id, conversation_id, asset_sha256),
|
||||
FOREIGN KEY (asset_id) REFERENCES media_library_assets(asset_id),
|
||||
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_library_sends_conversation
|
||||
ON media_library_sends(account_id, conversation_id, requested_at);
|
||||
`),
|
||||
];
|
||||
|
||||
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import {
|
||||
MEDIA_LIBRARY_TAGS,
|
||||
type MediaLibraryAsset,
|
||||
type MediaLibrarySafety,
|
||||
type MediaLibrarySend,
|
||||
type MediaLibraryTag,
|
||||
type MediaRequestKind,
|
||||
} from '../services/media-library-types.js';
|
||||
|
||||
interface AssetRow {
|
||||
asset_id: string;
|
||||
tags_json: string;
|
||||
safety: MediaLibrarySafety;
|
||||
classification_status: 'ready' | 'failed';
|
||||
description: string | null;
|
||||
confidence: number | null;
|
||||
file_name: string;
|
||||
relative_path: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
enabled: number;
|
||||
consent_confirmed_at: number;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
send_count?: number;
|
||||
last_sent_at?: number | null;
|
||||
}
|
||||
|
||||
interface SendRow {
|
||||
id: number;
|
||||
asset_id: string;
|
||||
account_id: string;
|
||||
conversation_id: string;
|
||||
incoming_key: string;
|
||||
request_kind: MediaRequestKind;
|
||||
status: MediaLibrarySend['status'];
|
||||
requested_at: number;
|
||||
sent_at: number | null;
|
||||
matrix_media_event_id: string | null;
|
||||
matrix_text_event_id: string | null;
|
||||
mxc_uri: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface CreateMediaLibraryAsset {
|
||||
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;
|
||||
sha256: string;
|
||||
enabled: boolean;
|
||||
consentConfirmedAt: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** Process-global metadata and durable outbound-use ledger for operator media. */
|
||||
export class MediaLibraryRepository {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
create(input: CreateMediaLibraryAsset): MediaLibraryAsset {
|
||||
this.db.prepare(`
|
||||
INSERT INTO media_library_assets (
|
||||
asset_id, tags_json, safety, classification_status, description,
|
||||
confidence, file_name, relative_path, mime_type, size_bytes, width,
|
||||
height, sha256, enabled, consent_confirmed_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
input.id, JSON.stringify(input.tags), input.safety, input.classificationStatus,
|
||||
input.description, input.confidence, input.fileName, input.relativePath,
|
||||
input.mimeType, input.sizeBytes, input.width, input.height, input.sha256,
|
||||
input.enabled ? 1 : 0, input.consentConfirmedAt, input.createdAt, input.createdAt,
|
||||
);
|
||||
return this.get(input.id)!;
|
||||
}
|
||||
|
||||
get(id: string): MediaLibraryAsset | undefined {
|
||||
const row = this.db.prepare(`
|
||||
SELECT asset.*, COUNT(sends.sent_at) AS send_count, MAX(sends.sent_at) AS last_sent_at
|
||||
FROM media_library_assets asset
|
||||
LEFT JOIN media_library_sends sends ON sends.asset_id = asset.asset_id AND sends.status = 'sent'
|
||||
WHERE asset.asset_id = ? AND asset.deleted_at IS NULL
|
||||
GROUP BY asset.asset_id
|
||||
`).get(id) as AssetRow | undefined;
|
||||
return row ? mapAsset(row) : undefined;
|
||||
}
|
||||
|
||||
getBySha256(sha256: string): MediaLibraryAsset | undefined {
|
||||
const row = this.db.prepare(`
|
||||
SELECT asset.*, COUNT(sends.sent_at) AS send_count, MAX(sends.sent_at) AS last_sent_at
|
||||
FROM media_library_assets asset
|
||||
LEFT JOIN media_library_sends sends ON sends.asset_id = asset.asset_id AND sends.status = 'sent'
|
||||
WHERE asset.sha256 = ? AND asset.deleted_at IS NULL
|
||||
GROUP BY asset.asset_id
|
||||
`).get(sha256) as AssetRow | undefined;
|
||||
return row ? mapAsset(row) : undefined;
|
||||
}
|
||||
|
||||
list(): MediaLibraryAsset[] {
|
||||
return (this.db.prepare(`
|
||||
SELECT asset.*, COUNT(sends.sent_at) AS send_count, MAX(sends.sent_at) AS last_sent_at
|
||||
FROM media_library_assets asset
|
||||
LEFT JOIN media_library_sends sends ON sends.asset_id = asset.asset_id AND sends.status = 'sent'
|
||||
WHERE asset.deleted_at IS NULL
|
||||
GROUP BY asset.asset_id
|
||||
ORDER BY asset.created_at DESC
|
||||
`).all() as AssetRow[]).map(mapAsset);
|
||||
}
|
||||
|
||||
listEligible(): MediaLibraryAsset[] {
|
||||
return (this.db.prepare(`
|
||||
SELECT * FROM media_library_assets
|
||||
WHERE deleted_at IS NULL AND enabled = 1 AND classification_status = 'ready'
|
||||
ORDER BY created_at DESC
|
||||
`).all() as AssetRow[]).map(mapAsset);
|
||||
}
|
||||
|
||||
update(id: string, tags: MediaLibraryTag[], safety: Exclude<MediaLibrarySafety, 'unknown'>, enabled: boolean, now = Date.now()): MediaLibraryAsset | undefined {
|
||||
this.db.prepare(`
|
||||
UPDATE media_library_assets
|
||||
SET tags_json = ?, safety = ?, classification_status = 'ready', enabled = ?, updated_at = ?
|
||||
WHERE asset_id = ? AND deleted_at IS NULL
|
||||
`).run(JSON.stringify(tags), safety, enabled ? 1 : 0, now, id);
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
softDelete(id: string, now = Date.now()): boolean {
|
||||
return this.db.prepare(`
|
||||
UPDATE media_library_assets
|
||||
SET enabled = 0, deleted_at = ?, updated_at = ?, sha256 = sha256 || ':deleted:' || asset_id
|
||||
WHERE asset_id = ? AND deleted_at IS NULL
|
||||
`).run(now, now, id).changes > 0;
|
||||
}
|
||||
|
||||
getSend(accountId: string, conversationId: string, incomingKey: string): MediaLibrarySend | undefined {
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM media_library_sends
|
||||
WHERE account_id = ? AND conversation_id = ? AND incoming_key = ?
|
||||
`).get(accountId, conversationId, incomingKey) as SendRow | undefined;
|
||||
return row ? mapSend(row) : undefined;
|
||||
}
|
||||
|
||||
listConversationSends(accountId: string, conversationId: string): MediaLibrarySend[] {
|
||||
return (this.db.prepare(`
|
||||
SELECT * FROM media_library_sends
|
||||
WHERE account_id = ? AND conversation_id = ?
|
||||
ORDER BY requested_at ASC, id ASC
|
||||
`).all(accountId, conversationId) as SendRow[]).map(mapSend);
|
||||
}
|
||||
|
||||
tryReserve(
|
||||
assetId: string,
|
||||
accountId: string,
|
||||
conversationId: string,
|
||||
incomingKey: string,
|
||||
requestKind: MediaRequestKind,
|
||||
now = Date.now(),
|
||||
): MediaLibrarySend | null {
|
||||
try {
|
||||
this.db.prepare(`
|
||||
INSERT INTO media_library_sends (
|
||||
asset_id, asset_sha256, account_id, conversation_id, incoming_key, request_kind,
|
||||
status, requested_at
|
||||
)
|
||||
SELECT asset_id, sha256, ?, ?, ?, ?, 'pending', ?
|
||||
FROM media_library_assets
|
||||
WHERE asset_id = ? AND deleted_at IS NULL AND enabled = 1
|
||||
`).run(accountId, conversationId, incomingKey, requestKind, now, assetId);
|
||||
return this.getSend(accountId, conversationId, incomingKey) ?? null;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && /UNIQUE constraint failed/.test(error.message)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
markMediaSent(id: number, mxcUri: string, eventId: string): void {
|
||||
this.db.prepare(`
|
||||
UPDATE media_library_sends
|
||||
SET mxc_uri = ?, matrix_media_event_id = ?, error = NULL
|
||||
WHERE id = ? AND status = 'pending'
|
||||
`).run(mxcUri, eventId, id);
|
||||
}
|
||||
|
||||
markSent(id: number, textEventId: string, now = Date.now()): boolean {
|
||||
return this.db.prepare(`
|
||||
UPDATE media_library_sends
|
||||
SET status = 'sent', matrix_text_event_id = ?, sent_at = ?, error = NULL
|
||||
WHERE id = ? AND status = 'pending' AND matrix_media_event_id IS NOT NULL
|
||||
`).run(textEventId, now, id).changes > 0;
|
||||
}
|
||||
|
||||
markFailed(id: number, error: string): boolean {
|
||||
return this.db.prepare(`
|
||||
UPDATE media_library_sends SET status = 'failed', error = ?
|
||||
WHERE id = ? AND status = 'pending'
|
||||
`).run(error.slice(0, 500), id).changes > 0;
|
||||
}
|
||||
}
|
||||
|
||||
function mapAsset(row: AssetRow): MediaLibraryAsset {
|
||||
let tags: MediaLibraryTag[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(row.tags_json) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
tags = parsed.filter((tag): tag is MediaLibraryTag =>
|
||||
typeof tag === 'string' && (MEDIA_LIBRARY_TAGS as readonly string[]).includes(tag));
|
||||
}
|
||||
} catch { /* invalid legacy data remains disabled by service validation */ }
|
||||
return {
|
||||
id: row.asset_id,
|
||||
tags,
|
||||
safety: row.safety,
|
||||
classificationStatus: row.classification_status,
|
||||
description: row.description,
|
||||
confidence: row.confidence,
|
||||
fileName: row.file_name,
|
||||
relativePath: row.relative_path,
|
||||
mimeType: row.mime_type,
|
||||
sizeBytes: row.size_bytes,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
enabled: Boolean(row.enabled),
|
||||
consentConfirmedAt: row.consent_confirmed_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
sendCount: row.send_count ?? 0,
|
||||
lastSentAt: row.last_sent_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSend(row: SendRow): MediaLibrarySend {
|
||||
return {
|
||||
id: row.id,
|
||||
assetId: row.asset_id,
|
||||
accountId: row.account_id,
|
||||
conversationId: row.conversation_id,
|
||||
incomingKey: row.incoming_key,
|
||||
requestKind: row.request_kind,
|
||||
status: row.status,
|
||||
requestedAt: row.requested_at,
|
||||
sentAt: row.sent_at,
|
||||
matrixMediaEventId: row.matrix_media_event_id,
|
||||
matrixTextEventId: row.matrix_text_event_id,
|
||||
mxcUri: row.mxc_uri,
|
||||
error: row.error,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -88,6 +88,16 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
|
||||
operation('getMediaSync', 'GET', '/api/media/sync', 'Get media sync state', ['Media']),
|
||||
operation('triggerMediaSync', 'POST', '/api/media/sync', 'Trigger media sync', ['Media']),
|
||||
operation('triggerFullMediaSync', 'POST', '/api/media/sync/full', 'Trigger full media sync', ['Media']),
|
||||
operation('listMediaLibrary', 'GET', '/api/media-library', 'List operator-owned exchange images', ['Media Library']),
|
||||
operation('uploadMediaLibraryImage', 'POST', '/api/media-library', 'Upload and classify an operator-owned exchange image', ['Media Library'], { successStatus: 201 }),
|
||||
operation('updateMediaLibraryImage', 'PATCH', '/api/media-library/{id}', 'Correct tags or enable an exchange image', ['Media Library'], {
|
||||
requestSchema: z.strictObject({
|
||||
tags: z.array(z.enum(['penis', 'chest', 'selfie', 'topless', 'feet'])).min(1),
|
||||
safety: z.enum(['sfw', 'nsfw']),
|
||||
enabled: z.boolean(),
|
||||
}),
|
||||
}),
|
||||
operation('deleteMediaLibraryImage', 'DELETE', '/api/media-library/{id}', 'Delete an exchange image', ['Media Library']),
|
||||
operation('startBot', 'POST', '/api/start', 'Start bot processing (legacy)', ['Control']),
|
||||
operation('stopBot', 'POST', '/api/stop', 'Stop bot processing (legacy)', ['Control']),
|
||||
operation('getTimers', 'GET', '/api/timers', 'Get rate-limit timers', ['Analytics']),
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ export function createDashboardAuth(token: string, agentTokens: AgentTokenOption
|
||||
res.status(403).json({ error: 'Agent credential lacks the required access' });
|
||||
return;
|
||||
}
|
||||
if (!req.path.startsWith('/api') && !req.path.startsWith('/media')) {
|
||||
if (!req.path.startsWith('/api') && !req.path.startsWith('/media') && !req.path.startsWith('/library-media')) {
|
||||
res.redirect(302, '/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { RedditAccountsService } from '../services/reddit-accounts.js';
|
||||
import type { RedditAccountRuntimeManager } from '../services/reddit-account-runtime.js';
|
||||
import type { AiUsageRepository } from '../database/ai-usage-repository.js';
|
||||
import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js';
|
||||
import type { MediaLibraryService } from '../services/media-library.js';
|
||||
|
||||
/** Bag of everything the route modules might need. */
|
||||
export interface WebContext {
|
||||
@@ -56,6 +57,7 @@ export interface WebContext {
|
||||
redditAccounts?: RedditAccountsService;
|
||||
accountRuntimes?: RedditAccountRuntimeManager;
|
||||
candidateQueue?: CandidateQueueRepository;
|
||||
mediaLibrary?: MediaLibraryService;
|
||||
};
|
||||
|
||||
/** Bot pause/resume toggle read by the main loop. */
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import express, { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import type { WebContext } from '../context.js';
|
||||
import {
|
||||
MediaLibraryConflictError,
|
||||
MediaLibraryInputError,
|
||||
MediaLibraryNotFoundError,
|
||||
} from '../../services/media-library.js';
|
||||
import { MEDIA_LIBRARY_TAGS, type MediaLibraryAsset } from '../../services/media-library-types.js';
|
||||
|
||||
const MediaLibraryUpdateSchema = z.strictObject({
|
||||
tags: z.array(z.enum(MEDIA_LIBRARY_TAGS)).min(1),
|
||||
safety: z.enum(['sfw', 'nsfw']),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export function createMediaLibraryRouter(ctx: WebContext): Router {
|
||||
const router = Router();
|
||||
|
||||
router.get('/media-library', (_req, res) => {
|
||||
const service = ctx.services.mediaLibrary;
|
||||
if (!service) return res.status(503).json({ error: 'Media library is not initialized' });
|
||||
return res.json(service.list().map(toResponse));
|
||||
});
|
||||
|
||||
router.post('/media-library',
|
||||
express.raw({ type: ['image/jpeg', 'image/png', 'image/webp'], limit: '15mb' }),
|
||||
async (req, res) => {
|
||||
const service = ctx.services.mediaLibrary;
|
||||
if (!service) return res.status(503).json({ error: 'Media library is not initialized' });
|
||||
if (!Buffer.isBuffer(req.body)) return res.status(415).json({ error: 'Upload a JPEG, PNG, or WebP image body' });
|
||||
try {
|
||||
const asset = await service.upload({
|
||||
bytes: req.body,
|
||||
contentType: req.headers['content-type'] ?? '',
|
||||
originalFileName: decodeFileName(req.headers['x-file-name']),
|
||||
consentConfirmed: req.headers['x-consent-confirmed'] === 'true',
|
||||
});
|
||||
return res.status(201).json(toResponse(asset));
|
||||
} catch (error) {
|
||||
if (error instanceof MediaLibraryConflictError) return res.status(409).json({ error: error.message });
|
||||
if (error instanceof MediaLibraryInputError) return res.status(400).json({ error: error.message });
|
||||
ctx.logger.error('Media library upload failed:', error);
|
||||
return res.status(500).json({ error: 'Media library upload failed' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.patch('/media-library/:id', (req, res) => {
|
||||
const service = ctx.services.mediaLibrary;
|
||||
if (!service) return res.status(503).json({ error: 'Media library is not initialized' });
|
||||
const parsed = MediaLibraryUpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid update' });
|
||||
try {
|
||||
return res.json(toResponse(service.update(req.params.id, parsed.data)));
|
||||
} catch (error) {
|
||||
if (error instanceof MediaLibraryInputError) return res.status(400).json({ error: error.message });
|
||||
if (error instanceof MediaLibraryNotFoundError) return res.status(404).json({ error: error.message });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/media-library/:id', async (req, res) => {
|
||||
const service = ctx.services.mediaLibrary;
|
||||
if (!service) return res.status(503).json({ error: 'Media library is not initialized' });
|
||||
return (await service.delete(req.params.id))
|
||||
? res.json({ success: true })
|
||||
: res.status(404).json({ error: 'Library image not found' });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function toResponse(asset: MediaLibraryAsset) {
|
||||
const { relativePath, ...publicAsset } = asset;
|
||||
return {
|
||||
...publicAsset,
|
||||
urlPath: `/library-media/${relativePath.split(/[\\/]/).map(encodeURIComponent).join('/')}`,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeFileName(value: string | string[] | undefined): string {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
if (!raw) return 'library-image';
|
||||
try { return decodeURIComponent(raw); } catch { return 'library-image'; }
|
||||
}
|
||||
@@ -45,6 +45,8 @@ import { createAttentionRouter } from './routes/attention.js';
|
||||
import { createOutreachRouter } from './routes/outreach.js';
|
||||
import type { AiUsageRepository } from '../database/ai-usage-repository.js';
|
||||
import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js';
|
||||
import type { MediaLibraryService } from '../services/media-library.js';
|
||||
import { createMediaLibraryRouter } from './routes/media-library.js';
|
||||
|
||||
export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js';
|
||||
|
||||
@@ -153,6 +155,7 @@ export class WebServer {
|
||||
// Media can contain private chat attachments, so it is intentionally behind
|
||||
// the same dashboard authentication middleware as the API and UI.
|
||||
this.app.use('/media', express.static(path.join(this.workspaceDirectory, 'data', 'media'), { index: false }));
|
||||
this.app.use('/library-media', express.static(path.join(this.workspaceDirectory, 'data', 'media-library'), { index: false }));
|
||||
this.app.use(express.static(dashboardAssets.directory, { index: false }));
|
||||
|
||||
const ctx = this.context();
|
||||
@@ -163,6 +166,7 @@ export class WebServer {
|
||||
this.app.use('/api', createChatsRouter(ctx));
|
||||
this.app.use('/api', createConversationsRouter(ctx));
|
||||
this.app.use('/api', createMediaRouter(ctx));
|
||||
this.app.use('/api', createMediaLibraryRouter(ctx));
|
||||
this.app.use('/api', createControlRouter(ctx));
|
||||
if (this.config.web.debugEnabled) {
|
||||
this.app.use('/api', createDebugRouter(ctx));
|
||||
@@ -204,6 +208,10 @@ export class WebServer {
|
||||
this.services.mediaSync = mediaSyncService;
|
||||
}
|
||||
|
||||
setMediaLibraryService(mediaLibrary: MediaLibraryService): void {
|
||||
this.services.mediaLibrary = mediaLibrary;
|
||||
}
|
||||
|
||||
setResponseChecker(responseChecker: ResponseCheckerService): void {
|
||||
this.services.responseChecker = responseChecker;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user