fix: show media from stopped accounts
This commit is contained in:
1 parent
b573b88b08
commit
f34129666e
6 files changed
+84
-6
No files matched your search
+1
-1
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user