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:
1 parent
e984bf20ac
commit
c8689af04d
9 files changed
+192
-67
No files matched your search
@@ -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<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(
|
||||
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<string>,
|
||||
|
||||
Reference in new issue
Block a user