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:
1 parent
725b5fc4e6
commit
ac12a3146a
35 files changed
+1164
-129
No files matched your search
@@ -198,4 +198,81 @@ describe('ChatService delta sync — media attachment', () => {
|
||||
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache, undefined, undefined, authenticatedAccountAuth);
|
||||
await expect(svc.getActiveChats()).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('backfills the room when Matrix marks a delta timeline as limited', async () => {
|
||||
const logger = createMockLogger();
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-limited-delta-media-'));
|
||||
try {
|
||||
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
|
||||
matrix.sync = vi.fn(async () => ({
|
||||
next_batch: 's2',
|
||||
rooms: { join: { '!room1:example.test': {
|
||||
timeline: {
|
||||
limited: true,
|
||||
prev_batch: 'gap-token',
|
||||
events: [],
|
||||
},
|
||||
} } },
|
||||
})) as typeof matrix.sync;
|
||||
matrix.getRoomMessages = vi.fn(async () => ({
|
||||
start: 'latest',
|
||||
chunk: [{
|
||||
event_id: '$missed-image', type: 'm.room.message', sender: '@t2_abc:example.test',
|
||||
origin_server_ts: 1_000,
|
||||
content: { msgtype: 'm.image', info: { thumbnail_url: 'mxc://example.test/missed' } },
|
||||
}],
|
||||
}));
|
||||
|
||||
const media = new MediaService(buildDownloader(), logger, tmpDir);
|
||||
const svc = new ChatService(
|
||||
matrix, logger, undefined, media, undefined, undefined,
|
||||
new ChatCache(logger, path.join(tmpDir, 'chat-cache.json')),
|
||||
undefined, undefined, authenticatedAccountAuth,
|
||||
);
|
||||
|
||||
await expect(svc.refreshTopChats()).resolves.toBe(1);
|
||||
expect(matrix.getRoomMessages).toHaveBeenCalledWith(
|
||||
'!room1:example.test', { from: undefined, limit: 100, dir: 'b' },
|
||||
);
|
||||
expect(await media.getMediaItems()).toEqual([
|
||||
expect.objectContaining({ originalUrl: 'mxc://example.test/missed' }),
|
||||
]);
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not persist the next delta cursor when a media download fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-media-retry-cursor-'));
|
||||
const cacheFile = path.join(tmpDir, 'chat-cache.json');
|
||||
try {
|
||||
await fs.writeFile(cacheFile, JSON.stringify({
|
||||
version: 1, savedAt: 1, chats: [], nextBatch: 's1',
|
||||
lastFullSyncAt: 1, lastDeltaSyncAt: 1, cooldown: null,
|
||||
}));
|
||||
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
|
||||
matrix.sync = vi.fn(async () => ({
|
||||
next_batch: 's2',
|
||||
rooms: { join: { '!room1:example.test': { timeline: { events: [{
|
||||
event_id: '$image', type: 'm.room.message', sender: '@t2_abc:example.test',
|
||||
origin_server_ts: 1_000, content: { msgtype: 'm.image', url: 'mxc://example.test/fails' },
|
||||
}] } } } },
|
||||
})) as typeof matrix.sync;
|
||||
const media = new MediaService({ fetch: vi.fn().mockResolvedValue(null) }, logger, tmpDir);
|
||||
const cache = new ChatCache(logger, cacheFile);
|
||||
const svc = new ChatService(
|
||||
matrix, logger, undefined, media, undefined, undefined, cache,
|
||||
undefined, undefined, authenticatedAccountAuth,
|
||||
);
|
||||
await svc.loadCache();
|
||||
|
||||
await expect(svc.refreshTopChats()).rejects.toThrow('Failed to retrieve');
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(cacheFile, 'utf-8')) as { nextBatch: string };
|
||||
expect(persisted.nextBatch).toBe('s1');
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user