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

+18
View File
@@ -13,6 +13,7 @@ function harness(
rapportTurnsBeforeMediaAsk?: number;
},
beforeSendLoader?: (conversationId: string, observed: ActiveChat) => Promise<ActiveChat | undefined>,
mediaExchange?: { detect(conversationId: string, text: string): string | null; handle(conversationId: string, incomingKey: string, text: string): Promise<object> },
) {
const saved = new Map<string, ConversationState>();
let lastSaved: ConversationState | undefined;
@@ -63,6 +64,7 @@ function harness(
() => 1_000,
0,
beforeSendLoader ?? (async (_conversationId, observed) => observed),
mediaExchange as never,
);
return { service, chat, ai, states, openers, claims, rateLimiter, getSaved: () => lastSaved };
}
@@ -79,6 +81,22 @@ function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ original
}
describe('conversation automation', () => {
it('handles a requested library image through the reply limiter before generating text', async () => {
const mediaExchange = {
detect: vi.fn().mockReturnValue('generic'),
handle: vi.fn().mockResolvedValue({ kind: 'sent', assetId: 'asset-one', requestKind: 'generic' }),
};
const fixture = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 }, undefined, mediaExchange);
const result = await fixture.service.processConversation(incoming(1, 'got any pics?'));
expect(result?.kind).toBe('sent');
expect(fixture.rateLimiter.throttle).toHaveBeenCalledOnce();
expect(mediaExchange.handle).toHaveBeenCalledWith('room-alice', 'in-1', 'got any pics?');
expect(fixture.ai.generateReply).not.toHaveBeenCalled();
expect(fixture.getSaved()).toMatchObject({ askedForMedia: true, automation: { turnsUsed: 0, status: 'active' } });
});
it('allows only one autonomous media request and then hands off', async () => {
const { service, chat, getSaved } = harness();
+76
View File
@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { MediaLibraryPage } from '../../apps/dashboard/src/features/media-library/media-library-page.js';
const asset = {
id: '019fe789-1b99-7ce1-8402-b3cbc32a445a', tags: ['selfie'], safety: 'sfw',
classificationStatus: 'ready', description: 'A face-led self portrait.', confidence: 0.91,
fileName: 'portrait.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 640, height: 480,
enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
sendCount: 0, lastSentAt: null,
urlPath: '/library-media/portrait.jpg',
};
const server = setupServer();
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
return render(<QueryClientProvider client={client}><MediaLibraryPage /></QueryClientProvider>);
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); });
afterAll(() => server.close());
describe('exchange library page', () => {
it('keeps images blurred and saves operator tag corrections', async () => {
let update: unknown;
server.use(
http.get('/api/media-library', () => HttpResponse.json([asset])),
http.patch('/api/media-library/:id', async ({ request }) => {
update = await request.json();
return HttpResponse.json({ ...asset, ...(update as object) });
}),
);
const user = userEvent.setup();
renderPage();
const image = await screen.findByRole('img', { name: /Library image tagged selfie/i });
expect(image).toHaveClass('is-blurred');
await user.click(screen.getByRole('button', { name: 'Reveal' }));
expect(image).not.toHaveClass('is-blurred');
await user.click(screen.getByRole('checkbox', { name: 'topless' }));
await user.selectOptions(screen.getByRole('combobox', { name: 'Safety' }), 'nsfw');
await user.click(screen.getByRole('button', { name: 'Save changes' }));
await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'topless'], safety: 'nsfw', enabled: true }));
});
it('requires consent in the UI and uploads the selected image for classification', async () => {
let consentHeader: string | null = null;
server.use(
http.get('/api/media-library', () => HttpResponse.json([])),
http.post('/api/media-library', ({ request }) => {
consentHeader = request.headers.get('x-consent-confirmed');
return HttpResponse.json(asset, { status: 201 });
}),
);
const user = userEvent.setup();
renderPage();
await screen.findByText('No exchange images yet.');
const upload = screen.getByRole('button', { name: 'Upload and classify' });
expect(upload).toBeDisabled();
await user.upload(screen.getByLabelText(/Choose image/), new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
expect(upload).toBeDisabled();
await user.click(screen.getByRole('checkbox', { name: /I confirm every depicted adult consented/i }));
expect(upload).toBeEnabled();
await user.click(upload);
await screen.findByText('Image added. Review its tags before relying on it.');
expect(consentHeader).toBe('true');
});
});
+5 -3
View File
@@ -107,10 +107,12 @@ describe('Database Client', () => {
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
expect(tables.length).toBe(18);
expect(tables.length).toBe(20);
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
'candidate_queue',
'runtime_state',
'media_library_assets',
'media_library_sends',
]));
});
@@ -160,7 +162,7 @@ describe('Database Client', () => {
DROP TABLE conversation_automation_claims;
DROP TABLE ai_usage;
`);
db.pragma(`user_version = ${latestVersion - 6}`);
db.pragma(`user_version = ${latestVersion - 7}`);
runMigrations(db);
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
@@ -197,7 +199,7 @@ describe('Database Client', () => {
'durable reply', 5, 11, 1
);
`);
db.pragma(`user_version = ${latestVersion - 1}`);
db.pragma(`user_version = ${latestVersion - 2}`);
runMigrations(db);
+32
View File
@@ -14,6 +14,38 @@ const creds = {
capturedAt: Date.now(),
};
describe('MatrixClient outbound image media', () => {
it('uploads raw bytes and sends an m.image event with a stable transaction ID', async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
const fetchImpl = ((url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), init });
if (String(url).includes('/upload')) {
return Promise.resolve(new Response(JSON.stringify({ content_uri: 'mxc://reddit.com/image-one' }), {
status: 200, headers: { 'content-type': 'application/json' },
}));
}
return Promise.resolve(new Response(JSON.stringify({ event_id: '$image-event' }), {
status: 200, headers: { 'content-type': 'application/json' },
}));
}) as typeof fetch;
const client = new MatrixClient(creds, createMockLogger(), { minRequestIntervalMs: 0, fetchImpl });
const mxc = await client.uploadImage(Buffer.from([1, 2, 3]), 'image/jpeg', 'portrait.jpg');
await expect(client.sendImageMessage(
'!room:reddit.com', 'portrait.jpg', mxc,
{ mimeType: 'image/jpeg', size: 3, width: 10, height: 20 }, 'stable-image-txn',
)).resolves.toBe('$image-event');
expect(calls[0].url).toContain('/_matrix/media/v3/upload?filename=portrait.jpg');
expect(new Headers(calls[0].init?.headers).get('content-type')).toBe('image/jpeg');
expect(calls[1].url).toContain('/stable-image-txn');
expect(JSON.parse(String(calls[1].init?.body))).toEqual({
msgtype: 'm.image', body: 'portrait.jpg', url: 'mxc://reddit.com/image-one',
info: { mimetype: 'image/jpeg', size: 3, w: 10, h: 20 },
});
});
});
describe('MatrixClient HTTP timeout', () => {
it('loads an encoded Matrix user profile with bearer authentication', async () => {
let requestedUrl = '';
+103
View File
@@ -0,0 +1,103 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MediaLibraryRepository } from '../src/database/media-library-repository.js';
import { detectMediaRequest, MediaLibraryExchangeService } from '../src/services/media-library-exchange.js';
import type { MediaLibraryTag } from '../src/services/media-library-types.js';
import { createTestDb } from './testUtils.js';
describe('media library request detection', () => {
it.each([
['send a dick pic', 'penis'],
['can I see you topless?', 'topless'],
['got a selfie?', 'selfie'],
['what do you look like?', 'generic'],
['got any pics?', 'generic'],
])('classifies %s as %s', (message, expected) => {
expect(detectMediaRequest(message)).toBe(expected);
});
it('does not treat an unrelated show-me instruction as an image request', () => {
expect(detectMediaRequest('show me how this works')).toBeNull();
});
});
describe('MediaLibraryExchangeService', () => {
let directory: string;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'redditbot-exchange-'));
});
afterEach(async () => fs.rm(directory, { recursive: true, force: true }));
it('starts generic requests at SFW, broadens progressively, and never reuses an asset', async () => {
const db = createTestDb();
const repository = new MediaLibraryRepository(db);
await addAsset(repository, directory, 'selfie', ['selfie'], 'sfw', 1);
await addAsset(repository, directory, 'feet', ['feet'], 'sfw', 2);
await addAsset(repository, directory, 'topless', ['topless'], 'nsfw', 3);
await addAsset(repository, directory, 'penis', ['penis'], 'nsfw', 4);
const sentAssets: string[] = [];
const chat = {
sendChatImage: vi.fn(async (_room: string, image: { fileName: string }) => {
sentAssets.push(image.fileName.split('.')[0]);
return { eventId: `$${sentAssets.length}`, mxcUri: `mxc://test/${sentAssets.length}` };
}),
sendChatMessage: vi.fn().mockResolvedValue(true),
};
const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory, false, () => 0);
expect((await service.handle('room', 'one', 'got any pics?')).kind).toBe('sent');
expect(sentAssets[0]).toMatch(/selfie|feet/);
expect((await service.handle('room', 'two', 'more')).kind).toBe('sent');
expect(sentAssets[1]).not.toBe(sentAssets[0]);
expect(sentAssets[1]).not.toBe('penis');
expect((await service.handle('room', 'three', 'show me your penis')).kind).toBe('sent');
expect(sentAssets[2]).toBe('penis');
expect(new Set(sentAssets).size).toBe(sentAssets.length);
expect(chat.sendChatImage.mock.invocationCallOrder[0]).toBeLessThan(chat.sendChatMessage.mock.invocationCallOrder[0]);
expect(repository.get('penis')).toMatchObject({ sendCount: 1, lastSentAt: expect.any(Number) });
db.close();
});
it('escalates instead of substituting or reusing when no exact match remains', async () => {
const db = createTestDb();
const repository = new MediaLibraryRepository(db);
await addAsset(repository, directory, 'only-penis', ['penis'], 'nsfw', 1, 'same-content');
const chat = {
sendChatImage: vi.fn().mockResolvedValue({ eventId: '$one', mxcUri: 'mxc://one' }),
sendChatMessage: vi.fn().mockResolvedValue(true),
};
const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory);
expect((await service.handle('room', 'one', 'penis pic')).kind).toBe('sent');
repository.softDelete('only-penis');
await addAsset(repository, directory, 'reuploaded-penis', ['penis'], 'nsfw', 2, 'same-content');
expect(await service.handle('room', 'two', 'another penis pic')).toMatchObject({ kind: 'human-required' });
expect(chat.sendChatImage).toHaveBeenCalledOnce();
db.close();
});
});
async function addAsset(
repository: MediaLibraryRepository,
directory: string,
id: string,
tags: MediaLibraryTag[],
safety: 'sfw' | 'nsfw',
createdAt: number,
sha256 = `hash-${id}`,
) {
const relativePath = `${id}.jpg`;
await fs.writeFile(path.join(directory, relativePath), Buffer.from(id));
repository.create({
id, tags, safety, classificationStatus: 'ready', description: id, confidence: 1,
fileName: relativePath, relativePath, mimeType: 'image/jpeg', sizeBytes: id.length,
width: 10, height: 10, sha256, enabled: true,
consentConfirmedAt: createdAt, createdAt,
});
}
function logger() {
return { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() } as never;
}
+71
View File
@@ -0,0 +1,71 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import sharp from 'sharp';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MediaLibraryRepository } from '../src/database/media-library-repository.js';
import { MediaLibraryConflictError, MediaLibraryService } from '../src/services/media-library.js';
import { createTestDb } from './testUtils.js';
describe('MediaLibraryService', () => {
let directory: string;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'redditbot-library-'));
});
afterEach(async () => {
await fs.rm(directory, { recursive: true, force: true });
});
it('re-encodes uploads without metadata and stores editable AI classifications', async () => {
const db = createTestDb();
const repository = new MediaLibraryRepository(db);
const classifyLibraryImage = vi.fn().mockResolvedValue({
tags: ['selfie', 'topless'], safety: 'nsfw', description: 'A self portrait.', confidence: 0.92,
});
const service = new MediaLibraryService(repository, { classifyLibraryImage }, logger(), directory, () => 1234);
const source = await sharp({ create: { width: 8, height: 6, channels: 3, background: '#cc8844' } })
.withMetadata({ orientation: 6 })
.jpeg()
.toBuffer();
const asset = await service.upload({
bytes: source,
contentType: 'image/jpeg',
originalFileName: '../private portrait.jpg',
consentConfirmed: true,
});
expect(asset).toMatchObject({
tags: ['selfie', 'topless'], safety: 'nsfw', classificationStatus: 'ready', enabled: true,
consentConfirmedAt: 1234,
});
expect(classifyLibraryImage).toHaveBeenCalledOnce();
const outputMetadata = await sharp(service.resolvePath(asset)).metadata();
expect(outputMetadata.exif).toBeUndefined();
expect(outputMetadata.orientation).toBeUndefined();
expect(service.update(asset.id, { tags: ['selfie'], safety: 'sfw', enabled: false }))
.toMatchObject({ tags: ['selfie'], safety: 'sfw', enabled: false });
db.close();
});
it('keeps failed or low-confidence classifications disabled and rejects duplicate content', async () => {
const db = createTestDb();
const repository = new MediaLibraryRepository(db);
const source = await sharp({ create: { width: 4, height: 4, channels: 3, background: '#123456' } }).png().toBuffer();
const classifier = { classifyLibraryImage: vi.fn().mockRejectedValue(new Error('refused')) };
const service = new MediaLibraryService(repository, classifier, logger(), directory);
const first = await service.upload({ bytes: source, contentType: 'image/png', originalFileName: 'one.png', consentConfirmed: true });
expect(first).toMatchObject({ classificationStatus: 'failed', safety: 'unknown', tags: [], enabled: false });
await expect(service.upload({ bytes: source, contentType: 'image/png', originalFileName: 'two.png', consentConfirmed: true }))
.rejects.toBeInstanceOf(MediaLibraryConflictError);
db.close();
});
});
function logger() {
return { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() } as never;
}
+12
View File
@@ -30,6 +30,18 @@ describe('OpenRouterClient', () => {
client = new OpenRouterClient(config, logger, mockOpenAI);
});
it('parses strict multi-tag media-library classifications', async () => {
mockCreate.mockResolvedValueOnce({
model: 'vision-model',
choices: [{ message: { content: '```json\n{"tags":["selfie","topless"],"safety":"nsfw","description":"A self portrait","confidence":0.88}\n```' } }],
usage: {},
});
await expect(client.classifyLibraryImage('base64data', 'image/jpeg')).resolves.toEqual({
tags: ['selfie', 'topless'], safety: 'nsfw', description: 'A self portrait', confidence: 0.88,
});
});
describe('isRefusal (internal)', () => {
// We test refusal detection by checking the analyzeImage behavior
// since isRefusal is private. We do this by mocking responses that
+6 -1
View File
@@ -24,6 +24,8 @@ function createWorkspace(includeReactBuild = true): string {
fs.writeFileSync(path.join(workspace, 'public', 'index.html'), '<h1>Legacy dashboard</h1>');
fs.mkdirSync(path.join(workspace, 'data', 'media'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'data', 'media', 'sample.txt'), 'private media');
fs.mkdirSync(path.join(workspace, 'data', 'media-library'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'data', 'media-library', 'library.txt'), 'private library media');
if (includeReactBuild) {
fs.mkdirSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'assets'), { recursive: true });
fs.writeFileSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'index.html'), '<h1>React dashboard</h1><script src="/assets/app.js"></script>');
@@ -81,19 +83,22 @@ describe('authenticated React dashboard cutover', () => {
fetch(`${running.baseUrl}/api/stats`),
fetch(`${running.baseUrl}/api/events/stream`),
fetch(`${running.baseUrl}/media/sample.txt`),
fetch(`${running.baseUrl}/library-media/library.txt`),
fetch(running.baseUrl, { redirect: 'manual' }),
]);
expect(anonymous.map((response) => response.status)).toEqual([401, 401, 401, 302]);
expect(anonymous.map((response) => response.status)).toEqual([401, 401, 401, 401, 302]);
const headers = { Authorization: `Bearer ${AUTH_TOKEN}` };
const api = await fetch(`${running.baseUrl}/api/stats`, { headers });
const media = await fetch(`${running.baseUrl}/media/sample.txt`, { headers });
const libraryMedia = await fetch(`${running.baseUrl}/library-media/library.txt`, { headers });
const root = await fetch(running.baseUrl, { headers });
const asset = await fetch(`${running.baseUrl}/assets/app.js`, { headers });
const spa = await fetch(`${running.baseUrl}/conversations/example`, { headers: { ...headers, Accept: 'text/html' } });
expect(api.status).toBe(200);
expect(await media.text()).toBe('private media');
expect(await libraryMedia.text()).toBe('private library media');
expect(await root.text()).toContain('React dashboard');
expect(await asset.text()).toBe('react-build');
expect(await spa.text()).toContain('React dashboard');
+75
View File
@@ -0,0 +1,75 @@
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MediaLibraryAssetSchema } from '../../packages/api-contracts/src/media.js';
import type { WebContext } from '../../src/web/context.js';
import { createMediaLibraryRouter } from '../../src/web/routes/media-library.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
});
describe('media library routes', () => {
it('lists private assets without exposing their filesystem path', async () => {
const asset = libraryAsset();
const context = { services: { mediaLibrary: { list: () => [asset] } } } as unknown as WebContext;
const response = await request(express().use('/api', createMediaLibraryRouter(context)), '/api/media-library');
const body = await response.json();
expect(MediaLibraryAssetSchema.parse(body[0])).toMatchObject({ id: asset.id, urlPath: `/library-media/${asset.relativePath}` });
expect(body[0]).not.toHaveProperty('relativePath');
});
it('requires consent and passes a raw image to classification', async () => {
const upload = vi.fn().mockResolvedValue(libraryAsset());
const context = { logger: { error: vi.fn() }, services: { mediaLibrary: { upload } } } as unknown as WebContext;
const app = express().use('/api', createMediaLibraryRouter(context));
const response = await request(app, '/api/media-library', {
method: 'POST',
headers: {
'Content-Type': 'image/jpeg',
'X-File-Name': encodeURIComponent('portrait.jpg'),
'X-Consent-Confirmed': 'true',
},
body: Uint8Array.from([1, 2, 3]),
});
expect(response.status).toBe(201);
expect(upload).toHaveBeenCalledWith(expect.objectContaining({
contentType: 'image/jpeg', originalFileName: 'portrait.jpg', consentConfirmed: true,
}));
expect(Buffer.isBuffer(upload.mock.calls[0][0].bytes)).toBe(true);
});
it('validates tag corrections before updating', async () => {
const update = vi.fn();
const context = { services: { mediaLibrary: { update } } } as unknown as WebContext;
const app = express().use(express.json()).use('/api', createMediaLibraryRouter(context));
const response = await request(app, `/api/media-library/${libraryAsset().id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tags: [], safety: 'sfw', enabled: true }),
});
expect(response.status).toBe(400);
expect(update).not.toHaveBeenCalled();
});
});
async function request(app: express.Express, pathname: string, init?: RequestInit): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${pathname}`, init);
}
function libraryAsset() {
return {
id: '019fe789-1b99-7ce1-8402-b3cbc32a445a', tags: ['selfie'] as const, safety: 'sfw' as const,
classificationStatus: 'ready' as const, description: 'Portrait', confidence: 0.9,
fileName: 'portrait.jpg', relativePath: '019fe789-1b99-7ce1-8402-b3cbc32a445a.jpg', mimeType: 'image/jpeg',
sizeBytes: 100, width: 10, height: 10, enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
sendCount: 0, lastSentAt: null,
};
}