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

+62
View File
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import { RedditRecipientAccountStatusRepository } from '../src/database/account-repositories.js';
import { runMigrations } from '../src/database/client.js';
import { RecipientAccountStatusService } from '../src/services/recipient-account-status.js';
import { createMockLogger } from './mockLogger.js';
describe('RecipientAccountStatusService', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
});
afterEach(() => db.close());
it('checks each real username once, persists results, and skips fresh checks', async () => {
let now = 10_000;
const repository = new RedditRecipientAccountStatusRepository(db);
const lookup = {
getUserAccountStatus: vi.fn(async (username: string) => username === 'Alice' ? 'deleted' as const : 'active' as const),
};
const service = new RecipientAccountStatusService(
{
getCachedConversations: vi.fn(async () => [
{ username: 'Alice' }, { username: 'alice' }, { username: 'Bob' },
{ username: 'unknown' }, { username: 't2_abc' },
]),
} as never,
lookup,
repository,
createMockLogger(),
{ isFullyAuthenticated: () => true, invalidate: () => undefined },
4_000,
() => now,
);
await expect(service.checkNow()).resolves.toBe(2);
expect(lookup.getUserAccountStatus).toHaveBeenCalledTimes(2);
expect(repository.get('alice')).toMatchObject({ status: 'deleted', checkedAt: 10_000 });
now = 12_000;
await expect(service.checkNow()).resolves.toBe(0);
now = 15_000;
await expect(service.checkNow()).resolves.toBe(2);
});
it('does no profile work while account authentication is unavailable', async () => {
const lookup = { getUserAccountStatus: vi.fn() };
const service = new RecipientAccountStatusService(
{ getCachedConversations: vi.fn() } as never,
lookup,
new RedditRecipientAccountStatusRepository(db),
createMockLogger(),
{ isFullyAuthenticated: () => false, invalidate: () => undefined },
);
await expect(service.checkNow()).resolves.toBe(0);
expect(lookup.getUserAccountStatus).not.toHaveBeenCalled();
});
});