fix: prevent duplicate outreach and follow-ups

This commit is contained in:
jjbrunton committed 2026-08-09 07:48:39 +01:00
1 parent acc610c2bf
commit 5c01d7d9aa
9 files changed
+274 -14

No files matched your search

+34
View File
@@ -217,6 +217,40 @@ export function runMigrations(db: Database.Database): void {
);
`);
},
() => db.exec(`
CREATE INDEX IF NOT EXISTS idx_reddit_conversations_global_username
ON reddit_conversations(username COLLATE NOCASE);
CREATE INDEX IF NOT EXISTS idx_sent_messages_global_recipient
ON sent_messages(recipient COLLATE NOCASE);
INSERT OR IGNORE INTO recipient_suppressions (recipient, reason, created_at)
SELECT recipient, 'sent-message-history', MIN(sent_at)
FROM sent_messages
WHERE trim(recipient) <> ''
GROUP BY recipient COLLATE NOCASE;
INSERT OR IGNORE INTO recipient_suppressions (recipient, reason, created_at)
SELECT rc.username, 'historical-conversation', MIN(rc.updated_at)
FROM reddit_conversations rc
WHERE trim(rc.username) <> ''
AND lower(trim(rc.username)) NOT IN ('unknown', '[deleted]')
AND lower(trim(rc.username)) NOT LIKE 't2\\_%' ESCAPE '\\'
AND trim(rc.username) NOT LIKE '@%'
GROUP BY rc.username COLLATE NOCASE;
CREATE TABLE conversation_automation_claims (
account_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
incoming_key TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending', 'sent')),
claimed_at INTEGER NOT NULL,
sent_at INTEGER,
PRIMARY KEY (account_id, conversation_id, incoming_key),
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
);
CREATE INDEX idx_conversation_automation_claims_status
ON conversation_automation_claims(account_id, status, claimed_at);
`),
];
let currentVersion = db.pragma('user_version', { simple: true }) as number;
+66 -2
View File
@@ -120,9 +120,12 @@ export interface DeliveryOutboxRecord {
sentMessageId: number | null;
}
/** Read-only recipient history shared by every Reddit account runtime. */
/** Global recipient history and durable pre-send claims shared by all account runtimes. */
export class RecipientHistoryRepository {
private readonly hasSentToStmt: Database.Statement;
private readonly tryClaimStmt: Database.Statement;
private readonly commitClaimStmt: Database.Statement;
private readonly releaseClaimStmt: Database.Statement;
constructor(db: Database.Database) {
this.hasSentToStmt = db.prepare(`
@@ -130,13 +133,49 @@ export class RecipientHistoryRepository {
SELECT recipient FROM sent_messages WHERE recipient = ? COLLATE NOCASE
UNION ALL
SELECT recipient FROM recipient_suppressions WHERE recipient = ? COLLATE NOCASE
UNION ALL
SELECT username AS recipient
FROM reddit_conversations
WHERE username = ? COLLATE NOCASE
)
LIMIT 1
`);
this.tryClaimStmt = db.prepare(`
INSERT INTO recipient_suppressions (recipient, reason, created_at)
SELECT ?, 'pending-send', ?
WHERE NOT EXISTS (
SELECT 1 FROM sent_messages WHERE recipient = ? COLLATE NOCASE
)
AND NOT EXISTS (
SELECT 1 FROM reddit_conversations
WHERE username = ? COLLATE NOCASE
)
ON CONFLICT(recipient) DO NOTHING
`);
this.commitClaimStmt = db.prepare(`
UPDATE recipient_suppressions SET reason = 'sent'
WHERE recipient = ? COLLATE NOCASE AND reason = 'pending-send'
`);
this.releaseClaimStmt = db.prepare(`
DELETE FROM recipient_suppressions
WHERE recipient = ? COLLATE NOCASE AND reason = 'pending-send'
`);
}
hasSentTo(recipient: string): boolean {
return this.hasSentToStmt.get(recipient, recipient) !== undefined;
return this.hasSentToStmt.get(recipient, recipient, recipient) !== undefined;
}
tryClaim(recipient: string, now: number = Date.now()): boolean {
return this.tryClaimStmt.run(recipient, now, recipient, recipient).changes === 1;
}
commitClaim(recipient: string): void {
this.commitClaimStmt.run(recipient);
}
releaseClaim(recipient: string): void {
this.releaseClaimStmt.run(recipient);
}
}
@@ -1022,6 +1061,31 @@ export class ConversationStateRepository {
}
}
/** Durable idempotency claims for AI replies to individual inbound events. */
export class ConversationAutomationClaimRepository {
constructor(
private readonly db: Database.Database,
private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID,
) {}
tryClaim(conversationId: string, incomingKey: string, now: number = Date.now()): boolean {
return this.db.prepare(`
INSERT INTO conversation_automation_claims (
account_id, conversation_id, incoming_key, status, claimed_at
) VALUES (?, ?, ?, 'pending', ?)
ON CONFLICT(account_id, conversation_id, incoming_key) DO NOTHING
`).run(this.accountId, conversationId, incomingKey, now).changes === 1;
}
markSent(conversationId: string, incomingKey: string, now: number = Date.now()): boolean {
return this.db.prepare(`
UPDATE conversation_automation_claims
SET status = 'sent', sent_at = ?
WHERE account_id = ? AND conversation_id = ? AND incoming_key = ? AND status = 'pending'
`).run(now, this.accountId, conversationId, incomingKey).changes === 1;
}
}
/** Outcome labels are recorded independently from a raw inbox response. */
export class ConversationOutcomesRepository {
constructor(