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

@@ -275,9 +275,10 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
> >
Share a saved image Share a saved image
</summary> </summary>
<p>Only enabled, unused images matching the latest inbound request are available.</p> <p>Pick any enabled image from your exchange library to send. Images already shared in this conversation are hidden.</p>
{mediaOptionsQuery.isLoading ? <span role="status">Loading saved images…</span> : null} {mediaOptionsQuery.isLoading ? <span role="status">Loading saved images…</span> : null}
{mediaOptionsQuery.isError ? <div className="conversation-inline-error" role="alert">{mediaOptionsQuery.error.message}</div> : null} {mediaOptionsQuery.isError ? <div className="conversation-inline-error" role="alert">{mediaOptionsQuery.error.message}</div> : null}
{mediaOptionsQuery.data?.warning ? <div className="conversation-media-warning" role="status">{mediaOptionsQuery.data.warning}</div> : null}
{mediaOptionsQuery.data?.reason ? <div className="conversation-media-unavailable">{mediaOptionsQuery.data.reason}</div> : null} {mediaOptionsQuery.data?.reason ? <div className="conversation-media-unavailable">{mediaOptionsQuery.data.reason}</div> : null}
{mediaOptions.length > 0 ? ( {mediaOptions.length > 0 ? (
<> <>
@@ -102,6 +102,7 @@
.conversation-media-share p, .conversation-media-unavailable { margin: 0; color: var(--muted); font-size: .8rem; } .conversation-media-share p, .conversation-media-unavailable { margin: 0; color: var(--muted); font-size: .8rem; }
.conversation-media-reveal { justify-self: start; } .conversation-media-reveal { justify-self: start; }
.conversation-media-hint { color: var(--warning-text) !important; font-size: .78rem !important; font-weight: 700; } .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 { 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 { 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); } .conversation-media-options label.is-selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
+4
View File
@@ -93,9 +93,13 @@ export const SuggestedReplyResponseSchema = z.strictObject({
}); });
export const SendReplyRequestSchema = z.strictObject({ message: z.string().min(1) }); export const SendReplyRequestSchema = z.strictObject({ message: z.string().min(1) });
export const ConversationMediaShareOptionsResponseSchema = z.strictObject({ 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(), requestKind: z.union([MediaLibraryTagSchema, z.literal('generic')]).nullable(),
assets: z.array(MediaLibraryAssetSchema), assets: z.array(MediaLibraryAssetSchema),
/** Set only when there is genuinely nothing to share (no room, or no enabled/unused images). */
reason: z.string().nullable(), 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 ShareConversationMediaRequestSchema = z.strictObject({ assetId: z.string().uuid() });
export const StartConversationRequestSchema = z.strictObject({ export const StartConversationRequestSchema = z.strictObject({
+98 -21
View File
@@ -21,6 +21,11 @@ export interface MediaExchangeOutboundAttribution {
const RECIPROCAL_MESSAGE = 'Your turn — send me one back if you’re comfortable.'; 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 = export type MediaExchangeResult =
| { kind: 'not-requested' } | { kind: 'not-requested' }
| { kind: 'dry-run'; requestKind: MediaRequestKind } | { kind: 'dry-run'; requestKind: MediaRequestKind }
@@ -88,6 +93,64 @@ export class MediaLibraryExchangeService {
return this.handleRequest(conversationId, incomingKey, incomingText, assetId); 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<MediaExchangeResult> {
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( private async handleRequest(
conversationId: string, conversationId: string,
incomingKey: string, incomingKey: string,
@@ -138,27 +201,9 @@ export class MediaLibraryExchangeService {
} }
try { try {
if (!send.matrixMediaEventId) { const delivered = await this.deliverReservedImage(conversationId, send, asset);
const bytes = await fs.readFile(resolveInside(this.rootDirectory, asset.relativePath)); if ('failure' in delivered) return delivered.failure;
const imageResult = await this.chat.sendChatImage( send = delivered.send;
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 textSent = send.matrixTextEventId const textSent = send.matrixTextEventId
? true ? 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( private pickEligible(
assets: MediaLibraryAsset[], assets: MediaLibraryAsset[],
used: Set<string>, used: Set<string>,
+1 -1
View File
@@ -83,7 +83,7 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
operation('suggestReply', 'POST', '/api/conversations/{id}/suggest-reply', 'Generate a reply suggestion', ['Conversations']), 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('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('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('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('startConversation', 'POST', '/api/conversations/start', 'Start a new conversation', ['Conversations']),
operation('getMedia', 'GET', '/api/media', 'List received private media', ['Media']), operation('getMedia', 'GET', '/api/media', 'List received private media', ['Media']),
+32 -36
View File
@@ -80,28 +80,25 @@ export function createConversationsRouter(ctx: WebContext): Router {
: conversation; : 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, conversation: ActiveChat,
runtime: NonNullable<ReturnType<NonNullable<WebContext['services']['accountRuntimes']>['get']>>, runtime: NonNullable<ReturnType<NonNullable<WebContext['services']['accountRuntimes']>['get']>>,
): { incomingKey: string; incomingText: string } | { reason: string } { ): string | null {
if (!conversation.roomId) return { reason: 'This conversation has no Matrix room.' }; if (!conversation.roomId) return null;
const messages = conversation.messages ?? []; 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( const state = mergeConversationState(
saved, runtime.resources.conversationStateRepo.get(conversation.roomId),
buildConversationState(messages.map(({ sender, text, isSelf }) => ({ sender, text, isSelf }))), 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.' }; const notes: string[] = [];
if (state.boundaries.length > 0) return { reason: `Library sharing is disabled after a conversation boundary: ${state.boundaries.join('; ')}` }; if (state.sellerDetected) notes.push('this conversation is flagged as a seller');
return { if (state.boundaries.length > 0) notes.push(`a boundary was detected (${state.boundaries.join('; ')})`);
incomingKey: latest.eventId ?? `${latest.timestamp}:${latest.sender}:${latest.text}`, return notes.length > 0 ? `Heads up: ${notes.join(', and ')}.` : null;
incomingText: latest.text,
};
} }
function toMediaLibraryResponse(asset: MediaLibraryAsset) { 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' }); if (!resolved) return res.status(404).json({ error: 'Conversation not found' });
const exchange = resolved.runtime?.resources.mediaExchange; const exchange = resolved.runtime?.resources.mediaExchange;
if (!resolved.runtime || !exchange) return res.status(503).json({ error: 'Media sharing is not initialized for this account' }); 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); const conversation = resolved.conversation;
if ('reason' in eligible) return res.json({ requestKind: null, assets: [], reason: eligible.reason }); if (!conversation.roomId) {
const options = exchange.listShareable(resolved.conversation.roomId!, eligible.incomingText); return res.json({ requestKind: null, assets: [], reason: 'This conversation has no Matrix room.', warning: null });
const reason = !options.requestKind }
? 'The latest inbound message does not ask for an image.' // Full manual control: every enabled, unused library image is offered
: options.assets.length === 0 // regardless of what (if anything) the latest message asked for. Safety
? 'No unused enabled library image matches this request.' // flags surface as a non-blocking warning, never a gate.
: null; const assets = exchange.listShareableManual(conversation.roomId);
return res.json({ ...options, assets: options.assets.map(toMediaLibraryResponse), reason }); 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) { } catch (error) {
ctx.logger.error('Failed to list shareable conversation media:', error); ctx.logger.error('Failed to list shareable conversation media:', error);
return res.status(500).json({ error: 'Failed to list shareable media' }); 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; const exchange = runtime?.resources.mediaExchange;
if (!runtime || !exchange) return res.status(503).json({ error: 'Media sharing is not initialized for this account' }); if (!runtime || !exchange) return res.status(503).json({ error: 'Media sharing is not initialized for this account' });
const chatService = runtime.resources.chatService; 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( const result = await runtime.resources.rateLimiter.throttle(
async () => { async () => exchange.shareManual(resolved.conversation.roomId!, parsed.data.assetId),
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,
);
},
(outcome) => outcome.kind === 'sent' || (outcome.kind === 'human-required' && outcome.remoteSent === true), (outcome) => outcome.kind === 'sent' || (outcome.kind === 'human-required' && outcome.remoteSent === true),
); );
+4 -3
View File
@@ -67,7 +67,8 @@ const server = setupServer(
isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2, isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2,
}])), }])),
http.get('/api/conversations/:id/shareable-media', () => HttpResponse.json({ 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({ 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 }) => { http.post('/api/conversations/room-alice/share-media', async ({ request }) => {
const body = await request.json() as { assetId: string }; 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', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())), http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())),
http.get('/api/conversations/room-alice/shareable-media', () => HttpResponse.json({ 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(); const user = userEvent.setup();
+43
View File
@@ -129,6 +129,49 @@ describe('MediaLibraryExchangeService', () => {
expect(chat.sendChatMessage).toHaveBeenCalledOnce(); expect(chat.sendChatMessage).toHaveBeenCalledOnce();
db.close(); 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( async function addAsset(
+7 -5
View File
@@ -133,7 +133,7 @@ describe('conversations route', () => {
expect(first.resources.activityLogRepo.insert).not.toHaveBeenCalled(); 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 accountId = '@t2_one:reddit.com';
const assetId = '11111111-1111-4111-8111-111111111111'; const assetId = '11111111-1111-4111-8111-111111111111';
const conversation = { const conversation = {
@@ -151,8 +151,9 @@ describe('conversations route', () => {
sendCount: 0, lastSentAt: null, sendCount: 0, lastSentAt: null,
}; };
const mediaExchange = { const mediaExchange = {
listShareable: vi.fn().mockReturnValue({ requestKind: 'selfie', assets: [asset] }), listShareableManual: vi.fn().mockReturnValue([asset]),
handleSelected: vi.fn().mockResolvedValue({ kind: 'sent', assetId, requestKind: 'selfie' }), detect: vi.fn().mockReturnValue('selfie'),
shareManual: vi.fn().mockResolvedValue({ kind: 'sent', assetId, requestKind: 'generic' }),
}; };
const runtime = { const runtime = {
snapshot: () => ({ state: 'running' }), snapshot: () => ({ state: 'running' }),
@@ -184,16 +185,17 @@ describe('conversations route', () => {
const optionsResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/shareable-media`); const optionsResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/shareable-media`);
expect(optionsResponse.status).toBe(200); expect(optionsResponse.status).toBe(200);
expect(await optionsResponse.json()).toEqual({ 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' })], 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`, { const sendResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/share-media`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assetId }), method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assetId }),
}); });
expect(sendResponse.status).toBe(200); expect(sendResponse.status).toBe(200);
expect(await sendResponse.json()).toEqual({ success: true }); 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({ expect(runtime.resources.conversationStateRepo.save).toHaveBeenCalledWith('room-one', expect.objectContaining({
automation: expect.objectContaining({ status: 'user-overridden' }), automation: expect.objectContaining({ status: 'user-overridden' }),
})); }));