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' }), }));