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