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

+32 -36
View File
@@ -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<ReturnType<NonNullable<WebContext['services']['accountRuntimes']>['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),
);