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
@@ -44,7 +44,14 @@ describe('conversations route', () => {
|
||||
});
|
||||
const runtime = (accountId: string, redditUsername: string, conversationUsername: string) => ({
|
||||
session: { accountId, redditUsername },
|
||||
resources: { chatService: { getCachedConversations: vi.fn().mockResolvedValue([chat(conversationUsername)]) } },
|
||||
resources: {
|
||||
chatService: { getCachedConversations: vi.fn().mockResolvedValue([chat(conversationUsername)]) },
|
||||
recipientAccountStatusRepo: {
|
||||
get: vi.fn((username: string) => username === 'alice'
|
||||
? { username, status: 'deleted', checkedAt: 123 }
|
||||
: null),
|
||||
},
|
||||
},
|
||||
});
|
||||
const runtimes = new Map([
|
||||
['@t2_one:reddit.com', runtime('@t2_one:reddit.com', 'owner-one', 'alice')],
|
||||
@@ -67,7 +74,11 @@ describe('conversations route', () => {
|
||||
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ roomId: 'same-room', username: 'alice', owner: { accountId: '@t2_one:reddit.com', redditUsername: 'owner-one' } }),
|
||||
expect.objectContaining({
|
||||
roomId: 'same-room', username: 'alice', needsReply: false,
|
||||
recipientAccountStatus: 'deleted', recipientAccountStatusCheckedAt: 123,
|
||||
owner: { accountId: '@t2_one:reddit.com', redditUsername: 'owner-one' },
|
||||
}),
|
||||
expect.objectContaining({ roomId: 'same-room', username: 'bob', owner: { accountId: '@t2_two:reddit.com', redditUsername: 'owner-two' } }),
|
||||
]));
|
||||
});
|
||||
@@ -319,7 +330,7 @@ describe('conversations route', () => {
|
||||
services: {
|
||||
chat: { getCachedConversations: vi.fn().mockResolvedValue([conversation]) },
|
||||
conversationState: {
|
||||
get: vi.fn(() => ({ sellerDetected: true })),
|
||||
get: vi.fn(() => ({ ...buildConversationState([]), sellerDetected: true })),
|
||||
},
|
||||
},
|
||||
} as unknown as WebContext;
|
||||
@@ -329,7 +340,57 @@ describe('conversations route', () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual([
|
||||
{ ...conversation, ...UNREAD_INBOUND_TRIAGE, sellerDetected: true },
|
||||
{
|
||||
...conversation,
|
||||
...UNREAD_INBOUND_TRIAGE,
|
||||
sellerDetected: true,
|
||||
automationStatus: 'active',
|
||||
automationPolicyMode: 'legacy',
|
||||
automationHandoffReason: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('projects the reply-loop status onto the list so manual conversations can be filtered', async () => {
|
||||
const automated = {
|
||||
username: 'alice', roomName: 'alice', roomId: 'room-alice',
|
||||
lastMessageTime: 1, lastMessageSnippet: 'hey', isFromUs: false, unread: true,
|
||||
};
|
||||
const manual = {
|
||||
username: 'bob', roomName: 'bob', roomId: 'room-bob',
|
||||
lastMessageTime: 2, lastMessageSnippet: 'thanks', isFromUs: true, unread: false,
|
||||
};
|
||||
const untouched = {
|
||||
username: 'carol', roomName: 'carol', roomId: 'room-carol',
|
||||
lastMessageTime: 3, lastMessageSnippet: 'hi', isFromUs: false, unread: true,
|
||||
};
|
||||
const handedOver = { ...buildConversationState([]) };
|
||||
handedOver.automation.status = 'user-overridden';
|
||||
handedOver.automation.handoffReason = 'A manual reply was sent in this conversation.';
|
||||
const context = {
|
||||
logger: { error: vi.fn() },
|
||||
services: {
|
||||
chat: { getCachedConversations: vi.fn().mockResolvedValue([automated, manual, untouched]) },
|
||||
conversationState: {
|
||||
get: vi.fn((roomId: string) => {
|
||||
if (roomId === 'room-alice') return buildConversationState([]);
|
||||
if (roomId === 'room-bob') return handedOver;
|
||||
return undefined;
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as unknown as WebContext;
|
||||
const app = express().use('/api', createConversationsRouter(context));
|
||||
|
||||
const response = await request(app, '/api/conversations');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json() as Array<Record<string, unknown>>;
|
||||
expect(body.map((item) => [item.username, item.automationStatus, item.automationHandoffReason])).toEqual([
|
||||
['alice', 'active', null],
|
||||
['bob', 'user-overridden', 'A manual reply was sent in this conversation.'],
|
||||
// Never through the reply loop, so there is nothing to report.
|
||||
['carol', undefined, undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user