Add consent-gated media exchange library

This commit is contained in:
jjbrunton committed 2026-08-09 19:24:38 +01:00
1 parent b8e4a1bb88
commit bf474dfe10
42 files changed
+2533 -9

No files matched your search

+48
View File
@@ -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;
+257
View File
@@ -0,0 +1,257 @@
import type Database from 'better-sqlite3';
import {
MEDIA_LIBRARY_TAGS,
type MediaLibraryAsset,
type MediaLibrarySafety,
type MediaLibrarySend,
type MediaLibraryTag,
type MediaRequestKind,
} from '../services/media-library-types.js';
interface AssetRow {
asset_id: string;
tags_json: string;
safety: MediaLibrarySafety;
classification_status: 'ready' | 'failed';
description: string | null;
confidence: number | null;
file_name: string;
relative_path: string;
mime_type: string;
size_bytes: number;
width: number;
height: number;
enabled: number;
consent_confirmed_at: number;
created_at: number;
updated_at: number;
send_count?: number;
last_sent_at?: number | null;
}
interface SendRow {
id: number;
asset_id: string;
account_id: string;
conversation_id: string;
incoming_key: string;
request_kind: MediaRequestKind;
status: MediaLibrarySend['status'];
requested_at: number;
sent_at: number | null;
matrix_media_event_id: string | null;
matrix_text_event_id: string | null;
mxc_uri: string | null;
error: string | null;
}
export interface CreateMediaLibraryAsset {
id: string;
tags: MediaLibraryTag[];
safety: MediaLibrarySafety;
classificationStatus: 'ready' | 'failed';
description: string | null;
confidence: number | null;
fileName: string;
relativePath: string;
mimeType: string;
sizeBytes: number;
width: number;
height: number;
sha256: string;
enabled: boolean;
consentConfirmedAt: number;
createdAt: number;
}
/** Process-global metadata and durable outbound-use ledger for operator media. */
export class MediaLibraryRepository {
constructor(private readonly db: Database.Database) {}
create(input: CreateMediaLibraryAsset): MediaLibraryAsset {
this.db.prepare(`
INSERT INTO media_library_assets (
asset_id, tags_json, safety, classification_status, description,
confidence, file_name, relative_path, mime_type, size_bytes, width,
height, sha256, enabled, consent_confirmed_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.id, JSON.stringify(input.tags), input.safety, input.classificationStatus,
input.description, input.confidence, input.fileName, input.relativePath,
input.mimeType, input.sizeBytes, input.width, input.height, input.sha256,
input.enabled ? 1 : 0, input.consentConfirmedAt, input.createdAt, input.createdAt,
);
return this.get(input.id)!;
}
get(id: string): MediaLibraryAsset | undefined {
const row = this.db.prepare(`
SELECT asset.*, COUNT(sends.sent_at) AS send_count, MAX(sends.sent_at) AS last_sent_at
FROM media_library_assets asset
LEFT JOIN media_library_sends sends ON sends.asset_id = asset.asset_id AND sends.status = 'sent'
WHERE asset.asset_id = ? AND asset.deleted_at IS NULL
GROUP BY asset.asset_id
`).get(id) as AssetRow | undefined;
return row ? mapAsset(row) : undefined;
}
getBySha256(sha256: string): MediaLibraryAsset | undefined {
const row = this.db.prepare(`
SELECT asset.*, COUNT(sends.sent_at) AS send_count, MAX(sends.sent_at) AS last_sent_at
FROM media_library_assets asset
LEFT JOIN media_library_sends sends ON sends.asset_id = asset.asset_id AND sends.status = 'sent'
WHERE asset.sha256 = ? AND asset.deleted_at IS NULL
GROUP BY asset.asset_id
`).get(sha256) as AssetRow | undefined;
return row ? mapAsset(row) : undefined;
}
list(): MediaLibraryAsset[] {
return (this.db.prepare(`
SELECT asset.*, COUNT(sends.sent_at) AS send_count, MAX(sends.sent_at) AS last_sent_at
FROM media_library_assets asset
LEFT JOIN media_library_sends sends ON sends.asset_id = asset.asset_id AND sends.status = 'sent'
WHERE asset.deleted_at IS NULL
GROUP BY asset.asset_id
ORDER BY asset.created_at DESC
`).all() as AssetRow[]).map(mapAsset);
}
listEligible(): MediaLibraryAsset[] {
return (this.db.prepare(`
SELECT * FROM media_library_assets
WHERE deleted_at IS NULL AND enabled = 1 AND classification_status = 'ready'
ORDER BY created_at DESC
`).all() as AssetRow[]).map(mapAsset);
}
update(id: string, tags: MediaLibraryTag[], safety: Exclude<MediaLibrarySafety, 'unknown'>, enabled: boolean, now = Date.now()): MediaLibraryAsset | undefined {
this.db.prepare(`
UPDATE media_library_assets
SET tags_json = ?, safety = ?, classification_status = 'ready', enabled = ?, updated_at = ?
WHERE asset_id = ? AND deleted_at IS NULL
`).run(JSON.stringify(tags), safety, enabled ? 1 : 0, now, id);
return this.get(id);
}
softDelete(id: string, now = Date.now()): boolean {
return this.db.prepare(`
UPDATE media_library_assets
SET enabled = 0, deleted_at = ?, updated_at = ?, sha256 = sha256 || ':deleted:' || asset_id
WHERE asset_id = ? AND deleted_at IS NULL
`).run(now, now, id).changes > 0;
}
getSend(accountId: string, conversationId: string, incomingKey: string): MediaLibrarySend | undefined {
const row = this.db.prepare(`
SELECT * FROM media_library_sends
WHERE account_id = ? AND conversation_id = ? AND incoming_key = ?
`).get(accountId, conversationId, incomingKey) as SendRow | undefined;
return row ? mapSend(row) : undefined;
}
listConversationSends(accountId: string, conversationId: string): MediaLibrarySend[] {
return (this.db.prepare(`
SELECT * FROM media_library_sends
WHERE account_id = ? AND conversation_id = ?
ORDER BY requested_at ASC, id ASC
`).all(accountId, conversationId) as SendRow[]).map(mapSend);
}
tryReserve(
assetId: string,
accountId: string,
conversationId: string,
incomingKey: string,
requestKind: MediaRequestKind,
now = Date.now(),
): MediaLibrarySend | null {
try {
this.db.prepare(`
INSERT INTO media_library_sends (
asset_id, asset_sha256, account_id, conversation_id, incoming_key, request_kind,
status, requested_at
)
SELECT asset_id, sha256, ?, ?, ?, ?, 'pending', ?
FROM media_library_assets
WHERE asset_id = ? AND deleted_at IS NULL AND enabled = 1
`).run(accountId, conversationId, incomingKey, requestKind, now, assetId);
return this.getSend(accountId, conversationId, incomingKey) ?? null;
} catch (error) {
if (error instanceof Error && /UNIQUE constraint failed/.test(error.message)) return null;
throw error;
}
}
markMediaSent(id: number, mxcUri: string, eventId: string): void {
this.db.prepare(`
UPDATE media_library_sends
SET mxc_uri = ?, matrix_media_event_id = ?, error = NULL
WHERE id = ? AND status = 'pending'
`).run(mxcUri, eventId, id);
}
markSent(id: number, textEventId: string, now = Date.now()): boolean {
return this.db.prepare(`
UPDATE media_library_sends
SET status = 'sent', matrix_text_event_id = ?, sent_at = ?, error = NULL
WHERE id = ? AND status = 'pending' AND matrix_media_event_id IS NOT NULL
`).run(textEventId, now, id).changes > 0;
}
markFailed(id: number, error: string): boolean {
return this.db.prepare(`
UPDATE media_library_sends SET status = 'failed', error = ?
WHERE id = ? AND status = 'pending'
`).run(error.slice(0, 500), id).changes > 0;
}
}
function mapAsset(row: AssetRow): MediaLibraryAsset {
let tags: MediaLibraryTag[] = [];
try {
const parsed = JSON.parse(row.tags_json) as unknown;
if (Array.isArray(parsed)) {
tags = parsed.filter((tag): tag is MediaLibraryTag =>
typeof tag === 'string' && (MEDIA_LIBRARY_TAGS as readonly string[]).includes(tag));
}
} catch { /* invalid legacy data remains disabled by service validation */ }
return {
id: row.asset_id,
tags,
safety: row.safety,
classificationStatus: row.classification_status,
description: row.description,
confidence: row.confidence,
fileName: row.file_name,
relativePath: row.relative_path,
mimeType: row.mime_type,
sizeBytes: row.size_bytes,
width: row.width,
height: row.height,
enabled: Boolean(row.enabled),
consentConfirmedAt: row.consent_confirmed_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
sendCount: row.send_count ?? 0,
lastSentAt: row.last_sent_at ?? null,
};
}
function mapSend(row: SendRow): MediaLibrarySend {
return {
id: row.id,
assetId: row.asset_id,
accountId: row.account_id,
conversationId: row.conversation_id,
incomingKey: row.incoming_key,
requestKind: row.request_kind,
status: row.status,
requestedAt: row.requested_at,
sentAt: row.sent_at,
matrixMediaEventId: row.matrix_media_event_id,
matrixTextEventId: row.matrix_text_event_id,
mxcUri: row.mxc_uri,
error: row.error,
};
}