diff --git a/apps/dashboard/src/app/navigation.ts b/apps/dashboard/src/app/navigation.ts index 9bae497..ca7400a 100644 --- a/apps/dashboard/src/app/navigation.ts +++ b/apps/dashboard/src/app/navigation.ts @@ -10,6 +10,7 @@ export const navigationItems: readonly NavigationItem[] = [ { label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery' }, { label: 'Conversations', path: '/conversations', description: 'Inbox and message history' }, { 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: 'Operations', path: '/operations', description: 'Jobs, events, and diagnostics' }, { label: 'Settings', path: '/settings', description: 'Runtime and access configuration' }, diff --git a/apps/dashboard/src/app/router.tsx b/apps/dashboard/src/app/router.tsx index 1541096..8d32f7d 100644 --- a/apps/dashboard/src/app/router.tsx +++ b/apps/dashboard/src/app/router.tsx @@ -9,6 +9,7 @@ import { SettingsRoute } from '../features/settings/settings-route.js'; import { RouteErrorPage } from './route-error-page.js'; import { AttentionPage } from '../features/attention/attention-page.js'; import { OutreachPage } from '../features/outreach/outreach-page.js'; +import { MediaLibraryPage } from '../features/media-library/media-library-page.js'; export function createDashboardRoutes(): RouteObject[] { return [ @@ -23,6 +24,7 @@ export function createDashboardRoutes(): RouteObject[] { { path: 'conversations', element: }, { path: 'conversations/:conversationId', element: }, { path: 'media', element: }, + { path: 'media-library', element: }, { path: 'performance', element: }, { path: 'operations', element: }, { path: 'settings', element: }, diff --git a/apps/dashboard/src/features/media-library/api.ts b/apps/dashboard/src/features/media-library/api.ts new file mode 100644 index 0000000..5de849b --- /dev/null +++ b/apps/dashboard/src/features/media-library/api.ts @@ -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(response: Response, schema: ZodType): Promise { + 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 { + return dashboardRequest('/api/media-library').then((response) => parseResponse(response, MediaLibraryResponseSchema)); +} + +export function uploadMediaLibraryImage(file: File): Promise { + 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 { + 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 { + 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})`); + } +} diff --git a/apps/dashboard/src/features/media-library/media-library-page.tsx b/apps/dashboard/src/features/media-library/media-library-page.tsx new file mode 100644 index 0000000..48105ef --- /dev/null +++ b/apps/dashboard/src/features/media-library/media-library-page.tsx @@ -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(null); + const [consent, setConsent] = useState(false); + + if (query.isPending) return
Loading exchange library…
; + if (query.isError) return
Exchange library unavailable{query.error.message}
; + + const assets = query.data ?? []; + return ( +
+
+
+ Operator-owned media +

Exchange library

+

Add consented images, review the automatic anatomy classification, and control what the bot may send after a request.

+
+
+ +
+
First askRandom SFWSelfie, chest, or feet
+
Asked againBroader revealSFW or topless
+
Further askFull libraryStill exact-match when named
+
+ +
+
+ Add an image +

Strip metadata and classify

+

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.

+
+ + + + {upload.isError ?
{upload.error.message}
: null} + {upload.isSuccess ?
Image added. Review its tags before relying on it.
: null} +
+ +
+
Available inventory

{assets.length} {assets.length === 1 ? 'image' : 'images'}

+ Images remain private and blurred until revealed. +
+ {assets.length === 0 ? ( +
No exchange images yet.Add the first consented image above.
+ ) : ( +
+ {assets.map((asset) => )} +
+ )} +
+ ); +} + +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(() => ({ + 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 ( +
+
+ {`Library + + {asset.safety.toUpperCase()} +
+
+
+ {asset.fileName} + {asset.enabled ? 'Enabled' : 'Disabled'} +
+ {needsReview ?
Review the AI result before enabling this image.
: null} + {asset.description ?

{asset.description}

:

AI classification was unavailable. Add tags manually.

} + {asset.width} × {asset.height} · {formatBytes(asset.sizeBytes)}{asset.confidence === null ? '' : ` · ${Math.round(asset.confidence * 100)}% confidence`} + {asset.sendCount === 0 ? 'Never sent' : `Sent ${asset.sendCount} ${asset.sendCount === 1 ? 'time' : 'times'}${asset.lastSentAt ? ` · last ${formatDate(asset.lastSentAt)}` : ''}`} +
+ Anatomy tags +
+ {TAGS.map((tag) => )} +
+
+
+ + +
+ {updateMutation.isError ?
{updateMutation.error.message}
: null} + {deleteMutation.isError ?
{deleteMutation.error.message}
: null} +
+ + {confirmDelete ? <> : } +
+
+
+ ); +} + +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); +} diff --git a/apps/dashboard/src/features/media-library/media-library.css b/apps/dashboard/src/features/media-library/media-library.css new file mode 100644 index 0000000..dddcf90 --- /dev/null +++ b/apps/dashboard/src/features/media-library/media-library.css @@ -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; } } diff --git a/apps/dashboard/src/features/media-library/queries.ts b/apps/dashboard/src/features/media-library/queries.ts new file mode 100644 index 0000000..2aac6ce --- /dev/null +++ b/apps/dashboard/src/features/media-library/queries.ts @@ -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 }), + }); +} diff --git a/docs/api.md b/docs/api.md index 535ba21..6871e4b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -80,6 +80,10 @@ an administrator credential. | `GET` | `/api/media/sync` | Get media sync state | read | no | | `POST` | `/api/media/sync` | Trigger 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/stop` | Stop bot processing (legacy) | admin | yes | | `GET` | `/api/timers` | Get rate-limit timers | read | no | diff --git a/docs/architecture.md b/docs/architecture.md index 40ee5d9..5730028 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,7 +58,8 @@ SIGINT/SIGTERM abort shared loops, stop account runtimes, stop the web server, c - Reddit request coordinator (shared across runtime construction); - recipient history/reservations across accounts; - 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 @@ -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 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. +- 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 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. diff --git a/docs/data-flow.md b/docs/data-flow.md index 3a51f21..e4083ae 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -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. +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. ## Dashboard data and events diff --git a/docs/database.md b/docs/database.md index 3bb02be..37f1f20 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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_account_sync_state`: account-scoped sync cursor, full/delta timestamps, and invitation cooldown. - `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. @@ -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. 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. +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 diff --git a/docs/frontend.md b/docs/frontend.md index e8f5a53..ab4ad9b 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -16,6 +16,7 @@ Shared HTTP/SSE schemas live in `packages/api-contracts`. The server and client | `/conversations` | Searchable account-aware conversation inbox. | | `/conversations/:conversationId` | Conversation history, context, suggestions, and guarded sending. | | `/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. | | `/operations` | Background jobs, sync state, events, rate limits, and technical logs. | | `/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. +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 Each route is a slice under `features//` 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/`. diff --git a/docs/product-and-safety.md b/docs/product-and-safety.md index 1b92820..f105c11 100644 --- a/docs/product-and-safety.md +++ b/docs/product-and-safety.md @@ -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. +## 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 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 -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. diff --git a/docs/services.md b/docs/services.md index 9ebb5a5..b113e47 100644 --- a/docs/services.md +++ b/docs/services.md @@ -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/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-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 diff --git a/package-lock.json b/package-lock.json index 1f87c07..16c4c5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.1", + "sharp": "^0.35.3", "winston": "^3.19.0", "yaml": "^2.8.2", "zod": "^4.3.5" @@ -601,6 +602,16 @@ "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": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -1073,6 +1084,554 @@ "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": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", @@ -2790,6 +3349,15 @@ "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": { "version": "0.5.16", "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==", "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -4686,6 +5266,55 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5032,6 +5661,13 @@ "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": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", diff --git a/package.json b/package.json index 996875e..2317bb5 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.13.1", + "sharp": "^0.35.3", "winston": "^3.19.0", "yaml": "^2.8.2", "zod": "^4.3.5" diff --git a/packages/api-contracts/src/media.ts b/packages/api-contracts/src/media.ts index bed0018..45a9607 100644 --- a/packages/api-contracts/src/media.ts +++ b/packages/api-contracts/src/media.ts @@ -32,3 +32,35 @@ export const MediaSyncTriggerResponseSchema = z.union([ export type MediaItem = z.infer; export type MediaSyncStatus = z.infer; + +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; +export type MediaLibraryAsset = z.infer; +export type MediaLibraryUpdate = z.infer; diff --git a/packages/api-contracts/tests/contracts.test.ts b/packages/api-contracts/tests/contracts.test.ts index 3afd21f..a8d987e 100644 --- a/packages/api-contracts/tests/contracts.test.ts +++ b/packages/api-contracts/tests/contracts.test.ts @@ -14,6 +14,7 @@ import { LoginStatusResponseSchema, LegacyDashboardStreamEventSchema, MediaResponseSchema, + MediaLibraryAssetSchema, MediaSyncStatusSchema, MessagesResponseSchema, ProcessedPostsResponseSchema, @@ -215,6 +216,18 @@ describe('dashboard endpoint contracts', () => { 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', () => { expect(LoginStatusResponseSchema.safeParse({ ready: true, loggedIn: true, status: 'authenticated', diff --git a/src/ai/openrouter.ts b/src/ai/openrouter.ts index 85e7bc6..6d3984a 100644 --- a/src/ai/openrouter.ts +++ b/src/ai/openrouter.ts @@ -1,4 +1,5 @@ import OpenAI from 'openai'; +import { z } from 'zod'; import type { Config } from '../config/index.js'; import type { Logger } from '../utils/logger.js'; import type { AiUsageRecordInput, AiUsageOutcome } from '../database/ai-usage-repository.js'; @@ -10,6 +11,10 @@ import { type ConversationState, } from '../services/conversation-state.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'; @@ -142,6 +147,61 @@ export class OpenRouterClient { throw new Error('AI_REFUSAL: All models refused to analyze image'); } + async classifyLibraryImage(imageBase64: string, mimeType: string): Promise { + 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 { 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 @@ -684,3 +744,18 @@ Just write the message, nothing else.`; function numericUsage(value: unknown): number { 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)); +} diff --git a/src/bootstrap.ts b/src/bootstrap.ts index dcc5038..d8d70da 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -52,6 +52,8 @@ import { importLegacyAccountContent } from './database/legacy-content-import.js' import { RecipientDeduplicator } from './services/recipient-deduplicator.js'; import { createConfigBackedSubredditPollingControl } from './services/subreddit-polling-control.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 { config: Config; @@ -82,6 +84,7 @@ export interface AppContainer { mediaService: MediaService; chatService: ChatService; mediaSyncService: MediaSyncService; + mediaLibraryService: MediaLibraryService; rateLimiter: RateLimiter; webServer: WebServer | null; @@ -178,6 +181,11 @@ export async function bootstrap( const aiClient = new OpenRouterClient(config, logger, undefined, (usage) => { aiUsageRepo.record({ ...usage, accountId: accountAuth.getSnapshot().accountId }); }); + const mediaLibraryService = new MediaLibraryService( + new MediaLibraryRepository(db), + aiClient, + logger, + ); const monitorService = new MonitorService( redditApi, postsRepo, recipientDeduplicator, config, logger, 1000, createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger), @@ -427,6 +435,7 @@ export async function bootstrap( }); webServer.setMediaService(mediaService); webServer.setMediaSyncService(mediaSyncService); + webServer.setMediaLibraryService(mediaLibraryService); webServer.setResponseChecker(responseChecker); webServer.setRateLimiter(rateLimiter); webServer.setAiClient(aiClient); @@ -483,6 +492,7 @@ export async function bootstrap( mediaService, chatService, mediaSyncService, + mediaLibraryService, rateLimiter, webServer, }; diff --git a/src/database/client.ts b/src/database/client.ts index 6675d87..dd59a2c 100644 --- a/src/database/client.ts +++ b/src/database/client.ts @@ -364,6 +364,54 @@ export function runMigrations(db: Database.Database): void { CREATE INDEX idx_conversation_automation_claims_status 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; diff --git a/src/database/media-library-repository.ts b/src/database/media-library-repository.ts new file mode 100644 index 0000000..db9c497 --- /dev/null +++ b/src/database/media-library-repository.ts @@ -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, 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, + }; +} diff --git a/src/services/chat/matrix/client.ts b/src/services/chat/matrix/client.ts index 01545f5..f2747c9 100644 --- a/src/services/chat/matrix/client.ts +++ b/src/services/chat/matrix/client.ts @@ -528,6 +528,87 @@ export class MatrixClient { 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 { + 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 { + 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. * Reddit's media is served from the same Matrix server. diff --git a/src/services/chat/service.ts b/src/services/chat/service.ts index 8589e59..671bcd3 100644 --- a/src/services/chat/service.ts +++ b/src/services/chat/service.ts @@ -582,7 +582,7 @@ export class ChatService { * `https://chat.reddit.com/room/` URL (web UI compatibility) or * a raw Matrix room ID like `!abc:reddit.com`. */ - async sendChatMessage(chatUrlOrRoomId: string, message: string): Promise { + async sendChatMessage(chatUrlOrRoomId: string, message: string, txnId?: string): Promise { if (!this.accountAuth?.isFullyAuthenticated()) return false; const active = this.getInvitationCooldown(); if (active && this.blocksExistingRoomSends(active)) { @@ -599,7 +599,7 @@ export class ChatService { this.logger.error(`[chat] cannot extract room ID from "${chatUrlOrRoomId}"`); 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})`); const chat = this.cache.get().find((c) => c.roomId === roomId); 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 * line. Returns the new chat (or null on failure). diff --git a/src/services/conversation-automation.ts b/src/services/conversation-automation.ts index 0242f05..2d76f5c 100644 --- a/src/services/conversation-automation.ts +++ b/src/services/conversation-automation.ts @@ -20,6 +20,7 @@ import { type ConversationState, } from './conversation-state.js'; import { containsMediaRequest } from './conversation-boundaries.js'; +import type { MediaLibraryExchangeService } from './media-library-exchange.js'; export type ConversationAutomationResultKind = | 'sent' @@ -94,6 +95,7 @@ export class ConversationAutomationService { private readonly now: () => number = Date.now, private readonly pendingReplyRetryDelayMs = PENDING_REPLY_RETRY_DELAY_MS, private readonly beforeSendLoader?: ConversationBeforeSendLoader, + private readonly mediaExchange?: MediaLibraryExchangeService, ) {} async processPendingConversations(signal?: AbortSignal): Promise { @@ -260,6 +262,52 @@ export class ConversationAutomationService { 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) { return this.finish( conversation, diff --git a/src/services/media-library-exchange.ts b/src/services/media-library-exchange.ts new file mode 100644 index 0000000..b52b038 --- /dev/null +++ b/src/services/media-library-exchange.ts @@ -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; +} + +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 { + 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, + 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(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); +} diff --git a/src/services/media-library-types.ts b/src/services/media-library-types.ts new file mode 100644 index 0000000..fbf6b1c --- /dev/null +++ b/src/services/media-library-types.ts @@ -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; + 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; +} diff --git a/src/services/media-library.ts b/src/services/media-library.ts new file mode 100644 index 0000000..8d26009 --- /dev/null +++ b/src/services/media-library.ts @@ -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; +} + +export interface MediaLibraryUpload { + bytes: Buffer; + contentType: string; + originalFileName: string; + consentConfirmed: boolean; +} + +export interface MediaLibraryUpdate { + tags: MediaLibraryTag[]; + safety: Exclude; + 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 { + 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 { + 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'; +} diff --git a/src/services/reddit-account-runtime-builder.ts b/src/services/reddit-account-runtime-builder.ts index 3439fa8..8cf30c8 100644 --- a/src/services/reddit-account-runtime-builder.ts +++ b/src/services/reddit-account-runtime-builder.ts @@ -44,6 +44,8 @@ import { ConfigStore } from '../config/persistence.js'; import { createConfigBackedSubredditPollingControl } from './subreddit-polling-control.js'; import { ConversationAutomationService } from './conversation-automation.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 { config: Config; @@ -193,6 +195,14 @@ async function buildRuntime( sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since), }); await rateLimiter.initialize(); + const mediaExchange = new MediaLibraryExchangeService( + new MediaLibraryRepository(db), + chatService, + logger, + session.accountId, + undefined, + accountConfig.browser.dryRun, + ); const conversationAutomation = new ConversationAutomationService( chatService, aiClient, @@ -205,6 +215,10 @@ async function buildRuntime( accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerKeywords : [], accountConfig.reddit.filterSellers ? accountConfig.reddit.sellerDomains : [], () => accountConfig.reddit.subreddits, + Date.now, + undefined, + undefined, + mediaExchange, ); const resources: RedditAccountRuntimeResources = { diff --git a/src/web/api-catalog.ts b/src/web/api-catalog.ts index c401e0e..1c9914c 100644 --- a/src/web/api-catalog.ts +++ b/src/web/api-catalog.ts @@ -88,6 +88,16 @@ export const API_OPERATIONS: readonly ApiOperation[] = [ operation('getMediaSync', 'GET', '/api/media/sync', 'Get media sync state', ['Media']), operation('triggerMediaSync', 'POST', '/api/media/sync', 'Trigger 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('stopBot', 'POST', '/api/stop', 'Stop bot processing (legacy)', ['Control']), operation('getTimers', 'GET', '/api/timers', 'Get rate-limit timers', ['Analytics']), diff --git a/src/web/auth.ts b/src/web/auth.ts index 634a38f..509c5fe 100644 --- a/src/web/auth.ts +++ b/src/web/auth.ts @@ -56,7 +56,7 @@ export function createDashboardAuth(token: string, agentTokens: AgentTokenOption res.status(403).json({ error: 'Agent credential lacks the required access' }); 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'); return; } diff --git a/src/web/context.ts b/src/web/context.ts index 1b3c604..e09a7dc 100644 --- a/src/web/context.ts +++ b/src/web/context.ts @@ -24,6 +24,7 @@ import type { RedditAccountsService } from '../services/reddit-accounts.js'; import type { RedditAccountRuntimeManager } from '../services/reddit-account-runtime.js'; import type { AiUsageRepository } from '../database/ai-usage-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. */ export interface WebContext { @@ -56,6 +57,7 @@ export interface WebContext { redditAccounts?: RedditAccountsService; accountRuntimes?: RedditAccountRuntimeManager; candidateQueue?: CandidateQueueRepository; + mediaLibrary?: MediaLibraryService; }; /** Bot pause/resume toggle read by the main loop. */ diff --git a/src/web/routes/media-library.ts b/src/web/routes/media-library.ts new file mode 100644 index 0000000..20be339 --- /dev/null +++ b/src/web/routes/media-library.ts @@ -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'; } +} diff --git a/src/web/server.ts b/src/web/server.ts index 00674d2..327af40 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -45,6 +45,8 @@ import { createAttentionRouter } from './routes/attention.js'; import { createOutreachRouter } from './routes/outreach.js'; import type { AiUsageRepository } from '../database/ai-usage-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'; @@ -153,6 +155,7 @@ export class WebServer { // Media can contain private chat attachments, so it is intentionally behind // 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('/library-media', express.static(path.join(this.workspaceDirectory, 'data', 'media-library'), { index: false })); this.app.use(express.static(dashboardAssets.directory, { index: false })); const ctx = this.context(); @@ -163,6 +166,7 @@ export class WebServer { this.app.use('/api', createChatsRouter(ctx)); this.app.use('/api', createConversationsRouter(ctx)); this.app.use('/api', createMediaRouter(ctx)); + this.app.use('/api', createMediaLibraryRouter(ctx)); this.app.use('/api', createControlRouter(ctx)); if (this.config.web.debugEnabled) { this.app.use('/api', createDebugRouter(ctx)); @@ -204,6 +208,10 @@ export class WebServer { this.services.mediaSync = mediaSyncService; } + setMediaLibraryService(mediaLibrary: MediaLibraryService): void { + this.services.mediaLibrary = mediaLibrary; + } + setResponseChecker(responseChecker: ResponseCheckerService): void { this.services.responseChecker = responseChecker; } diff --git a/tests/conversation-automation.test.ts b/tests/conversation-automation.test.ts index 3fcc82f..d7d4ed8 100644 --- a/tests/conversation-automation.test.ts +++ b/tests/conversation-automation.test.ts @@ -13,6 +13,7 @@ function harness( rapportTurnsBeforeMediaAsk?: number; }, beforeSendLoader?: (conversationId: string, observed: ActiveChat) => Promise, + mediaExchange?: { detect(conversationId: string, text: string): string | null; handle(conversationId: string, incomingKey: string, text: string): Promise }, ) { const saved = new Map(); let lastSaved: ConversationState | undefined; @@ -63,6 +64,7 @@ function harness( () => 1_000, 0, beforeSendLoader ?? (async (_conversationId, observed) => observed), + mediaExchange as never, ); 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', () => { + 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 () => { const { service, chat, getSaved } = harness(); diff --git a/tests/dashboard/media-library.test.tsx b/tests/dashboard/media-library.test.tsx new file mode 100644 index 0000000..6dabe1f --- /dev/null +++ b/tests/dashboard/media-library.test.tsx @@ -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(); +} + +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'); + }); +}); diff --git a/tests/database.test.ts b/tests/database.test.ts index 4d9cf70..277fa32 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -107,10 +107,12 @@ describe('Database Client', () => { .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") .all() as { name: string }[]; // 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([ 'candidate_queue', 'runtime_state', + 'media_library_assets', + 'media_library_sends', ])); }); @@ -160,7 +162,7 @@ describe('Database Client', () => { DROP TABLE conversation_automation_claims; DROP TABLE ai_usage; `); - db.pragma(`user_version = ${latestVersion - 6}`); + db.pragma(`user_version = ${latestVersion - 7}`); runMigrations(db); 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 ); `); - db.pragma(`user_version = ${latestVersion - 1}`); + db.pragma(`user_version = ${latestVersion - 2}`); runMigrations(db); diff --git a/tests/matrix-client.test.ts b/tests/matrix-client.test.ts index a07a5c4..367ae9b 100644 --- a/tests/matrix-client.test.ts +++ b/tests/matrix-client.test.ts @@ -14,6 +14,38 @@ const creds = { 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', () => { it('loads an encoded Matrix user profile with bearer authentication', async () => { let requestedUrl = ''; diff --git a/tests/media-library-exchange.test.ts b/tests/media-library-exchange.test.ts new file mode 100644 index 0000000..e272e08 --- /dev/null +++ b/tests/media-library-exchange.test.ts @@ -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; +} diff --git a/tests/media-library.test.ts b/tests/media-library.test.ts new file mode 100644 index 0000000..6167b81 --- /dev/null +++ b/tests/media-library.test.ts @@ -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; +} diff --git a/tests/openrouter-client.test.ts b/tests/openrouter-client.test.ts index 9dfd6d7..15b32e2 100644 --- a/tests/openrouter-client.test.ts +++ b/tests/openrouter-client.test.ts @@ -30,6 +30,18 @@ describe('OpenRouterClient', () => { 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)', () => { // We test refusal detection by checking the analyzeImage behavior // since isRefusal is private. We do this by mocking responses that diff --git a/tests/web-auth-cutover.test.ts b/tests/web-auth-cutover.test.ts index 7d9bc0a..12dd2f5 100644 --- a/tests/web-auth-cutover.test.ts +++ b/tests/web-auth-cutover.test.ts @@ -24,6 +24,8 @@ function createWorkspace(includeReactBuild = true): string { fs.writeFileSync(path.join(workspace, 'public', 'index.html'), '

Legacy dashboard

'); fs.mkdirSync(path.join(workspace, 'data', 'media'), { recursive: true }); 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) { fs.mkdirSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'assets'), { recursive: true }); fs.writeFileSync(path.join(workspace, 'apps', 'dashboard', 'dist', 'index.html'), '

React dashboard

'); @@ -81,19 +83,22 @@ describe('authenticated React dashboard cutover', () => { fetch(`${running.baseUrl}/api/stats`), fetch(`${running.baseUrl}/api/events/stream`), fetch(`${running.baseUrl}/media/sample.txt`), + fetch(`${running.baseUrl}/library-media/library.txt`), 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 api = await fetch(`${running.baseUrl}/api/stats`, { 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 asset = await fetch(`${running.baseUrl}/assets/app.js`, { headers }); const spa = await fetch(`${running.baseUrl}/conversations/example`, { headers: { ...headers, Accept: 'text/html' } }); expect(api.status).toBe(200); expect(await media.text()).toBe('private media'); + expect(await libraryMedia.text()).toBe('private library media'); expect(await root.text()).toContain('React dashboard'); expect(await asset.text()).toBe('react-build'); expect(await spa.text()).toContain('React dashboard'); diff --git a/tests/web/media-library-route.test.ts b/tests/web/media-library-route.test.ts new file mode 100644 index 0000000..7f6aef7 --- /dev/null +++ b/tests/web/media-library-route.test.ts @@ -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> = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((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 { + const server = app.listen(0); + servers.push(server); + await new Promise((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, + }; +}