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(); }); it('treats clear acceptance of the bot\'s prior generic media offer as a request', () => { expect(detectMediaRequest('yes please 😏', [], 'Want me to send you a pic?')).toBe('generic'); expect(detectMediaRequest('yes please', [], 'Want to hear a joke?')).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) }); expect(service.getOutboundAttribution('room')).toEqual({ eventIds: ['$1', '$2', '$3'], texts: Array(3).fill('Your turn — send me one back if you’re comfortable.'), }); 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(); }); it('lists eligible choices and sends only the operator-selected matching asset', async () => { const db = createTestDb(); const repository = new MediaLibraryRepository(db); await addAsset(repository, directory, 'selfie-one', ['selfie'], 'sfw', 1); await addAsset(repository, directory, 'selfie-two', ['selfie'], 'sfw', 2); await addAsset(repository, directory, 'topless', ['topless'], 'nsfw', 3); const chat = { sendChatImage: vi.fn().mockResolvedValue({ eventId: '$selected', mxcUri: 'mxc://selected' }), sendChatMessage: vi.fn().mockResolvedValue(true), }; const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory); expect(service.listShareable('room', 'send a selfie').assets.map((asset) => asset.id)).toEqual([ 'selfie-two', 'selfie-one', ]); expect(await service.handleSelected('room', '$request', 'send a selfie', 'topless')).toMatchObject({ kind: 'human-required', }); expect(await service.handleSelected('room', '$request', 'send a selfie', 'selfie-one')).toMatchObject({ kind: 'sent', assetId: 'selfie-one', requestKind: 'selfie', }); expect(chat.sendChatImage).toHaveBeenCalledWith( 'room', expect.objectContaining({ fileName: 'selfie-one.jpg' }), expect.any(String), null, ); db.close(); }); it('keeps an image-only partial delivery human-required on later attempts', async () => { const db = createTestDb(); const repository = new MediaLibraryRepository(db); await addAsset(repository, directory, 'selfie', ['selfie'], 'sfw', 1); const chat = { sendChatImage: vi.fn().mockResolvedValue({ eventId: '$image', mxcUri: 'mxc://image' }), sendChatMessage: vi.fn().mockResolvedValue(false), }; const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory); await expect(service.handleSelected('room', '$request', 'send a selfie', 'selfie')).resolves.toMatchObject({ kind: 'human-required', remoteSent: true, }); await expect(service.handleSelected('room', '$request', 'send a selfie', 'selfie')).resolves.toMatchObject({ kind: 'human-required', remoteSent: true, }); expect(chat.sendChatImage).toHaveBeenCalledOnce(); expect(chat.sendChatMessage).toHaveBeenCalledOnce(); db.close(); }); it('manually shares any enabled image with no request gating and no reciprocal text', async () => { const db = createTestDb(); const repository = new MediaLibraryRepository(db); await addAsset(repository, directory, 'selfie', ['selfie'], 'sfw', 1); await addAsset(repository, directory, 'topless', ['topless'], 'nsfw', 2); const chat = { sendChatImage: vi.fn().mockResolvedValue({ eventId: '$img', mxcUri: 'mxc://img' }), sendChatMessage: vi.fn().mockResolvedValue(true), }; const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory); // No image request in the conversation, yet every enabled image is offered. expect(service.listShareableManual('room').map((asset) => asset.id).sort()).toEqual(['selfie', 'topless']); const result = await service.shareManual('room', 'topless'); expect(result).toMatchObject({ kind: 'sent', assetId: 'topless' }); expect(chat.sendChatImage).toHaveBeenCalledOnce(); expect(chat.sendChatMessage).not.toHaveBeenCalled(); // The shared image leaves the manual list; re-sharing it is idempotent and re-sends nothing. expect(service.listShareableManual('room').map((asset) => asset.id)).toEqual(['selfie']); expect(await service.shareManual('room', 'topless')).toMatchObject({ kind: 'sent', assetId: 'topless' }); expect(chat.sendChatImage).toHaveBeenCalledOnce(); db.close(); }); it('refuses a manual share of an unknown or disabled image', async () => { const db = createTestDb(); const repository = new MediaLibraryRepository(db); await addAsset(repository, directory, 'selfie', ['selfie'], 'sfw', 1); repository.update('selfie', ['selfie'], 'sfw', false); const chat = { sendChatImage: vi.fn().mockResolvedValue({ eventId: '$img', mxcUri: 'mxc://img' }), sendChatMessage: vi.fn().mockResolvedValue(true), }; const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory); expect(await service.shareManual('room', 'does-not-exist')).toMatchObject({ kind: 'human-required' }); expect(await service.shareManual('room', 'selfie')).toMatchObject({ kind: 'human-required' }); expect(chat.sendChatImage).not.toHaveBeenCalled(); 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; }