feat: decouple manual image sharing from automation gating

The "Share a saved image" panel was wired to the automated exchange
engine, so an operator could only share an image right after an inbound
image request, limited to assets the auto-exchanger would itself pick.

Add a manual path that offers any enabled, unused library image at any
point (no request detection, tier, seller, or boundary gating) and sends
it with no reciprocal "your turn" text. Safety flags now surface as a
non-blocking warning instead of disabling the panel. The automated
auto-send path keeps all its guardrails; the shared image-delivery step
is extracted into a helper used by both paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 4.8 committed 2026-08-10 09:18:37 +01:00
1 parent e984bf20ac
commit c8689af04d
9 files changed
+192 -67

No files matched your search

+4 -3
View File
@@ -67,7 +67,8 @@ const server = setupServer(
isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2,
}])),
http.get('/api/conversations/:id/shareable-media', () => HttpResponse.json({
requestKind: null, assets: [], reason: 'The latest inbound message does not ask for an image.',
requestKind: null, assets: [], warning: null,
reason: 'No enabled, unused library images are available to share.',
})),
);
@@ -354,7 +355,7 @@ describe('ConversationsPage', () => {
],
})),
http.get('/api/conversations/room-alice/shareable-media', () => HttpResponse.json({
requestKind: 'selfie', assets: [asset], reason: null,
requestKind: 'selfie', assets: [asset], reason: null, warning: null,
})),
http.post('/api/conversations/room-alice/share-media', async ({ request }) => {
const body = await request.json() as { assetId: string };
@@ -541,7 +542,7 @@ describe('ConversationsPage', () => {
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())),
http.get('/api/conversations/room-alice/shareable-media', () => HttpResponse.json({
requestKind: 'selfie', assets: [asset], reason: null,
requestKind: 'selfie', assets: [asset], reason: null, warning: null,
})),
);
const user = userEvent.setup();
+43
View File
@@ -129,6 +129,49 @@ describe('MediaLibraryExchangeService', () => {
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(
+7 -5
View File
@@ -133,7 +133,7 @@ describe('conversations route', () => {
expect(first.resources.activityLogRepo.insert).not.toHaveBeenCalled();
});
it('lists and sends only request-matched library media through the owning runtime', async () => {
it('lists every enabled image and shares the operator-selected one through the owning runtime', async () => {
const accountId = '@t2_one:reddit.com';
const assetId = '11111111-1111-4111-8111-111111111111';
const conversation = {
@@ -151,8 +151,9 @@ describe('conversations route', () => {
sendCount: 0, lastSentAt: null,
};
const mediaExchange = {
listShareable: vi.fn().mockReturnValue({ requestKind: 'selfie', assets: [asset] }),
handleSelected: vi.fn().mockResolvedValue({ kind: 'sent', assetId, requestKind: 'selfie' }),
listShareableManual: vi.fn().mockReturnValue([asset]),
detect: vi.fn().mockReturnValue('selfie'),
shareManual: vi.fn().mockResolvedValue({ kind: 'sent', assetId, requestKind: 'generic' }),
};
const runtime = {
snapshot: () => ({ state: 'running' }),
@@ -184,16 +185,17 @@ describe('conversations route', () => {
const optionsResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/shareable-media`);
expect(optionsResponse.status).toBe(200);
expect(await optionsResponse.json()).toEqual({
requestKind: 'selfie', reason: null,
requestKind: 'selfie', reason: null, warning: null,
assets: [expect.objectContaining({ id: assetId, urlPath: '/library-media/nested/selfie.jpg' })],
});
expect(mediaExchange.listShareableManual).toHaveBeenCalledWith('room-one');
const sendResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/share-media`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assetId }),
});
expect(sendResponse.status).toBe(200);
expect(await sendResponse.json()).toEqual({ success: true });
expect(mediaExchange.handleSelected).toHaveBeenCalledWith('room-one', '$request', 'send a selfie', assetId);
expect(mediaExchange.shareManual).toHaveBeenCalledWith('room-one', assetId);
expect(runtime.resources.conversationStateRepo.save).toHaveBeenCalledWith('room-one', expect.objectContaining({
automation: expect.objectContaining({ status: 'user-overridden' }),
}));