Add consent-gated media exchange library
This commit is contained in:
1 parent
b8e4a1bb88
commit
bf474dfe10
42 files changed
+2533
-9
No files matched your search
@@ -10,6 +10,7 @@ export const navigationItems: readonly NavigationItem[] = [
|
|||||||
{ label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery' },
|
{ label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery' },
|
||||||
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history' },
|
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history' },
|
||||||
{ label: 'Media', path: '/media', description: 'Private media and sync controls' },
|
{ label: 'Media', path: '/media', description: 'Private media and sync controls' },
|
||||||
|
{ label: 'Exchange library', path: '/media-library', description: 'Images the bot may send when asked' },
|
||||||
{ label: 'Performance', path: '/performance', description: 'Response and strategy trends' },
|
{ label: 'Performance', path: '/performance', description: 'Response and strategy trends' },
|
||||||
{ label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics' },
|
{ label: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics' },
|
||||||
{ label: 'Settings', path: '/settings', description: 'Runtime and access configuration' },
|
{ label: 'Settings', path: '/settings', description: 'Runtime and access configuration' },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SettingsRoute } from '../features/settings/settings-route.js';
|
|||||||
import { RouteErrorPage } from './route-error-page.js';
|
import { RouteErrorPage } from './route-error-page.js';
|
||||||
import { AttentionPage } from '../features/attention/attention-page.js';
|
import { AttentionPage } from '../features/attention/attention-page.js';
|
||||||
import { OutreachPage } from '../features/outreach/outreach-page.js';
|
import { OutreachPage } from '../features/outreach/outreach-page.js';
|
||||||
|
import { MediaLibraryPage } from '../features/media-library/media-library-page.js';
|
||||||
|
|
||||||
export function createDashboardRoutes(): RouteObject[] {
|
export function createDashboardRoutes(): RouteObject[] {
|
||||||
return [
|
return [
|
||||||
@@ -23,6 +24,7 @@ export function createDashboardRoutes(): RouteObject[] {
|
|||||||
{ path: 'conversations', element: <ConversationRoute /> },
|
{ path: 'conversations', element: <ConversationRoute /> },
|
||||||
{ path: 'conversations/:conversationId', element: <ConversationRoute /> },
|
{ path: 'conversations/:conversationId', element: <ConversationRoute /> },
|
||||||
{ path: 'media', element: <MediaRoute /> },
|
{ path: 'media', element: <MediaRoute /> },
|
||||||
|
{ path: 'media-library', element: <MediaLibraryPage /> },
|
||||||
{ path: 'performance', element: <PerformancePage /> },
|
{ path: 'performance', element: <PerformancePage /> },
|
||||||
{ path: 'operations', element: <OperationsPage /> },
|
{ path: 'operations', element: <OperationsPage /> },
|
||||||
{ path: 'settings', element: <SettingsRoute /> },
|
{ path: 'settings', element: <SettingsRoute /> },
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ZodType } from 'zod';
|
||||||
|
import {
|
||||||
|
ErrorResponseSchema,
|
||||||
|
MediaLibraryAssetSchema,
|
||||||
|
MediaLibraryResponseSchema,
|
||||||
|
type MediaLibraryAsset,
|
||||||
|
type MediaLibraryUpdate,
|
||||||
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { dashboardRequest } from '../../api/request.js';
|
||||||
|
|
||||||
|
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const parsed = ErrorResponseSchema.safeParse(payload);
|
||||||
|
throw new Error(parsed.success ? parsed.data.error : `Request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
return schema.parse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchMediaLibrary(): Promise<MediaLibraryAsset[]> {
|
||||||
|
return dashboardRequest('/api/media-library').then((response) => parseResponse(response, MediaLibraryResponseSchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadMediaLibraryImage(file: File): Promise<MediaLibraryAsset> {
|
||||||
|
return dashboardRequest('/api/media-library', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': file.type,
|
||||||
|
'X-File-Name': encodeURIComponent(file.name),
|
||||||
|
'X-Consent-Confirmed': 'true',
|
||||||
|
},
|
||||||
|
body: file,
|
||||||
|
}).then((response) => parseResponse(response, MediaLibraryAssetSchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMediaLibraryImage(id: string, update: MediaLibraryUpdate): Promise<MediaLibraryAsset> {
|
||||||
|
return dashboardRequest(`/api/media-library/${encodeURIComponent(id)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(update),
|
||||||
|
}).then((response) => parseResponse(response, MediaLibraryAssetSchema));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMediaLibraryImage(id: string): Promise<void> {
|
||||||
|
const response = await dashboardRequest(`/api/media-library/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
const parsed = ErrorResponseSchema.safeParse(payload);
|
||||||
|
throw new Error(parsed.success ? parsed.data.error : `Request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { MediaLibraryAsset, MediaLibraryTag, MediaLibraryUpdate } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { useDeleteMediaLibraryMutation, useMediaLibraryQuery, useUpdateMediaLibraryMutation, useUploadMediaLibraryMutation } from './queries.js';
|
||||||
|
import './media-library.css';
|
||||||
|
|
||||||
|
const TAGS: readonly MediaLibraryTag[] = ['selfie', 'chest', 'topless', 'feet', 'penis'];
|
||||||
|
|
||||||
|
export function MediaLibraryPage() {
|
||||||
|
const query = useMediaLibraryQuery();
|
||||||
|
const upload = useUploadMediaLibraryMutation();
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [consent, setConsent] = useState(false);
|
||||||
|
|
||||||
|
if (query.isPending) return <div className="page-state" role="status"><span className="spinner" />Loading exchange library…</div>;
|
||||||
|
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Exchange library unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
|
||||||
|
|
||||||
|
const assets = query.data ?? [];
|
||||||
|
return (
|
||||||
|
<div className="page exchange-library-page">
|
||||||
|
<header className="page-header">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Operator-owned media</span>
|
||||||
|
<h1>Exchange library</h1>
|
||||||
|
<p>Add consented images, review the automatic anatomy classification, and control what the bot may send after a request.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section aria-label="Nonspecific request progression" className="exchange-ladder">
|
||||||
|
<div><span>First ask</span><strong>Random SFW</strong><small>Selfie, chest, or feet</small></div>
|
||||||
|
<div><span>Asked again</span><strong>Broader reveal</strong><small>SFW or topless</small></div>
|
||||||
|
<div><span>Further ask</span><strong>Full library</strong><small>Still exact-match when named</small></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel library-upload-panel">
|
||||||
|
<div>
|
||||||
|
<span className="eyebrow">Add an image</span>
|
||||||
|
<h2>Strip metadata and classify</h2>
|
||||||
|
<p>JPEG, PNG, or WebP up to 15 MB. Location and camera metadata are removed before the cleaned image is sent to the configured OpenRouter model for classification.</p>
|
||||||
|
</div>
|
||||||
|
<label className="library-file-field">
|
||||||
|
<span>Choose image</span>
|
||||||
|
<input accept="image/jpeg,image/png,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} type="file" />
|
||||||
|
<small>{file ? file.name : 'No image selected'}</small>
|
||||||
|
</label>
|
||||||
|
<label className="library-consent-field">
|
||||||
|
<input checked={consent} onChange={(event) => setConsent(event.target.checked)} type="checkbox" />
|
||||||
|
<span>I confirm every depicted adult consented to this image being stored and sent by the bot.</span>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
disabled={!file || !consent || upload.isPending}
|
||||||
|
onClick={() => file && upload.mutate(file, { onSuccess: () => { setFile(null); setConsent(false); } })}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{upload.isPending ? 'Classifying…' : 'Upload and classify'}
|
||||||
|
</button>
|
||||||
|
{upload.isError ? <div className="media-notice error" role="alert">{upload.error.message}</div> : null}
|
||||||
|
{upload.isSuccess ? <div className="media-notice success" role="status">Image added. Review its tags before relying on it.</div> : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="library-section-heading">
|
||||||
|
<div><span className="eyebrow">Available inventory</span><h2>{assets.length} {assets.length === 1 ? 'image' : 'images'}</h2></div>
|
||||||
|
<small>Images remain private and blurred until revealed.</small>
|
||||||
|
</div>
|
||||||
|
{assets.length === 0 ? (
|
||||||
|
<section className="panel empty-state"><strong>No exchange images yet.</strong><span>Add the first consented image above.</span></section>
|
||||||
|
) : (
|
||||||
|
<section aria-label="Exchange library images" className="library-grid">
|
||||||
|
{assets.map((asset) => <LibraryAssetCard asset={asset} key={asset.id} />)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LibraryAssetCard({ asset }: { asset: MediaLibraryAsset }) {
|
||||||
|
const updateMutation = useUpdateMediaLibraryMutation();
|
||||||
|
const deleteMutation = useDeleteMediaLibraryMutation();
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [form, setForm] = useState<MediaLibraryUpdate>(() => ({
|
||||||
|
tags: asset.tags,
|
||||||
|
safety: asset.safety === 'nsfw' ? 'nsfw' : 'sfw',
|
||||||
|
enabled: asset.enabled,
|
||||||
|
}));
|
||||||
|
useEffect(() => {
|
||||||
|
setForm({ tags: asset.tags, safety: asset.safety === 'nsfw' ? 'nsfw' : 'sfw', enabled: asset.enabled });
|
||||||
|
}, [asset]);
|
||||||
|
|
||||||
|
function toggleTag(tag: MediaLibraryTag) {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
tags: current.tags.includes(tag) ? current.tags.filter((item) => item !== tag) : [...current.tags, tag],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsReview = asset.classificationStatus === 'failed' || asset.safety === 'unknown' || (asset.confidence ?? 0) < 0.7;
|
||||||
|
return (
|
||||||
|
<article className={asset.enabled ? 'library-card is-enabled' : 'library-card'}>
|
||||||
|
<div className="library-image-frame">
|
||||||
|
<img alt={`Library image tagged ${asset.tags.join(', ') || 'unclassified'}`} className={revealed ? '' : 'is-blurred'} src={asset.urlPath} />
|
||||||
|
<button className="library-reveal" onClick={() => setRevealed((value) => !value)} type="button">{revealed ? 'Blur' : 'Reveal'}</button>
|
||||||
|
<span className={`library-safety is-${asset.safety}`}>{asset.safety.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="library-card-body">
|
||||||
|
<div className="library-card-title">
|
||||||
|
<strong>{asset.fileName}</strong>
|
||||||
|
<span className={asset.enabled ? 'pill success' : 'pill neutral'}>{asset.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||||
|
</div>
|
||||||
|
{needsReview ? <div className="library-review-note" role="status">Review the AI result before enabling this image.</div> : null}
|
||||||
|
{asset.description ? <p>{asset.description}</p> : <p>AI classification was unavailable. Add tags manually.</p>}
|
||||||
|
<small>{asset.width} × {asset.height} · {formatBytes(asset.sizeBytes)}{asset.confidence === null ? '' : ` · ${Math.round(asset.confidence * 100)}% confidence`}</small>
|
||||||
|
<small>{asset.sendCount === 0 ? 'Never sent' : `Sent ${asset.sendCount} ${asset.sendCount === 1 ? 'time' : 'times'}${asset.lastSentAt ? ` · last ${formatDate(asset.lastSentAt)}` : ''}`}</small>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Anatomy tags</legend>
|
||||||
|
<div className="library-tag-grid">
|
||||||
|
{TAGS.map((tag) => <label key={tag}><input checked={form.tags.includes(tag)} onChange={() => toggleTag(tag)} type="checkbox" /><span>{tag}</span></label>)}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div className="library-controls">
|
||||||
|
<label>Safety<select onChange={(event) => setForm((current) => ({ ...current, safety: event.target.value as 'sfw' | 'nsfw' }))} value={form.safety}><option value="sfw">SFW</option><option value="nsfw">NSFW</option></select></label>
|
||||||
|
<label className="library-enable"><input checked={form.enabled} onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))} type="checkbox" /><span>Available to bot</span></label>
|
||||||
|
</div>
|
||||||
|
{updateMutation.isError ? <div className="media-notice error" role="alert">{updateMutation.error.message}</div> : null}
|
||||||
|
{deleteMutation.isError ? <div className="media-notice error" role="alert">{deleteMutation.error.message}</div> : null}
|
||||||
|
<div className="library-card-actions">
|
||||||
|
<button className="button primary" disabled={form.tags.length === 0 || updateMutation.isPending} onClick={() => updateMutation.mutate({ id: asset.id, update: form })} type="button">{updateMutation.isPending ? 'Saving…' : 'Save changes'}</button>
|
||||||
|
{confirmDelete ? <><button className="button danger" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(asset.id)} type="button">Confirm delete</button><button className="button secondary" onClick={() => setConfirmDelete(false)} type="button">Cancel</button></> : <button className="button secondary" onClick={() => setConfirmDelete(true)} type="button">Delete</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value: number): string {
|
||||||
|
return value < 1024 * 1024 ? `${Math.round(value / 1024)} KB` : `${(value / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: number): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
.exchange-library-page { --library-cyan: #7dd8d0; --library-violet: #b7a0ff; }
|
||||||
|
.exchange-ladder { display: grid; gap: 1px; margin-bottom: 1rem; overflow: hidden; border: 1px solid var(--border); border-radius: .9rem; background: var(--border); }
|
||||||
|
.exchange-ladder > div { position: relative; display: grid; gap: .25rem; min-height: 8rem; padding: 1.15rem; background: linear-gradient(145deg, #111c18, #14211d); }
|
||||||
|
.exchange-ladder > div::after { content: ''; position: absolute; inset: auto 1.15rem 0; height: .2rem; background: var(--accent); }
|
||||||
|
.exchange-ladder > div:nth-child(2)::after { background: var(--library-cyan); }
|
||||||
|
.exchange-ladder > div:nth-child(3)::after { background: var(--library-violet); }
|
||||||
|
.exchange-ladder span { color: var(--muted); font-size: .72rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||||
|
.exchange-ladder strong { align-self: end; font-size: 1.2rem; }
|
||||||
|
.exchange-ladder small { color: var(--muted); }
|
||||||
|
.library-upload-panel { display: grid; gap: 1rem; margin-bottom: 2rem; }
|
||||||
|
.library-upload-panel p { margin: .35rem 0 0; color: var(--muted); }
|
||||||
|
.library-file-field { display: grid; gap: .4rem; padding: 1rem; border: 1px dashed #4d685d; border-radius: .75rem; background: #0c1511; }
|
||||||
|
.library-file-field span { font-weight: 750; }
|
||||||
|
.library-file-field small { color: var(--muted); }
|
||||||
|
.library-file-field input { color: var(--muted); }
|
||||||
|
.library-consent-field, .library-enable { display: flex; align-items: flex-start; gap: .65rem; color: var(--muted); }
|
||||||
|
.library-consent-field input, .library-enable input { flex: 0 0 auto; width: 1.1rem; height: 1.1rem; margin-top: .1rem; accent-color: var(--accent); }
|
||||||
|
.library-upload-panel > .button { justify-self: start; }
|
||||||
|
.library-section-heading { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
|
||||||
|
.library-section-heading small { color: var(--muted); text-align: right; }
|
||||||
|
.library-grid { display: grid; gap: 1rem; }
|
||||||
|
.library-card { overflow: hidden; border: 1px solid var(--border); border-radius: .9rem; background: var(--surface); }
|
||||||
|
.library-card.is-enabled { border-color: #386d4d; box-shadow: inset 0 0 0 1px rgba(117, 214, 157, .08); }
|
||||||
|
.library-image-frame { position: relative; min-height: 17rem; overflow: hidden; background: #050907; }
|
||||||
|
.library-image-frame img { display: block; width: 100%; height: 17rem; object-fit: contain; transition: filter 180ms ease, transform 180ms ease; }
|
||||||
|
.library-image-frame img.is-blurred { filter: blur(24px) brightness(.5); transform: scale(1.12); }
|
||||||
|
.library-reveal { position: absolute; right: .75rem; bottom: .75rem; padding: .45rem .65rem; color: white; border: 1px solid rgba(255,255,255,.35); border-radius: .55rem; background: rgba(0,0,0,.78); font-weight: 750; }
|
||||||
|
.library-safety { position: absolute; top: .75rem; left: .75rem; padding: .3rem .5rem; border-radius: 999px; color: #08110d; background: var(--muted); font-size: .68rem; font-weight: 900; letter-spacing: .08em; }
|
||||||
|
.library-safety.is-sfw { background: var(--accent); }
|
||||||
|
.library-safety.is-nsfw { color: #21132d; background: var(--library-violet); }
|
||||||
|
.library-card-body { display: grid; gap: .85rem; padding: 1rem; }
|
||||||
|
.library-card-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
|
||||||
|
.library-card-title strong { overflow-wrap: anywhere; }
|
||||||
|
.library-card-body p { margin: 0; color: var(--muted); line-height: 1.45; }
|
||||||
|
.library-card-body > small { color: var(--muted); }
|
||||||
|
.library-review-note { padding: .6rem .7rem; color: #ffe1a0; border: 1px solid #59451e; border-radius: .55rem; background: #2a2111; font-size: .82rem; }
|
||||||
|
.library-card fieldset { padding: 0; margin: 0; border: 0; }
|
||||||
|
.library-card legend { margin-bottom: .55rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
|
||||||
|
.library-tag-grid { display: flex; flex-wrap: wrap; gap: .45rem; }
|
||||||
|
.library-tag-grid label { position: relative; }
|
||||||
|
.library-tag-grid input { position: absolute; opacity: 0; pointer-events: none; }
|
||||||
|
.library-tag-grid span { display: block; padding: .35rem .6rem; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); background: #0c1511; text-transform: capitalize; cursor: pointer; }
|
||||||
|
.library-tag-grid input:checked + span { color: #07130c; border-color: var(--accent); background: var(--accent); font-weight: 800; }
|
||||||
|
.library-tag-grid input:focus-visible + span { outline: 2px solid white; outline-offset: 2px; }
|
||||||
|
.library-controls { display: grid; gap: .8rem; }
|
||||||
|
.library-controls > label:first-child { display: grid; gap: .35rem; color: var(--muted); font-size: .8rem; font-weight: 750; }
|
||||||
|
.library-controls select { min-height: 2.5rem; padding: .45rem; color: white; border: 1px solid var(--border); border-radius: .55rem; background: var(--surface-raised); }
|
||||||
|
.library-card-actions { display: flex; flex-wrap: wrap; gap: .55rem; padding-top: .25rem; }
|
||||||
|
@media (min-width: 48rem) {
|
||||||
|
.exchange-ladder { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.library-upload-panel { grid-template-columns: minmax(16rem, 1fr) minmax(18rem, 1.2fr); align-items: center; }
|
||||||
|
.library-upload-panel .library-consent-field, .library-upload-panel .media-notice { grid-column: 1 / -1; }
|
||||||
|
.library-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.library-controls { grid-template-columns: 1fr 1fr; align-items: end; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) { .library-image-frame img { transition: none; } }
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { MediaLibraryUpdate } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { deleteMediaLibraryImage, fetchMediaLibrary, updateMediaLibraryImage, uploadMediaLibraryImage } from './api.js';
|
||||||
|
|
||||||
|
export const mediaLibraryQueryKey = ['dashboard', 'media-library'] as const;
|
||||||
|
|
||||||
|
export function useMediaLibraryQuery() {
|
||||||
|
return useQuery({ queryKey: mediaLibraryQueryKey, queryFn: fetchMediaLibrary });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadMediaLibraryMutation() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: uploadMediaLibraryImage,
|
||||||
|
onSuccess: () => client.invalidateQueries({ queryKey: mediaLibraryQueryKey }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateMediaLibraryMutation() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, update }: { id: string; update: MediaLibraryUpdate }) => updateMediaLibraryImage(id, update),
|
||||||
|
onSuccess: () => client.invalidateQueries({ queryKey: mediaLibraryQueryKey }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteMediaLibraryMutation() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteMediaLibraryImage,
|
||||||
|
onSuccess: () => client.invalidateQueries({ queryKey: mediaLibraryQueryKey }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -80,6 +80,10 @@ an administrator credential.
|
|||||||
| `GET` | `/api/media/sync` | Get media sync state | read | no |
|
| `GET` | `/api/media/sync` | Get media sync state | read | no |
|
||||||
| `POST` | `/api/media/sync` | Trigger media sync | admin | yes |
|
| `POST` | `/api/media/sync` | Trigger media sync | admin | yes |
|
||||||
| `POST` | `/api/media/sync/full` | Trigger full media sync | admin | yes |
|
| `POST` | `/api/media/sync/full` | Trigger full media sync | admin | yes |
|
||||||
|
| `GET` | `/api/media-library` | List operator-owned exchange images | read | no |
|
||||||
|
| `POST` | `/api/media-library` | Upload and classify an operator-owned exchange image | admin | yes |
|
||||||
|
| `PATCH` | `/api/media-library/{id}` | Correct tags or enable an exchange image | admin | yes |
|
||||||
|
| `DELETE` | `/api/media-library/{id}` | Delete an exchange image | admin | yes |
|
||||||
| `POST` | `/api/start` | Start bot processing (legacy) | admin | yes |
|
| `POST` | `/api/start` | Start bot processing (legacy) | admin | yes |
|
||||||
| `POST` | `/api/stop` | Stop bot processing (legacy) | admin | yes |
|
| `POST` | `/api/stop` | Stop bot processing (legacy) | admin | yes |
|
||||||
| `GET` | `/api/timers` | Get rate-limit timers | read | no |
|
| `GET` | `/api/timers` | Get rate-limit timers | read | no |
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ SIGINT/SIGTERM abort shared loops, stop account runtimes, stop the web server, c
|
|||||||
- Reddit request coordinator (shared across runtime construction);
|
- Reddit request coordinator (shared across runtime construction);
|
||||||
- recipient history/reservations across accounts;
|
- recipient history/reservations across accounts;
|
||||||
- Express server, dashboard assets, event stream, and operational jobs;
|
- Express server, dashboard assets, event stream, and operational jobs;
|
||||||
- configured subreddit catalog and application policy.
|
- configured subreddit catalog and application policy;
|
||||||
|
- shared operator-owned exchange library, classification metadata, and outbound-use ledger;
|
||||||
|
|
||||||
### Account-scoped
|
### Account-scoped
|
||||||
|
|
||||||
@@ -132,6 +133,7 @@ docs/ Maintainer and agent documentation
|
|||||||
- New transport field: define it in `packages/api-contracts`, return it from the route, consume it in the dashboard, and add contract/route/UI tests.
|
- New transport field: define it in `packages/api-contracts`, return it from the route, consume it in the dashboard, and add contract/route/UI tests.
|
||||||
- New API operation: add a focused router, register it in `src/web/server.ts`, describe it in `src/web/api-catalog.ts`, regenerate `docs/api.md`, and add authorization tests.
|
- New API operation: add a focused router, register it in `src/web/server.ts`, describe it in `src/web/api-catalog.ts`, regenerate `docs/api.md`, and add authorization tests.
|
||||||
- New persisted state: add an append-only migration, expose it through a small repository, and test fresh plus upgraded databases.
|
- New persisted state: add an append-only migration, expose it through a small repository, and test fresh plus upgraded databases.
|
||||||
|
- Shared operator media: keep it under `MediaLibraryService`/`MediaLibraryRepository`; never merge it with account-owned received media.
|
||||||
- New account behavior: place it in the account runtime or a service injected into it; do not add mutable cross-account identity to a singleton.
|
- New account behavior: place it in the account runtime or a service injected into it; do not add mutable cross-account identity to a singleton.
|
||||||
- New candidate stage: make transitions durable and idempotent, expose counts/reasons, and define lease/retry behavior.
|
- New candidate stage: make transitions durable and idempotent, expose counts/reasons, and define lease/retry behavior.
|
||||||
- New dashboard feature: add a feature slice and route, use shared schemas and query keys, and treat HTTP as authoritative.
|
- New dashboard feature: add a feature slice and route, use shared schemas and query keys, and treat HTTP as authoritative.
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ Legacy conversations may make at most one optional autonomous media request, inc
|
|||||||
|
|
||||||
`MediaService` resolves Matrix media, downloads it into the persistent data directory, and writes an account-scoped media index. `MediaSyncService` schedules incremental work only while the account is authenticated. Private media is served behind the same authorization boundary as the dashboard and APIs.
|
`MediaService` resolves Matrix media, downloads it into the persistent data directory, and writes an account-scoped media index. `MediaSyncService` schedules incremental work only while the account is authenticated. Private media is served behind the same authorization boundary as the dashboard and APIs.
|
||||||
|
|
||||||
|
The process-global exchange library is separate. Dashboard uploads are decoded and re-encoded with `sharp`, which validates the actual JPEG/PNG/WebP contents, applies orientation, and removes EXIF/location/camera metadata. OpenRouter then assigns overlapping anatomy tags plus SFW/NSFW classification. Failed or low-confidence classifications remain disabled until the operator corrects them.
|
||||||
|
|
||||||
|
For each latest inbound text event, `MediaLibraryExchangeService` detects direct or indirect image requests before normal reply generation. Exact requests require the matching tag. Generic requests use the conversation's completed library-send count to broaden the eligible safety tier and randomly choose an unused asset. A unique durable reservation on account + conversation + asset prevents reuse, while account + conversation + inbound event makes restart handling idempotent.
|
||||||
|
|
||||||
|
After the normal reply limiter waits, automation refreshes Matrix and rechecks that the same inbound request is still latest and that no image, boundary, seller signal, or other terminal state has appeared. It then uploads the private file to Matrix, sends an `m.image` event with a stable transaction ID, and only after confirmation sends the optional reciprocal text. If the image is confirmed but the text is not, the asset remains consumed and the conversation is handed to the operator rather than risking a duplicate.
|
||||||
|
|
||||||
Conversation performance is derived from the complete attributed inbound transcript. Engagement cannot become a prompt-learning example while automation is active, and a later decline, boundary, or upsell removes an earlier example before future prompts load it.
|
Conversation performance is derived from the complete attributed inbound transcript. Engagement cannot become a prompt-learning example while automation is active, and a later decline, boundary, or upsell removes an earlier example before future prompts load it.
|
||||||
|
|
||||||
## Dashboard data and events
|
## Dashboard data and events
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ The application uses one `better-sqlite3` database, normally `data/reddit-bot.db
|
|||||||
- `reddit_conversations`: account-scoped cached Matrix conversation payloads.
|
- `reddit_conversations`: account-scoped cached Matrix conversation payloads.
|
||||||
- `reddit_account_sync_state`: account-scoped sync cursor, full/delta timestamps, and invitation cooldown.
|
- `reddit_account_sync_state`: account-scoped sync cursor, full/delta timestamps, and invitation cooldown.
|
||||||
- `reddit_media`: account-scoped private-media metadata and relative file path.
|
- `reddit_media`: account-scoped private-media metadata and relative file path.
|
||||||
|
- `media_library_assets`: process-global operator-owned image metadata, overlapping anatomy tags, SFW/NSFW classification, consent time, enablement, dimensions, hash, and private relative path.
|
||||||
|
- `media_library_sends`: account/conversation/inbound-event reservation and confirmed/failed Matrix delivery audit for a shared library asset.
|
||||||
|
|
||||||
The special legacy-unassigned account exists only to preserve rows created before account ownership was introduced. New runtime data must use a verified account ID.
|
The special legacy-unassigned account exists only to preserve rows created before account ownership was introduced. New runtime data must use a verified account ID.
|
||||||
|
|
||||||
@@ -50,6 +52,8 @@ The special legacy-unassigned account exists only to preserve rows created befor
|
|||||||
5. Conversation policy is snapshotted in state JSON when automation starts for that conversation.
|
5. Conversation policy is snapshotted in state JSON when automation starts for that conversation.
|
||||||
6. Secrets and the database path are not stored in `app_config`.
|
6. Secrets and the database path are not stored in `app_config`.
|
||||||
7. Private media paths in SQLite are relative to the persistent data root; HTTP access remains authenticated.
|
7. Private media paths in SQLite are relative to the persistent data root; HTTP access remains authenticated.
|
||||||
|
8. Received `reddit_media` and operator `media_library_assets` are separate ownership domains; no automatic copy or promotion exists.
|
||||||
|
9. A library image content hash has at most one reservation per account conversation, even if the same file is later re-uploaded under a new asset ID; an inbound request also has at most one selected asset. Failed or uncertain attempts remain reserved to prevent duplicates.
|
||||||
|
|
||||||
## Repository map
|
## Repository map
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Shared HTTP/SSE schemas live in `packages/api-contracts`. The server and client
|
|||||||
| `/conversations` | Searchable account-aware conversation inbox. |
|
| `/conversations` | Searchable account-aware conversation inbox. |
|
||||||
| `/conversations/:conversationId` | Conversation history, context, suggestions, and guarded sending. |
|
| `/conversations/:conversationId` | Conversation history, context, suggestions, and guarded sending. |
|
||||||
| `/media` | Authenticated, privacy-conscious media gallery and sync controls. |
|
| `/media` | Authenticated, privacy-conscious media gallery and sync controls. |
|
||||||
|
| `/media-library` | Upload, classify, correct, enable, preview, and delete shared exchange images. |
|
||||||
| `/performance` | Mature outcome trends, strategy/subreddit results, and model usage. |
|
| `/performance` | Mature outcome trends, strategy/subreddit results, and model usage. |
|
||||||
| `/operations` | Background jobs, sync state, events, rate limits, and technical logs. |
|
| `/operations` | Background jobs, sync state, events, rate limits, and technical logs. |
|
||||||
| `/settings` | Validated runtime configuration and account/session management. |
|
| `/settings` | Validated runtime configuration and account/session management. |
|
||||||
@@ -58,6 +59,8 @@ The login form exchanges the administrator token for an HttpOnly, SameSite=Stric
|
|||||||
|
|
||||||
Media may be private conversation content. Preserve authenticated URLs, blur/privacy defaults, explicit reveal behavior, safe new-tab links, and no token leakage into query strings or local storage.
|
Media may be private conversation content. Preserve authenticated URLs, blur/privacy defaults, explicit reveal behavior, safe new-tab links, and no token leakage into query strings or local storage.
|
||||||
|
|
||||||
|
Exchange-library uploads require an explicit consent checkbox before the raw image request is enabled. Previews remain blurred by default. The management card exposes AI tags, safety level, confidence/review state, enablement, and a two-step delete action. Its progression rail mirrors the actual nonspecific-request selection tiers rather than implying that explicit anatomy requests follow the same sequence.
|
||||||
|
|
||||||
## Feature structure
|
## Feature structure
|
||||||
|
|
||||||
Each route is a slice under `features/<name>/` with page/route components, local styles, and feature-specific helpers. Cross-feature visual/domain components live under `components/`; application lifecycle belongs under `app/`; transport belongs under `api/`.
|
Each route is a slice under `features/<name>/` with page/route components, local styles, and feature-specific helpers. Cross-feature visual/domain components live under `components/`; application lifecycle belongs under `app/`; transport belongs under `api/`.
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ Each subreddit snapshots a policy into a conversation when automation first hand
|
|||||||
|
|
||||||
The snapshot matters: changing configuration later must not silently rewrite the rules for an active conversation. Automation stops for seller/upsell evidence, an image received, a detected boundary, unsafe content, an exhausted turn budget, a repeated media request, or a manual policy. Short refusals are interpreted in sequence: phrases such as “I’m good” become media boundaries only when they answer a media request.
|
The snapshot matters: changing configuration later must not silently rewrite the rules for an active conversation. Automation stops for seller/upsell evidence, an image received, a detected boundary, unsafe content, an exhausted turn budget, a repeated media request, or a manual policy. Short refusals are interpreted in sequence: phrases such as “I’m good” become media boundaries only when they answer a media request.
|
||||||
|
|
||||||
|
## Operator exchange media
|
||||||
|
|
||||||
|
The exchange library contains only operator-uploaded images. Upload requires confirmation that every depicted adult consented to storage and bot delivery. Received conversation media is account-owned evidence and can never be promoted into this shared library.
|
||||||
|
|
||||||
|
The bot may continue to ask another person for an optional photo under the configured conversation policy, but it must never mention, advertise, or offer an exchange-library image proactively. A library send requires a direct or indirect inbound image request. Exact anatomy requests match the operator-reviewed tags `penis`, `chest`, `selfie`, `topless`, or `feet`; the bot does not substitute a different anatomy when a match is unavailable.
|
||||||
|
|
||||||
|
Nonspecific requests broaden progressively within one conversation. The first selects a random unused SFW asset, a later request may include topless/NSFW assets, and further requests may select from the full library. An asset is never selected twice in the same conversation, including after an uncertain send. After Matrix confirms the image event, the bot asks the other person to reciprocate without pressure. Missing inventory, failed delivery, changed conversation state, or an unconfirmed reciprocal message becomes an operator handoff.
|
||||||
|
|
||||||
|
Reddit candidate eligibility remains the product's adult-entry assumption; this feature adds no separate verification workflow. Existing deterministic underage disclosures and conversation boundaries still stop automation before a library send.
|
||||||
|
|
||||||
## Success and analytics
|
## Success and analytics
|
||||||
|
|
||||||
Do not collapse all progress into “messages sent.” Useful states include:
|
Do not collapse all progress into “messages sent.” Useful states include:
|
||||||
@@ -75,6 +85,6 @@ Read-only inspection, builds, tests, and documentation work do not grant any of
|
|||||||
|
|
||||||
## Privacy and secrets
|
## Privacy and secrets
|
||||||
|
|
||||||
Treat these as sensitive: `.env`, Reddit cookies, exported session JSON, Matrix access tokens, dashboard/agent tokens, the SQLite database, browser profiles, conversation text, usernames, private media, and logs that may contain request metadata.
|
Treat these as sensitive: `.env`, Reddit cookies, exported session JSON, Matrix access tokens, dashboard/agent tokens, the SQLite database, browser profiles, conversation text, usernames, received private media, operator exchange media, and logs that may contain request metadata.
|
||||||
|
|
||||||
Never commit or reproduce them in documentation, fixtures, logs, issue text, or tool output. Prefer redacted structural evidence. Production backups contain account and private conversation data and need the same protection as the live volume.
|
Never commit or reproduce them in documentation, fixtures, logs, issue text, or tool output. Prefer redacted structural evidence. Production backups contain account and private conversation data and need the same protection as the live volume.
|
||||||
@@ -44,6 +44,9 @@ This is an ownership map, not an API dump. Read the owning implementation and te
|
|||||||
| `services/response-checker.ts` | Inbox/performance response checks and successful examples | Non-overlapping; do not confuse chat sync with outcome maturity. |
|
| `services/response-checker.ts` | Inbox/performance response checks and successful examples | Non-overlapping; do not confuse chat sync with outcome maturity. |
|
||||||
| `services/media.ts` | Download, deduplicate, index, and list private media | Preserve account ownership and authenticated serving. |
|
| `services/media.ts` | Download, deduplicate, index, and list private media | Preserve account ownership and authenticated serving. |
|
||||||
| `services/media-sync.ts` | Periodic authenticated media sync | Stop while auth is unavailable and on runtime shutdown. |
|
| `services/media-sync.ts` | Periodic authenticated media sync | Stop while auth is unavailable and on runtime shutdown. |
|
||||||
|
| `services/media-library.ts` | Shared operator uploads, image validation/re-encoding, classification state, and private files | Require consent confirmation; received media never enters this store. |
|
||||||
|
| `services/media-library-exchange.ts` | Inbound request detection, progressive selection, reservation, and image-first exchange | Never offer proactively; exact tags do not substitute; no asset reuse per conversation. |
|
||||||
|
| `database/media-library-repository.ts` | Shared asset metadata and durable account/conversation send audit | Reserve before remote send; uncertain sends stay consumed. |
|
||||||
|
|
||||||
## Persistence
|
## Persistence
|
||||||
|
|
||||||
|
|||||||
Generated
+636
@@ -22,6 +22,7 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
@@ -601,6 +602,16 @@
|
|||||||
"kuler": "^2.0.0"
|
"kuler": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emnapi/runtime": {
|
||||||
|
"version": "1.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||||
|
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||||
@@ -1073,6 +1084,554 @@
|
|||||||
"hono": "^4"
|
"hono": "^4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.11.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@img/sharp-wasm32": "0.35.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@inquirer/ansi": {
|
"node_modules/@inquirer/ansi": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz",
|
||||||
@@ -2790,6 +3349,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
"node_modules/dom-accessibility-api": {
|
||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
@@ -4603,6 +5171,18 @@
|
|||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||||
@@ -4686,6 +5266,55 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.35.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||||
|
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@img/colour": "^1.1.0",
|
||||||
|
"detect-libc": "^2.1.2",
|
||||||
|
"semver": "^7.8.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.35.3",
|
||||||
|
"@img/sharp-darwin-x64": "0.35.3",
|
||||||
|
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||||
|
"@img/sharp-linux-arm": "0.35.3",
|
||||||
|
"@img/sharp-linux-arm64": "0.35.3",
|
||||||
|
"@img/sharp-linux-ppc64": "0.35.3",
|
||||||
|
"@img/sharp-linux-riscv64": "0.35.3",
|
||||||
|
"@img/sharp-linux-s390x": "0.35.3",
|
||||||
|
"@img/sharp-linux-x64": "0.35.3",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||||
|
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||||
|
"@img/sharp-win32-arm64": "0.35.3",
|
||||||
|
"@img/sharp-win32-ia32": "0.35.3",
|
||||||
|
"@img/sharp-win32-x64": "0.35.3"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -5032,6 +5661,13 @@
|
|||||||
"node": ">= 14.0.0"
|
"node": ">= 14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
|
|||||||
@@ -32,3 +32,35 @@ export const MediaSyncTriggerResponseSchema = z.union([
|
|||||||
|
|
||||||
export type MediaItem = z.infer<typeof MediaItemSchema>;
|
export type MediaItem = z.infer<typeof MediaItemSchema>;
|
||||||
export type MediaSyncStatus = z.infer<typeof MediaSyncStatusSchema>;
|
export type MediaSyncStatus = z.infer<typeof MediaSyncStatusSchema>;
|
||||||
|
|
||||||
|
export const MediaLibraryTagSchema = z.enum(['penis', 'chest', 'selfie', 'topless', 'feet']);
|
||||||
|
export const MediaLibraryAssetSchema = z.strictObject({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
tags: z.array(MediaLibraryTagSchema),
|
||||||
|
safety: z.enum(['sfw', 'nsfw', 'unknown']),
|
||||||
|
classificationStatus: z.enum(['ready', 'failed']),
|
||||||
|
description: z.string().nullable(),
|
||||||
|
confidence: z.number().min(0).max(1).nullable(),
|
||||||
|
fileName: z.string(),
|
||||||
|
mimeType: z.enum(['image/jpeg', 'image/png', 'image/webp']),
|
||||||
|
sizeBytes: z.number().int().nonnegative(),
|
||||||
|
width: z.number().int().positive(),
|
||||||
|
height: z.number().int().positive(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
consentConfirmedAt: z.number(),
|
||||||
|
createdAt: z.number(),
|
||||||
|
updatedAt: z.number(),
|
||||||
|
sendCount: z.number().int().nonnegative(),
|
||||||
|
lastSentAt: z.number().nullable(),
|
||||||
|
urlPath: z.string(),
|
||||||
|
});
|
||||||
|
export const MediaLibraryResponseSchema = z.array(MediaLibraryAssetSchema);
|
||||||
|
export const MediaLibraryUpdateSchema = z.strictObject({
|
||||||
|
tags: z.array(MediaLibraryTagSchema).min(1),
|
||||||
|
safety: z.enum(['sfw', 'nsfw']),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MediaLibraryTag = z.infer<typeof MediaLibraryTagSchema>;
|
||||||
|
export type MediaLibraryAsset = z.infer<typeof MediaLibraryAssetSchema>;
|
||||||
|
export type MediaLibraryUpdate = z.infer<typeof MediaLibraryUpdateSchema>;
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
LoginStatusResponseSchema,
|
LoginStatusResponseSchema,
|
||||||
LegacyDashboardStreamEventSchema,
|
LegacyDashboardStreamEventSchema,
|
||||||
MediaResponseSchema,
|
MediaResponseSchema,
|
||||||
|
MediaLibraryAssetSchema,
|
||||||
MediaSyncStatusSchema,
|
MediaSyncStatusSchema,
|
||||||
MessagesResponseSchema,
|
MessagesResponseSchema,
|
||||||
ProcessedPostsResponseSchema,
|
ProcessedPostsResponseSchema,
|
||||||
@@ -215,6 +216,18 @@ describe('dashboard endpoint contracts', () => {
|
|||||||
expect(MediaResponseSchema.safeParse([{ id: 'sha1', timestamp: 'now' }]).success).toBe(false);
|
expect(MediaResponseSchema.safeParse([{ id: 'sha1', timestamp: 'now' }]).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('validates exchange-library classifications without exposing local paths', () => {
|
||||||
|
const payload = {
|
||||||
|
id: '019fe789-1b99-7ce1-8402-b3cbc32a445a', tags: ['selfie', 'topless'], safety: 'nsfw',
|
||||||
|
classificationStatus: 'ready', description: 'A self portrait', confidence: 0.9,
|
||||||
|
fileName: 'portrait.jpg', mimeType: 'image/jpeg', sizeBytes: 100, width: 10, height: 20,
|
||||||
|
enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1, sendCount: 0, lastSentAt: null,
|
||||||
|
urlPath: '/library-media/id.jpg',
|
||||||
|
};
|
||||||
|
expect(MediaLibraryAssetSchema.safeParse(payload).success).toBe(true);
|
||||||
|
expect(MediaLibraryAssetSchema.safeParse({ ...payload, relativePath: 'private/id.jpg' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('validates every current login-status branch', () => {
|
it('validates every current login-status branch', () => {
|
||||||
expect(LoginStatusResponseSchema.safeParse({
|
expect(LoginStatusResponseSchema.safeParse({
|
||||||
ready: true, loggedIn: true, status: 'authenticated',
|
ready: true, loggedIn: true, status: 'authenticated',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
|
import { z } from 'zod';
|
||||||
import type { Config } from '../config/index.js';
|
import type { Config } from '../config/index.js';
|
||||||
import type { Logger } from '../utils/logger.js';
|
import type { Logger } from '../utils/logger.js';
|
||||||
import type { AiUsageRecordInput, AiUsageOutcome } from '../database/ai-usage-repository.js';
|
import type { AiUsageRecordInput, AiUsageOutcome } from '../database/ai-usage-repository.js';
|
||||||
@@ -10,6 +11,10 @@ import {
|
|||||||
type ConversationState,
|
type ConversationState,
|
||||||
} from '../services/conversation-state.js';
|
} from '../services/conversation-state.js';
|
||||||
import { isSafeOutboundMessage } from '../services/outbound-message-safety.js';
|
import { isSafeOutboundMessage } from '../services/outbound-message-safety.js';
|
||||||
|
import {
|
||||||
|
MEDIA_LIBRARY_TAGS,
|
||||||
|
type MediaLibraryClassification,
|
||||||
|
} from '../services/media-library-types.js';
|
||||||
|
|
||||||
export type DetectedGender = 'male' | 'female' | 'other' | 'unknown';
|
export type DetectedGender = 'male' | 'female' | 'other' | 'unknown';
|
||||||
|
|
||||||
@@ -142,6 +147,61 @@ export class OpenRouterClient {
|
|||||||
throw new Error('AI_REFUSAL: All models refused to analyze image');
|
throw new Error('AI_REFUSAL: All models refused to analyze image');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async classifyLibraryImage(imageBase64: string, mimeType: string): Promise<MediaLibraryClassification> {
|
||||||
|
const models = [
|
||||||
|
this.config.openrouter.imageAnalysisModel,
|
||||||
|
...this.config.openrouter.imageAnalysisFallbackModels,
|
||||||
|
];
|
||||||
|
let lastError: unknown = null;
|
||||||
|
for (const model of models) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const response = await this.client.chat.completions.create({
|
||||||
|
model,
|
||||||
|
messages: [{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Classify this operator-owned adult image for a private media library. Return JSON only with this exact shape:
|
||||||
|
{"tags":["selfie"],"safety":"sfw","description":"short neutral description","confidence":0.95}
|
||||||
|
|
||||||
|
Allowed tags: ${MEDIA_LIBRARY_TAGS.join(', ')}.
|
||||||
|
- selfie: a face-led or self-portrait style image
|
||||||
|
- chest: a clothed chest or upper torso
|
||||||
|
- topless: a bare chest or bare upper torso
|
||||||
|
- penis: a visible penis
|
||||||
|
- feet: feet are a material focus
|
||||||
|
|
||||||
|
Use every applicable tag; tags may overlap. Set safety to "nsfw" when nudity, exposed genitals, or clearly sexual presentation is visible, otherwise "sfw". Confidence must be between 0 and 1. Do not identify the person, infer age, or add attributes outside this taxonomy.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: { url: `data:${mimeType};base64,${imageBase64}` },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
max_tokens: 180,
|
||||||
|
temperature: 0,
|
||||||
|
});
|
||||||
|
const content = response.choices[0]?.message?.content ?? '';
|
||||||
|
const parsed = MediaLibraryClassificationSchema.parse(parseJsonObject(content));
|
||||||
|
this.recordUsage('classifyLibraryImage', model, startedAt, response, 'success');
|
||||||
|
return {
|
||||||
|
tags: [...new Set(parsed.tags)],
|
||||||
|
safety: parsed.safety,
|
||||||
|
description: parsed.description.trim().slice(0, 300),
|
||||||
|
confidence: parsed.confidence,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
this.recordError('classifyLibraryImage', model, startedAt, error);
|
||||||
|
this.logger.warn(`[AI] classifyLibraryImage: ${model} failed${model !== models.at(-1) ? ', trying fallback...' : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?? new Error('Library image classification failed');
|
||||||
|
}
|
||||||
|
|
||||||
private async tryAnalyzeWithModel(model: string, imageBase64: string, mimeType: string): Promise<ImageAnalysisResult | null> {
|
private async tryAnalyzeWithModel(model: string, imageBase64: string, mimeType: string): Promise<ImageAnalysisResult | null> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const genderPrompt = `First, identify the gender of the person in this image. Respond with EXACTLY one of these on the first line: GENDER:male, GENDER:female, GENDER:other, or GENDER:unknown
|
const genderPrompt = `First, identify the gender of the person in this image. Respond with EXACTLY one of these on the first line: GENDER:male, GENDER:female, GENDER:other, or GENDER:unknown
|
||||||
@@ -684,3 +744,18 @@ Just write the message, nothing else.`;
|
|||||||
function numericUsage(value: unknown): number {
|
function numericUsage(value: unknown): number {
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MediaLibraryClassificationSchema = z.strictObject({
|
||||||
|
tags: z.array(z.enum(MEDIA_LIBRARY_TAGS)).min(1),
|
||||||
|
safety: z.enum(['sfw', 'nsfw']),
|
||||||
|
description: z.string().min(1).max(500),
|
||||||
|
confidence: z.number().min(0).max(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
function parseJsonObject(text: string): unknown {
|
||||||
|
const cleaned = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
||||||
|
const start = cleaned.indexOf('{');
|
||||||
|
const end = cleaned.lastIndexOf('}');
|
||||||
|
if (start < 0 || end < start) throw new Error('Classifier returned no JSON object');
|
||||||
|
return JSON.parse(cleaned.slice(start, end + 1));
|
||||||
|
}
|
||||||
@@ -52,6 +52,8 @@ import { importLegacyAccountContent } from './database/legacy-content-import.js'
|
|||||||
import { RecipientDeduplicator } from './services/recipient-deduplicator.js';
|
import { RecipientDeduplicator } from './services/recipient-deduplicator.js';
|
||||||
import { createConfigBackedSubredditPollingControl } from './services/subreddit-polling-control.js';
|
import { createConfigBackedSubredditPollingControl } from './services/subreddit-polling-control.js';
|
||||||
import { CandidateQueueRepository, RuntimeStateRepository } from './database/candidate-queue-repository.js';
|
import { CandidateQueueRepository, RuntimeStateRepository } from './database/candidate-queue-repository.js';
|
||||||
|
import { MediaLibraryRepository } from './database/media-library-repository.js';
|
||||||
|
import { MediaLibraryService } from './services/media-library.js';
|
||||||
|
|
||||||
export interface AppContainer {
|
export interface AppContainer {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -82,6 +84,7 @@ export interface AppContainer {
|
|||||||
mediaService: MediaService;
|
mediaService: MediaService;
|
||||||
chatService: ChatService;
|
chatService: ChatService;
|
||||||
mediaSyncService: MediaSyncService;
|
mediaSyncService: MediaSyncService;
|
||||||
|
mediaLibraryService: MediaLibraryService;
|
||||||
rateLimiter: RateLimiter;
|
rateLimiter: RateLimiter;
|
||||||
|
|
||||||
webServer: WebServer | null;
|
webServer: WebServer | null;
|
||||||
@@ -178,6 +181,11 @@ export async function bootstrap(
|
|||||||
const aiClient = new OpenRouterClient(config, logger, undefined, (usage) => {
|
const aiClient = new OpenRouterClient(config, logger, undefined, (usage) => {
|
||||||
aiUsageRepo.record({ ...usage, accountId: accountAuth.getSnapshot().accountId });
|
aiUsageRepo.record({ ...usage, accountId: accountAuth.getSnapshot().accountId });
|
||||||
});
|
});
|
||||||
|
const mediaLibraryService = new MediaLibraryService(
|
||||||
|
new MediaLibraryRepository(db),
|
||||||
|
aiClient,
|
||||||
|
logger,
|
||||||
|
);
|
||||||
const monitorService = new MonitorService(
|
const monitorService = new MonitorService(
|
||||||
redditApi, postsRepo, recipientDeduplicator, config, logger, 1000,
|
redditApi, postsRepo, recipientDeduplicator, config, logger, 1000,
|
||||||
createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
|
createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
|
||||||
@@ -427,6 +435,7 @@ export async function bootstrap(
|
|||||||
});
|
});
|
||||||
webServer.setMediaService(mediaService);
|
webServer.setMediaService(mediaService);
|
||||||
webServer.setMediaSyncService(mediaSyncService);
|
webServer.setMediaSyncService(mediaSyncService);
|
||||||
|
webServer.setMediaLibraryService(mediaLibraryService);
|
||||||
webServer.setResponseChecker(responseChecker);
|
webServer.setResponseChecker(responseChecker);
|
||||||
webServer.setRateLimiter(rateLimiter);
|
webServer.setRateLimiter(rateLimiter);
|
||||||
webServer.setAiClient(aiClient);
|
webServer.setAiClient(aiClient);
|
||||||
@@ -483,6 +492,7 @@ export async function bootstrap(
|
|||||||
mediaService,
|
mediaService,
|
||||||
chatService,
|
chatService,
|
||||||
mediaSyncService,
|
mediaSyncService,
|
||||||
|
mediaLibraryService,
|
||||||
rateLimiter,
|
rateLimiter,
|
||||||
webServer,
|
webServer,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -364,6 +364,54 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
CREATE INDEX idx_conversation_automation_claims_status
|
CREATE INDEX idx_conversation_automation_claims_status
|
||||||
ON conversation_automation_claims(account_id, status, claimed_at);
|
ON conversation_automation_claims(account_id, status, claimed_at);
|
||||||
`),
|
`),
|
||||||
|
() => db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS media_library_assets (
|
||||||
|
asset_id TEXT PRIMARY KEY,
|
||||||
|
tags_json TEXT NOT NULL,
|
||||||
|
safety TEXT NOT NULL CHECK(safety IN ('sfw', 'nsfw', 'unknown')),
|
||||||
|
classification_status TEXT NOT NULL CHECK(classification_status IN ('ready', 'failed')),
|
||||||
|
description TEXT,
|
||||||
|
confidence REAL,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
relative_path TEXT NOT NULL UNIQUE,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
width INTEGER NOT NULL,
|
||||||
|
height INTEGER NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL UNIQUE,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0 CHECK(enabled IN (0, 1)),
|
||||||
|
consent_confirmed_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
deleted_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_library_assets_enabled
|
||||||
|
ON media_library_assets(enabled, classification_status, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS media_library_sends (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
asset_id TEXT NOT NULL,
|
||||||
|
asset_sha256 TEXT NOT NULL,
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
conversation_id TEXT NOT NULL,
|
||||||
|
incoming_key TEXT NOT NULL,
|
||||||
|
request_kind TEXT NOT NULL CHECK(request_kind IN ('generic', 'penis', 'chest', 'selfie', 'topless', 'feet')),
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending', 'sent', 'failed', 'superseded')),
|
||||||
|
requested_at INTEGER NOT NULL,
|
||||||
|
sent_at INTEGER,
|
||||||
|
matrix_media_event_id TEXT,
|
||||||
|
matrix_text_event_id TEXT,
|
||||||
|
mxc_uri TEXT,
|
||||||
|
error TEXT,
|
||||||
|
UNIQUE(account_id, conversation_id, incoming_key),
|
||||||
|
UNIQUE(account_id, conversation_id, asset_id),
|
||||||
|
UNIQUE(account_id, conversation_id, asset_sha256),
|
||||||
|
FOREIGN KEY (asset_id) REFERENCES media_library_assets(asset_id),
|
||||||
|
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_library_sends_conversation
|
||||||
|
ON media_library_sends(account_id, conversation_id, requested_at);
|
||||||
|
`),
|
||||||
];
|
];
|
||||||
|
|
||||||
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import {
|
||||||
|
MEDIA_LIBRARY_TAGS,
|
||||||
|
type MediaLibraryAsset,
|
||||||
|
type MediaLibrarySafety,
|
||||||
|
type MediaLibrarySend,
|
||||||
|
type MediaLibraryTag,
|
||||||
|
type MediaRequestKind,
|
||||||
|
} from '../services/media-library-types.js';
|
||||||
|
|
||||||
|
interface AssetRow {
|
||||||
|
asset_id: string;
|
||||||
|
tags_json: string;
|
||||||
|
safety: MediaLibrarySafety;
|
||||||
|
classification_status: 'ready' | 'failed';
|
||||||
|
description: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
file_name: string;
|
||||||
|
relative_path: string;
|
||||||
|
mime_type: string;
|
||||||
|
size_bytes: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
enabled: number;
|
||||||
|
consent_confirmed_at: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
send_count?: number;
|
||||||
|
last_sent_at?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendRow {
|
||||||
|
id: number;
|
||||||
|
asset_id: string;
|
||||||
|
account_id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
incoming_key: string;
|
||||||
|
request_kind: MediaRequestKind;
|
||||||
|
status: MediaLibrarySend['status'];
|
||||||
|
requested_at: number;
|
||||||
|
sent_at: number | null;
|
||||||
|
matrix_media_event_id: string | null;
|
||||||
|
matrix_text_event_id: string | null;
|
||||||
|
mxc_uri: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMediaLibraryAsset {
|
||||||
|
id: string;
|
||||||
|
tags: MediaLibraryTag[];
|
||||||
|
safety: MediaLibrarySafety;
|
||||||
|
classificationStatus: 'ready' | 'failed';
|
||||||
|
description: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
fileName: string;
|
||||||
|
relativePath: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
sha256: string;
|
||||||
|
enabled: boolean;
|
||||||
|
consentConfirmedAt: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Process-global metadata and durable outbound-use ledger for operator media. */
|
||||||
|
export class MediaLibraryRepository {
|
||||||
|
constructor(private readonly db: Database.Database) {}
|
||||||
|
|
||||||
|
create(input: CreateMediaLibraryAsset): MediaLibraryAsset {
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO media_library_assets (
|
||||||
|
asset_id, tags_json, safety, classification_status, description,
|
||||||
|
confidence, file_name, relative_path, mime_type, size_bytes, width,
|
||||||
|
height, sha256, enabled, consent_confirmed_at, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
input.id, JSON.stringify(input.tags), input.safety, input.classificationStatus,
|
||||||
|
input.description, input.confidence, input.fileName, input.relativePath,
|
||||||
|
input.mimeType, input.sizeBytes, input.width, input.height, input.sha256,
|
||||||
|
input.enabled ? 1 : 0, input.consentConfirmedAt, input.createdAt, input.createdAt,
|
||||||
|
);
|
||||||
|
return this.get(input.id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): MediaLibraryAsset | undefined {
|
||||||
|
const row = this.db.prepare(`
|
||||||
|
SELECT asset.*, COUNT(sends.sent_at) AS send_count, MAX(sends.sent_at) AS last_sent_at
|
||||||
|
FROM media_library_assets asset
|
||||||
|
LEFT JOIN media_library_sends sends ON sends.asset_id = asset.asset_id AND sends.status = 'sent'
|
||||||
|
WHERE asset.asset_id = ? AND asset.deleted_at IS NULL
|
||||||
|
GROUP BY asset.asset_id
|
||||||
|
`).get(id) as AssetRow | undefined;
|
||||||
|
return row ? mapAsset(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getBySha256(sha256: string): MediaLibraryAsset | undefined {
|
||||||
|
const row = this.db.prepare(`
|
||||||
|
SELECT asset.*, COUNT(sends.sent_at) AS send_count, MAX(sends.sent_at) AS last_sent_at
|
||||||
|
FROM media_library_assets asset
|
||||||
|
LEFT JOIN media_library_sends sends ON sends.asset_id = asset.asset_id AND sends.status = 'sent'
|
||||||
|
WHERE asset.sha256 = ? AND asset.deleted_at IS NULL
|
||||||
|
GROUP BY asset.asset_id
|
||||||
|
`).get(sha256) as AssetRow | undefined;
|
||||||
|
return row ? mapAsset(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): MediaLibraryAsset[] {
|
||||||
|
return (this.db.prepare(`
|
||||||
|
SELECT asset.*, COUNT(sends.sent_at) AS send_count, MAX(sends.sent_at) AS last_sent_at
|
||||||
|
FROM media_library_assets asset
|
||||||
|
LEFT JOIN media_library_sends sends ON sends.asset_id = asset.asset_id AND sends.status = 'sent'
|
||||||
|
WHERE asset.deleted_at IS NULL
|
||||||
|
GROUP BY asset.asset_id
|
||||||
|
ORDER BY asset.created_at DESC
|
||||||
|
`).all() as AssetRow[]).map(mapAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
listEligible(): MediaLibraryAsset[] {
|
||||||
|
return (this.db.prepare(`
|
||||||
|
SELECT * FROM media_library_assets
|
||||||
|
WHERE deleted_at IS NULL AND enabled = 1 AND classification_status = 'ready'
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`).all() as AssetRow[]).map(mapAsset);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, tags: MediaLibraryTag[], safety: Exclude<MediaLibrarySafety, 'unknown'>, enabled: boolean, now = Date.now()): MediaLibraryAsset | undefined {
|
||||||
|
this.db.prepare(`
|
||||||
|
UPDATE media_library_assets
|
||||||
|
SET tags_json = ?, safety = ?, classification_status = 'ready', enabled = ?, updated_at = ?
|
||||||
|
WHERE asset_id = ? AND deleted_at IS NULL
|
||||||
|
`).run(JSON.stringify(tags), safety, enabled ? 1 : 0, now, id);
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
softDelete(id: string, now = Date.now()): boolean {
|
||||||
|
return this.db.prepare(`
|
||||||
|
UPDATE media_library_assets
|
||||||
|
SET enabled = 0, deleted_at = ?, updated_at = ?, sha256 = sha256 || ':deleted:' || asset_id
|
||||||
|
WHERE asset_id = ? AND deleted_at IS NULL
|
||||||
|
`).run(now, now, id).changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSend(accountId: string, conversationId: string, incomingKey: string): MediaLibrarySend | undefined {
|
||||||
|
const row = this.db.prepare(`
|
||||||
|
SELECT * FROM media_library_sends
|
||||||
|
WHERE account_id = ? AND conversation_id = ? AND incoming_key = ?
|
||||||
|
`).get(accountId, conversationId, incomingKey) as SendRow | undefined;
|
||||||
|
return row ? mapSend(row) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
listConversationSends(accountId: string, conversationId: string): MediaLibrarySend[] {
|
||||||
|
return (this.db.prepare(`
|
||||||
|
SELECT * FROM media_library_sends
|
||||||
|
WHERE account_id = ? AND conversation_id = ?
|
||||||
|
ORDER BY requested_at ASC, id ASC
|
||||||
|
`).all(accountId, conversationId) as SendRow[]).map(mapSend);
|
||||||
|
}
|
||||||
|
|
||||||
|
tryReserve(
|
||||||
|
assetId: string,
|
||||||
|
accountId: string,
|
||||||
|
conversationId: string,
|
||||||
|
incomingKey: string,
|
||||||
|
requestKind: MediaRequestKind,
|
||||||
|
now = Date.now(),
|
||||||
|
): MediaLibrarySend | null {
|
||||||
|
try {
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO media_library_sends (
|
||||||
|
asset_id, asset_sha256, account_id, conversation_id, incoming_key, request_kind,
|
||||||
|
status, requested_at
|
||||||
|
)
|
||||||
|
SELECT asset_id, sha256, ?, ?, ?, ?, 'pending', ?
|
||||||
|
FROM media_library_assets
|
||||||
|
WHERE asset_id = ? AND deleted_at IS NULL AND enabled = 1
|
||||||
|
`).run(accountId, conversationId, incomingKey, requestKind, now, assetId);
|
||||||
|
return this.getSend(accountId, conversationId, incomingKey) ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && /UNIQUE constraint failed/.test(error.message)) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markMediaSent(id: number, mxcUri: string, eventId: string): void {
|
||||||
|
this.db.prepare(`
|
||||||
|
UPDATE media_library_sends
|
||||||
|
SET mxc_uri = ?, matrix_media_event_id = ?, error = NULL
|
||||||
|
WHERE id = ? AND status = 'pending'
|
||||||
|
`).run(mxcUri, eventId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
markSent(id: number, textEventId: string, now = Date.now()): boolean {
|
||||||
|
return this.db.prepare(`
|
||||||
|
UPDATE media_library_sends
|
||||||
|
SET status = 'sent', matrix_text_event_id = ?, sent_at = ?, error = NULL
|
||||||
|
WHERE id = ? AND status = 'pending' AND matrix_media_event_id IS NOT NULL
|
||||||
|
`).run(textEventId, now, id).changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
markFailed(id: number, error: string): boolean {
|
||||||
|
return this.db.prepare(`
|
||||||
|
UPDATE media_library_sends SET status = 'failed', error = ?
|
||||||
|
WHERE id = ? AND status = 'pending'
|
||||||
|
`).run(error.slice(0, 500), id).changes > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapAsset(row: AssetRow): MediaLibraryAsset {
|
||||||
|
let tags: MediaLibraryTag[] = [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.tags_json) as unknown;
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
tags = parsed.filter((tag): tag is MediaLibraryTag =>
|
||||||
|
typeof tag === 'string' && (MEDIA_LIBRARY_TAGS as readonly string[]).includes(tag));
|
||||||
|
}
|
||||||
|
} catch { /* invalid legacy data remains disabled by service validation */ }
|
||||||
|
return {
|
||||||
|
id: row.asset_id,
|
||||||
|
tags,
|
||||||
|
safety: row.safety,
|
||||||
|
classificationStatus: row.classification_status,
|
||||||
|
description: row.description,
|
||||||
|
confidence: row.confidence,
|
||||||
|
fileName: row.file_name,
|
||||||
|
relativePath: row.relative_path,
|
||||||
|
mimeType: row.mime_type,
|
||||||
|
sizeBytes: row.size_bytes,
|
||||||
|
width: row.width,
|
||||||
|
height: row.height,
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
consentConfirmedAt: row.consent_confirmed_at,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
sendCount: row.send_count ?? 0,
|
||||||
|
lastSentAt: row.last_sent_at ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSend(row: SendRow): MediaLibrarySend {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
assetId: row.asset_id,
|
||||||
|
accountId: row.account_id,
|
||||||
|
conversationId: row.conversation_id,
|
||||||
|
incomingKey: row.incoming_key,
|
||||||
|
requestKind: row.request_kind,
|
||||||
|
status: row.status,
|
||||||
|
requestedAt: row.requested_at,
|
||||||
|
sentAt: row.sent_at,
|
||||||
|
matrixMediaEventId: row.matrix_media_event_id,
|
||||||
|
matrixTextEventId: row.matrix_text_event_id,
|
||||||
|
mxcUri: row.mxc_uri,
|
||||||
|
error: row.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -528,6 +528,87 @@ export class MatrixClient {
|
|||||||
return res.event_id;
|
return res.event_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Upload an image to the Matrix content repository and return its mxc URI. */
|
||||||
|
async uploadImage(bytes: Buffer, mimeType: string, fileName: string): Promise<string> {
|
||||||
|
const candidates = [
|
||||||
|
'/_matrix/media/v3/upload',
|
||||||
|
'/_matrix/client/v1/media/upload',
|
||||||
|
];
|
||||||
|
for (const endpoint of candidates) {
|
||||||
|
const url = new URL(this.creds.homeserver + endpoint);
|
||||||
|
url.searchParams.set('filename', fileName);
|
||||||
|
await this.acquireRequestSlot();
|
||||||
|
const response = await this.timedFetch(url.toString(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.creds.accessToken}`,
|
||||||
|
'Content-Type': mimeType,
|
||||||
|
},
|
||||||
|
body: Uint8Array.from(bytes).buffer,
|
||||||
|
});
|
||||||
|
if (response === null) throw new Error('Matrix media upload timed out');
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
const failure = await readMatrixFailure(response);
|
||||||
|
if (isMatrixAuthenticationFailure(response.status, failure.errcode)) {
|
||||||
|
throw new MatrixAuthError(`Matrix media upload → ${response.status} (token rejected)`);
|
||||||
|
}
|
||||||
|
throw new Error(`Matrix media upload → ${response.status}: ${failure.text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
if (response.status === 404 || response.status === 405) continue;
|
||||||
|
if (response.status === 429) {
|
||||||
|
const failure = await response.json().catch(() => ({})) as {
|
||||||
|
retry_after_ms?: unknown;
|
||||||
|
errcode?: unknown;
|
||||||
|
['com.reddit.error.code']?: unknown;
|
||||||
|
};
|
||||||
|
throw new MatrixRateLimitError(
|
||||||
|
'Matrix media upload rate limited',
|
||||||
|
typeof failure.retry_after_ms === 'number' ? failure.retry_after_ms : 500,
|
||||||
|
typeof failure.errcode === 'string' ? failure.errcode : null,
|
||||||
|
typeof failure['com.reddit.error.code'] === 'string' ? failure['com.reddit.error.code'] : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const failure = await response.text().catch(() => '');
|
||||||
|
throw new Error(`Matrix media upload → ${response.status}: ${failure.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const payload = await response.json() as { content_uri?: unknown };
|
||||||
|
if (typeof payload.content_uri !== 'string' || !payload.content_uri.startsWith('mxc://')) {
|
||||||
|
throw new Error('Matrix media upload returned no content URI');
|
||||||
|
}
|
||||||
|
return payload.content_uri;
|
||||||
|
}
|
||||||
|
throw new Error('Matrix homeserver does not expose a supported media upload endpoint');
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendImageMessage(
|
||||||
|
roomId: string,
|
||||||
|
body: string,
|
||||||
|
mxcUri: string,
|
||||||
|
info: { mimeType: string; size: number; width: number; height: number },
|
||||||
|
txnId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const id = txnId ?? `rbot-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const response = await this.request<{ event_id: string }>(
|
||||||
|
'PUT',
|
||||||
|
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(id)}`,
|
||||||
|
{
|
||||||
|
body: {
|
||||||
|
msgtype: 'm.image',
|
||||||
|
body,
|
||||||
|
url: mxcUri,
|
||||||
|
info: {
|
||||||
|
mimetype: info.mimeType,
|
||||||
|
size: info.size,
|
||||||
|
w: info.width,
|
||||||
|
h: info.height,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return response.event_id;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert an mxc:// URI to an HTTPS download URL on this homeserver.
|
* Convert an mxc:// URI to an HTTPS download URL on this homeserver.
|
||||||
* Reddit's media is served from the same Matrix server.
|
* Reddit's media is served from the same Matrix server.
|
||||||
|
|||||||
@@ -582,7 +582,7 @@ export class ChatService {
|
|||||||
* `https://chat.reddit.com/room/<roomId>` URL (web UI compatibility) or
|
* `https://chat.reddit.com/room/<roomId>` URL (web UI compatibility) or
|
||||||
* a raw Matrix room ID like `!abc:reddit.com`.
|
* a raw Matrix room ID like `!abc:reddit.com`.
|
||||||
*/
|
*/
|
||||||
async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise<boolean> {
|
async sendChatMessage(chatUrlOrRoomId: string, message: string, txnId?: string): Promise<boolean> {
|
||||||
if (!this.accountAuth?.isFullyAuthenticated()) return false;
|
if (!this.accountAuth?.isFullyAuthenticated()) return false;
|
||||||
const active = this.getInvitationCooldown();
|
const active = this.getInvitationCooldown();
|
||||||
if (active && this.blocksExistingRoomSends(active)) {
|
if (active && this.blocksExistingRoomSends(active)) {
|
||||||
@@ -599,7 +599,7 @@ export class ChatService {
|
|||||||
this.logger.error(`[chat] cannot extract room ID from "${chatUrlOrRoomId}"`);
|
this.logger.error(`[chat] cannot extract room ID from "${chatUrlOrRoomId}"`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const eventId = await this.withAuth(() => this.matrix.sendTextMessage(roomId, message));
|
const eventId = await this.withAuth(() => this.matrix.sendTextMessage(roomId, message, txnId));
|
||||||
this.logger.info(`[chat] sent message to ${roomId} (event_id=${eventId})`);
|
this.logger.info(`[chat] sent message to ${roomId} (event_id=${eventId})`);
|
||||||
const chat = this.cache.get().find((c) => c.roomId === roomId);
|
const chat = this.cache.get().find((c) => c.roomId === roomId);
|
||||||
if (chat) {
|
if (chat) {
|
||||||
@@ -619,6 +619,47 @@ export class ChatService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendChatImage(
|
||||||
|
chatUrlOrRoomId: string,
|
||||||
|
image: { bytes: Buffer; mimeType: string; fileName: string; width: number; height: number },
|
||||||
|
txnId: string,
|
||||||
|
existingMxcUri?: string | null,
|
||||||
|
): Promise<{ eventId: string; mxcUri: string } | null> {
|
||||||
|
if (!this.accountAuth?.isFullyAuthenticated()) return null;
|
||||||
|
const active = this.getInvitationCooldown();
|
||||||
|
if (active && this.blocksExistingRoomSends(active)) return null;
|
||||||
|
return this.enqueue(async () => {
|
||||||
|
try {
|
||||||
|
const roomId = ChatService.extractRoomId(chatUrlOrRoomId);
|
||||||
|
if (!roomId) return null;
|
||||||
|
const mxcUri = existingMxcUri
|
||||||
|
?? await this.withAuth(() => this.matrix.uploadImage(image.bytes, image.mimeType, image.fileName));
|
||||||
|
const eventId = await this.withAuth(() => this.matrix.sendImageMessage(
|
||||||
|
roomId,
|
||||||
|
image.fileName,
|
||||||
|
mxcUri,
|
||||||
|
{ mimeType: image.mimeType, size: image.bytes.length, width: image.width, height: image.height },
|
||||||
|
txnId,
|
||||||
|
));
|
||||||
|
this.logger.info(`[chat] sent image to ${roomId} (event_id=${eventId})`);
|
||||||
|
const chat = this.cache.get().find((item) => item.roomId === roomId);
|
||||||
|
if (chat) {
|
||||||
|
chat.lastMessageSnippet = '[Image]';
|
||||||
|
chat.lastMessageTime = Date.now();
|
||||||
|
chat.isFromUs = true;
|
||||||
|
}
|
||||||
|
return { eventId, mxcUri };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof MatrixRateLimitError) {
|
||||||
|
this.setInvitationCooldown(error, 'all-sends');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this.logger.error('[chat] sendChatImage failed:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a brand-new DM with the given Reddit user and send the opening
|
* Start a brand-new DM with the given Reddit user and send the opening
|
||||||
* line. Returns the new chat (or null on failure).
|
* line. Returns the new chat (or null on failure).
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type ConversationState,
|
type ConversationState,
|
||||||
} from './conversation-state.js';
|
} from './conversation-state.js';
|
||||||
import { containsMediaRequest } from './conversation-boundaries.js';
|
import { containsMediaRequest } from './conversation-boundaries.js';
|
||||||
|
import type { MediaLibraryExchangeService } from './media-library-exchange.js';
|
||||||
|
|
||||||
export type ConversationAutomationResultKind =
|
export type ConversationAutomationResultKind =
|
||||||
| 'sent'
|
| 'sent'
|
||||||
@@ -94,6 +95,7 @@ export class ConversationAutomationService {
|
|||||||
private readonly now: () => number = Date.now,
|
private readonly now: () => number = Date.now,
|
||||||
private readonly pendingReplyRetryDelayMs = PENDING_REPLY_RETRY_DELAY_MS,
|
private readonly pendingReplyRetryDelayMs = PENDING_REPLY_RETRY_DELAY_MS,
|
||||||
private readonly beforeSendLoader?: ConversationBeforeSendLoader,
|
private readonly beforeSendLoader?: ConversationBeforeSendLoader,
|
||||||
|
private readonly mediaExchange?: MediaLibraryExchangeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async processPendingConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
|
async processPendingConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
|
||||||
@@ -260,6 +262,52 @@ export class ConversationAutomationService {
|
|||||||
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', safetyReason);
|
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', safetyReason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this.mediaExchange?.detect(conversationId, latest.text)) {
|
||||||
|
const exchangeAttempt = await this.rateLimiter.throttle(
|
||||||
|
async () => {
|
||||||
|
const refreshed = await this.loadConversationBeforeSend(conversationId, conversation);
|
||||||
|
const refreshedLatest = refreshed ? latestInboundMessage(refreshed.messages ?? []) : null;
|
||||||
|
if (!refreshed || !refreshedLatest) return { kind: 'superseded' as const };
|
||||||
|
const refreshedState = mergeConversationState(
|
||||||
|
this.states.get(conversationId),
|
||||||
|
buildConversationState((refreshed.messages ?? []).map(({ sender, text, isSelf }) => ({ sender, text, isSelf }))),
|
||||||
|
);
|
||||||
|
if (refreshedState.boundaries.length > 0 || hasReceivedImage(refreshed.messages ?? [])) {
|
||||||
|
return { kind: 'human-required' as const, reason: 'The conversation became ineligible before the image could be sent.' };
|
||||||
|
}
|
||||||
|
const refreshedSellerSignal = findSellerSignal(
|
||||||
|
refreshedLatest.text,
|
||||||
|
[...this.sellerKeywords],
|
||||||
|
[...this.sellerDomains],
|
||||||
|
);
|
||||||
|
if (refreshedSellerSignal) {
|
||||||
|
return { kind: 'human-required' as const, reason: 'Potential upsell appeared before the image could be sent.' };
|
||||||
|
}
|
||||||
|
if (messageKey(refreshedLatest) !== incomingKey) return { kind: 'superseded' as const };
|
||||||
|
return this.mediaExchange!.handle(conversationId, incomingKey, refreshedLatest.text);
|
||||||
|
},
|
||||||
|
(result) => result.kind === 'sent' || (result.kind === 'human-required' && result.remoteSent === true),
|
||||||
|
signal,
|
||||||
|
REPLY_SEND_PRIORITY,
|
||||||
|
);
|
||||||
|
const exchange = exchangeAttempt;
|
||||||
|
if (exchange.kind === 'sent') {
|
||||||
|
state.askedForMedia = true;
|
||||||
|
state.automation.lastIncomingKey = incomingKey;
|
||||||
|
this.states.save(conversationId, state);
|
||||||
|
return {
|
||||||
|
conversationId,
|
||||||
|
username: conversation.username,
|
||||||
|
kind: 'sent',
|
||||||
|
remainingTurns: remainingAutomationTurns(state),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (exchange.kind === 'human-required') {
|
||||||
|
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', exchange.reason);
|
||||||
|
}
|
||||||
|
if (exchange.kind === 'retryable' || exchange.kind === 'dry-run') return null;
|
||||||
|
if (exchange.kind === 'superseded') return null;
|
||||||
|
}
|
||||||
if (remainingAutomationTurns(state) === 0) {
|
if (remainingAutomationTurns(state) === 0) {
|
||||||
return this.finish(
|
return this.finish(
|
||||||
conversation,
|
conversation,
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { MediaLibraryRepository } from '../database/media-library-repository.js';
|
||||||
|
import type { Logger } from '../utils/logger.js';
|
||||||
|
import type { MediaLibraryAsset, MediaLibrarySend, MediaLibraryTag, MediaRequestKind } from './media-library-types.js';
|
||||||
|
|
||||||
|
export interface MediaExchangeChat {
|
||||||
|
sendChatImage(
|
||||||
|
roomId: string,
|
||||||
|
image: { bytes: Buffer; mimeType: string; fileName: string; width: number; height: number },
|
||||||
|
txnId: string,
|
||||||
|
existingMxcUri?: string | null,
|
||||||
|
): Promise<{ eventId: string; mxcUri: string } | null>;
|
||||||
|
sendChatMessage(roomId: string, message: string, txnId?: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaExchangeResult =
|
||||||
|
| { kind: 'not-requested' }
|
||||||
|
| { kind: 'dry-run'; requestKind: MediaRequestKind }
|
||||||
|
| { kind: 'sent'; assetId: string; requestKind: MediaRequestKind }
|
||||||
|
| { kind: 'retryable'; assetId: string; requestKind: MediaRequestKind }
|
||||||
|
| { kind: 'human-required'; reason: string; remoteSent?: boolean };
|
||||||
|
|
||||||
|
/** Detects inbound requests, reserves an unused asset, then sends image before reciprocal text. */
|
||||||
|
export class MediaLibraryExchangeService {
|
||||||
|
constructor(
|
||||||
|
private readonly repository: MediaLibraryRepository,
|
||||||
|
private readonly chat: MediaExchangeChat,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
private readonly accountId: string,
|
||||||
|
private readonly rootDirectory = path.join(process.cwd(), 'data', 'media-library'),
|
||||||
|
private readonly dryRun = false,
|
||||||
|
private readonly random: () => number = Math.random,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
detect(conversationId: string, incomingText: string): MediaRequestKind | null {
|
||||||
|
return detectMediaRequest(
|
||||||
|
incomingText,
|
||||||
|
this.repository.listConversationSends(this.accountId, conversationId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(
|
||||||
|
conversationId: string,
|
||||||
|
incomingKey: string,
|
||||||
|
incomingText: string,
|
||||||
|
): Promise<MediaExchangeResult> {
|
||||||
|
const prior = this.repository.listConversationSends(this.accountId, conversationId);
|
||||||
|
const requestKind = detectMediaRequest(incomingText, prior);
|
||||||
|
if (!requestKind) return { kind: 'not-requested' };
|
||||||
|
if (this.dryRun) return { kind: 'dry-run', requestKind };
|
||||||
|
|
||||||
|
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 media send for this request failed or became uncertain.' };
|
||||||
|
}
|
||||||
|
if (!send) {
|
||||||
|
const used = new Set(prior.map((item) => item.assetId));
|
||||||
|
const eligible = this.pickEligible(this.repository.listEligible(), used, requestKind, prior);
|
||||||
|
for (const asset of shuffled(eligible, this.random)) {
|
||||||
|
send = this.repository.tryReserve(asset.id, this.accountId, conversationId, incomingKey, requestKind) ?? undefined;
|
||||||
|
if (send) break;
|
||||||
|
}
|
||||||
|
if (!send) {
|
||||||
|
return {
|
||||||
|
kind: 'human-required',
|
||||||
|
reason: `No unused enabled library image matches the ${requestKind === 'generic' ? 'current safe escalation tier' : requestKind} request.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 textSent = send.matrixTextEventId
|
||||||
|
? true
|
||||||
|
: await this.chat.sendChatMessage(
|
||||||
|
conversationId,
|
||||||
|
'Your turn — send me one back if you’re comfortable.',
|
||||||
|
`rbot-library-reciprocal-${send.id}`,
|
||||||
|
);
|
||||||
|
if (!textSent) {
|
||||||
|
// The image event is already confirmed. Consume the asset and surface
|
||||||
|
// a handoff rather than risking a duplicate image on a later retry.
|
||||||
|
this.repository.markSent(send.id, 'unconfirmed');
|
||||||
|
return { kind: 'human-required', reason: 'The image was sent, but the reciprocal text was not confirmed.', remoteSent: true };
|
||||||
|
}
|
||||||
|
if (!send.matrixTextEventId) this.repository.markSent(send.id, `rbot-library-reciprocal-${send.id}`);
|
||||||
|
this.logger.info(`[media-library] Sent ${requestKind} asset ${asset.id} in ${conversationId}`);
|
||||||
|
return { kind: 'sent', assetId: asset.id, requestKind };
|
||||||
|
} catch (error) {
|
||||||
|
this.repository.markFailed(send.id, safeError(error));
|
||||||
|
this.logger.warn(`[media-library] Exchange failed in ${conversationId}: ${safeError(error)}`);
|
||||||
|
return { kind: 'human-required', reason: 'The requested library image could not be read or sent.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickEligible(
|
||||||
|
assets: MediaLibraryAsset[],
|
||||||
|
used: Set<string>,
|
||||||
|
requestKind: MediaRequestKind,
|
||||||
|
prior: MediaLibrarySend[],
|
||||||
|
): MediaLibraryAsset[] {
|
||||||
|
const unused = assets.filter((asset) => !used.has(asset.id));
|
||||||
|
if (requestKind !== 'generic') return unused.filter((asset) => asset.tags.includes(requestKind));
|
||||||
|
const completed = prior.filter((item) => item.status === 'sent').length;
|
||||||
|
const maxTier = Math.min(2, completed);
|
||||||
|
return unused.filter((asset) => assetTier(asset) <= maxTier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectMediaRequest(text: string, prior: readonly MediaLibrarySend[] = []): MediaRequestKind | null {
|
||||||
|
const normalized = text.toLowerCase().replace(/[’]/g, "'");
|
||||||
|
if (/\b(?:penis|dick|cock|schlong)\b/.test(normalized)) return 'penis';
|
||||||
|
if (/\b(?:topless|nude|nudes|boobs?|tits?)\b/.test(normalized)) return 'topless';
|
||||||
|
if (/\b(?:feet|foot|toes)\b/.test(normalized)) return 'feet';
|
||||||
|
if (/\b(?:selfie|face pic|picture of (?:your|ur) face)\b/.test(normalized)) return 'selfie';
|
||||||
|
if (/\b(?:chest|torso)\b/.test(normalized)) return 'chest';
|
||||||
|
|
||||||
|
const generic = /\b(?:pic|pics|picture|pictures|photo|photos|image|images)\b/.test(normalized)
|
||||||
|
|| /\b(?:show me (?:you|u|yourself)|let me see you|what do you look like)\b/.test(normalized)
|
||||||
|
|| (prior.length > 0 && /^(?:more|another|again|one more|send (?:me )?one)[.!?\s]*$/.test(normalized.trim()));
|
||||||
|
return generic ? 'generic' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assetTier(asset: MediaLibraryAsset): number {
|
||||||
|
if (asset.tags.includes('penis')) return 2;
|
||||||
|
if (asset.tags.includes('topless') || asset.safety === 'nsfw') return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffled<T>(items: readonly T[], random: () => number): T[] {
|
||||||
|
const copy = [...items];
|
||||||
|
for (let index = copy.length - 1; index > 0; index -= 1) {
|
||||||
|
const swap = Math.floor(random() * (index + 1));
|
||||||
|
[copy[index], copy[swap]] = [copy[swap], copy[index]];
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInside(root: string, relativePath: string): string {
|
||||||
|
const resolvedRoot = path.resolve(root);
|
||||||
|
const resolved = path.resolve(resolvedRoot, relativePath);
|
||||||
|
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
||||||
|
throw new Error('Unsafe media-library path');
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeError(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export const MEDIA_LIBRARY_TAGS = ['penis', 'chest', 'selfie', 'topless', 'feet'] as const;
|
||||||
|
export type MediaLibraryTag = (typeof MEDIA_LIBRARY_TAGS)[number];
|
||||||
|
|
||||||
|
export const MEDIA_LIBRARY_SAFETY = ['sfw', 'nsfw', 'unknown'] as const;
|
||||||
|
export type MediaLibrarySafety = (typeof MEDIA_LIBRARY_SAFETY)[number];
|
||||||
|
|
||||||
|
export interface MediaLibraryClassification {
|
||||||
|
tags: MediaLibraryTag[];
|
||||||
|
safety: Exclude<MediaLibrarySafety, 'unknown'>;
|
||||||
|
description: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaLibraryAsset {
|
||||||
|
id: string;
|
||||||
|
tags: MediaLibraryTag[];
|
||||||
|
safety: MediaLibrarySafety;
|
||||||
|
classificationStatus: 'ready' | 'failed';
|
||||||
|
description: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
fileName: string;
|
||||||
|
relativePath: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
enabled: boolean;
|
||||||
|
consentConfirmedAt: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
sendCount: number;
|
||||||
|
lastSentAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaRequestKind = MediaLibraryTag | 'generic';
|
||||||
|
|
||||||
|
export interface MediaLibrarySend {
|
||||||
|
id: number;
|
||||||
|
assetId: string;
|
||||||
|
accountId: string;
|
||||||
|
conversationId: string;
|
||||||
|
incomingKey: string;
|
||||||
|
requestKind: MediaRequestKind;
|
||||||
|
status: 'pending' | 'sent' | 'failed' | 'superseded';
|
||||||
|
requestedAt: number;
|
||||||
|
sentAt: number | null;
|
||||||
|
matrixMediaEventId: string | null;
|
||||||
|
matrixTextEventId: string | null;
|
||||||
|
mxcUri: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import type { Logger } from '../utils/logger.js';
|
||||||
|
import type { MediaLibraryRepository } from '../database/media-library-repository.js';
|
||||||
|
import {
|
||||||
|
MEDIA_LIBRARY_TAGS,
|
||||||
|
type MediaLibraryAsset,
|
||||||
|
type MediaLibraryClassification,
|
||||||
|
type MediaLibrarySafety,
|
||||||
|
type MediaLibraryTag,
|
||||||
|
} from './media-library-types.js';
|
||||||
|
|
||||||
|
export interface MediaLibraryClassifier {
|
||||||
|
classifyLibraryImage(imageBase64: string, mimeType: string): Promise<MediaLibraryClassification>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaLibraryUpload {
|
||||||
|
bytes: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
originalFileName: string;
|
||||||
|
consentConfirmed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaLibraryUpdate {
|
||||||
|
tags: MediaLibraryTag[];
|
||||||
|
safety: Exclude<MediaLibrarySafety, 'unknown'>;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
|
||||||
|
const MAX_INPUT_PIXELS = 40_000_000;
|
||||||
|
|
||||||
|
/** Owns validation, metadata removal, AI classification, and private files. */
|
||||||
|
export class MediaLibraryService {
|
||||||
|
readonly rootDirectory: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly repository: MediaLibraryRepository,
|
||||||
|
private readonly classifier: MediaLibraryClassifier,
|
||||||
|
private readonly logger: Logger,
|
||||||
|
rootDirectory = path.join(process.cwd(), 'data', 'media-library'),
|
||||||
|
private readonly now: () => number = Date.now,
|
||||||
|
) {
|
||||||
|
this.rootDirectory = rootDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): MediaLibraryAsset[] {
|
||||||
|
return this.repository.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): MediaLibraryAsset | undefined {
|
||||||
|
return this.repository.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(input: MediaLibraryUpload): Promise<MediaLibraryAsset> {
|
||||||
|
if (!input.consentConfirmed) {
|
||||||
|
throw new MediaLibraryInputError('Confirm that every depicted adult consented to this use');
|
||||||
|
}
|
||||||
|
if (input.bytes.length === 0 || input.bytes.length > MAX_UPLOAD_BYTES) {
|
||||||
|
throw new MediaLibraryInputError('Image must be between 1 byte and 15 MB');
|
||||||
|
}
|
||||||
|
const declaredMimeType = input.contentType.split(';', 1)[0].trim().toLowerCase();
|
||||||
|
if (!['image/jpeg', 'image/png', 'image/webp'].includes(declaredMimeType)) {
|
||||||
|
throw new MediaLibraryInputError('Only JPEG, PNG, and WebP images are supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = await stripImageMetadata(input.bytes, declaredMimeType);
|
||||||
|
const sha256 = crypto.createHash('sha256').update(cleaned.bytes).digest('hex');
|
||||||
|
const duplicate = this.repository.getBySha256(sha256);
|
||||||
|
if (duplicate) throw new MediaLibraryConflictError('This image is already in the library');
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const extension = extensionForMime(cleaned.mimeType);
|
||||||
|
const fileName = `${id}${extension}`;
|
||||||
|
const relativePath = fileName;
|
||||||
|
const fullPath = path.join(this.rootDirectory, relativePath);
|
||||||
|
await fs.mkdir(this.rootDirectory, { recursive: true });
|
||||||
|
await fs.writeFile(fullPath, cleaned.bytes, { flag: 'wx' });
|
||||||
|
|
||||||
|
let classification: MediaLibraryClassification | null = null;
|
||||||
|
try {
|
||||||
|
classification = await this.classifier.classifyLibraryImage(
|
||||||
|
cleaned.bytes.toString('base64'),
|
||||||
|
cleaned.mimeType,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`Media library classification failed for ${id}: ${safeError(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = this.now();
|
||||||
|
try {
|
||||||
|
return this.repository.create({
|
||||||
|
id,
|
||||||
|
tags: classification?.tags ?? [],
|
||||||
|
safety: classification?.safety ?? 'unknown',
|
||||||
|
classificationStatus: classification ? 'ready' : 'failed',
|
||||||
|
description: classification?.description ?? null,
|
||||||
|
confidence: classification?.confidence ?? null,
|
||||||
|
fileName: sanitizeDisplayFileName(input.originalFileName, extension),
|
||||||
|
relativePath,
|
||||||
|
mimeType: cleaned.mimeType,
|
||||||
|
sizeBytes: cleaned.bytes.length,
|
||||||
|
width: cleaned.width,
|
||||||
|
height: cleaned.height,
|
||||||
|
sha256,
|
||||||
|
// Low-confidence results remain disabled until the operator reviews them.
|
||||||
|
enabled: Boolean(classification && classification.confidence >= 0.7),
|
||||||
|
consentConfirmedAt: createdAt,
|
||||||
|
createdAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await fs.unlink(fullPath).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id: string, input: MediaLibraryUpdate): MediaLibraryAsset {
|
||||||
|
const tags = [...new Set(input.tags)];
|
||||||
|
if (tags.length === 0 || tags.some((tag) => !(MEDIA_LIBRARY_TAGS as readonly string[]).includes(tag))) {
|
||||||
|
throw new MediaLibraryInputError('Select at least one valid anatomy tag');
|
||||||
|
}
|
||||||
|
const updated = this.repository.update(id, tags, input.safety, input.enabled, this.now());
|
||||||
|
if (!updated) throw new MediaLibraryNotFoundError('Library image not found');
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
const asset = this.repository.get(id);
|
||||||
|
if (!asset) return false;
|
||||||
|
if (!this.repository.softDelete(id, this.now())) return false;
|
||||||
|
const fullPath = resolveInside(this.rootDirectory, asset.relativePath);
|
||||||
|
await fs.unlink(fullPath).catch((error: unknown) => {
|
||||||
|
if (!isMissingFile(error)) this.logger.warn(`Failed to remove library file ${id}: ${safeError(error)}`);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvePath(asset: MediaLibraryAsset): string {
|
||||||
|
return resolveInside(this.rootDirectory, asset.relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MediaLibraryInputError extends Error {}
|
||||||
|
export class MediaLibraryConflictError extends Error {}
|
||||||
|
export class MediaLibraryNotFoundError extends Error {}
|
||||||
|
|
||||||
|
async function stripImageMetadata(bytes: Buffer, declaredMime: string): Promise<{
|
||||||
|
bytes: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const image = sharp(bytes, { limitInputPixels: MAX_INPUT_PIXELS, animated: false });
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
if (!metadata.width || !metadata.height || !metadata.format) {
|
||||||
|
throw new MediaLibraryInputError('The upload is not a readable image');
|
||||||
|
}
|
||||||
|
if ((metadata.pages ?? 1) > 1) {
|
||||||
|
throw new MediaLibraryInputError('Animated images are not supported');
|
||||||
|
}
|
||||||
|
const actualMime = mimeForFormat(metadata.format);
|
||||||
|
if (!actualMime || actualMime !== declaredMime.toLowerCase()) {
|
||||||
|
throw new MediaLibraryInputError('The image contents do not match its declared file type');
|
||||||
|
}
|
||||||
|
const oriented = image.rotate();
|
||||||
|
const output = actualMime === 'image/jpeg'
|
||||||
|
? oriented.jpeg({ quality: 92, mozjpeg: true })
|
||||||
|
: actualMime === 'image/png'
|
||||||
|
? oriented.png({ compressionLevel: 9 })
|
||||||
|
: oriented.webp({ quality: 92 });
|
||||||
|
const cleaned = await output.toBuffer({ resolveWithObject: true });
|
||||||
|
return {
|
||||||
|
bytes: cleaned.data,
|
||||||
|
mimeType: actualMime,
|
||||||
|
width: cleaned.info.width,
|
||||||
|
height: cleaned.info.height,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof MediaLibraryInputError) throw error;
|
||||||
|
throw new MediaLibraryInputError(`Unable to process image: ${safeError(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeForFormat(format: string): string | null {
|
||||||
|
if (format === 'jpeg') return 'image/jpeg';
|
||||||
|
if (format === 'png') return 'image/png';
|
||||||
|
if (format === 'webp') return 'image/webp';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionForMime(mimeType: string): string {
|
||||||
|
if (mimeType === 'image/jpeg') return '.jpg';
|
||||||
|
if (mimeType === 'image/png') return '.png';
|
||||||
|
return '.webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeDisplayFileName(original: string, extension: string): string {
|
||||||
|
const base = path.basename(original || `library-image${extension}`)
|
||||||
|
.replace(/[^a-zA-Z0-9._ -]/g, '_')
|
||||||
|
.slice(0, 120);
|
||||||
|
return base || `library-image${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInside(root: string, relativePath: string): string {
|
||||||
|
const resolvedRoot = path.resolve(root);
|
||||||
|
const resolved = path.resolve(resolvedRoot, relativePath);
|
||||||
|
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) {
|
||||||
|
throw new Error('Unsafe media-library path');
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeError(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingFile(error: unknown): boolean {
|
||||||
|
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
||||||
|
}
|
||||||
@@ -44,6 +44,8 @@ import { ConfigStore } from '../config/persistence.js';
|
|||||||
import { createConfigBackedSubredditPollingControl } from './subreddit-polling-control.js';
|
import { createConfigBackedSubredditPollingControl } from './subreddit-polling-control.js';
|
||||||
import { ConversationAutomationService } from './conversation-automation.js';
|
import { ConversationAutomationService } from './conversation-automation.js';
|
||||||
import { AiUsageRepository } from '../database/ai-usage-repository.js';
|
import { AiUsageRepository } from '../database/ai-usage-repository.js';
|
||||||
|
import { MediaLibraryRepository } from '../database/media-library-repository.js';
|
||||||
|
import { MediaLibraryExchangeService } from './media-library-exchange.js';
|
||||||
|
|
||||||
export interface ConcreteRuntimeFactoryOptions {
|
export interface ConcreteRuntimeFactoryOptions {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -193,6 +195,14 @@ async function buildRuntime(
|
|||||||
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
|
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
|
||||||
});
|
});
|
||||||
await rateLimiter.initialize();
|
await rateLimiter.initialize();
|
||||||
|
const mediaExchange = new MediaLibraryExchangeService(
|
||||||
|
new MediaLibraryRepository(db),
|
||||||
|
chatService,
|
||||||
|
logger,
|
||||||
|
session.accountId,
|
||||||
|
undefined,
|
||||||
|
accountConfig.browser.dryRun,
|
||||||
|
);
|
||||||
const conversationAutomation = new ConversationAutomationService(
|
const conversationAutomation = new ConversationAutomationService(
|
||||||
chatService,
|
chatService,
|
||||||
aiClient,
|
aiClient,
|
||||||
@@ -205,6 +215,10 @@ async function buildRuntime(
|
|||||||
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerKeywords : [],
|
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerKeywords : [],
|
||||||
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerDomains : [],
|
accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerDomains : [],
|
||||||
() => accountConfig.reddit.subreddits,
|
() => accountConfig.reddit.subreddits,
|
||||||
|
Date.now,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
mediaExchange,
|
||||||
);
|
);
|
||||||
|
|
||||||
const resources: RedditAccountRuntimeResources = {
|
const resources: RedditAccountRuntimeResources = {
|
||||||
|
|||||||
@@ -88,6 +88,16 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
|
|||||||
operation('getMediaSync', 'GET', '/api/media/sync', 'Get media sync state', ['Media']),
|
operation('getMediaSync', 'GET', '/api/media/sync', 'Get media sync state', ['Media']),
|
||||||
operation('triggerMediaSync', 'POST', '/api/media/sync', 'Trigger media sync', ['Media']),
|
operation('triggerMediaSync', 'POST', '/api/media/sync', 'Trigger media sync', ['Media']),
|
||||||
operation('triggerFullMediaSync', 'POST', '/api/media/sync/full', 'Trigger full media sync', ['Media']),
|
operation('triggerFullMediaSync', 'POST', '/api/media/sync/full', 'Trigger full media sync', ['Media']),
|
||||||
|
operation('listMediaLibrary', 'GET', '/api/media-library', 'List operator-owned exchange images', ['Media Library']),
|
||||||
|
operation('uploadMediaLibraryImage', 'POST', '/api/media-library', 'Upload and classify an operator-owned exchange image', ['Media Library'], { successStatus: 201 }),
|
||||||
|
operation('updateMediaLibraryImage', 'PATCH', '/api/media-library/{id}', 'Correct tags or enable an exchange image', ['Media Library'], {
|
||||||
|
requestSchema: z.strictObject({
|
||||||
|
tags: z.array(z.enum(['penis', 'chest', 'selfie', 'topless', 'feet'])).min(1),
|
||||||
|
safety: z.enum(['sfw', 'nsfw']),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
operation('deleteMediaLibraryImage', 'DELETE', '/api/media-library/{id}', 'Delete an exchange image', ['Media Library']),
|
||||||
operation('startBot', 'POST', '/api/start', 'Start bot processing (legacy)', ['Control']),
|
operation('startBot', 'POST', '/api/start', 'Start bot processing (legacy)', ['Control']),
|
||||||
operation('stopBot', 'POST', '/api/stop', 'Stop bot processing (legacy)', ['Control']),
|
operation('stopBot', 'POST', '/api/stop', 'Stop bot processing (legacy)', ['Control']),
|
||||||
operation('getTimers', 'GET', '/api/timers', 'Get rate-limit timers', ['Analytics']),
|
operation('getTimers', 'GET', '/api/timers', 'Get rate-limit timers', ['Analytics']),
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ export function createDashboardAuth(token: string, agentTokens: AgentTokenOption
|
|||||||
res.status(403).json({ error: 'Agent credential lacks the required access' });
|
res.status(403).json({ error: 'Agent credential lacks the required access' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!req.path.startsWith('/api') && !req.path.startsWith('/media')) {
|
if (!req.path.startsWith('/api') && !req.path.startsWith('/media') && !req.path.startsWith('/library-media')) {
|
||||||
res.redirect(302, '/auth/login');
|
res.redirect(302, '/auth/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { RedditAccountsService } from '../services/reddit-accounts.js';
|
|||||||
import type { RedditAccountRuntimeManager } from '../services/reddit-account-runtime.js';
|
import type { RedditAccountRuntimeManager } from '../services/reddit-account-runtime.js';
|
||||||
import type { AiUsageRepository } from '../database/ai-usage-repository.js';
|
import type { AiUsageRepository } from '../database/ai-usage-repository.js';
|
||||||
import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js';
|
import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js';
|
||||||
|
import type { MediaLibraryService } from '../services/media-library.js';
|
||||||
|
|
||||||
/** Bag of everything the route modules might need. */
|
/** Bag of everything the route modules might need. */
|
||||||
export interface WebContext {
|
export interface WebContext {
|
||||||
@@ -56,6 +57,7 @@ export interface WebContext {
|
|||||||
redditAccounts?: RedditAccountsService;
|
redditAccounts?: RedditAccountsService;
|
||||||
accountRuntimes?: RedditAccountRuntimeManager;
|
accountRuntimes?: RedditAccountRuntimeManager;
|
||||||
candidateQueue?: CandidateQueueRepository;
|
candidateQueue?: CandidateQueueRepository;
|
||||||
|
mediaLibrary?: MediaLibraryService;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Bot pause/resume toggle read by the main loop. */
|
/** Bot pause/resume toggle read by the main loop. */
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import express, { Router } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { WebContext } from '../context.js';
|
||||||
|
import {
|
||||||
|
MediaLibraryConflictError,
|
||||||
|
MediaLibraryInputError,
|
||||||
|
MediaLibraryNotFoundError,
|
||||||
|
} from '../../services/media-library.js';
|
||||||
|
import { MEDIA_LIBRARY_TAGS, type MediaLibraryAsset } from '../../services/media-library-types.js';
|
||||||
|
|
||||||
|
const MediaLibraryUpdateSchema = z.strictObject({
|
||||||
|
tags: z.array(z.enum(MEDIA_LIBRARY_TAGS)).min(1),
|
||||||
|
safety: z.enum(['sfw', 'nsfw']),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createMediaLibraryRouter(ctx: WebContext): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/media-library', (_req, res) => {
|
||||||
|
const service = ctx.services.mediaLibrary;
|
||||||
|
if (!service) return res.status(503).json({ error: 'Media library is not initialized' });
|
||||||
|
return res.json(service.list().map(toResponse));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/media-library',
|
||||||
|
express.raw({ type: ['image/jpeg', 'image/png', 'image/webp'], limit: '15mb' }),
|
||||||
|
async (req, res) => {
|
||||||
|
const service = ctx.services.mediaLibrary;
|
||||||
|
if (!service) return res.status(503).json({ error: 'Media library is not initialized' });
|
||||||
|
if (!Buffer.isBuffer(req.body)) return res.status(415).json({ error: 'Upload a JPEG, PNG, or WebP image body' });
|
||||||
|
try {
|
||||||
|
const asset = await service.upload({
|
||||||
|
bytes: req.body,
|
||||||
|
contentType: req.headers['content-type'] ?? '',
|
||||||
|
originalFileName: decodeFileName(req.headers['x-file-name']),
|
||||||
|
consentConfirmed: req.headers['x-consent-confirmed'] === 'true',
|
||||||
|
});
|
||||||
|
return res.status(201).json(toResponse(asset));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof MediaLibraryConflictError) return res.status(409).json({ error: error.message });
|
||||||
|
if (error instanceof MediaLibraryInputError) return res.status(400).json({ error: error.message });
|
||||||
|
ctx.logger.error('Media library upload failed:', error);
|
||||||
|
return res.status(500).json({ error: 'Media library upload failed' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.patch('/media-library/:id', (req, res) => {
|
||||||
|
const service = ctx.services.mediaLibrary;
|
||||||
|
if (!service) return res.status(503).json({ error: 'Media library is not initialized' });
|
||||||
|
const parsed = MediaLibraryUpdateSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.issues[0]?.message ?? 'Invalid update' });
|
||||||
|
try {
|
||||||
|
return res.json(toResponse(service.update(req.params.id, parsed.data)));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof MediaLibraryInputError) return res.status(400).json({ error: error.message });
|
||||||
|
if (error instanceof MediaLibraryNotFoundError) return res.status(404).json({ error: error.message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/media-library/:id', async (req, res) => {
|
||||||
|
const service = ctx.services.mediaLibrary;
|
||||||
|
if (!service) return res.status(503).json({ error: 'Media library is not initialized' });
|
||||||
|
return (await service.delete(req.params.id))
|
||||||
|
? res.json({ success: true })
|
||||||
|
: res.status(404).json({ error: 'Library image not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toResponse(asset: MediaLibraryAsset) {
|
||||||
|
const { relativePath, ...publicAsset } = asset;
|
||||||
|
return {
|
||||||
|
...publicAsset,
|
||||||
|
urlPath: `/library-media/${relativePath.split(/[\\/]/).map(encodeURIComponent).join('/')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeFileName(value: string | string[] | undefined): string {
|
||||||
|
const raw = Array.isArray(value) ? value[0] : value;
|
||||||
|
if (!raw) return 'library-image';
|
||||||
|
try { return decodeURIComponent(raw); } catch { return 'library-image'; }
|
||||||
|
}
|
||||||
@@ -45,6 +45,8 @@ import { createAttentionRouter } from './routes/attention.js';
|
|||||||
import { createOutreachRouter } from './routes/outreach.js';
|
import { createOutreachRouter } from './routes/outreach.js';
|
||||||
import type { AiUsageRepository } from '../database/ai-usage-repository.js';
|
import type { AiUsageRepository } from '../database/ai-usage-repository.js';
|
||||||
import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js';
|
import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js';
|
||||||
|
import type { MediaLibraryService } from '../services/media-library.js';
|
||||||
|
import { createMediaLibraryRouter } from './routes/media-library.js';
|
||||||
|
|
||||||
export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js';
|
export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js';
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@ export class WebServer {
|
|||||||
// Media can contain private chat attachments, so it is intentionally behind
|
// Media can contain private chat attachments, so it is intentionally behind
|
||||||
// the same dashboard authentication middleware as the API and UI.
|
// the same dashboard authentication middleware as the API and UI.
|
||||||
this.app.use('/media', express.static(path.join(this.workspaceDirectory, 'data', 'media'), { index: false }));
|
this.app.use('/media', express.static(path.join(this.workspaceDirectory, 'data', 'media'), { index: false }));
|
||||||
|
this.app.use('/library-media', express.static(path.join(this.workspaceDirectory, 'data', 'media-library'), { index: false }));
|
||||||
this.app.use(express.static(dashboardAssets.directory, { index: false }));
|
this.app.use(express.static(dashboardAssets.directory, { index: false }));
|
||||||
|
|
||||||
const ctx = this.context();
|
const ctx = this.context();
|
||||||
@@ -163,6 +166,7 @@ export class WebServer {
|
|||||||
this.app.use('/api', createChatsRouter(ctx));
|
this.app.use('/api', createChatsRouter(ctx));
|
||||||
this.app.use('/api', createConversationsRouter(ctx));
|
this.app.use('/api', createConversationsRouter(ctx));
|
||||||
this.app.use('/api', createMediaRouter(ctx));
|
this.app.use('/api', createMediaRouter(ctx));
|
||||||
|
this.app.use('/api', createMediaLibraryRouter(ctx));
|
||||||
this.app.use('/api', createControlRouter(ctx));
|
this.app.use('/api', createControlRouter(ctx));
|
||||||
if (this.config.web.debugEnabled) {
|
if (this.config.web.debugEnabled) {
|
||||||
this.app.use('/api', createDebugRouter(ctx));
|
this.app.use('/api', createDebugRouter(ctx));
|
||||||
@@ -204,6 +208,10 @@ export class WebServer {
|
|||||||
this.services.mediaSync = mediaSyncService;
|
this.services.mediaSync = mediaSyncService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMediaLibraryService(mediaLibrary: MediaLibraryService): void {
|
||||||
|
this.services.mediaLibrary = mediaLibrary;
|
||||||
|
}
|
||||||
|
|
||||||
setResponseChecker(responseChecker: ResponseCheckerService): void {
|
setResponseChecker(responseChecker: ResponseCheckerService): void {
|
||||||
this.services.responseChecker = responseChecker;
|
this.services.responseChecker = responseChecker;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function harness(
|
|||||||
rapportTurnsBeforeMediaAsk?: number;
|
rapportTurnsBeforeMediaAsk?: number;
|
||||||
},
|
},
|
||||||
beforeSendLoader?: (conversationId: string, observed: ActiveChat) => Promise<ActiveChat | undefined>,
|
beforeSendLoader?: (conversationId: string, observed: ActiveChat) => Promise<ActiveChat | undefined>,
|
||||||
|
mediaExchange?: { detect(conversationId: string, text: string): string | null; handle(conversationId: string, incomingKey: string, text: string): Promise<object> },
|
||||||
) {
|
) {
|
||||||
const saved = new Map<string, ConversationState>();
|
const saved = new Map<string, ConversationState>();
|
||||||
let lastSaved: ConversationState | undefined;
|
let lastSaved: ConversationState | undefined;
|
||||||
@@ -63,6 +64,7 @@ function harness(
|
|||||||
() => 1_000,
|
() => 1_000,
|
||||||
0,
|
0,
|
||||||
beforeSendLoader ?? (async (_conversationId, observed) => observed),
|
beforeSendLoader ?? (async (_conversationId, observed) => observed),
|
||||||
|
mediaExchange as never,
|
||||||
);
|
);
|
||||||
return { service, chat, ai, states, openers, claims, rateLimiter, getSaved: () => lastSaved };
|
return { service, chat, ai, states, openers, claims, rateLimiter, getSaved: () => lastSaved };
|
||||||
}
|
}
|
||||||
@@ -79,6 +81,22 @@ function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ original
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('conversation automation', () => {
|
describe('conversation automation', () => {
|
||||||
|
it('handles a requested library image through the reply limiter before generating text', async () => {
|
||||||
|
const mediaExchange = {
|
||||||
|
detect: vi.fn().mockReturnValue('generic'),
|
||||||
|
handle: vi.fn().mockResolvedValue({ kind: 'sent', assetId: 'asset-one', requestKind: 'generic' }),
|
||||||
|
};
|
||||||
|
const fixture = harness([], [], 0, { mode: 'r4r', maxAutomatedTurns: 5 }, undefined, mediaExchange);
|
||||||
|
|
||||||
|
const result = await fixture.service.processConversation(incoming(1, 'got any pics?'));
|
||||||
|
|
||||||
|
expect(result?.kind).toBe('sent');
|
||||||
|
expect(fixture.rateLimiter.throttle).toHaveBeenCalledOnce();
|
||||||
|
expect(mediaExchange.handle).toHaveBeenCalledWith('room-alice', 'in-1', 'got any pics?');
|
||||||
|
expect(fixture.ai.generateReply).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.getSaved()).toMatchObject({ askedForMedia: true, automation: { turnsUsed: 0, status: 'active' } });
|
||||||
|
});
|
||||||
|
|
||||||
it('allows only one autonomous media request and then hands off', async () => {
|
it('allows only one autonomous media request and then hands off', async () => {
|
||||||
const { service, chat, getSaved } = harness();
|
const { service, chat, getSaved } = harness();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { HttpResponse, http } from 'msw';
|
||||||
|
import { setupServer } from 'msw/node';
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { MediaLibraryPage } from '../../apps/dashboard/src/features/media-library/media-library-page.js';
|
||||||
|
|
||||||
|
const asset = {
|
||||||
|
id: '019fe789-1b99-7ce1-8402-b3cbc32a445a', tags: ['selfie'], safety: 'sfw',
|
||||||
|
classificationStatus: 'ready', description: 'A face-led self portrait.', confidence: 0.91,
|
||||||
|
fileName: 'portrait.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 640, height: 480,
|
||||||
|
enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
|
||||||
|
sendCount: 0, lastSentAt: null,
|
||||||
|
urlPath: '/library-media/portrait.jpg',
|
||||||
|
};
|
||||||
|
const server = setupServer();
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||||
|
return render(<QueryClientProvider client={client}><MediaLibraryPage /></QueryClientProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
||||||
|
afterEach(() => { cleanup(); server.resetHandlers(); vi.restoreAllMocks(); });
|
||||||
|
afterAll(() => server.close());
|
||||||
|
|
||||||
|
describe('exchange library page', () => {
|
||||||
|
it('keeps images blurred and saves operator tag corrections', async () => {
|
||||||
|
let update: unknown;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/media-library', () => HttpResponse.json([asset])),
|
||||||
|
http.patch('/api/media-library/:id', async ({ request }) => {
|
||||||
|
update = await request.json();
|
||||||
|
return HttpResponse.json({ ...asset, ...(update as object) });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const image = await screen.findByRole('img', { name: /Library image tagged selfie/i });
|
||||||
|
expect(image).toHaveClass('is-blurred');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Reveal' }));
|
||||||
|
expect(image).not.toHaveClass('is-blurred');
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: 'topless' }));
|
||||||
|
await user.selectOptions(screen.getByRole('combobox', { name: 'Safety' }), 'nsfw');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||||
|
await waitFor(() => expect(update).toEqual({ tags: ['selfie', 'topless'], safety: 'nsfw', enabled: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires consent in the UI and uploads the selected image for classification', async () => {
|
||||||
|
let consentHeader: string | null = null;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/media-library', () => HttpResponse.json([])),
|
||||||
|
http.post('/api/media-library', ({ request }) => {
|
||||||
|
consentHeader = request.headers.get('x-consent-confirmed');
|
||||||
|
return HttpResponse.json(asset, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText('No exchange images yet.');
|
||||||
|
const upload = screen.getByRole('button', { name: 'Upload and classify' });
|
||||||
|
expect(upload).toBeDisabled();
|
||||||
|
await user.upload(screen.getByLabelText(/Choose image/), new File(['image'], 'portrait.jpg', { type: 'image/jpeg' }));
|
||||||
|
expect(upload).toBeDisabled();
|
||||||
|
await user.click(screen.getByRole('checkbox', { name: /I confirm every depicted adult consented/i }));
|
||||||
|
expect(upload).toBeEnabled();
|
||||||
|
await user.click(upload);
|
||||||
|
await screen.findByText('Image added. Review its tags before relying on it.');
|
||||||
|
expect(consentHeader).toBe('true');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -107,10 +107,12 @@ describe('Database Client', () => {
|
|||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||||
.all() as { name: string }[];
|
.all() as { name: string }[];
|
||||||
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
|
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
|
||||||
expect(tables.length).toBe(18);
|
expect(tables.length).toBe(20);
|
||||||
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
|
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
|
||||||
'candidate_queue',
|
'candidate_queue',
|
||||||
'runtime_state',
|
'runtime_state',
|
||||||
|
'media_library_assets',
|
||||||
|
'media_library_sends',
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,7 +162,7 @@ describe('Database Client', () => {
|
|||||||
DROP TABLE conversation_automation_claims;
|
DROP TABLE conversation_automation_claims;
|
||||||
DROP TABLE ai_usage;
|
DROP TABLE ai_usage;
|
||||||
`);
|
`);
|
||||||
db.pragma(`user_version = ${latestVersion - 6}`);
|
db.pragma(`user_version = ${latestVersion - 7}`);
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
|
|
||||||
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
|
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
|
||||||
@@ -197,7 +199,7 @@ describe('Database Client', () => {
|
|||||||
'durable reply', 5, 11, 1
|
'durable reply', 5, 11, 1
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
db.pragma(`user_version = ${latestVersion - 1}`);
|
db.pragma(`user_version = ${latestVersion - 2}`);
|
||||||
|
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,38 @@ const creds = {
|
|||||||
capturedAt: Date.now(),
|
capturedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
describe('MatrixClient outbound image media', () => {
|
||||||
|
it('uploads raw bytes and sends an m.image event with a stable transaction ID', async () => {
|
||||||
|
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||||
|
const fetchImpl = ((url: string | URL | Request, init?: RequestInit) => {
|
||||||
|
calls.push({ url: String(url), init });
|
||||||
|
if (String(url).includes('/upload')) {
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ content_uri: 'mxc://reddit.com/image-one' }), {
|
||||||
|
status: 200, headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return Promise.resolve(new Response(JSON.stringify({ event_id: '$image-event' }), {
|
||||||
|
status: 200, headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
}) as typeof fetch;
|
||||||
|
const client = new MatrixClient(creds, createMockLogger(), { minRequestIntervalMs: 0, fetchImpl });
|
||||||
|
|
||||||
|
const mxc = await client.uploadImage(Buffer.from([1, 2, 3]), 'image/jpeg', 'portrait.jpg');
|
||||||
|
await expect(client.sendImageMessage(
|
||||||
|
'!room:reddit.com', 'portrait.jpg', mxc,
|
||||||
|
{ mimeType: 'image/jpeg', size: 3, width: 10, height: 20 }, 'stable-image-txn',
|
||||||
|
)).resolves.toBe('$image-event');
|
||||||
|
|
||||||
|
expect(calls[0].url).toContain('/_matrix/media/v3/upload?filename=portrait.jpg');
|
||||||
|
expect(new Headers(calls[0].init?.headers).get('content-type')).toBe('image/jpeg');
|
||||||
|
expect(calls[1].url).toContain('/stable-image-txn');
|
||||||
|
expect(JSON.parse(String(calls[1].init?.body))).toEqual({
|
||||||
|
msgtype: 'm.image', body: 'portrait.jpg', url: 'mxc://reddit.com/image-one',
|
||||||
|
info: { mimetype: 'image/jpeg', size: 3, w: 10, h: 20 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('MatrixClient HTTP timeout', () => {
|
describe('MatrixClient HTTP timeout', () => {
|
||||||
it('loads an encoded Matrix user profile with bearer authentication', async () => {
|
it('loads an encoded Matrix user profile with bearer authentication', async () => {
|
||||||
let requestedUrl = '';
|
let requestedUrl = '';
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { MediaLibraryRepository } from '../src/database/media-library-repository.js';
|
||||||
|
import { detectMediaRequest, MediaLibraryExchangeService } from '../src/services/media-library-exchange.js';
|
||||||
|
import type { MediaLibraryTag } from '../src/services/media-library-types.js';
|
||||||
|
import { createTestDb } from './testUtils.js';
|
||||||
|
|
||||||
|
describe('media library request detection', () => {
|
||||||
|
it.each([
|
||||||
|
['send a dick pic', 'penis'],
|
||||||
|
['can I see you topless?', 'topless'],
|
||||||
|
['got a selfie?', 'selfie'],
|
||||||
|
['what do you look like?', 'generic'],
|
||||||
|
['got any pics?', 'generic'],
|
||||||
|
])('classifies %s as %s', (message, expected) => {
|
||||||
|
expect(detectMediaRequest(message)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat an unrelated show-me instruction as an image request', () => {
|
||||||
|
expect(detectMediaRequest('show me how this works')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MediaLibraryExchangeService', () => {
|
||||||
|
let directory: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'redditbot-exchange-'));
|
||||||
|
});
|
||||||
|
afterEach(async () => fs.rm(directory, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it('starts generic requests at SFW, broadens progressively, and never reuses an asset', async () => {
|
||||||
|
const db = createTestDb();
|
||||||
|
const repository = new MediaLibraryRepository(db);
|
||||||
|
await addAsset(repository, directory, 'selfie', ['selfie'], 'sfw', 1);
|
||||||
|
await addAsset(repository, directory, 'feet', ['feet'], 'sfw', 2);
|
||||||
|
await addAsset(repository, directory, 'topless', ['topless'], 'nsfw', 3);
|
||||||
|
await addAsset(repository, directory, 'penis', ['penis'], 'nsfw', 4);
|
||||||
|
const sentAssets: string[] = [];
|
||||||
|
const chat = {
|
||||||
|
sendChatImage: vi.fn(async (_room: string, image: { fileName: string }) => {
|
||||||
|
sentAssets.push(image.fileName.split('.')[0]);
|
||||||
|
return { eventId: `$${sentAssets.length}`, mxcUri: `mxc://test/${sentAssets.length}` };
|
||||||
|
}),
|
||||||
|
sendChatMessage: vi.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory, false, () => 0);
|
||||||
|
|
||||||
|
expect((await service.handle('room', 'one', 'got any pics?')).kind).toBe('sent');
|
||||||
|
expect(sentAssets[0]).toMatch(/selfie|feet/);
|
||||||
|
expect((await service.handle('room', 'two', 'more')).kind).toBe('sent');
|
||||||
|
expect(sentAssets[1]).not.toBe(sentAssets[0]);
|
||||||
|
expect(sentAssets[1]).not.toBe('penis');
|
||||||
|
expect((await service.handle('room', 'three', 'show me your penis')).kind).toBe('sent');
|
||||||
|
expect(sentAssets[2]).toBe('penis');
|
||||||
|
expect(new Set(sentAssets).size).toBe(sentAssets.length);
|
||||||
|
expect(chat.sendChatImage.mock.invocationCallOrder[0]).toBeLessThan(chat.sendChatMessage.mock.invocationCallOrder[0]);
|
||||||
|
expect(repository.get('penis')).toMatchObject({ sendCount: 1, lastSentAt: expect.any(Number) });
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escalates instead of substituting or reusing when no exact match remains', async () => {
|
||||||
|
const db = createTestDb();
|
||||||
|
const repository = new MediaLibraryRepository(db);
|
||||||
|
await addAsset(repository, directory, 'only-penis', ['penis'], 'nsfw', 1, 'same-content');
|
||||||
|
const chat = {
|
||||||
|
sendChatImage: vi.fn().mockResolvedValue({ eventId: '$one', mxcUri: 'mxc://one' }),
|
||||||
|
sendChatMessage: vi.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory);
|
||||||
|
expect((await service.handle('room', 'one', 'penis pic')).kind).toBe('sent');
|
||||||
|
repository.softDelete('only-penis');
|
||||||
|
await addAsset(repository, directory, 'reuploaded-penis', ['penis'], 'nsfw', 2, 'same-content');
|
||||||
|
expect(await service.handle('room', 'two', 'another penis pic')).toMatchObject({ kind: 'human-required' });
|
||||||
|
expect(chat.sendChatImage).toHaveBeenCalledOnce();
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function addAsset(
|
||||||
|
repository: MediaLibraryRepository,
|
||||||
|
directory: string,
|
||||||
|
id: string,
|
||||||
|
tags: MediaLibraryTag[],
|
||||||
|
safety: 'sfw' | 'nsfw',
|
||||||
|
createdAt: number,
|
||||||
|
sha256 = `hash-${id}`,
|
||||||
|
) {
|
||||||
|
const relativePath = `${id}.jpg`;
|
||||||
|
await fs.writeFile(path.join(directory, relativePath), Buffer.from(id));
|
||||||
|
repository.create({
|
||||||
|
id, tags, safety, classificationStatus: 'ready', description: id, confidence: 1,
|
||||||
|
fileName: relativePath, relativePath, mimeType: 'image/jpeg', sizeBytes: id.length,
|
||||||
|
width: 10, height: 10, sha256, enabled: true,
|
||||||
|
consentConfirmedAt: createdAt, createdAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function logger() {
|
||||||
|
return { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() } as never;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { MediaLibraryRepository } from '../src/database/media-library-repository.js';
|
||||||
|
import { MediaLibraryConflictError, MediaLibraryService } from '../src/services/media-library.js';
|
||||||
|
import { createTestDb } from './testUtils.js';
|
||||||
|
|
||||||
|
describe('MediaLibraryService', () => {
|
||||||
|
let directory: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'redditbot-library-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fs.rm(directory, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-encodes uploads without metadata and stores editable AI classifications', async () => {
|
||||||
|
const db = createTestDb();
|
||||||
|
const repository = new MediaLibraryRepository(db);
|
||||||
|
const classifyLibraryImage = vi.fn().mockResolvedValue({
|
||||||
|
tags: ['selfie', 'topless'], safety: 'nsfw', description: 'A self portrait.', confidence: 0.92,
|
||||||
|
});
|
||||||
|
const service = new MediaLibraryService(repository, { classifyLibraryImage }, logger(), directory, () => 1234);
|
||||||
|
const source = await sharp({ create: { width: 8, height: 6, channels: 3, background: '#cc8844' } })
|
||||||
|
.withMetadata({ orientation: 6 })
|
||||||
|
.jpeg()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const asset = await service.upload({
|
||||||
|
bytes: source,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
originalFileName: '../private portrait.jpg',
|
||||||
|
consentConfirmed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(asset).toMatchObject({
|
||||||
|
tags: ['selfie', 'topless'], safety: 'nsfw', classificationStatus: 'ready', enabled: true,
|
||||||
|
consentConfirmedAt: 1234,
|
||||||
|
});
|
||||||
|
expect(classifyLibraryImage).toHaveBeenCalledOnce();
|
||||||
|
const outputMetadata = await sharp(service.resolvePath(asset)).metadata();
|
||||||
|
expect(outputMetadata.exif).toBeUndefined();
|
||||||
|
expect(outputMetadata.orientation).toBeUndefined();
|
||||||
|
|
||||||
|
expect(service.update(asset.id, { tags: ['selfie'], safety: 'sfw', enabled: false }))
|
||||||
|
.toMatchObject({ tags: ['selfie'], safety: 'sfw', enabled: false });
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps failed or low-confidence classifications disabled and rejects duplicate content', async () => {
|
||||||
|
const db = createTestDb();
|
||||||
|
const repository = new MediaLibraryRepository(db);
|
||||||
|
const source = await sharp({ create: { width: 4, height: 4, channels: 3, background: '#123456' } }).png().toBuffer();
|
||||||
|
const classifier = { classifyLibraryImage: vi.fn().mockRejectedValue(new Error('refused')) };
|
||||||
|
const service = new MediaLibraryService(repository, classifier, logger(), directory);
|
||||||
|
|
||||||
|
const first = await service.upload({ bytes: source, contentType: 'image/png', originalFileName: 'one.png', consentConfirmed: true });
|
||||||
|
expect(first).toMatchObject({ classificationStatus: 'failed', safety: 'unknown', tags: [], enabled: false });
|
||||||
|
await expect(service.upload({ bytes: source, contentType: 'image/png', originalFileName: 'two.png', consentConfirmed: true }))
|
||||||
|
.rejects.toBeInstanceOf(MediaLibraryConflictError);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function logger() {
|
||||||
|
return { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() } as never;
|
||||||
|
}
|
||||||
@@ -30,6 +30,18 @@ describe('OpenRouterClient', () => {
|
|||||||
client = new OpenRouterClient(config, logger, mockOpenAI);
|
client = new OpenRouterClient(config, logger, mockOpenAI);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses strict multi-tag media-library classifications', async () => {
|
||||||
|
mockCreate.mockResolvedValueOnce({
|
||||||
|
model: 'vision-model',
|
||||||
|
choices: [{ message: { content: '```json\n{"tags":["selfie","topless"],"safety":"nsfw","description":"A self portrait","confidence":0.88}\n```' } }],
|
||||||
|
usage: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.classifyLibraryImage('base64data', 'image/jpeg')).resolves.toEqual({
|
||||||
|
tags: ['selfie', 'topless'], safety: 'nsfw', description: 'A self portrait', confidence: 0.88,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isRefusal (internal)', () => {
|
describe('isRefusal (internal)', () => {
|
||||||
// We test refusal detection by checking the analyzeImage behavior
|
// We test refusal detection by checking the analyzeImage behavior
|
||||||
// since isRefusal is private. We do this by mocking responses that
|
// since isRefusal is private. We do this by mocking responses that
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ function createWorkspace(includeReactBuild = true): string {
|
|||||||
fs.writeFileSync(path.join(workspace, 'public', 'index.html'), '<h1>Legacy dashboard</h1>');
|
fs.writeFileSync(path.join(workspace, 'public', 'index.html'), '<h1>Legacy dashboard</h1>');
|
||||||
fs.mkdirSync(path.join(workspace, 'data', 'media'), { recursive: true });
|
fs.mkdirSync(path.join(workspace, 'data', 'media'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(workspace, 'data', 'media', 'sample.txt'), 'private media');
|
fs.writeFileSync(path.join(workspace, 'data', 'media', 'sample.txt'), 'private media');
|
||||||
|
fs.mkdirSync(path.join(workspace, 'data', 'media-library'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(workspace, 'data', 'media-library', 'library.txt'), 'private library media');
|
||||||
if (includeReactBuild) {
|
if (includeReactBuild) {
|
||||||
fs.mkdirSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'assets'), { recursive: true });
|
fs.mkdirSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'assets'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'index.html'), '<h1>React dashboard</h1><script src="/assets/app.js"></script>');
|
fs.writeFileSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'index.html'), '<h1>React dashboard</h1><script src="/assets/app.js"></script>');
|
||||||
@@ -81,19 +83,22 @@ describe('authenticated React dashboard cutover', () => {
|
|||||||
fetch(`${running.baseUrl}/api/stats`),
|
fetch(`${running.baseUrl}/api/stats`),
|
||||||
fetch(`${running.baseUrl}/api/events/stream`),
|
fetch(`${running.baseUrl}/api/events/stream`),
|
||||||
fetch(`${running.baseUrl}/media/sample.txt`),
|
fetch(`${running.baseUrl}/media/sample.txt`),
|
||||||
|
fetch(`${running.baseUrl}/library-media/library.txt`),
|
||||||
fetch(running.baseUrl, { redirect: 'manual' }),
|
fetch(running.baseUrl, { redirect: 'manual' }),
|
||||||
]);
|
]);
|
||||||
expect(anonymous.map((response) => response.status)).toEqual([401, 401, 401, 302]);
|
expect(anonymous.map((response) => response.status)).toEqual([401, 401, 401, 401, 302]);
|
||||||
|
|
||||||
const headers = { Authorization: `Bearer ${AUTH_TOKEN}` };
|
const headers = { Authorization: `Bearer ${AUTH_TOKEN}` };
|
||||||
const api = await fetch(`${running.baseUrl}/api/stats`, { headers });
|
const api = await fetch(`${running.baseUrl}/api/stats`, { headers });
|
||||||
const media = await fetch(`${running.baseUrl}/media/sample.txt`, { headers });
|
const media = await fetch(`${running.baseUrl}/media/sample.txt`, { headers });
|
||||||
|
const libraryMedia = await fetch(`${running.baseUrl}/library-media/library.txt`, { headers });
|
||||||
const root = await fetch(running.baseUrl, { headers });
|
const root = await fetch(running.baseUrl, { headers });
|
||||||
const asset = await fetch(`${running.baseUrl}/assets/app.js`, { headers });
|
const asset = await fetch(`${running.baseUrl}/assets/app.js`, { headers });
|
||||||
const spa = await fetch(`${running.baseUrl}/conversations/example`, { headers: { ...headers, Accept: 'text/html' } });
|
const spa = await fetch(`${running.baseUrl}/conversations/example`, { headers: { ...headers, Accept: 'text/html' } });
|
||||||
|
|
||||||
expect(api.status).toBe(200);
|
expect(api.status).toBe(200);
|
||||||
expect(await media.text()).toBe('private media');
|
expect(await media.text()).toBe('private media');
|
||||||
|
expect(await libraryMedia.text()).toBe('private library media');
|
||||||
expect(await root.text()).toContain('React dashboard');
|
expect(await root.text()).toContain('React dashboard');
|
||||||
expect(await asset.text()).toBe('react-build');
|
expect(await asset.text()).toBe('react-build');
|
||||||
expect(await spa.text()).toContain('React dashboard');
|
expect(await spa.text()).toContain('React dashboard');
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import type { AddressInfo } from 'node:net';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { MediaLibraryAssetSchema } from '../../packages/api-contracts/src/media.js';
|
||||||
|
import type { WebContext } from '../../src/web/context.js';
|
||||||
|
import { createMediaLibraryRouter } from '../../src/web/routes/media-library.js';
|
||||||
|
|
||||||
|
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('media library routes', () => {
|
||||||
|
it('lists private assets without exposing their filesystem path', async () => {
|
||||||
|
const asset = libraryAsset();
|
||||||
|
const context = { services: { mediaLibrary: { list: () => [asset] } } } as unknown as WebContext;
|
||||||
|
const response = await request(express().use('/api', createMediaLibraryRouter(context)), '/api/media-library');
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(MediaLibraryAssetSchema.parse(body[0])).toMatchObject({ id: asset.id, urlPath: `/library-media/${asset.relativePath}` });
|
||||||
|
expect(body[0]).not.toHaveProperty('relativePath');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires consent and passes a raw image to classification', async () => {
|
||||||
|
const upload = vi.fn().mockResolvedValue(libraryAsset());
|
||||||
|
const context = { logger: { error: vi.fn() }, services: { mediaLibrary: { upload } } } as unknown as WebContext;
|
||||||
|
const app = express().use('/api', createMediaLibraryRouter(context));
|
||||||
|
|
||||||
|
const response = await request(app, '/api/media-library', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/jpeg',
|
||||||
|
'X-File-Name': encodeURIComponent('portrait.jpg'),
|
||||||
|
'X-Consent-Confirmed': 'true',
|
||||||
|
},
|
||||||
|
body: Uint8Array.from([1, 2, 3]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(upload).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
contentType: 'image/jpeg', originalFileName: 'portrait.jpg', consentConfirmed: true,
|
||||||
|
}));
|
||||||
|
expect(Buffer.isBuffer(upload.mock.calls[0][0].bytes)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates tag corrections before updating', async () => {
|
||||||
|
const update = vi.fn();
|
||||||
|
const context = { services: { mediaLibrary: { update } } } as unknown as WebContext;
|
||||||
|
const app = express().use(express.json()).use('/api', createMediaLibraryRouter(context));
|
||||||
|
const response = await request(app, `/api/media-library/${libraryAsset().id}`, {
|
||||||
|
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tags: [], safety: 'sfw', enabled: true }),
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function request(app: express.Express, pathname: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const server = app.listen(0);
|
||||||
|
servers.push(server);
|
||||||
|
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
return fetch(`http://127.0.0.1:${port}${pathname}`, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
function libraryAsset() {
|
||||||
|
return {
|
||||||
|
id: '019fe789-1b99-7ce1-8402-b3cbc32a445a', tags: ['selfie'] as const, safety: 'sfw' as const,
|
||||||
|
classificationStatus: 'ready' as const, description: 'Portrait', confidence: 0.9,
|
||||||
|
fileName: 'portrait.jpg', relativePath: '019fe789-1b99-7ce1-8402-b3cbc32a445a.jpg', mimeType: 'image/jpeg',
|
||||||
|
sizeBytes: 100, width: 10, height: 10, enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
|
||||||
|
sendCount: 0, lastSentAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user