fix: show media from stopped accounts

This commit is contained in:
jjbrunton committed 2026-08-09 22:31:51 +01:00
1 parent b573b88b08
commit f34129666e
6 files changed
+84 -6

No files matched your search

+1 -1
View File
@@ -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. `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. 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.
+1
View File
@@ -376,6 +376,7 @@ export async function bootstrap(
aiUsageRepo, aiUsageRepo,
runtimeStateRepo.isBotRunning(), runtimeStateRepo.isBotRunning(),
(running) => runtimeStateRepo.setBotRunning(running), (running) => runtimeStateRepo.setBotRunning(running),
accountContentRepo,
); );
webServer.setChatService(chatService); webServer.setChatService(chatService);
+2
View File
@@ -25,6 +25,7 @@ import type { RedditAccountRuntimeManager } from '../services/reddit-account-run
import type { AiUsageRepository } from '../database/ai-usage-repository.js'; import type { AiUsageRepository } from '../database/ai-usage-repository.js';
import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js'; import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js';
import type { MediaLibraryService } from '../services/media-library.js'; import type { MediaLibraryService } from '../services/media-library.js';
import type { AccountContentRepository } from '../database/account-repositories.js';
/** Bag of everything the route modules might need. */ /** Bag of everything the route modules might need. */
export interface WebContext { export interface WebContext {
@@ -39,6 +40,7 @@ export interface WebContext {
messages: MessagesRepository; messages: MessagesRepository;
successful: SuccessfulMessagesRepository; successful: SuccessfulMessagesRepository;
aiUsage?: AiUsageRepository; aiUsage?: AiUsageRepository;
accountContent?: AccountContentRepository;
}; };
services: { services: {
+26 -5
View File
@@ -1,6 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import type { WebContext } from '../context.js'; import type { WebContext } from '../context.js';
import { resolveRedditUsername } from '../reddit-identities.js'; import { resolveRedditUsername } from '../reddit-identities.js';
import type { AccountMediaRecord } from '../../database/account-repositories.js';
export function createMediaRouter(ctx: WebContext): Router { export function createMediaRouter(ctx: WebContext): Router {
const router = Router(); const router = Router();
@@ -12,11 +13,16 @@ export function createMediaRouter(ctx: WebContext): Router {
} }
try { try {
const runtimes = ctx.services.accountRuntimes?.list().filter((runtime) => runtime.state === 'running') ?? []; const runtimes = ctx.services.accountRuntimes?.list().filter((runtime) => runtime.state === 'running') ?? [];
const items = runtimes.length > 0 const accounts = ctx.services.redditAccounts?.list() ?? [];
? (await Promise.all(runtimes.map((snapshot) => const items = ctx.repos?.accountContent && accounts.length > 0
ctx.services.accountRuntimes!.get(snapshot.accountId)!.resources.mediaService.getMediaItems(), ? accounts.flatMap((account) =>
))).flat() ctx.repos.accountContent!.listMedia(account.accountId).map(toMediaRecord),
: await ctx.services.media.getMediaItems(); )
: 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 // Build a roomId → current chat lookup so we can override stale
// stored usernames (e.g. items captured before username enrichment ran) // 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); 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,
};
}
+3
View File
@@ -47,6 +47,7 @@ import type { AiUsageRepository } from '../database/ai-usage-repository.js';
import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js'; import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js';
import type { MediaLibraryService } from '../services/media-library.js'; import type { MediaLibraryService } from '../services/media-library.js';
import { createMediaLibraryRouter } from './routes/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'; export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js';
@@ -83,6 +84,7 @@ export class WebServer {
private aiUsageRepo?: AiUsageRepository, private aiUsageRepo?: AiUsageRepository,
initialBotRunning = false, initialBotRunning = false,
private readonly persistBotRunning: (running: boolean) => void = () => undefined, private readonly persistBotRunning: (running: boolean) => void = () => undefined,
private readonly accountContentRepo?: AccountContentRepository,
) { ) {
this.botRunning = initialBotRunning; this.botRunning = initialBotRunning;
this.broadcaster = broadcaster ?? new LogBroadcaster(); this.broadcaster = broadcaster ?? new LogBroadcaster();
@@ -116,6 +118,7 @@ export class WebServer {
messages: this.messagesRepo, messages: this.messagesRepo,
successful: this.successfulMessagesRepo, successful: this.successfulMessagesRepo,
aiUsage: this.aiUsageRepo, aiUsage: this.aiUsageRepo,
accountContent: this.accountContentRepo,
}, },
services: this.services, services: this.services,
getBotRunning: () => this.botRunning, getBotRunning: () => this.botRunning,
+51
View File
@@ -20,6 +20,55 @@ afterEach(async () => {
}); });
describe('media route username presentation', () => { 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 () => { it('lists received media while excluding items sent by the owning account', async () => {
const mediaItem = (id: string, sender: string) => ({ const mediaItem = (id: string, sender: string) => ({
accountId: 't2_owner', id, chatUsername: 'alice', chatRoomId: 'room-one', sender, 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), resolveSenderDisplay: vi.fn((identity: string) => identity),
}, },
redditAccounts: { redditAccounts: {
list: vi.fn().mockReturnValue([]),
get: vi.fn().mockReturnValue({ accountId: 't2_owner', redditUsername: 'owner' }), get: vi.fn().mockReturnValue({ accountId: 't2_owner', redditUsername: 'owner' }),
}, },
}, },
@@ -111,6 +161,7 @@ describe('media route username presentation', () => {
resolveSenderDisplay: vi.fn((identity: string) => identity), resolveSenderDisplay: vi.fn((identity: string) => identity),
}, },
redditAccounts: { redditAccounts: {
list: vi.fn().mockReturnValue([]),
get: vi.fn().mockReturnValue({ accountId: 't2_owner', redditUsername: 'owner' }), get: vi.fn().mockReturnValue({ accountId: 't2_owner', redditUsername: 'owner' }),
}, },
}, },