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

+53 -8
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { MediaService } from '../src/services/media.js';
import { MediaAttachmentError, MediaService } from '../src/services/media.js';
import type { MediaDownloader } from '../src/services/media.js';
import type { ActiveChat, ChatMessage } from '../src/services/chat.js';
import { createMockLogger } from '../tests/mockLogger.js';
@@ -47,11 +47,24 @@ describe('MediaService', () => {
});
describe('resolveFullMediaUrl', () => {
it('converts blurred thumbnail to full resolution', () => {
it('falls back to the blurred thumbnail when the full-resolution response is not an image', async () => {
const blurred = 'https://matrix.redditspace.com/_matrix/media/v3/thumbnail/reddit.com/blur_XXXXX?height=300';
// We can't test private method directly, but we can test via attachMedia
// For now, verify the service is created
expect(service).toBeDefined();
const fetch = vi.fn()
.mockResolvedValueOnce({ buffer: Buffer.from('not an image'), contentType: 'text/html' })
.mockResolvedValueOnce({ buffer: Buffer.from('image data'), contentType: 'image/jpeg' });
service = new MediaService({ fetch }, logger, tempDir);
const [message] = await service.attachMedia(createMockChat(), [{
eventId: '$blurred', sender: 'user1', text: '', timestamp: 1, isSelf: false,
mediaUrls: [blurred],
}]);
expect(fetch).toHaveBeenNthCalledWith(
1,
'https://matrix.redditspace.com/_matrix/media/v3/download/reddit.com/XXXXX',
);
expect(fetch).toHaveBeenNthCalledWith(2, blurred);
expect(message.media).toHaveLength(1);
});
it('leaves non-thumbnail URLs unchanged', () => {
@@ -109,7 +122,7 @@ describe('MediaService', () => {
expect(result[0].media).toBeUndefined();
});
it('handles download failures gracefully', async () => {
it('surfaces download failures so the sync cursor can be retried', async () => {
const failingDownloader = createMockDownloader(null);
service = new MediaService(failingDownloader, logger);
@@ -118,8 +131,7 @@ describe('MediaService', () => {
{ sender: 'user1', text: 'Test', timestamp: Date.now(), isSelf: false, mediaUrls: ['http://example.com/img.jpg'] },
];
const result = await service.attachMedia(chat, messages);
expect(result[0].media).toBeUndefined();
await expect(service.attachMedia(chat, messages)).rejects.toBeInstanceOf(MediaAttachmentError);
});
it('caches downloaded media by URL hash', async () => {
@@ -147,6 +159,39 @@ describe('MediaService', () => {
expect(downloader.fetch).not.toHaveBeenCalled();
});
it('keeps separate gallery occurrences when the same asset appears in separate events', async () => {
const chat = createMockChat();
const url = 'http://example.com/reused.jpg';
await service.attachMedia(chat, [
{ eventId: '$one', sender: 'user1', text: 'First', timestamp: 1, isSelf: false, mediaUrls: [url] },
{ eventId: '$two', sender: 'user1', text: 'Second', timestamp: 2, isSelf: false, mediaUrls: [url] },
]);
const items = await service.getMediaItems();
expect(items).toHaveLength(2);
expect(new Set(items.map((item) => item.id)).size).toBe(2);
expect(new Set(items.map((item) => item.relativePath)).size).toBe(1);
expect(downloader.fetch).toHaveBeenCalledTimes(1);
});
it('recognizes image bytes when Matrix returns a generic content type', async () => {
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
);
service = new MediaService({
fetch: vi.fn(async () => ({ buffer: png, contentType: 'application/octet-stream' })),
}, logger, tempDir);
const [message] = await service.attachMedia(createMockChat(), [{
eventId: '$generic-mime', sender: 'user1', text: '', timestamp: 1, isSelf: false,
mediaUrls: ['mxc://example.test/no-extension'],
}]);
expect(message.media?.[0].fileName).toMatch(/\.png$/);
});
it('processes multiple messages', async () => {
const chat = createMockChat();
const messages: Array<ChatMessage & { mediaUrls: string[] }> = [