feat: expand outreach targeting and messaging
This commit is contained in:
1 parent
7defff422d
commit
d0a8bd0e2f
40 files changed
+890
-82
No files matched your search
@@ -11,6 +11,7 @@ describe('agent API discovery', () => {
|
||||
expect(keys).toContain('POST /api/v1/control/start');
|
||||
expect(keys).toContain('POST /api/v1/control/stop');
|
||||
expect(keys).toContain('POST /api/v1/jobs');
|
||||
expect(keys).toContain('POST /api/v1/messages');
|
||||
expect(keys).toContain('GET /api/events/stream');
|
||||
});
|
||||
|
||||
@@ -39,6 +40,7 @@ describe('agent API discovery', () => {
|
||||
expect(document.paths['/api/v1/control/start'].post['x-mutates']).toBe(true);
|
||||
expect(document.paths['/api/v1/jobs'].post.requestBody).toBeDefined();
|
||||
expect((document.paths['/api/v1/jobs'].post.responses as Record<string, unknown>)['202']).toBeDefined();
|
||||
expect(document.paths['/api/v1/messages'].post.requestBody).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns a compact capability catalogue for agents', () => {
|
||||
@@ -48,5 +50,9 @@ describe('agent API discovery', () => {
|
||||
access: 'admin',
|
||||
mutates: true,
|
||||
});
|
||||
expect(result.operations.find((item) => item.operationId === 'sendRedditUserMessage')).toMatchObject({
|
||||
access: 'admin',
|
||||
mutates: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,10 @@ function prepared(candidate: RedditPost): ProcessedPost {
|
||||
post: candidate,
|
||||
imageAnalysis: 'analysis',
|
||||
detectedGender: 'female',
|
||||
attractivenessScore: 64,
|
||||
physiqueScore: 5,
|
||||
physiqueTargeting: { enabled: true, minScore: 3, maxScore: 7 },
|
||||
sourceImageUrl: 'https://i.redd.it/example.jpg',
|
||||
generatedMessage: 'prepared message',
|
||||
strategyId: null,
|
||||
experimentId: null,
|
||||
|
||||
@@ -113,6 +113,7 @@ describe('dashboard foundation', () => {
|
||||
|
||||
it('shows the live outreach stages and the potential posts being burned down', async () => {
|
||||
installSuccessfulApi();
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: true, status: 'authenticated' })),
|
||||
http.get('/api/chats/sync', () => HttpResponse.json({
|
||||
@@ -131,6 +132,17 @@ describe('dashboard foundation', () => {
|
||||
status: 'ready', priority: 2, postCreatedAt: Date.parse('2026-08-09T06:50:00Z'),
|
||||
discoveredAt: Date.parse('2026-08-09T07:00:00Z'), updatedAt: Date.parse('2026-08-09T07:30:00Z'),
|
||||
availableAt: Date.parse('2026-08-09T07:30:00Z'), attemptCount: 1, hasPreparedMessage: true,
|
||||
physiqueScore: 6,
|
||||
preparation: {
|
||||
message: 'You look incredible in that pose — what inspired this post?',
|
||||
imageAnalysis: 'VISIBLE_FACTS:\nBODY: VISIBLE - full-body pose',
|
||||
detectedGender: 'female', attractivenessScore: 68, physiqueScore: 6,
|
||||
physiqueTargeting: { enabled: true, minScore: 4, maxScore: 7 },
|
||||
sourceImageUrl: 'https://i.redd.it/candidate-1.jpg',
|
||||
strategyId: 'direct-observation', experimentId: 'strategies:direct-observation:abc123',
|
||||
promptVersion: 'abc123', selectionReason: 'exploit', modelId: 'example/image-model',
|
||||
toneId: 'direct', scripted: false,
|
||||
},
|
||||
note: null, owner: null,
|
||||
}],
|
||||
})),
|
||||
@@ -141,11 +153,21 @@ describe('dashboard foundation', () => {
|
||||
expect(await screen.findByRole('heading', { name: 'Outreach funnel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '3 active posts' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: 'Active outreach candidates' })).toHaveTextContent('A potential post');
|
||||
expect(screen.getByRole('link', { name: '[25F] A potential post' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.reddit.com/r/example/comments/candidate-1/',
|
||||
);
|
||||
expect(screen.getAllByRole('link', { name: '[25F] A potential post' }))
|
||||
.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ href: 'https://www.reddit.com/r/example/comments/candidate-1/' }),
|
||||
]));
|
||||
expect(within(screen.getByRole('list', { name: 'Active outreach candidates' })).getByText('Message prepared')).toBeInTheDocument();
|
||||
const preparation = screen.getByRole('region', { name: 'Prepared outreach for u/alice' });
|
||||
expect(preparation).toHaveTextContent('You look incredible in that pose');
|
||||
expect(preparation).toHaveTextContent('68/100');
|
||||
expect(preparation).toHaveTextContent('6/10');
|
||||
expect(preparation).toHaveTextContent('Target 4–7');
|
||||
expect(preparation).toHaveTextContent('example/image-model');
|
||||
const sourceImage = within(preparation).getByRole('img', { name: 'Source post: [25F] A potential post' });
|
||||
expect(sourceImage).toHaveClass('is-blurred');
|
||||
await user.click(within(preparation).getByRole('button', { name: 'Reveal source image: [25F] A potential post' }));
|
||||
expect(sourceImage).not.toHaveClass('is-blurred');
|
||||
});
|
||||
|
||||
it('hydrates media filters from the routed URL', async () => {
|
||||
|
||||
@@ -13,7 +13,12 @@ import { SettingsRoute } from '../../apps/dashboard/src/features/settings/settin
|
||||
const config = {
|
||||
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
|
||||
browser: { dryRun: true },
|
||||
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25 },
|
||||
reddit: {
|
||||
prioritizeRecentPosts: false,
|
||||
recentWindowMinutes: 60,
|
||||
maxReadyCandidates: 25,
|
||||
physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 },
|
||||
},
|
||||
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
|
||||
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
|
||||
advanced: { openrouter: { chatModel: 'example/chat-model' } },
|
||||
@@ -204,6 +209,46 @@ describe('settings and access', () => {
|
||||
expect(screen.queryByText('Settings saved.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates and saves the physique targeting range', async () => {
|
||||
installConfigApi();
|
||||
let requestBody: { reddit?: typeof config.reddit } | undefined;
|
||||
server.use(http.put('/api/config', async ({ request }) => {
|
||||
requestBody = await request.json() as typeof requestBody;
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
config: {
|
||||
rateLimit: config.rateLimit,
|
||||
browser: config.browser,
|
||||
reddit: requestBody?.reddit ?? config.reddit,
|
||||
},
|
||||
subreddits: config.subreddits,
|
||||
});
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderSettings('/settings?section=monitoring');
|
||||
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'Enable physique targeting' }));
|
||||
const minimum = screen.getByRole('spinbutton', { name: 'Minimum physique score' });
|
||||
const maximum = screen.getByRole('spinbutton', { name: 'Maximum physique score' });
|
||||
await user.clear(minimum);
|
||||
await user.type(minimum, '7');
|
||||
await user.clear(maximum);
|
||||
await user.type(maximum, '4');
|
||||
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||
expect(screen.getByText('Minimum physique score cannot exceed maximum physique score.')).toBeInTheDocument();
|
||||
expect(requestBody).toBeUndefined();
|
||||
|
||||
await user.clear(maximum);
|
||||
await user.type(maximum, '9');
|
||||
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||
|
||||
await waitFor(() => expect(requestBody?.reddit?.physiqueTargeting).toEqual({
|
||||
enabled: true,
|
||||
minScore: 7,
|
||||
maxScore: 9,
|
||||
}));
|
||||
});
|
||||
|
||||
it('validates subreddit rows and confirms disabling dry run before saving behaviour', async () => {
|
||||
installConfigApi();
|
||||
let updateRequests = 0;
|
||||
|
||||
@@ -29,10 +29,11 @@ describe('RedditBot MCP server', () => {
|
||||
|
||||
expect(names).toEqual(expect.arrayContaining([
|
||||
'get_overview', 'list_accounts', 'list_conversations', 'start_bot', 'stop_bot',
|
||||
'set_bot_running', 'trigger_sync', 'send_reply', 'start_conversation',
|
||||
'set_bot_running', 'trigger_sync', 'send_reply', 'send_message_to_user', 'start_conversation',
|
||||
]));
|
||||
expect(tools.tools.find((tool) => tool.name === 'get_overview')?.annotations?.readOnlyHint).toBe(true);
|
||||
expect(tools.tools.find((tool) => tool.name === 'send_reply')?.annotations?.destructiveHint).toBe(true);
|
||||
expect(tools.tools.find((tool) => tool.name === 'send_message_to_user')?.annotations?.destructiveHint).toBe(true);
|
||||
});
|
||||
|
||||
it('routes start and stop tools to the versioned control API', async () => {
|
||||
@@ -44,4 +45,12 @@ describe('RedditBot MCP server', () => {
|
||||
expect(post).toHaveBeenNthCalledWith(1, '/api/v1/control/start');
|
||||
expect(post).toHaveBeenNthCalledWith(2, '/api/v1/control/stop');
|
||||
});
|
||||
|
||||
it('routes automatic user messages to the versioned messaging API', async () => {
|
||||
const { client, post } = await connectedClient();
|
||||
|
||||
await client.callTool({ name: 'send_message_to_user', arguments: { username: 'alice', message: 'hello' } });
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/api/v1/messages', { username: 'alice', message: 'hello' });
|
||||
});
|
||||
});
|
||||
@@ -315,6 +315,26 @@ describe('OpenRouterClient', () => {
|
||||
expect(result.attractivenessScore).toBeNull();
|
||||
});
|
||||
|
||||
it('parses and removes a grounded PHYSIQUE_SCORE', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:female\nPHYSIQUE_SCORE:7\nRATING:73\n\nDescription here.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.physiqueScore).toBe(7);
|
||||
expect(result.description).toBe('Description here.');
|
||||
});
|
||||
|
||||
it('returns null for PHYSIQUE_SCORE:UNKNOWN', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:female\nPHYSIQUE_SCORE:UNKNOWN\n\nClose-cropped portrait.' } }],
|
||||
});
|
||||
|
||||
const result = await client.analyzeImage('base64data', 'image/jpeg');
|
||||
expect(result.physiqueScore).toBeNull();
|
||||
expect(result.description).toBe('Close-cropped portrait.');
|
||||
});
|
||||
|
||||
it('handles case-insensitive GENDER prefix', async () => {
|
||||
mockCreate.mockResolvedValueOnce({
|
||||
choices: [{ message: { content: 'GENDER:FEMALE\n\nA woman.' } }],
|
||||
|
||||
+75
-19
@@ -24,13 +24,18 @@ function createMockRedditApi(imageUrl: string | null = 'http://example.com/img.j
|
||||
|
||||
function createMockAiClient(
|
||||
gender: DetectedGender = 'female',
|
||||
analysis: ImageAnalysisResult = { description: 'Beautiful woman', detectedGender: 'female', attractivenessScore: null },
|
||||
analysis: ImageAnalysisResult = {
|
||||
description: 'Beautiful woman',
|
||||
detectedGender: 'female',
|
||||
attractivenessScore: null,
|
||||
physiqueScore: null,
|
||||
},
|
||||
message: string = 'Hey gorgeous!'
|
||||
): OpenRouterClient {
|
||||
return {
|
||||
detectGenderOnly: vi.fn(async () => gender),
|
||||
analyzeImage: vi.fn(async () => analysis),
|
||||
generateMessage: vi.fn(async (analysis: string) => message),
|
||||
generateMessage: vi.fn(async () => message),
|
||||
generateReply: vi.fn(async () => 'Reply'),
|
||||
detectUpsell: vi.fn(() => ({ isUpsell: false })),
|
||||
analyzeAndGenerateMessage: vi.fn(async (base64: string, mimeType: string, title: string, author: string, examples?: string[], strategyPrompt?: string, postFlair?: string | null, toneGuidance?: string) => {
|
||||
@@ -123,9 +128,60 @@ describe('ProcessorService', () => {
|
||||
expect(isSkip(result)).toBe(false);
|
||||
});
|
||||
|
||||
it('screens out a scored image outside the enabled physique range before generating a message', async () => {
|
||||
aiClient = createMockAiClient('female', {
|
||||
description: 'Visible full-body pose',
|
||||
detectedGender: 'female',
|
||||
attractivenessScore: 72,
|
||||
physiqueScore: 8,
|
||||
});
|
||||
const config = getTestConfig({
|
||||
reddit: { physiqueTargeting: { enabled: true, minScore: 3, maxScore: 6 } },
|
||||
});
|
||||
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
|
||||
|
||||
const result = await processor.processPost(mockPost);
|
||||
|
||||
expect(result).toEqual({ skipReason: 'physique score 8 outside target range 3-6' });
|
||||
expect(aiClient.generateMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('screens out an unscorable image when physique targeting is enabled', async () => {
|
||||
const config = getTestConfig({
|
||||
reddit: { physiqueTargeting: { enabled: true, minScore: 3, maxScore: 6 } },
|
||||
});
|
||||
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
|
||||
|
||||
const result = await processor.processPost(mockPost);
|
||||
|
||||
expect(result).toEqual({
|
||||
skipReason: 'physique score unavailable while physique targeting is enabled',
|
||||
});
|
||||
expect(aiClient.generateMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains the physique score when it falls inside the enabled range', async () => {
|
||||
aiClient = createMockAiClient('female', {
|
||||
description: 'Visible full-body pose',
|
||||
detectedGender: 'female',
|
||||
attractivenessScore: 66,
|
||||
physiqueScore: 5,
|
||||
});
|
||||
const config = getTestConfig({
|
||||
reddit: { physiqueTargeting: { enabled: true, minScore: 3, maxScore: 6 } },
|
||||
});
|
||||
processor = new ProcessorService(redditApi, aiClient, config, logger, fakeFetchImage);
|
||||
|
||||
const result = await processor.processPost(mockPost);
|
||||
if (isSkip(result)) throw new Error(`expected success, got skip: ${result.skipReason}`);
|
||||
|
||||
expect(result.physiqueScore).toBe(5);
|
||||
expect(aiClient.generateMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('skips when AI refuses', async () => {
|
||||
const error = new Error('AI_REFUSAL: All models refused to analyze image');
|
||||
vi.mocked(aiClient.analyzeAndGenerateMessage).mockRejectedValue(error);
|
||||
vi.mocked(aiClient.analyzeImage).mockRejectedValue(error);
|
||||
|
||||
const result = await processor.processPost(mockPost);
|
||||
expect(isSkip(result)).toBe(true);
|
||||
@@ -134,14 +190,14 @@ describe('ProcessorService', () => {
|
||||
|
||||
it('re-throws non-AI_REFUSAL errors', async () => {
|
||||
const error = new Error('Network error');
|
||||
vi.mocked(aiClient.analyzeAndGenerateMessage).mockRejectedValue(error);
|
||||
vi.mocked(aiClient.analyzeImage).mockRejectedValue(error);
|
||||
|
||||
await expect(processor.processPost(mockPost)).rejects.toThrow('Network error');
|
||||
});
|
||||
|
||||
it('skips when generated message is too short', async () => {
|
||||
aiClient = createMockAiClient('female',
|
||||
{ description: 'Nice', detectedGender: 'female' },
|
||||
{ description: 'Nice', detectedGender: 'female', attractivenessScore: null, physiqueScore: null },
|
||||
'Short'
|
||||
);
|
||||
const config = getTestConfig();
|
||||
@@ -154,7 +210,7 @@ describe('ProcessorService', () => {
|
||||
|
||||
it('skips when generated message is empty', async () => {
|
||||
aiClient = createMockAiClient('female',
|
||||
{ description: 'Nice', detectedGender: 'female' },
|
||||
{ description: 'Nice', detectedGender: 'female', attractivenessScore: null, physiqueScore: null },
|
||||
''
|
||||
);
|
||||
const config = getTestConfig();
|
||||
@@ -188,13 +244,13 @@ describe('ProcessorService', () => {
|
||||
|
||||
await processor.processPost(mockPost);
|
||||
|
||||
expect(aiClient.analyzeAndGenerateMessage).toHaveBeenCalledWith(
|
||||
expect.any(String), // base64
|
||||
'image/jpeg',
|
||||
expect(aiClient.generateMessage).toHaveBeenCalledWith(
|
||||
'Beautiful woman',
|
||||
'Test Post',
|
||||
'testuser',
|
||||
['Example 1: Hey', 'Example 2: Nice'],
|
||||
undefined,
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
@@ -211,13 +267,13 @@ describe('ProcessorService', () => {
|
||||
|
||||
await processor.processPost(mockPost);
|
||||
|
||||
expect(aiClient.analyzeAndGenerateMessage).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'image/jpeg',
|
||||
expect(aiClient.generateMessage).toHaveBeenCalledWith(
|
||||
'Beautiful woman',
|
||||
'Test Post',
|
||||
'testuser',
|
||||
[],
|
||||
'Be teasing',
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
@@ -238,13 +294,13 @@ describe('ProcessorService', () => {
|
||||
|
||||
await processor.processPost(mockPost);
|
||||
|
||||
expect(aiClient.analyzeAndGenerateMessage).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'image/jpeg',
|
||||
expect(aiClient.generateMessage).toHaveBeenCalledWith(
|
||||
'Beautiful woman',
|
||||
'Test Post',
|
||||
'testuser',
|
||||
[],
|
||||
undefined,
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
recentMessages,
|
||||
@@ -281,13 +337,13 @@ describe('ProcessorService', () => {
|
||||
|
||||
await processor.processPost(mockPost);
|
||||
|
||||
expect(aiClient.analyzeAndGenerateMessage).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'image/jpeg',
|
||||
expect(aiClient.generateMessage).toHaveBeenCalledWith(
|
||||
'Beautiful woman',
|
||||
'Test Post',
|
||||
'testuser',
|
||||
['Example A'],
|
||||
undefined,
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
@@ -428,7 +484,7 @@ describe('ProcessorService', () => {
|
||||
await processor.processPost(tonedPost);
|
||||
|
||||
// 8th arg (index 7) after strategyPrompt and postFlair is toneGuidance
|
||||
const callArgs = vi.mocked(aiClient.analyzeAndGenerateMessage).mock.calls[0];
|
||||
const callArgs = vi.mocked(aiClient.generateMessage).mock.calls[0];
|
||||
expect(callArgs[7]).toContain('STYLE TONE');
|
||||
expect(callArgs[7]).toContain('commanding');
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ describe('materializePreparedPostForAccount', () => {
|
||||
thumbnail: '', is_self: false, created_utc: 123, over_18: true, spoiler: false,
|
||||
};
|
||||
const processed: ProcessedPost = {
|
||||
post, imageAnalysis: 'analysis', detectedGender: 'female', generatedMessage: 'message',
|
||||
post, imageAnalysis: 'analysis', detectedGender: 'female', attractivenessScore: 64,
|
||||
physiqueScore: 5, physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 },
|
||||
sourceImageUrl: 'https://example.com/image.jpg', generatedMessage: 'message',
|
||||
strategyId: null, experimentId: null, promptVersion: null, selectionReason: null,
|
||||
modelId: 'model', sourceType: 'image', toneId: 'default', scripted: false,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RedditUserMessenger } from '../src/services/reddit-user-messenger.js';
|
||||
import type { RedditAccountRuntime, RedditAccountRuntimeManager } from '../src/services/reddit-account-runtime.js';
|
||||
|
||||
function runtime(accountId: string, redditUsername: string, conversations: Array<Record<string, unknown>>, sends = 0) {
|
||||
return {
|
||||
session: { accountId, redditUsername },
|
||||
resources: {
|
||||
accountAuth: { isFullyAuthenticated: () => true },
|
||||
chatService: {
|
||||
getCachedConversations: vi.fn().mockResolvedValue(conversations),
|
||||
getInvitationCooldown: () => null,
|
||||
sendChatMessage: vi.fn().mockResolvedValue(true),
|
||||
startConversationDetailed: vi.fn().mockResolvedValue({
|
||||
status: 'sent',
|
||||
chat: { username: 'new-user', roomId: 'new-room', messages: [] },
|
||||
}),
|
||||
refreshTopChats: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
rateLimiter: {
|
||||
getStats: () => ({ messagesThisHour: sends }),
|
||||
throttle: vi.fn((work: () => Promise<unknown>) => work()),
|
||||
},
|
||||
activityLogRepo: { insert: vi.fn() },
|
||||
conversationStateRepo: { get: vi.fn(), save: vi.fn() },
|
||||
},
|
||||
} as unknown as RedditAccountRuntime;
|
||||
}
|
||||
|
||||
function manager(items: RedditAccountRuntime[]) {
|
||||
return {
|
||||
list: () => items.map((item) => ({
|
||||
accountId: item.session.accountId,
|
||||
redditUsername: item.session.redditUsername,
|
||||
matrixUserId: item.session.accountId,
|
||||
state: 'running',
|
||||
updatedAt: 1,
|
||||
failure: null,
|
||||
})),
|
||||
get: (accountId: string) => items.find((item) => item.session.accountId === accountId) ?? null,
|
||||
} as unknown as RedditAccountRuntimeManager;
|
||||
}
|
||||
|
||||
describe('RedditUserMessenger', () => {
|
||||
it('prefers the account with the most recent existing interaction', async () => {
|
||||
const first = runtime('one', 'owner-one', [{
|
||||
username: 'Alice', roomId: 'old-room', url: '/room/old-room', lastMessageTime: 10,
|
||||
messages: [],
|
||||
}]);
|
||||
const second = runtime('two', 'owner-two', [{
|
||||
username: 'u/alice', roomId: 'recent-room', url: '/room/recent-room', lastMessageTime: 20,
|
||||
messages: [],
|
||||
}]);
|
||||
|
||||
const result = await new RedditUserMessenger(manager([first, second])).send('u/Alice', 'hello');
|
||||
|
||||
expect(result).toMatchObject({ accountId: 'two', conversationId: 'recent-room', reusedConversation: true });
|
||||
expect(second.resources.chatService.sendChatMessage).toHaveBeenCalledWith('/room/recent-room', 'hello');
|
||||
expect(first.resources.chatService.sendChatMessage).not.toHaveBeenCalled();
|
||||
expect(second.resources.conversationStateRepo.save).toHaveBeenCalledWith('recent-room', expect.objectContaining({
|
||||
automation: expect.objectContaining({ status: 'user-overridden' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('starts a new conversation on the running account with the lowest send count', async () => {
|
||||
const busy = runtime('busy', 'z-owner', [], 4);
|
||||
const available = runtime('available', 'a-owner', [], 1);
|
||||
|
||||
const result = await new RedditUserMessenger(manager([busy, available])).send('new-user', 'hello');
|
||||
|
||||
expect(result).toMatchObject({ accountId: 'available', conversationId: 'new-room', reusedConversation: false });
|
||||
expect(available.resources.chatService.startConversationDetailed).toHaveBeenCalledWith('new-user', 'hello');
|
||||
expect(busy.resources.chatService.startConversationDetailed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
|
||||
prioritizeRecentPosts: false,
|
||||
recentWindowMinutes: 60,
|
||||
maxReadyCandidates: 25,
|
||||
physiqueTargeting: { enabled: false, minScore: 1, maxScore: 10 },
|
||||
...overrides?.reddit,
|
||||
},
|
||||
openrouter: {
|
||||
|
||||
@@ -109,3 +109,43 @@ describe('config route conversation kill controls', () => {
|
||||
expect(config.conversations.aiReplyScreening.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config route physique targeting', () => {
|
||||
it('hot-updates and persists the image-candidate score range', async () => {
|
||||
const { app, config, saved } = harness();
|
||||
const targetingRef = config.reddit.physiqueTargeting;
|
||||
|
||||
const response = await request(app, '/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reddit: {
|
||||
physiqueTargeting: { enabled: true, minScore: 3, maxScore: 7 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(config.reddit.physiqueTargeting).toBe(targetingRef);
|
||||
expect(config.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 });
|
||||
expect(saved.at(-1)?.reddit.physiqueTargeting).toEqual({ enabled: true, minScore: 3, maxScore: 7 });
|
||||
});
|
||||
|
||||
it('rejects an inverted physique range without mutating configuration', async () => {
|
||||
const { app, config, saved } = harness();
|
||||
|
||||
const response = await request(app, '/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
reddit: {
|
||||
physiqueTargeting: { enabled: true, minScore: 8, maxScore: 4 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(config.reddit.physiqueTargeting).toEqual({ enabled: false, minScore: 1, maxScore: 10 });
|
||||
expect(saved).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('outreach route', () => {
|
||||
it('publishes active candidates and queue outcomes without prepared message content', async () => {
|
||||
it('publishes prepared candidate context without leaking worker lease internals', async () => {
|
||||
const candidateQueue = {
|
||||
getStats: vi.fn(() => ({
|
||||
total: 9,
|
||||
@@ -37,7 +37,22 @@ describe('outreach route', () => {
|
||||
title: '[25F] Hello', url: 'https://i.redd.it/post-1.jpg', permalink: '/r/example/comments/post-1/hello/',
|
||||
thumbnail: '', is_self: false, created_utc: 100, over_18: true, spoiler: false,
|
||||
},
|
||||
prepared: { generatedMessage: 'private prepared text' }, priority: 2, status: 'sending',
|
||||
prepared: {
|
||||
generatedMessage: 'Prepared opener for Alice',
|
||||
imageAnalysis: 'VISIBLE_FACTS:\nBODY: VISIBLE - full-body pose',
|
||||
detectedGender: 'female',
|
||||
attractivenessScore: 68,
|
||||
physiqueScore: 6,
|
||||
physiqueTargeting: { enabled: true, minScore: 4, maxScore: 7 },
|
||||
sourceImageUrl: 'https://i.redd.it/post-1.jpg',
|
||||
strategyId: 'direct-observation',
|
||||
experimentId: 'strategies:direct-observation:abc123',
|
||||
promptVersion: 'abc123',
|
||||
selectionReason: 'exploit',
|
||||
modelId: 'example/image-model',
|
||||
toneId: 'direct',
|
||||
scripted: false,
|
||||
}, priority: 2, status: 'sending',
|
||||
discoveredAt: 1_000, updatedAt: 2_000, availableAt: 2_000,
|
||||
leaseOwner: 'worker-secret', leaseExpiresAt: 3_000, assignedAccountId: 'account-a',
|
||||
attemptCount: 2, lastError: null,
|
||||
@@ -64,9 +79,16 @@ describe('outreach route', () => {
|
||||
postId: 'post-1', status: 'sending', hasPreparedMessage: true,
|
||||
postUrl: 'https://www.reddit.com/r/example/comments/post-1/hello/',
|
||||
owner: { accountId: 'account-a', redditUsername: 'operator' },
|
||||
preparation: {
|
||||
message: 'Prepared opener for Alice',
|
||||
attractivenessScore: 68,
|
||||
physiqueScore: 6,
|
||||
physiqueTargeting: { enabled: true, minScore: 4, maxScore: 7 },
|
||||
sourceImageUrl: 'https://i.redd.it/post-1.jpg',
|
||||
strategyId: 'direct-observation',
|
||||
},
|
||||
}],
|
||||
});
|
||||
expect(JSON.stringify(body)).not.toContain('private prepared text');
|
||||
expect(JSON.stringify(body)).not.toContain('worker-secret');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user