fix: prevent duplicate outreach and follow-ups
This commit is contained in:
1 parent
acc610c2bf
commit
5c01d7d9aa
9 files changed
+274
-14
No files matched your search
@@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationAutomationService } from '../src/services/conversation-automation.js';
|
||||
import type { ConversationState } from '../src/services/conversation-state.js';
|
||||
|
||||
function harness(sellerKeywords: string[] = [], sellerDomains: string[] = []) {
|
||||
function harness(
|
||||
sellerKeywords: string[] = [],
|
||||
sellerDomains: string[] = [],
|
||||
openerSentAt: number | null = 0,
|
||||
) {
|
||||
let saved: ConversationState | undefined;
|
||||
const chat = {
|
||||
refreshTopChats: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -14,15 +18,24 @@ function harness(sellerKeywords: string[] = [], sellerDomains: string[] = []) {
|
||||
get: vi.fn(() => saved),
|
||||
save: vi.fn((_id: string, state: ConversationState) => { saved = structuredClone(state); }),
|
||||
};
|
||||
const openers = {
|
||||
getByChatRoomId: vi.fn(() => openerSentAt === null
|
||||
? undefined
|
||||
: { sentAt: openerSentAt }),
|
||||
};
|
||||
const claims = {
|
||||
tryClaim: vi.fn(() => true),
|
||||
markSent: vi.fn(() => true),
|
||||
};
|
||||
const rateLimiter = {
|
||||
throttle: vi.fn(async (operation: () => Promise<boolean>) => operation()),
|
||||
};
|
||||
const logger = { info: vi.fn() };
|
||||
const service = new ConversationAutomationService(
|
||||
chat as never, ai as never, states as never, rateLimiter as never, logger as never,
|
||||
chat as never, ai as never, states as never, openers as never, claims, rateLimiter as never, logger as never,
|
||||
false, sellerKeywords, sellerDomains,
|
||||
);
|
||||
return { service, chat, ai, states, getSaved: () => saved };
|
||||
return { service, chat, ai, states, openers, claims, getSaved: () => saved };
|
||||
}
|
||||
|
||||
function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ originalUrl: string; localPath: string; fileName: string }>) {
|
||||
@@ -98,4 +111,41 @@ describe('conversation automation', () => {
|
||||
expect(chat.sendChatMessage).toHaveBeenCalledTimes(1);
|
||||
expect(ai.generateMediaAsk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not reply when the room has no recorded opener', async () => {
|
||||
const { service, chat, ai, claims } = harness([], [], null);
|
||||
|
||||
expect(await service.processConversation(incoming(1))).toBeNull();
|
||||
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
|
||||
expect(chat.sendChatMessage).not.toHaveBeenCalled();
|
||||
expect(claims.tryClaim).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reply to an inbound event older than the recorded opener', async () => {
|
||||
const { service, chat, ai, claims } = harness([], [], 4);
|
||||
|
||||
expect(await service.processConversation(incoming(1))).toBeNull();
|
||||
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
|
||||
expect(chat.sendChatMessage).not.toHaveBeenCalled();
|
||||
expect(claims.tryClaim).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('claims an inbound event durably before sending and marks it sent afterwards', async () => {
|
||||
const { service, chat, claims } = harness();
|
||||
|
||||
await service.processConversation(incoming(1));
|
||||
|
||||
expect(claims.tryClaim).toHaveBeenCalledWith('room-alice', 'in-1');
|
||||
expect(claims.tryClaim.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(chat.sendChatMessage.mock.invocationCallOrder[0]);
|
||||
expect(claims.markSent).toHaveBeenCalledWith('room-alice', 'in-1');
|
||||
});
|
||||
|
||||
it('does not send when another worker already claimed the inbound event', async () => {
|
||||
const { service, chat, claims } = harness();
|
||||
claims.tryClaim.mockReturnValue(false);
|
||||
|
||||
expect(await service.processConversation(incoming(1))).toBeNull();
|
||||
expect(chat.sendChatMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+33
-1
@@ -107,7 +107,7 @@ describe('Database Client', () => {
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
|
||||
expect(tables.length).toBe(14);
|
||||
expect(tables.length).toBe(15);
|
||||
});
|
||||
|
||||
it('tracks schema version and migrates a legacy sent_messages table transactionally', () => {
|
||||
@@ -133,6 +133,38 @@ describe('Database Client', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('backfills global recipient suppression from sent messages and known conversations', () => {
|
||||
runMigrations(db);
|
||||
db.prepare(`
|
||||
INSERT INTO reddit_conversations (
|
||||
account_id, conversation_id, username, room_name, payload_json, updated_at
|
||||
) VALUES (?, 'old-room', 'Old_Contact', 'Old Contact', '{}', 20)
|
||||
`).run('__legacy_unassigned__');
|
||||
db.prepare(`
|
||||
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
|
||||
VALUES (?, 'old-post', 'test', 'sent-contact', 'title', 10)
|
||||
`).run('__legacy_unassigned__');
|
||||
db.prepare(`
|
||||
INSERT INTO sent_messages (
|
||||
account_id, post_id, recipient, subject, message, sent_at
|
||||
) VALUES (?, 'old-post', 'Sent_Contact', 'subject', 'message', 11)
|
||||
`).run('__legacy_unassigned__');
|
||||
|
||||
const latestVersion = db.pragma('user_version', { simple: true }) as number;
|
||||
db.exec(`
|
||||
DELETE FROM recipient_suppressions;
|
||||
DROP TABLE conversation_automation_claims;
|
||||
`);
|
||||
db.pragma(`user_version = ${latestVersion - 1}`);
|
||||
runMigrations(db);
|
||||
|
||||
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
|
||||
.toEqual([
|
||||
{ recipient: 'Old_Contact', reason: 'historical-conversation' },
|
||||
{ recipient: 'Sent_Contact', reason: 'sent-message-history' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('processed_posts has correct schema', () => {
|
||||
runMigrations(db);
|
||||
const columns = db.prepare('PRAGMA table_info(processed_posts)').all() as { name: string; notnull: number }[];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { runMigrations } from '../src/database/client.js';
|
||||
import {
|
||||
ActivityLogRepository,
|
||||
ConversationOutcomesRepository,
|
||||
ConversationAutomationClaimRepository,
|
||||
ConversationStateRepository,
|
||||
DeliveryOutboxRepository,
|
||||
MessagesRepository,
|
||||
@@ -101,6 +102,45 @@ describe('account-bound domain repositories', () => {
|
||||
expect(statesB.get('same-room')?.boundaries).toContain('does not want further sexual escalation');
|
||||
});
|
||||
|
||||
it('atomically claims each inbound automation event once per account', () => {
|
||||
const claimsA = new ConversationAutomationClaimRepository(db, ACCOUNT_A);
|
||||
const claimsB = new ConversationAutomationClaimRepository(db, ACCOUNT_B);
|
||||
|
||||
expect(claimsA.tryClaim('same-room', 'same-event', 10)).toBe(true);
|
||||
expect(claimsA.tryClaim('same-room', 'same-event', 11)).toBe(false);
|
||||
expect(claimsB.tryClaim('same-room', 'same-event', 12)).toBe(true);
|
||||
expect(claimsA.markSent('same-room', 'same-event', 13)).toBe(true);
|
||||
expect(claimsA.markSent('same-room', 'same-event', 14)).toBe(false);
|
||||
});
|
||||
|
||||
it('claims recipients globally and blocks usernames with existing chat history', () => {
|
||||
db.prepare(`
|
||||
INSERT INTO reddit_conversations (
|
||||
account_id, conversation_id, username, room_name, payload_json, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
ACCOUNT_A,
|
||||
'historical-room',
|
||||
'Historical_User',
|
||||
'Historical User',
|
||||
JSON.stringify({
|
||||
messages: [{ sender: 'Me', text: 'prior opener', timestamp: 1, isSelf: true }],
|
||||
}),
|
||||
2,
|
||||
);
|
||||
const history = new RecipientHistoryRepository(db);
|
||||
|
||||
expect(history.hasSentTo('historical_user')).toBe(true);
|
||||
expect(history.tryClaim('HISTORICAL_USER', 3)).toBe(false);
|
||||
expect(history.tryClaim('new_user', 4)).toBe(true);
|
||||
expect(history.tryClaim('NEW_USER', 5)).toBe(false);
|
||||
history.releaseClaim('new_user');
|
||||
expect(history.tryClaim('new_user', 6)).toBe(true);
|
||||
history.commitClaim('new_user');
|
||||
history.releaseClaim('new_user');
|
||||
expect(history.hasSentTo('NEW_USER')).toBe(true);
|
||||
});
|
||||
|
||||
it('isolates outbox claims, successful examples, activity and outcomes', () => {
|
||||
const postsA = new PostsRepository(db, ACCOUNT_A);
|
||||
const postsB = new PostsRepository(db, ACCOUNT_B);
|
||||
|
||||
Reference in new issue
Block a user