The "Share a saved image" panel was wired to the automated exchange engine, so an operator could only share an image right after an inbound image request, limited to assets the auto-exchanger would itself pick. Add a manual path that offers any enabled, unused library image at any point (no request detection, tier, seller, or boundary gating) and sends it with no reciprocal "your turn" text. Safety flags now surface as a non-blocking warning instead of disabling the panel. The automated auto-send path keeps all its guardrails; the shared image-delivery step is extracted into a helper used by both paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
462 lines
19 KiB
TypeScript
462 lines
19 KiB
TypeScript
import express from 'express';
|
|
import type { AddressInfo } from 'node:net';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import type { WebContext } from '../../src/web/context.js';
|
|
import { createConversationsRouter } from '../../src/web/routes/conversations.js';
|
|
import { buildConversationState, conversationMessageFingerprint } from '../../src/services/conversation-state.js';
|
|
|
|
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
|
|
|
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}`, init);
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
|
});
|
|
|
|
describe('conversations route', () => {
|
|
it('aggregates simultaneous account runtimes and labels identical room IDs with their owner', async () => {
|
|
const chat = (username: string) => ({
|
|
username, roomName: username, roomId: 'same-room', lastMessageTime: 1,
|
|
lastMessageSnippet: `from ${username}`, isFromUs: false, unread: false,
|
|
});
|
|
const runtime = (accountId: string, redditUsername: string, conversationUsername: string) => ({
|
|
session: { accountId, redditUsername },
|
|
resources: { chatService: { getCachedConversations: vi.fn().mockResolvedValue([chat(conversationUsername)]) } },
|
|
});
|
|
const runtimes = new Map([
|
|
['@t2_one:reddit.com', runtime('@t2_one:reddit.com', 'owner-one', 'alice')],
|
|
['@t2_two:reddit.com', runtime('@t2_two:reddit.com', 'owner-two', 'bob')],
|
|
]);
|
|
const context = {
|
|
logger: { error: vi.fn() },
|
|
services: {
|
|
chat: { getCachedConversations: vi.fn() },
|
|
accountRuntimes: {
|
|
list: () => [...runtimes].map(([accountId, item]) => ({ accountId, redditUsername: item.session.redditUsername, state: 'running' })),
|
|
get: (accountId: string) => runtimes.get(accountId) ?? null,
|
|
},
|
|
},
|
|
} as unknown as WebContext;
|
|
const app = express().use('/api', createConversationsRouter(context));
|
|
|
|
const response = await request(app, '/api/conversations');
|
|
const body = await response.json();
|
|
|
|
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: 'bob', owner: { accountId: '@t2_two:reddit.com', redditUsername: 'owner-two' } }),
|
|
]));
|
|
});
|
|
|
|
it('sends an owned conversation reply only through its account runtime', async () => {
|
|
const firstAccountId = '@t2_one:reddit.com';
|
|
const secondAccountId = '@t2_two:reddit.com';
|
|
const conversation = {
|
|
username: 'alice', roomName: 'alice', roomId: 'same-room', url: '/room/same-room',
|
|
lastMessageTime: 1, lastMessageSnippet: 'hello', isFromUs: false, unread: false,
|
|
messages: [{ sender: 'alice', text: 'hello', timestamp: 1, isSelf: false }],
|
|
};
|
|
const runtime = (accountId: string, redditUsername: string) => ({
|
|
session: { accountId, redditUsername },
|
|
snapshot: () => ({ state: 'running' }),
|
|
resources: {
|
|
chatService: {
|
|
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
|
|
sendChatMessage: vi.fn().mockResolvedValue(true),
|
|
refreshTopChats: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
conversationStateRepo: { get: vi.fn(), save: vi.fn() },
|
|
activityLogRepo: { insert: vi.fn() },
|
|
},
|
|
});
|
|
const first = runtime(firstAccountId, 'owner-one');
|
|
const second = runtime(secondAccountId, 'owner-two');
|
|
const singletonSend = vi.fn();
|
|
const runtimes = new Map([[firstAccountId, first], [secondAccountId, second]]);
|
|
const context = {
|
|
logger: { error: vi.fn() },
|
|
activity: { push: vi.fn() },
|
|
broadcaster: { push: vi.fn() },
|
|
services: {
|
|
chat: { sendChatMessage: singletonSend },
|
|
accountRuntimes: {
|
|
list: () => [...runtimes.values()].map((item) => ({
|
|
accountId: item.session.accountId,
|
|
redditUsername: item.session.redditUsername,
|
|
state: 'running',
|
|
})),
|
|
get: (accountId: string) => runtimes.get(accountId) ?? null,
|
|
},
|
|
},
|
|
} as unknown as WebContext;
|
|
const app = express()
|
|
.use(express.json())
|
|
.use('/api', createConversationsRouter(context));
|
|
const ownedId = `${encodeURIComponent(secondAccountId)}~${encodeURIComponent('same-room')}`;
|
|
|
|
const response = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/send-reply`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ message: 'reply from account two' }),
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(second.resources.chatService.sendChatMessage).toHaveBeenCalledWith(
|
|
'/room/same-room',
|
|
'reply from account two',
|
|
);
|
|
expect(first.resources.chatService.sendChatMessage).not.toHaveBeenCalled();
|
|
expect(singletonSend).not.toHaveBeenCalled();
|
|
expect(second.resources.activityLogRepo.insert).toHaveBeenCalledWith(expect.objectContaining({
|
|
type: 'sent', recipient: 'alice',
|
|
}));
|
|
expect(second.resources.conversationStateRepo.save).toHaveBeenCalledWith(
|
|
'same-room',
|
|
expect.objectContaining({
|
|
automation: expect.objectContaining({
|
|
status: 'user-overridden',
|
|
handoffReason: 'A manual reply was sent in this conversation.',
|
|
}),
|
|
}),
|
|
);
|
|
expect(first.resources.activityLogRepo.insert).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lists every enabled image and shares the operator-selected one through the owning runtime', async () => {
|
|
const accountId = '@t2_one:reddit.com';
|
|
const assetId = '11111111-1111-4111-8111-111111111111';
|
|
const conversation = {
|
|
username: 'alice', roomName: 'alice', roomId: 'room-one', url: '/room/room-one',
|
|
lastMessageTime: 2, lastMessageSnippet: 'send a selfie', isFromUs: false, unread: true,
|
|
messages: [
|
|
{ sender: 'me', text: 'hello', timestamp: 1, isSelf: true },
|
|
{ eventId: '$request', sender: 'alice', text: 'send a selfie', timestamp: 2, isSelf: false },
|
|
],
|
|
};
|
|
const asset = {
|
|
id: assetId, tags: ['selfie'], safety: 'sfw', classificationStatus: 'ready', description: 'selfie', confidence: 1,
|
|
fileName: 'selfie.jpg', relativePath: 'nested/selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 4,
|
|
width: 10, height: 10, enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
|
|
sendCount: 0, lastSentAt: null,
|
|
};
|
|
const mediaExchange = {
|
|
listShareableManual: vi.fn().mockReturnValue([asset]),
|
|
detect: vi.fn().mockReturnValue('selfie'),
|
|
shareManual: vi.fn().mockResolvedValue({ kind: 'sent', assetId, requestKind: 'generic' }),
|
|
};
|
|
const runtime = {
|
|
snapshot: () => ({ state: 'running' }),
|
|
resources: {
|
|
chatService: {
|
|
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
|
|
loadConversationDetails: vi.fn().mockResolvedValue(undefined),
|
|
refreshTopChats: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
conversationStateRepo: { get: vi.fn(), save: vi.fn() },
|
|
activityLogRepo: { insert: vi.fn() },
|
|
mediaExchange,
|
|
rateLimiter: { throttle: vi.fn((work: () => Promise<unknown>) => work()) },
|
|
},
|
|
};
|
|
const context = {
|
|
logger: { error: vi.fn() }, activity: { push: vi.fn() }, broadcaster: { push: vi.fn() },
|
|
services: {
|
|
chat: {},
|
|
accountRuntimes: {
|
|
list: () => [{ accountId, redditUsername: 'owner-one', state: 'running' }],
|
|
get: (requested: string) => requested === accountId ? runtime : null,
|
|
},
|
|
},
|
|
} as unknown as WebContext;
|
|
const app = express().use(express.json()).use('/api', createConversationsRouter(context));
|
|
const ownedId = `${encodeURIComponent(accountId)}~room-one`;
|
|
|
|
const optionsResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/shareable-media`);
|
|
expect(optionsResponse.status).toBe(200);
|
|
expect(await optionsResponse.json()).toEqual({
|
|
requestKind: 'selfie', reason: null, warning: null,
|
|
assets: [expect.objectContaining({ id: assetId, urlPath: '/library-media/nested/selfie.jpg' })],
|
|
});
|
|
expect(mediaExchange.listShareableManual).toHaveBeenCalledWith('room-one');
|
|
|
|
const sendResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/share-media`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assetId }),
|
|
});
|
|
expect(sendResponse.status).toBe(200);
|
|
expect(await sendResponse.json()).toEqual({ success: true });
|
|
expect(mediaExchange.shareManual).toHaveBeenCalledWith('room-one', assetId);
|
|
expect(runtime.resources.conversationStateRepo.save).toHaveBeenCalledWith('room-one', expect.objectContaining({
|
|
automation: expect.objectContaining({ status: 'user-overridden' }),
|
|
}));
|
|
});
|
|
|
|
it('rejects an unowned conversation ID while account runtimes are active', async () => {
|
|
const context = {
|
|
logger: { error: vi.fn() },
|
|
services: {
|
|
chat: { getCachedConversations: vi.fn().mockResolvedValue([]) },
|
|
accountRuntimes: {
|
|
list: () => [{ accountId: '@t2_one:reddit.com', redditUsername: 'owner-one', state: 'running' }],
|
|
get: vi.fn(),
|
|
},
|
|
},
|
|
} as unknown as WebContext;
|
|
const app = express().use('/api', createConversationsRouter(context));
|
|
|
|
const response = await request(app, '/api/conversations/same-room');
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(context.services.chat.getCachedConversations).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports an unavailable chat service instead of presenting it as an empty inbox', async () => {
|
|
const context = {
|
|
logger: { error: vi.fn() },
|
|
services: {},
|
|
} as unknown as WebContext;
|
|
const app = express().use('/api', createConversationsRouter(context));
|
|
|
|
const response = await request(app, '/api/conversations');
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(await response.json()).toEqual({ error: 'Chat service not initialized' });
|
|
});
|
|
|
|
it('reports a cache read failure instead of presenting it as an empty inbox', async () => {
|
|
const logger = { error: vi.fn() };
|
|
const context = {
|
|
logger,
|
|
services: {
|
|
chat: {
|
|
getCachedConversations: vi.fn().mockRejectedValue(new Error('cache unavailable')),
|
|
},
|
|
},
|
|
} as unknown as WebContext;
|
|
const app = express().use('/api', createConversationsRouter(context));
|
|
|
|
const response = await request(app, '/api/conversations');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(await response.json()).toEqual({ error: 'Failed to fetch conversations' });
|
|
expect(logger.error).toHaveBeenCalledWith('Failed to fetch conversations:', expect.any(Error));
|
|
});
|
|
|
|
it('marks conversations that have received images in the durable media index', async () => {
|
|
const conversation = {
|
|
username: 'alice',
|
|
roomName: 'alice',
|
|
roomId: 'room-alice',
|
|
lastMessageTime: 1_786_000_000_000,
|
|
lastMessageSnippet: '[Image]',
|
|
isFromUs: false,
|
|
unread: true,
|
|
};
|
|
const context = {
|
|
logger: { error: vi.fn() },
|
|
services: {
|
|
chat: { getCachedConversations: vi.fn().mockResolvedValue([conversation]) },
|
|
media: {
|
|
getMediaItems: vi.fn().mockResolvedValue([
|
|
{
|
|
id: 'image-one',
|
|
chatUsername: 'alice',
|
|
chatRoomId: 'room-alice',
|
|
sender: 'alice',
|
|
timestamp: 1_786_000_000_000,
|
|
originalUrl: 'mxc://reddit.com/image-one',
|
|
fileName: 'image-one.jpg',
|
|
relativePath: 'image-one.jpg',
|
|
createdAt: 1_786_000_000_100,
|
|
},
|
|
]),
|
|
},
|
|
},
|
|
} as unknown as WebContext;
|
|
const app = express().use('/api', createConversationsRouter(context));
|
|
|
|
const legacyResponse = await request(app, '/api/conversations');
|
|
const response = await request(app, '/api/conversations?includeImageMetadata=true');
|
|
|
|
expect(legacyResponse.status).toBe(200);
|
|
expect(await legacyResponse.json()).toEqual([conversation]);
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual([{ ...conversation, hasReceivedImages: true }]);
|
|
});
|
|
|
|
it('marks conversations with a durable seller detection', async () => {
|
|
const conversation = {
|
|
username: 'seller', roomName: 'seller', roomId: 'room-seller',
|
|
lastMessageTime: 1, lastMessageSnippet: 'my page', isFromUs: false, unread: true,
|
|
};
|
|
const context = {
|
|
logger: { error: vi.fn() },
|
|
services: {
|
|
chat: { getCachedConversations: vi.fn().mockResolvedValue([conversation]) },
|
|
conversationState: {
|
|
get: vi.fn(() => ({ sellerDetected: true })),
|
|
},
|
|
},
|
|
} as unknown as WebContext;
|
|
const app = express().use('/api', createConversationsRouter(context));
|
|
|
|
const response = await request(app, '/api/conversations');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual([{ ...conversation, sellerDetected: true }]);
|
|
});
|
|
|
|
it('serves conversation details without starting a delta sync', async () => {
|
|
const conversation = {
|
|
username: 'alice',
|
|
roomName: 'alice',
|
|
roomId: 'room-alice',
|
|
lastMessageTime: 1_786_000_000_000,
|
|
lastMessageSnippet: 'Hello',
|
|
isFromUs: false,
|
|
unread: false,
|
|
messages: [
|
|
{ sender: 'me', text: 'Automated hello', timestamp: 1_785_999_900_000, isSelf: true },
|
|
{ sender: 'alice', text: 'Hello', timestamp: 1_786_000_000_000, isSelf: false },
|
|
],
|
|
};
|
|
const savedState = buildConversationState(conversation.messages);
|
|
savedState.automation.turnsUsed = 1;
|
|
savedState.automation.aiMessageFingerprints = [conversationMessageFingerprint('Automated hello')];
|
|
const refreshTopChats = vi.fn();
|
|
const loadConversationDetails = vi.fn();
|
|
const context = {
|
|
logger: { error: vi.fn(), warn: vi.fn() },
|
|
repos: {
|
|
messages: { getLatestByRecipient: vi.fn().mockReturnValue(undefined) },
|
|
},
|
|
services: {
|
|
conversationState: { get: vi.fn().mockReturnValue(savedState), save: vi.fn() },
|
|
chat: {
|
|
getSyncStatus: vi.fn().mockReturnValue({
|
|
syncing: false,
|
|
syncingKind: null,
|
|
progress: null,
|
|
}),
|
|
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
|
|
refreshTopChats,
|
|
loadConversationDetails,
|
|
saveCacheExternal: vi.fn(),
|
|
},
|
|
},
|
|
} as unknown as WebContext;
|
|
const app = express().use('/api', createConversationsRouter(context));
|
|
|
|
const response = await request(app, '/api/conversations/room-alice');
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.username).toBe('alice');
|
|
expect(body.automation).toEqual({
|
|
turnsUsed: 1,
|
|
remainingTurns: 2,
|
|
status: 'active',
|
|
handoffReason: null,
|
|
});
|
|
expect(body.messages[0]).toMatchObject({ text: 'Automated hello', isAi: true });
|
|
expect(body.messages[1].isAi).toBeUndefined();
|
|
expect(refreshTopChats).not.toHaveBeenCalled();
|
|
expect(loadConversationDetails).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('attaches the exact room origin with its saved URL, title, and image', async () => {
|
|
const conversation = {
|
|
username: 'alice', roomName: 'alice', roomId: 'room-alice',
|
|
lastMessageTime: 10, lastMessageSnippet: 'Hello', isFromUs: true, unread: false,
|
|
messages: [{ sender: 'me', text: 'Hello', timestamp: 10, isSelf: true }],
|
|
};
|
|
const roomOrigin = {
|
|
id: 7, postId: 'post-exact', recipient: 'alice', subject: 'alice', message: 'Hello',
|
|
sentAt: 9, gotResponse: false, responseCheckedAt: null, strategyId: null,
|
|
chatRoomId: 'room-alice', subreddit: 'example', postTitle: 'Exact source post',
|
|
postUrl: 'https://www.reddit.com/r/example/comments/post-exact/exact_source_post/',
|
|
postImageUrl: 'https://i.redd.it/exact-source.jpg',
|
|
postCreatedAt: 8,
|
|
};
|
|
const latestRecipientOrigin = { ...roomOrigin, postId: 'wrong-latest-post', postTitle: 'Wrong post' };
|
|
const context = {
|
|
logger: { error: vi.fn(), warn: vi.fn() },
|
|
repos: { messages: {
|
|
getByChatRoomId: vi.fn().mockReturnValue(roomOrigin),
|
|
getLatestByRecipient: vi.fn().mockReturnValue(latestRecipientOrigin),
|
|
} },
|
|
services: { chat: {
|
|
getSyncStatus: vi.fn().mockReturnValue({ syncing: false, syncingKind: null, progress: null }),
|
|
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
|
|
loadConversationDetails: vi.fn(), saveCacheExternal: vi.fn(),
|
|
} },
|
|
} as unknown as WebContext;
|
|
const app = express().use('/api', createConversationsRouter(context));
|
|
|
|
const response = await request(app, '/api/conversations/room-alice');
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await response.json();
|
|
expect(body.triggeringPost).toEqual({
|
|
id: 'post-exact', subreddit: 'example', title: 'Exact source post',
|
|
url: roomOrigin.postUrl, imageUrl: roomOrigin.postImageUrl, postedAt: 8, sentAt: 9,
|
|
});
|
|
expect(body.messages[0]).toMatchObject({ text: 'Hello', isAi: true });
|
|
expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('waits for the conversation delta sync after sending a reply', async () => {
|
|
const conversation = {
|
|
username: 'alice', roomName: 'alice', roomId: 'room-alice',
|
|
url: 'https://chat.reddit.com/room/room-alice',
|
|
lastMessageTime: 10, lastMessageSnippet: 'Hello', isFromUs: false, unread: false,
|
|
messages: [{ sender: 'alice', text: 'Hello', timestamp: 10, isSelf: false }],
|
|
};
|
|
let completeSync: (() => void) | undefined;
|
|
const refreshTopChats = vi.fn(() => new Promise<void>((resolve) => {
|
|
completeSync = resolve;
|
|
}));
|
|
const context = {
|
|
logger: { error: vi.fn() },
|
|
activity: { push: vi.fn() },
|
|
broadcaster: { push: vi.fn() },
|
|
services: { chat: {
|
|
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
|
|
sendChatMessage: vi.fn().mockResolvedValue(true),
|
|
refreshTopChats,
|
|
} },
|
|
} as unknown as WebContext;
|
|
const app = express()
|
|
.use(express.json())
|
|
.use('/api', createConversationsRouter(context));
|
|
|
|
let responseSettled = false;
|
|
const responsePromise = request(app, '/api/conversations/room-alice/send-reply', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ message: 'AI-assisted reply' }),
|
|
}).then((response) => {
|
|
responseSettled = true;
|
|
return response;
|
|
});
|
|
|
|
await vi.waitFor(() => expect(refreshTopChats).toHaveBeenCalledWith(5));
|
|
expect(responseSettled).toBe(false);
|
|
completeSync?.();
|
|
|
|
const response = await responsePromise;
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ success: true });
|
|
});
|
|
});
|