feat: recover missing conversations, fix media attachment, add manual inbox tab

Missing conversations: recipient Reddit accounts that are deleted or suspended
left conversations unaccounted for. A new RecipientAccountStatusService polls
Reddit for the users behind cached conversations and records their account
status durably, with progressive backfill and delta-media fixes in ChatService
so history and images are no longer dropped mid-sync.

Media: attachMedia now surfaces download failures as MediaAttachmentError so the
sync cursor can be retried instead of silently losing an image, recognizes image
bytes when Matrix returns a generic content type, and keeps separate gallery
occurrences when the same asset appears in more than one event.

Manual inbox tab: the conversations list now carries the reply-loop status, so
the inbox can separate conversations the operator is driving by hand from the
ones automation is still handling. A Manual filter pill and a per-row chip
explain why each conversation was handed over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 11:03:33 +01:00
1 parent 725b5fc4e6
commit ac12a3146a
35 files changed
+1164 -129

No files matched your search

+22 -2
View File
@@ -7,12 +7,12 @@ import { createMediaRouter } from '../../src/web/routes/media.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string): Promise<Response> {
async function request(app: express.Express, path: string, init?: RequestInit): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`);
return fetch(`http://127.0.0.1:${port}${path}`, init);
}
afterEach(async () => {
@@ -179,4 +179,24 @@ describe('media route username presentation', () => {
]);
expect(body).not.toEqual([expect.objectContaining({ accountId: expect.anything() })]);
});
it('returns the recorded failure instead of claiming a manual full sync succeeded', async () => {
const context = {
logger: { error: vi.fn() },
broadcaster: { push: vi.fn() },
services: {
media: { getMediaItems: vi.fn().mockResolvedValue([]) },
mediaSync: {
triggerSync: vi.fn().mockResolvedValue(false),
getStatus: vi.fn().mockReturnValue({ lastError: '1 conversation failed' }),
},
},
} as unknown as WebContext;
const app = express().use('/api', createMediaRouter(context));
const response = await request(app, '/api/media/sync/full', { method: 'POST' });
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: false, reason: '1 conversation failed' });
});
});