diff --git a/docs/data-flow.md b/docs/data-flow.md index 676b97e..64ad4aa 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -96,7 +96,7 @@ Legacy conversations may make at most one optional autonomous media request, inc `ChatService` communicates with Reddit's Matrix homeserver, maintains an account-scoped SQLite-backed cache, resolves `t2_` identities to Reddit usernames, and emits sync progress/new-reply callbacks. Startup chooses full or delta sync from persisted cursor state. Operators can explicitly run tracked sync jobs through the versioned API. -`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. The media listing exposes received items only: records attributed to the owning Reddit/Matrix account are excluded, including historical self-sender forms. Private media is served behind the same authorization boundary as the dashboard and APIs. +`MediaService` resolves Matrix media, downloads it into the persistent data directory, and writes an account-scoped media index. `MediaSyncService` schedules incremental work only while the account is authenticated. The media listing reads persisted indexes for every stored account, including accounts whose runtimes are stopped or require login, and exposes received items only: records attributed to the owning Reddit/Matrix account are excluded, including historical self-sender forms. 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. diff --git a/src/bootstrap.ts b/src/bootstrap.ts index d8d70da..58ac039 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -376,6 +376,7 @@ export async function bootstrap( aiUsageRepo, runtimeStateRepo.isBotRunning(), (running) => runtimeStateRepo.setBotRunning(running), + accountContentRepo, ); webServer.setChatService(chatService); diff --git a/src/web/context.ts b/src/web/context.ts index e09a7dc..60e89d5 100644 --- a/src/web/context.ts +++ b/src/web/context.ts @@ -25,6 +25,7 @@ import type { RedditAccountRuntimeManager } from '../services/reddit-account-run 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 type { AccountContentRepository } from '../database/account-repositories.js'; /** Bag of everything the route modules might need. */ export interface WebContext { @@ -39,6 +40,7 @@ export interface WebContext { messages: MessagesRepository; successful: SuccessfulMessagesRepository; aiUsage?: AiUsageRepository; + accountContent?: AccountContentRepository; }; services: { diff --git a/src/web/routes/media.ts b/src/web/routes/media.ts index 04b9aff..e3140fb 100644 --- a/src/web/routes/media.ts +++ b/src/web/routes/media.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import type { WebContext } from '../context.js'; import { resolveRedditUsername } from '../reddit-identities.js'; +import type { AccountMediaRecord } from '../../database/account-repositories.js'; export function createMediaRouter(ctx: WebContext): Router { const router = Router(); @@ -12,11 +13,16 @@ export function createMediaRouter(ctx: WebContext): Router { } try { const runtimes = ctx.services.accountRuntimes?.list().filter((runtime) => runtime.state === 'running') ?? []; - const items = runtimes.length > 0 - ? (await Promise.all(runtimes.map((snapshot) => - ctx.services.accountRuntimes!.get(snapshot.accountId)!.resources.mediaService.getMediaItems(), - ))).flat() - : await ctx.services.media.getMediaItems(); + const accounts = ctx.services.redditAccounts?.list() ?? []; + const items = ctx.repos?.accountContent && accounts.length > 0 + ? accounts.flatMap((account) => + ctx.repos.accountContent!.listMedia(account.accountId).map(toMediaRecord), + ) + : runtimes.length > 0 + ? (await Promise.all(runtimes.map((snapshot) => + ctx.services.accountRuntimes!.get(snapshot.accountId)!.resources.mediaService.getMediaItems(), + ))).flat() + : await ctx.services.media.getMediaItems(); // Build a roomId → current chat lookup so we can override stale // stored usernames (e.g. items captured before username enrichment ran) @@ -171,3 +177,18 @@ function isOwnedSender(sender: string, accountId?: string, redditUsername?: stri return ownedIdentities.has(normalizedSender); } + +function toMediaRecord(item: AccountMediaRecord) { + return { + accountId: item.accountId, + id: item.mediaId, + chatUsername: item.chatUsername, + chatRoomId: item.chatRoomId, + sender: item.sender, + timestamp: item.timestamp, + originalUrl: item.originalUrl, + fileName: item.fileName, + relativePath: item.relativePath, + createdAt: item.createdAt, + }; +} diff --git a/src/web/server.ts b/src/web/server.ts index 327af40..bd745f8 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -47,6 +47,7 @@ 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'; +import type { AccountContentRepository } from '../database/account-repositories.js'; export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js'; @@ -83,6 +84,7 @@ export class WebServer { private aiUsageRepo?: AiUsageRepository, initialBotRunning = false, private readonly persistBotRunning: (running: boolean) => void = () => undefined, + private readonly accountContentRepo?: AccountContentRepository, ) { this.botRunning = initialBotRunning; this.broadcaster = broadcaster ?? new LogBroadcaster(); @@ -116,6 +118,7 @@ export class WebServer { messages: this.messagesRepo, successful: this.successfulMessagesRepo, aiUsage: this.aiUsageRepo, + accountContent: this.accountContentRepo, }, services: this.services, getBotRunning: () => this.botRunning, diff --git a/tests/web/media-route.test.ts b/tests/web/media-route.test.ts index 571d631..ac21323 100644 --- a/tests/web/media-route.test.ts +++ b/tests/web/media-route.test.ts @@ -20,6 +20,55 @@ afterEach(async () => { }); describe('media route username presentation', () => { + it('lists persisted received media for accounts whose runtimes are stopped', async () => { + const storedItem = (accountId: string, mediaId: string, sender: string) => ({ + accountId, mediaId, chatUsername: 'alice', chatRoomId: `room-${accountId}`, sender, + timestamp: 1, originalUrl: `mxc://${mediaId}`, fileName: `${mediaId}.jpg`, relativePath: `${mediaId}.jpg`, createdAt: 2, + }); + const accounts = [ + { accountId: 't2_running', redditUsername: 'running-owner' }, + { accountId: 't2_stopped', redditUsername: 'stopped-owner' }, + ]; + const listMedia = vi.fn((accountId: string) => accountId === 't2_running' + ? [storedItem(accountId, 'running-received', 'alice')] + : [ + storedItem(accountId, 'stopped-received', 'bob'), + storedItem(accountId, 'stopped-sent', '@t2_stopped:reddit.com'), + ]); + const context = { + logger: { error: vi.fn() }, + repos: { accountContent: { listMedia } }, + services: { + media: { getMediaItems: vi.fn().mockResolvedValue([]) }, + chat: { + getCachedConversations: vi.fn().mockResolvedValue([]), + resolveSenderDisplay: vi.fn((identity: string) => identity), + }, + redditAccounts: { + list: vi.fn().mockReturnValue(accounts), + get: vi.fn((accountId: string) => accounts.find((account) => account.accountId === accountId) ?? null), + }, + accountRuntimes: { + list: vi.fn().mockReturnValue([{ accountId: 't2_running', state: 'running' }]), + get: vi.fn().mockReturnValue({ resources: { + mediaService: { getMediaItems: vi.fn().mockResolvedValue([]) }, + chatService: { getCachedConversations: vi.fn().mockResolvedValue([]) }, + } }), + }, + }, + } as unknown as WebContext; + const app = express().use('/api', createMediaRouter(context)); + + const response = await request(app, '/api/media'); + const body = await response.json(); + + expect(listMedia).toHaveBeenCalledTimes(2); + expect(body).toEqual([ + expect.objectContaining({ id: 'running-received', owner: accounts[0] }), + expect.objectContaining({ id: 'stopped-received', owner: accounts[1] }), + ]); + }); + it('lists received media while excluding items sent by the owning account', async () => { const mediaItem = (id: string, sender: string) => ({ accountId: 't2_owner', id, chatUsername: 'alice', chatRoomId: 'room-one', sender, @@ -39,6 +88,7 @@ describe('media route username presentation', () => { resolveSenderDisplay: vi.fn((identity: string) => identity), }, redditAccounts: { + list: vi.fn().mockReturnValue([]), get: vi.fn().mockReturnValue({ accountId: 't2_owner', redditUsername: 'owner' }), }, }, @@ -111,6 +161,7 @@ describe('media route username presentation', () => { resolveSenderDisplay: vi.fn((identity: string) => identity), }, redditAccounts: { + list: vi.fn().mockReturnValue([]), get: vi.fn().mockReturnValue({ accountId: 't2_owner', redditUsername: 'owner' }), }, },