From c8689af04dc7adc7eb5cdc004b8eb99e049671e7 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Mon, 10 Aug 2026 09:18:37 +0100 Subject: [PATCH] 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 --- .../conversations/conversation-detail.tsx | 3 +- .../features/conversations/conversations.css | 1 + packages/api-contracts/src/chats.ts | 4 + src/services/media-library-exchange.ts | 119 ++++++++++++++---- src/web/api-catalog.ts | 2 +- src/web/routes/conversations.ts | 68 +++++----- tests/dashboard/conversations.test.tsx | 7 +- tests/media-library-exchange.test.ts | 43 +++++++ tests/web/conversations-route.test.ts | 12 +- 9 files changed, 192 insertions(+), 67 deletions(-) diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx index 0fe8c32..8a77eae 100644 --- a/apps/dashboard/src/features/conversations/conversation-detail.tsx +++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx @@ -275,9 +275,10 @@ function ConversationComposer({ conversation, conversationId }: { conversation: > Share a saved image -

Only enabled, unused images matching the latest inbound request are available.

+

Pick any enabled image from your exchange library to send. Images already shared in this conversation are hidden.

{mediaOptionsQuery.isLoading ? Loading saved images… : null} {mediaOptionsQuery.isError ?
{mediaOptionsQuery.error.message}
: null} + {mediaOptionsQuery.data?.warning ?
{mediaOptionsQuery.data.warning}
: null} {mediaOptionsQuery.data?.reason ?
{mediaOptionsQuery.data.reason}
: null} {mediaOptions.length > 0 ? ( <> diff --git a/apps/dashboard/src/features/conversations/conversations.css b/apps/dashboard/src/features/conversations/conversations.css index 049c457..4993084 100644 --- a/apps/dashboard/src/features/conversations/conversations.css +++ b/apps/dashboard/src/features/conversations/conversations.css @@ -102,6 +102,7 @@ .conversation-media-share p, .conversation-media-unavailable { margin: 0; color: var(--muted); font-size: .8rem; } .conversation-media-reveal { justify-self: start; } .conversation-media-hint { color: var(--warning-text) !important; font-size: .78rem !important; font-weight: 700; } +.conversation-media-warning { margin: 0; padding: .5rem .65rem; border-radius: .5rem; border: 1px solid var(--warning-text); background: color-mix(in srgb, var(--warning-text) 12%, transparent); color: var(--warning-text); font-size: .78rem; font-weight: 700; } .conversation-media-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); gap: .65rem; } .conversation-media-options label { display: grid; gap: .35rem; padding: .35rem; overflow: hidden; border: 1px solid var(--border); border-radius: .55rem; cursor: pointer; } .conversation-media-options label.is-selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } diff --git a/packages/api-contracts/src/chats.ts b/packages/api-contracts/src/chats.ts index 8d9f5eb..6b22a86 100644 --- a/packages/api-contracts/src/chats.ts +++ b/packages/api-contracts/src/chats.ts @@ -93,9 +93,13 @@ export const SuggestedReplyResponseSchema = z.strictObject({ }); export const SendReplyRequestSchema = z.strictObject({ message: z.string().min(1) }); export const ConversationMediaShareOptionsResponseSchema = z.strictObject({ + /** What the latest inbound message asked for, if anything. Informational only — never gates sharing. */ requestKind: z.union([MediaLibraryTagSchema, z.literal('generic')]).nullable(), assets: z.array(MediaLibraryAssetSchema), + /** Set only when there is genuinely nothing to share (no room, or no enabled/unused images). */ reason: z.string().nullable(), + /** Non-blocking safety note (seller/boundary flags). The operator can still share. */ + warning: z.string().nullable(), }); export const ShareConversationMediaRequestSchema = z.strictObject({ assetId: z.string().uuid() }); export const StartConversationRequestSchema = z.strictObject({ diff --git a/src/services/media-library-exchange.ts b/src/services/media-library-exchange.ts index cf2ea80..77df640 100644 --- a/src/services/media-library-exchange.ts +++ b/src/services/media-library-exchange.ts @@ -21,6 +21,11 @@ export interface MediaExchangeOutboundAttribution { const RECIPROCAL_MESSAGE = 'Your turn — send me one back if you’re comfortable.'; +/** Ledger `incoming_key` prefix for operator-initiated manual shares (no inbound request event). */ +const MANUAL_INCOMING_KEY_PREFIX = 'manual:'; +/** Sentinel stored in `matrix_text_event_id` for a manual share, which sends no reciprocal text. */ +const MANUAL_NO_RECIPROCAL_MARKER = 'manual-no-reciprocal'; + export type MediaExchangeResult = | { kind: 'not-requested' } | { kind: 'dry-run'; requestKind: MediaRequestKind } @@ -88,6 +93,64 @@ export class MediaLibraryExchangeService { return this.handleRequest(conversationId, incomingKey, incomingText, assetId); } + /** + * Every enabled library image not yet shared in this conversation. Unlike + * {@link listShareable}, this ignores whether the latest message requested an + * image and does not filter by request kind or safety tier — the operator + * chooses. Used by the dashboard's manual "Share a saved image" panel. + */ + listShareableManual(conversationId: string): MediaLibraryAsset[] { + const used = new Set( + this.repository.listConversationSends(this.accountId, conversationId).map((item) => item.assetId), + ); + return this.repository.listEligible().filter((asset) => !used.has(asset.id)); + } + + /** + * Operator-initiated share of a specific library image, with none of the + * automated exchange's gating: no request detection, no tier/boundary/seller + * checks, and no reciprocal "your turn" text. The human made the call. + */ + async shareManual(conversationId: string, assetId: string): Promise { + if (this.dryRun) return { kind: 'dry-run', requestKind: 'generic' }; + const incomingKey = `${MANUAL_INCOMING_KEY_PREFIX}${assetId}`; + + 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 manual share of this image failed or became uncertain.' }; + } + if (!send) { + const chosen = this.repository.get(assetId); + if (!chosen || !chosen.enabled) { + return { kind: 'human-required', reason: 'That library image is disabled or unavailable.' }; + } + send = this.repository.tryReserve(assetId, this.accountId, conversationId, incomingKey, 'generic') ?? undefined; + if (!send) { + return { kind: 'human-required', reason: 'That library image has already been shared in this conversation.' }; + } + } + + 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 { + const delivered = await this.deliverReservedImage(conversationId, send, asset); + if ('failure' in delivered) return delivered.failure; + send = delivered.send; + if (!send.matrixTextEventId) this.repository.markSent(send.id, MANUAL_NO_RECIPROCAL_MARKER); + this.logger.info(`[media-library] Manually shared asset ${asset.id} in ${conversationId}`); + return { kind: 'sent', assetId: asset.id, requestKind: 'generic' }; + } catch (error) { + this.repository.markFailed(send.id, safeError(error)); + this.logger.warn(`[media-library] Manual share failed in ${conversationId}: ${safeError(error)}`); + return { kind: 'human-required', reason: 'The requested library image could not be read or sent.' }; + } + } + private async handleRequest( conversationId: string, incomingKey: string, @@ -138,27 +201,9 @@ export class MediaLibraryExchangeService { } 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 delivered = await this.deliverReservedImage(conversationId, send, asset); + if ('failure' in delivered) return delivered.failure; + send = delivered.send; const textSent = send.matrixTextEventId ? true @@ -183,6 +228,38 @@ export class MediaLibraryExchangeService { } } + /** + * Sends the reserved asset's bytes if the image event isn't already + * confirmed, then returns the refreshed send row. Callers own the reciprocal + * text (or lack of it) and the surrounding try/catch. + */ + private async deliverReservedImage( + conversationId: string, + send: MediaLibrarySend, + asset: MediaLibraryAsset, + ): Promise<{ send: MediaLibrarySend } | { failure: MediaExchangeResult }> { + if (send.matrixMediaEventId) return { send }; + 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 { failure: { kind: 'human-required', reason: 'Matrix did not confirm the requested image send.' } }; + } + this.repository.markMediaSent(send.id, imageResult.mxcUri, imageResult.eventId); + return { send: this.repository.getSend(this.accountId, conversationId, send.incomingKey)! }; + } + private pickEligible( assets: MediaLibraryAsset[], used: Set, diff --git a/src/web/api-catalog.ts b/src/web/api-catalog.ts index d25bbf9..6474419 100644 --- a/src/web/api-catalog.ts +++ b/src/web/api-catalog.ts @@ -83,7 +83,7 @@ export const API_OPERATIONS: readonly ApiOperation[] = [ operation('suggestReply', 'POST', '/api/conversations/{id}/suggest-reply', 'Generate a reply suggestion', ['Conversations']), operation('suggestMediaAsk', 'POST', '/api/conversations/{id}/suggest-media-ask', 'Generate a media request suggestion', ['Conversations']), operation('sendReply', 'POST', '/api/conversations/{id}/send-reply', 'Send a conversation reply', ['Conversations']), - operation('getConversationMediaOptions', 'GET', '/api/conversations/{id}/shareable-media', 'List exchange-library images allowed for the latest inbound request', ['Conversations']), + operation('getConversationMediaOptions', 'GET', '/api/conversations/{id}/shareable-media', 'List every enabled exchange-library image not yet shared in this conversation', ['Conversations']), operation('shareConversationMedia', 'POST', '/api/conversations/{id}/share-media', 'Send a selected exchange-library image', ['Conversations']), operation('startConversation', 'POST', '/api/conversations/start', 'Start a new conversation', ['Conversations']), operation('getMedia', 'GET', '/api/media', 'List received private media', ['Media']), diff --git a/src/web/routes/conversations.ts b/src/web/routes/conversations.ts index 732af5f..0b4673a 100644 --- a/src/web/routes/conversations.ts +++ b/src/web/routes/conversations.ts @@ -80,28 +80,25 @@ export function createConversationsRouter(ctx: WebContext): Router { : conversation; } - function mediaShareEligibility( + /** + * A non-blocking heads-up shown next to the manual share panel when the + * conversation carries safety flags. The operator can still share — this only + * makes the flags visible so the decision is informed. + */ + function manualShareWarning( conversation: ActiveChat, runtime: NonNullable['get']>>, - ): { incomingKey: string; incomingText: string } | { reason: string } { - if (!conversation.roomId) return { reason: 'This conversation has no Matrix room.' }; + ): string | null { + if (!conversation.roomId) return null; const messages = conversation.messages ?? []; - const latest = messages.at(-1); - if (!latest || latest.isSelf) return { reason: 'The latest message is not an inbound image request.' }; - if (messages.some((message) => !message.isSelf && (message.media?.length ?? 0) > 0)) { - return { reason: 'An image has already been received, so library sharing is disabled.' }; - } - const saved = runtime.resources.conversationStateRepo.get(conversation.roomId); const state = mergeConversationState( - saved, + runtime.resources.conversationStateRepo.get(conversation.roomId), buildConversationState(messages.map(({ sender, text, isSelf }) => ({ sender, text, isSelf }))), ); - if (state.sellerDetected) return { reason: 'Library sharing is disabled for a conversation marked as a seller.' }; - if (state.boundaries.length > 0) return { reason: `Library sharing is disabled after a conversation boundary: ${state.boundaries.join('; ')}` }; - return { - incomingKey: latest.eventId ?? `${latest.timestamp}:${latest.sender}:${latest.text}`, - incomingText: latest.text, - }; + const notes: string[] = []; + if (state.sellerDetected) notes.push('this conversation is flagged as a seller'); + if (state.boundaries.length > 0) notes.push(`a boundary was detected (${state.boundaries.join('; ')})`); + return notes.length > 0 ? `Heads up: ${notes.join(', and ')}.` : null; } function toMediaLibraryResponse(asset: MediaLibraryAsset) { @@ -461,15 +458,23 @@ export function createConversationsRouter(ctx: WebContext): Router { if (!resolved) return res.status(404).json({ error: 'Conversation not found' }); const exchange = resolved.runtime?.resources.mediaExchange; if (!resolved.runtime || !exchange) return res.status(503).json({ error: 'Media sharing is not initialized for this account' }); - const eligible = mediaShareEligibility(resolved.conversation, resolved.runtime); - if ('reason' in eligible) return res.json({ requestKind: null, assets: [], reason: eligible.reason }); - const options = exchange.listShareable(resolved.conversation.roomId!, eligible.incomingText); - const reason = !options.requestKind - ? 'The latest inbound message does not ask for an image.' - : options.assets.length === 0 - ? 'No unused enabled library image matches this request.' - : null; - return res.json({ ...options, assets: options.assets.map(toMediaLibraryResponse), reason }); + const conversation = resolved.conversation; + if (!conversation.roomId) { + return res.json({ requestKind: null, assets: [], reason: 'This conversation has no Matrix room.', warning: null }); + } + // Full manual control: every enabled, unused library image is offered + // regardless of what (if anything) the latest message asked for. Safety + // flags surface as a non-blocking warning, never a gate. + const assets = exchange.listShareableManual(conversation.roomId); + const latestInbound = (conversation.messages ?? []).filter((message) => !message.isSelf).at(-1); + const requestKind = latestInbound ? exchange.detect(conversation.roomId, latestInbound.text) : null; + const reason = assets.length === 0 ? 'No enabled, unused library images are available to share.' : null; + return res.json({ + requestKind, + assets: assets.map(toMediaLibraryResponse), + reason, + warning: manualShareWarning(conversation, resolved.runtime), + }); } catch (error) { ctx.logger.error('Failed to list shareable conversation media:', error); return res.status(500).json({ error: 'Failed to list shareable media' }); @@ -486,18 +491,9 @@ export function createConversationsRouter(ctx: WebContext): Router { const exchange = runtime?.resources.mediaExchange; if (!runtime || !exchange) return res.status(503).json({ error: 'Media sharing is not initialized for this account' }); const chatService = runtime.resources.chatService; + if (!resolved.conversation.roomId) return res.status(409).json({ error: 'This conversation has no Matrix room.' }); const result = await runtime.resources.rateLimiter.throttle( - async () => { - await chatService.loadConversationDetails(resolved.conversation); - const eligible = mediaShareEligibility(resolved.conversation, runtime); - if ('reason' in eligible) return { kind: 'human-required' as const, reason: eligible.reason }; - return exchange.handleSelected( - resolved.conversation.roomId!, - eligible.incomingKey, - eligible.incomingText, - parsed.data.assetId, - ); - }, + async () => exchange.shareManual(resolved.conversation.roomId!, parsed.data.assetId), (outcome) => outcome.kind === 'sent' || (outcome.kind === 'human-required' && outcome.remoteSent === true), ); diff --git a/tests/dashboard/conversations.test.tsx b/tests/dashboard/conversations.test.tsx index 556fdc5..bdd6599 100644 --- a/tests/dashboard/conversations.test.tsx +++ b/tests/dashboard/conversations.test.tsx @@ -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(); diff --git a/tests/media-library-exchange.test.ts b/tests/media-library-exchange.test.ts index e2e399c..54178c0 100644 --- a/tests/media-library-exchange.test.ts +++ b/tests/media-library-exchange.test.ts @@ -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( diff --git a/tests/web/conversations-route.test.ts b/tests/web/conversations-route.test.ts index c384f18..6ff9f88 100644 --- a/tests/web/conversations-route.test.ts +++ b/tests/web/conversations-route.test.ts @@ -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' }), }));