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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user