Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8689af04d |
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); }
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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']),
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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' }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in new issue
Block a user