Harden conversation replies and prioritize newest candidates
This commit is contained in:
1 parent
f98c5c732d
commit
b8e4a1bb88
23 files changed
+598
-113
No files matched your search
+13
-5
@@ -25,7 +25,7 @@ stateDiagram-v2
|
||||
|
||||
The discovery loop rotates through enabled subreddits and authenticated runtimes. A runtime fetches Reddit `/new` data using the process-shared `RedditRequestCoordinator`. Intake deduplicates post IDs and authors, excludes processed/contacted history, stores the full post snapshot, source type, weight, and timestamps, and logs exact intake counts.
|
||||
|
||||
The queue grows to a demand-based target rather than a fixed backlog. The target is the smaller of configured `reddit.maxReadyCandidates` and the work that authenticated running accounts can still send within the recent-post window, accounting for rolling hourly capacity and minimum message spacing. Intake admits only the current deficit, pauses at zero outbound capacity, and reports fetched posts left unevaluated because the funnel is full. Reddit request pacing, not a tight application poll, remains the primary request limiter.
|
||||
The queue grows to a demand-based target rather than a fixed backlog. The target is the smaller of configured `reddit.maxReadyCandidates` and the work that authenticated running accounts can still send within the recent-post window, accounting for rolling hourly capacity and minimum message spacing. Intake admits only the current deficit, newest Reddit posts first, pauses at zero outbound capacity, and reports fetched posts left unevaluated because the funnel is full. Reddit request pacing, not a tight application poll, remains the primary request limiter.
|
||||
|
||||
### 2. Screening and preparation
|
||||
|
||||
@@ -46,7 +46,7 @@ An account is eligible for new outreach only when its runtime is running, both i
|
||||
|
||||
Matrix cooldowns retain the operation that triggered them. A room-creation or invitation limit pauses new outreach but does not suppress replies in existing rooms; a limit returned while sending into an existing room pauses those sends as well.
|
||||
|
||||
The dispatch worker leases a ready candidate for an account, materializes the processed post in that account's repository, applies the account rate limiter, checks dry-run, and atomically claims the recipient globally. `ChatService.startConversationDetailed()` returns one of:
|
||||
Screening, retry promotion, dispatch, and the operator's active-queue view are ordered by the Reddit post creation time, newest first; source priority is only a tie-breaker. The dispatch worker leases the newest ready candidate for an account, materializes the processed post in that account's repository, applies the account rate limiter, checks dry-run, and atomically claims the recipient globally. `ChatService.startConversationDetailed()` returns one of:
|
||||
|
||||
- `sent`: persist the message and outcome metadata, commit recipient history, update activity, and mark the candidate sent;
|
||||
- `terminal`: release the recipient reservation and reject the candidate;
|
||||
@@ -77,12 +77,18 @@ flowchart TD
|
||||
STOP -- no --> GEN["Generate policy-specific reply"]
|
||||
GEN --> SAFE{"Outbound checks pass?"}
|
||||
SAFE -- no --> HANDOFF
|
||||
SAFE -- yes --> PERSIST["Claim event and persist intended state"]
|
||||
PERSIST --> SEND["Rate-limited Matrix send"]
|
||||
SAFE -- yes --> PERSIST["Persist intended reply claim"]
|
||||
PERSIST --> WAIT["Wait for the account rate limiter"]
|
||||
WAIT --> RECHECK{"Refresh Matrix; claimed event still latest and safe?"}
|
||||
RECHECK -- no --> SUPERSEDE["Mark claim superseded; persist boundary/handoff if present"]
|
||||
RECHECK -- yes --> SNAPSHOT["Persist intended conversation state"]
|
||||
SNAPSHOT --> SEND["Matrix send"]
|
||||
SEND --> MARK["Mark claim sent and record latency"]
|
||||
```
|
||||
|
||||
The inbound event key, intended reply, claim status, and conversation state make retries idempotent. If the process dies after Matrix accepted a reply, reconciliation finds the outbound event and marks the old claim sent instead of sending it again.
|
||||
The inbound event key, intended reply, claim status, and conversation state make retries idempotent. Immediately after any limiter wait, automation refreshes Matrix and revalidates the latest inbound event, boundaries, images, seller evidence, policy status, and the one-media-request limit. Newer state makes the old claim terminally `superseded`; it is never sent. If the process dies after Matrix accepted a reply, reconciliation finds the outbound event and marks the old claim sent instead of sending it again.
|
||||
|
||||
Legacy conversations may make at most one optional autonomous media request, including a request already present in the opener. The hybrid policy retains its single rapport-gated media ask. Deterministic, sequence-aware refusal checks run before generation and again at send time; generated media asks must pass the same non-explicit, non-coercive validator.
|
||||
|
||||
## Chat and media synchronization
|
||||
|
||||
@@ -90,6 +96,8 @@ The inbound event key, intended reply, claim status, and conversation state make
|
||||
|
||||
`MediaService` resolves Matrix media, downloads it into the persistent data directory, and writes an account-scoped media index. `MediaSyncService` schedules incremental work only while the account is authenticated. Private media is served behind the same authorization boundary as the dashboard and APIs.
|
||||
|
||||
Conversation performance is derived from the complete attributed inbound transcript. Engagement cannot become a prompt-learning example while automation is active, and a later decline, boundary, or upsell removes an earlier example before future prompts load it.
|
||||
|
||||
## Dashboard data and events
|
||||
|
||||
Routes assemble snapshots from repositories and injected services. `LogBroadcaster`/the realtime event hub publishes typed events for bot state, sends, failures, conversations, replies, sync, sessions, rate limits, and logs. The React client validates incoming events, invalidates matching TanStack Query keys, and reconnects with replay support. Cached screens remain visible but stale during a disconnect.
|
||||
|
||||
+5
-3
@@ -21,7 +21,7 @@ The special legacy-unassigned account exists only to preserve rows created befor
|
||||
|
||||
- `processed_posts`: account/post key, source metadata, analysis, generated opener, send marker, and terminal error/rejection reason.
|
||||
- `sent_messages`: account-owned opener/reply attribution, strategy/experiment/model metadata, Matrix room, response/image flags, and source type.
|
||||
- `successful_messages`: examples derived from positive outcomes for prompt context.
|
||||
- `successful_messages`: mature positive examples for prompt context; terminal negative reclassification removes the linked example.
|
||||
- `conversation_outcomes`: normalized outcome for a sent message (`engaged`, `declined`, `boundary`, `upsell`, or `unknown`).
|
||||
- `activity_log`: operator-facing events with optional recipient/post context.
|
||||
- `reddit_users`: global mapping between Reddit fullname/username and Matrix identity.
|
||||
@@ -32,7 +32,7 @@ The special legacy-unassigned account exists only to preserve rows created befor
|
||||
- `candidate_queue`: global candidate snapshot, source/priority, state, prepared payload, worker lease, retry timing, selected account, and rejection/error details.
|
||||
- `delivery_outbox`: account-scoped prepared delivery and idempotency status retained for legacy/recovery paths.
|
||||
- `conversation_states`: account/conversation policy and derived state JSON.
|
||||
- `conversation_automation_claims`: account/conversation/inbound-event claim with intended reply and pending/sent state.
|
||||
- `conversation_automation_claims`: account/conversation/inbound-event claim with intended reply and `pending`, `sent`, or terminal `superseded` state plus audit timestamps.
|
||||
- `runtime_state`: small process controls such as whether bot work is running.
|
||||
|
||||
### Configuration and telemetry
|
||||
@@ -71,10 +71,12 @@ Any new candidate state needs:
|
||||
|
||||
## Conversation reply claims
|
||||
|
||||
The unique identity is account + conversation + inbound message key. The claim persists the intended reply before the remote send. A pending claim is not blindly retried: Matrix history is checked first. This closes the process-crash window between remote acceptance and local completion.
|
||||
The unique identity is account + conversation + inbound message key. The claim persists the intended reply before the remote send. A pending claim is not blindly retried: Matrix history is checked first. After rate-limit waiting, Matrix is refreshed again; a newer event, boundary, received image, seller signal, operator-owned state, or repeated media request changes the claim to `superseded` and records `superseded_at`. This closes both the process-crash window between remote acceptance and local completion and the stale-send window while a reply is queued.
|
||||
|
||||
Changes to this flow must prove at-most-once observable replies across retry and restart tests.
|
||||
|
||||
Outcome rows are re-evaluated from the complete attributed recipient transcript. Negative outcomes take precedence over reciprocal activity. `successful_messages` rows are created only after the conversation becomes terminal/human-owned and are deleted by `sent_message_id` when later evidence changes the outcome.
|
||||
|
||||
## Configuration persistence
|
||||
|
||||
On an empty database, `config.yaml` is parsed, environment-owned values are injected, the result is validated, and the application-owned subset is saved to `app_config`. Later starts load SQLite first. Dashboard changes update that record.
|
||||
|
||||
@@ -19,7 +19,7 @@ The product has five connected goals:
|
||||
- Candidate inventory follows near-term outbound capacity and the recent-post window; discovery pauses instead of accumulating an ever-older backlog when accounts cannot keep pace.
|
||||
- Discovery is global while authentication, rate limits, conversations, and content remain account-owned.
|
||||
- Recipient deduplication is case-insensitive, durable across restarts, and protected by a live reservation during sends.
|
||||
- Remote side effects are fail-closed: a send is recorded only after the chat service confirms it; interrupted reply claims reconcile against Matrix history before retrying.
|
||||
- Remote side effects are fail-closed: a send is recorded only after the chat service confirms it; interrupted reply claims reconcile against Matrix history before retrying. A queued reply refreshes Matrix immediately before sending and becomes an auditable superseded claim if a newer inbound event or terminal state appeared while it waited.
|
||||
- Dry-run prepares and records decisions but never sends.
|
||||
- Operators can see funnel stages, account state, rate limits, failures, handoffs, and recent activity rather than inferring success from a process-health check.
|
||||
|
||||
@@ -41,11 +41,11 @@ Each subreddit snapshots a policy into a conversation when automation first hand
|
||||
| Mode | Meaning |
|
||||
| --- | --- |
|
||||
| `manual` | Never auto-reply; surface a human handoff. |
|
||||
| `legacy` | A short, bounded media-request flow retained for existing behavior. |
|
||||
| `legacy` | At most one optional autonomous media request, counting a request already present in the opener, followed by human handoff. |
|
||||
| `r4r` | Bounded rapport-style replies with autonomous safety checks. |
|
||||
| `r4r-media` | Rapport first, one bounded media ask, then a handoff/terminal boundary. |
|
||||
|
||||
The snapshot matters: changing configuration later must not silently rewrite the rules for an active conversation. Automation stops for seller/upsell evidence, an image received, a detected boundary, unsafe content, an exhausted turn budget, or a manual policy.
|
||||
The snapshot matters: changing configuration later must not silently rewrite the rules for an active conversation. Automation stops for seller/upsell evidence, an image received, a detected boundary, unsafe content, an exhausted turn budget, a repeated media request, or a manual policy. Short refusals are interpreted in sequence: phrases such as “I’m good” become media boundaries only when they answer a media request.
|
||||
|
||||
## Success and analytics
|
||||
|
||||
@@ -56,6 +56,8 @@ Do not collapse all progress into “messages sent.” Useful states include:
|
||||
- replies, declined/boundary/upsell outcomes, and images received;
|
||||
- model usage, fallback behavior, strategy/sample size, and account ownership.
|
||||
|
||||
Learning examples mature only after the conversation policy is no longer active. Full recipient history is reclassified on sync; a later decline or boundary overrides earlier reciprocal activity and removes any previously promoted example.
|
||||
|
||||
Fetched listing size is not an eligibility count. Logs and UI should quantify rejection and deferral reasons so a dry funnel can be diagnosed without guessing.
|
||||
|
||||
## Operational authority
|
||||
|
||||
+2
-1
@@ -36,8 +36,9 @@ This is an ownership map, not an API dump. Read the owning implementation and te
|
||||
| `services/chat/cache.ts` | Account-scoped cached conversations and sync state | SQLite is durable; do not share caches across accounts. |
|
||||
| `services/chat/matrix/username-resolver.ts` | Matrix/Reddit identity mapping | Preserve unresolved internal IDs without leaking them in operator text. |
|
||||
| `services/chat/matrix/token-refresher.ts` | Explicit refresh-unavailable behavior | New credentials come from verified extension dumps, not token sniffing. |
|
||||
| `services/conversation-automation.ts` | Reply-first policy, claims, retries, handoffs, turn budgets | Snapshot policy; claim before send; reconcile pending claims; fail closed. |
|
||||
| `services/conversation-automation.ts` | Reply-first policy, claims, retries, send-time refresh, handoffs, turn budgets | Snapshot policy; claim before send; supersede stale queued work; reconcile pending claims; fail closed. |
|
||||
| `services/conversation-state.ts` | Pure conversation parsing/state/safety helpers | Conversation text is untrusted data; keep prompt boundaries explicit. |
|
||||
| `services/conversation-boundaries.ts` | Sequence-aware media requests, refusals, and full-stop detection | Short ambiguous replies become boundaries only in the matching request context. |
|
||||
| `services/outbound-message-safety.ts` | Final no-proximity/no-meeting rule | This is defense in depth beyond prompts. |
|
||||
| `services/conversation-outcome*.ts` | Response outcome classification/recording | Keep analytics attribution account/message scoped. |
|
||||
| `services/response-checker.ts` | Inbox/performance response checks and successful examples | Non-overlapping; do not confuse chat sync with outcome maturity. |
|
||||
|
||||
@@ -298,6 +298,7 @@ export async function bootstrap(
|
||||
redditUsersRepo,
|
||||
accountAuth,
|
||||
conversationOutcomesRepo,
|
||||
conversationStateRepo,
|
||||
);
|
||||
|
||||
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs, accountAuth);
|
||||
|
||||
@@ -96,10 +96,11 @@ export class CandidateQueueRepository {
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'discovered', ?, ?, ?)
|
||||
`);
|
||||
|
||||
const newestPostsFirst = [...posts].sort((left, right) => right.created_utc - left.created_utc);
|
||||
this.db.transaction(() => {
|
||||
for (const [index, post] of posts.entries()) {
|
||||
for (const [index, post] of newestPostsFirst.entries()) {
|
||||
if (result.discovered >= maxNewCandidates) {
|
||||
result.deferredAtCapacity += posts.length - index;
|
||||
result.deferredAtCapacity += newestPostsFirst.length - index;
|
||||
break;
|
||||
}
|
||||
if (existingPost.get(post.id)) {
|
||||
@@ -214,7 +215,7 @@ export class CandidateQueueRepository {
|
||||
WHERE post_id IN (
|
||||
SELECT post_id FROM candidate_queue
|
||||
WHERE status = 'retryable' AND available_at <= ?
|
||||
ORDER BY priority DESC, discovered_at ASC
|
||||
ORDER BY post_created_at DESC, priority DESC, discovered_at ASC
|
||||
LIMIT ?
|
||||
)
|
||||
`).run(now, now, capacity).changes;
|
||||
@@ -302,15 +303,8 @@ export class CandidateQueueRepository {
|
||||
SELECT * FROM candidate_queue
|
||||
WHERE status IN (${ACTIVE_STATUSES.map(() => '?').join(', ')})
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'sending' THEN 1
|
||||
WHEN 'screening' THEN 2
|
||||
WHEN 'ready' THEN 3
|
||||
WHEN 'retryable' THEN 4
|
||||
ELSE 5
|
||||
END,
|
||||
priority DESC,
|
||||
post_created_at DESC,
|
||||
priority DESC,
|
||||
discovered_at ASC
|
||||
LIMIT ?
|
||||
`).all(...ACTIVE_STATUSES, limit) as Array<Record<string, unknown>>;
|
||||
@@ -332,7 +326,7 @@ export class CandidateQueueRepository {
|
||||
const row = this.db.prepare(`
|
||||
SELECT post_id FROM candidate_queue
|
||||
WHERE status = ? AND available_at <= ? ${requirePrepared ? 'AND prepared_json IS NOT NULL' : ''}
|
||||
ORDER BY priority DESC, post_created_at DESC, discovered_at ASC
|
||||
ORDER BY post_created_at DESC, priority DESC, discovered_at ASC
|
||||
LIMIT 1
|
||||
`).get(from, now) as { post_id: string } | undefined;
|
||||
if (!row) return null;
|
||||
|
||||
@@ -336,6 +336,34 @@ export function runMigrations(db: Database.Database): void {
|
||||
addColumnIfNotExists(db, 'conversation_automation_claims', 'last_attempt_at', 'INTEGER');
|
||||
addColumnIfNotExists(db, 'conversation_automation_claims', 'attempt_count', 'INTEGER NOT NULL DEFAULT 0');
|
||||
},
|
||||
() => db.exec(`
|
||||
CREATE TABLE conversation_automation_claims_with_superseded (
|
||||
account_id TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
incoming_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending', 'sent', 'superseded')),
|
||||
claimed_at INTEGER NOT NULL,
|
||||
sent_at INTEGER,
|
||||
superseded_at INTEGER,
|
||||
reply_text TEXT,
|
||||
incoming_at INTEGER,
|
||||
last_attempt_at INTEGER,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (account_id, conversation_id, incoming_key),
|
||||
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO conversation_automation_claims_with_superseded (
|
||||
account_id, conversation_id, incoming_key, status, claimed_at, sent_at,
|
||||
reply_text, incoming_at, last_attempt_at, attempt_count
|
||||
) SELECT
|
||||
account_id, conversation_id, incoming_key, status, claimed_at, sent_at,
|
||||
reply_text, incoming_at, last_attempt_at, attempt_count
|
||||
FROM conversation_automation_claims;
|
||||
DROP TABLE conversation_automation_claims;
|
||||
ALTER TABLE conversation_automation_claims_with_superseded RENAME TO conversation_automation_claims;
|
||||
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;
|
||||
|
||||
@@ -1010,6 +1010,7 @@ export class SuccessfulMessagesRepository {
|
||||
private getBySubredditStmt: Database.Statement;
|
||||
private countStmt: Database.Statement;
|
||||
private existsByMessageStmt: Database.Statement;
|
||||
private deleteBySentMessageIdStmt: Database.Statement;
|
||||
|
||||
constructor(
|
||||
private db: Database.Database,
|
||||
@@ -1051,6 +1052,9 @@ export class SuccessfulMessagesRepository {
|
||||
this.existsByMessageStmt = db.prepare(`
|
||||
SELECT 1 FROM successful_messages WHERE account_id = ? AND original_message = ? LIMIT 1
|
||||
`);
|
||||
this.deleteBySentMessageIdStmt = db.prepare(`
|
||||
DELETE FROM successful_messages WHERE account_id = ? AND sent_message_id = ?
|
||||
`);
|
||||
}
|
||||
|
||||
insert(data: {
|
||||
@@ -1077,6 +1081,10 @@ export class SuccessfulMessagesRepository {
|
||||
return this.existsByMessageStmt.get(this.accountId, originalMessage) !== undefined;
|
||||
}
|
||||
|
||||
deleteBySentMessageId(sentMessageId: number): boolean {
|
||||
return this.deleteBySentMessageIdStmt.run(this.accountId, sentMessageId).changes > 0;
|
||||
}
|
||||
|
||||
getRecent(limit: number = 5): SuccessfulMessageRecord[] {
|
||||
const rows = this.getRecentStmt.all(this.accountId, limit) as Record<string, unknown>[];
|
||||
return rows.map((row) => this.mapRow(row));
|
||||
@@ -1176,13 +1184,14 @@ export class ConversationStateRepository {
|
||||
export interface ConversationAutomationClaimRecord {
|
||||
conversationId: string;
|
||||
incomingKey: string;
|
||||
status: 'pending' | 'sent';
|
||||
status: 'pending' | 'sent' | 'superseded';
|
||||
replyText: string | null;
|
||||
incomingAt: number | null;
|
||||
claimedAt: number;
|
||||
lastAttemptAt: number | null;
|
||||
attemptCount: number;
|
||||
sentAt: number | null;
|
||||
supersededAt: number | null;
|
||||
}
|
||||
|
||||
export class ConversationAutomationClaimRepository {
|
||||
@@ -1211,7 +1220,7 @@ export class ConversationAutomationClaimRepository {
|
||||
getClaim(conversationId: string, incomingKey: string): ConversationAutomationClaimRecord | undefined {
|
||||
const row = this.db.prepare(`
|
||||
SELECT conversation_id, incoming_key, status, reply_text, incoming_at,
|
||||
claimed_at, last_attempt_at, attempt_count, sent_at
|
||||
claimed_at, last_attempt_at, attempt_count, sent_at, superseded_at
|
||||
FROM conversation_automation_claims
|
||||
WHERE account_id = ? AND conversation_id = ? AND incoming_key = ?
|
||||
`).get(this.accountId, conversationId, incomingKey) as Record<string, unknown> | undefined;
|
||||
@@ -1219,20 +1228,21 @@ export class ConversationAutomationClaimRepository {
|
||||
return {
|
||||
conversationId: row.conversation_id as string,
|
||||
incomingKey: row.incoming_key as string,
|
||||
status: row.status as 'pending' | 'sent',
|
||||
status: row.status as 'pending' | 'sent' | 'superseded',
|
||||
replyText: (row.reply_text as string | null) ?? null,
|
||||
incomingAt: (row.incoming_at as number | null) ?? null,
|
||||
claimedAt: row.claimed_at as number,
|
||||
lastAttemptAt: (row.last_attempt_at as number | null) ?? null,
|
||||
attemptCount: row.attempt_count as number,
|
||||
sentAt: (row.sent_at as number | null) ?? null,
|
||||
supersededAt: (row.superseded_at as number | null) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
listPending(): ConversationAutomationClaimRecord[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT conversation_id, incoming_key, status, reply_text, incoming_at,
|
||||
claimed_at, last_attempt_at, attempt_count, sent_at
|
||||
claimed_at, last_attempt_at, attempt_count, sent_at, superseded_at
|
||||
FROM conversation_automation_claims
|
||||
WHERE account_id = ? AND status = 'pending'
|
||||
ORDER BY claimed_at ASC
|
||||
@@ -1247,6 +1257,7 @@ export class ConversationAutomationClaimRepository {
|
||||
lastAttemptAt: (row.last_attempt_at as number | null) ?? null,
|
||||
attemptCount: row.attempt_count as number,
|
||||
sentAt: (row.sent_at as number | null) ?? null,
|
||||
supersededAt: (row.superseded_at as number | null) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1272,6 +1283,14 @@ export class ConversationAutomationClaimRepository {
|
||||
WHERE account_id = ? AND conversation_id = ? AND incoming_key = ? AND status = 'pending'
|
||||
`).run(now, this.accountId, conversationId, incomingKey).changes === 1;
|
||||
}
|
||||
|
||||
markSuperseded(conversationId: string, incomingKey: string, now: number = Date.now()): boolean {
|
||||
return this.db.prepare(`
|
||||
UPDATE conversation_automation_claims
|
||||
SET status = 'superseded', superseded_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. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Logger } from '../../utils/logger.js';
|
||||
import type {
|
||||
ConversationOutcomesRepository,
|
||||
ConversationStateRepository,
|
||||
MessagesRepository,
|
||||
RedditUsersRepository,
|
||||
SentMessageRecord,
|
||||
@@ -21,6 +22,7 @@ import type { ActiveChat, ChatMessage, ScrapedChatMessage, InvitationCooldown }
|
||||
export type { InvitationCooldown } from './types.js';
|
||||
import { fetchWithTimeout } from '../../utils/fetch-with-timeout.js';
|
||||
import type { AccountAuthGate } from '../account-auth-context.js';
|
||||
import { containsMediaRequest } from '../conversation-boundaries.js';
|
||||
|
||||
const ACTIVE_WINDOW_MS = 96 * 60 * 60 * 1000;
|
||||
const ROOM_URL_PREFIX = 'https://chat.reddit.com/room/';
|
||||
@@ -159,6 +161,7 @@ export class ChatService {
|
||||
private redditUsersRepo?: RedditUsersRepository,
|
||||
private accountAuth?: AccountAuthGate,
|
||||
private outcomesRepo?: ConversationOutcomesRepository,
|
||||
private conversationStateRepo?: ConversationStateRepository,
|
||||
) {
|
||||
this.cache = cache ?? new ChatCache(logger);
|
||||
if (this.redditUsersRepo) {
|
||||
@@ -1104,17 +1107,25 @@ export class ChatService {
|
||||
|
||||
if (hasReply && this.successfulMessagesRepo && this.outcomesRepo) {
|
||||
const firstReply = incomingMessages[0];
|
||||
const latestReply = incomingMessages.at(-1);
|
||||
const cachedSelfCount = conversationMessages.filter((message) => message.isSelf).length;
|
||||
const selfCount = cachedSelfCount + (firstSelf ? 0 : 1);
|
||||
const otherCount = incomingMessages.length;
|
||||
const wasMissingOutcome = this.outcomesRepo.get(performanceMessage.id) === undefined;
|
||||
const conversationState = this.conversationStateRepo?.get(chat.roomId);
|
||||
try {
|
||||
recordConversationReply(
|
||||
performanceMessage,
|
||||
firstReply?.text.slice(0, 200) ?? '',
|
||||
latestReply?.text.slice(0, 200) ?? '',
|
||||
{
|
||||
messageCount: selfCount + otherCount,
|
||||
reciprocal: selfCount >= 2 && otherCount >= 2,
|
||||
responses: incomingMessages.map((message) => message.text),
|
||||
learningEligible: conversationState !== undefined && conversationState.automation.status !== 'active',
|
||||
receivedImage: hasReceivedImage,
|
||||
mediaWasRequested: conversationState?.askedForMedia
|
||||
?? conversationMessages.some((message) => message.isSelf && containsMediaRequest(message.text)),
|
||||
authoritativeTranscript: true,
|
||||
},
|
||||
{
|
||||
messages: this.messagesRepo,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
remainingAutomationTurns,
|
||||
type ConversationState,
|
||||
} from './conversation-state.js';
|
||||
import { containsMediaRequest } from './conversation-boundaries.js';
|
||||
|
||||
export type ConversationAutomationResultKind =
|
||||
| 'sent'
|
||||
@@ -44,8 +45,14 @@ export interface ConversationAutomationClaims {
|
||||
markAttempt?(conversationId: string, incomingKey: string): boolean;
|
||||
releasePending?(conversationId: string, incomingKey: string): boolean;
|
||||
markSent(conversationId: string, incomingKey: string): boolean;
|
||||
markSuperseded?(conversationId: string, incomingKey: string): boolean;
|
||||
}
|
||||
|
||||
export type ConversationBeforeSendLoader = (
|
||||
conversationId: string,
|
||||
observed: ActiveChat,
|
||||
) => Promise<ActiveChat | undefined>;
|
||||
|
||||
export interface ReplyPriorityStatus {
|
||||
processing: boolean;
|
||||
actionableBacklog: number;
|
||||
@@ -86,6 +93,7 @@ export class ConversationAutomationService {
|
||||
private readonly configuredSubreddits: () => readonly SubredditConfig[] = () => [],
|
||||
private readonly now: () => number = Date.now,
|
||||
private readonly pendingReplyRetryDelayMs = PENDING_REPLY_RETRY_DELAY_MS,
|
||||
private readonly beforeSendLoader?: ConversationBeforeSendLoader,
|
||||
) {}
|
||||
|
||||
async processPendingConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
|
||||
@@ -199,26 +207,21 @@ export class ConversationAutomationService {
|
||||
);
|
||||
}
|
||||
if (this.now() - existingClaim.claimedAt < this.pendingReplyRetryDelayMs) return null;
|
||||
const retryPlan = replyPlan(state);
|
||||
const exhausted = this.advanceStateForReply(
|
||||
state,
|
||||
conversationId,
|
||||
incomingKey,
|
||||
existingClaim.replyText,
|
||||
retryPlan.isLegacy,
|
||||
retryPlan.isHybridMediaAsk,
|
||||
);
|
||||
const requestsMedia = containsMediaRequest(existingClaim.replyText);
|
||||
const isHybridMediaAsk = state.automation.policyMode === 'r4r-media' && requestsMedia;
|
||||
return this.sendClaimedReply(
|
||||
conversation,
|
||||
state,
|
||||
incomingKey,
|
||||
existingClaim.replyText,
|
||||
latest.timestamp,
|
||||
exhausted,
|
||||
requestsMedia,
|
||||
isHybridMediaAsk,
|
||||
signal,
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (existingClaim?.status === 'sent') return null;
|
||||
if (existingClaim?.status === 'sent' || existingClaim?.status === 'superseded') return null;
|
||||
if (state.automation.lastIncomingKey === incomingKey) return null;
|
||||
if (state.automation.status !== 'active') return null;
|
||||
|
||||
@@ -269,6 +272,16 @@ export class ConversationAutomationService {
|
||||
}
|
||||
|
||||
const { isLegacy, isHybridMediaAsk } = replyPlan(state);
|
||||
if (isLegacy && state.askedForMedia) {
|
||||
return this.finish(
|
||||
conversation,
|
||||
conversationId,
|
||||
state,
|
||||
incomingKey,
|
||||
'human-required',
|
||||
'A media request has already been made; further autonomous requests are disabled.',
|
||||
);
|
||||
}
|
||||
if (isHybridMediaAsk) {
|
||||
state.automation.hybridPhase = 'media-ask';
|
||||
this.states.save(conversationId, state);
|
||||
@@ -283,7 +296,9 @@ export class ConversationAutomationService {
|
||||
: isHybridMediaAsk
|
||||
? await this.ai.generateR4RMediaAsk(mapped, conversation.username, state)
|
||||
: await this.ai.generateReply(mapped, conversation.username, state, intent);
|
||||
const safeReply = isHybridMediaAsk ? isSafeHybridMediaAsk(reply) : isLegacy || isSafeR4RReply(reply);
|
||||
const safeReply = isLegacy || isHybridMediaAsk
|
||||
? isSafeHybridMediaAsk(reply)
|
||||
: isSafeR4RReply(reply);
|
||||
if (!safeReply) {
|
||||
return this.finish(
|
||||
conversation,
|
||||
@@ -291,7 +306,7 @@ export class ConversationAutomationService {
|
||||
state,
|
||||
incomingKey,
|
||||
'human-required',
|
||||
'The generated R4R reply failed autonomous safety checks.',
|
||||
'The generated reply failed autonomous safety checks.',
|
||||
);
|
||||
}
|
||||
if (this.dryRun) {
|
||||
@@ -301,24 +316,16 @@ export class ConversationAutomationService {
|
||||
|
||||
if (!this.claims.tryClaim(conversationId, incomingKey, reply, latest.timestamp)) return null;
|
||||
|
||||
// Persist the full intended state before the remote send. If the process
|
||||
// dies after Matrix accepts the message, both this state and the durable
|
||||
// event claim make the retry fail closed instead of sending twice.
|
||||
const exhausted = this.advanceStateForReply(
|
||||
state,
|
||||
conversationId,
|
||||
incomingKey,
|
||||
reply,
|
||||
isLegacy,
|
||||
isHybridMediaAsk,
|
||||
);
|
||||
// The durable claim stores the intended body. Send-time validation then
|
||||
// snapshots the intended state immediately before the Matrix request.
|
||||
return this.sendClaimedReply(
|
||||
conversation,
|
||||
state,
|
||||
incomingKey,
|
||||
reply,
|
||||
latest.timestamp,
|
||||
exhausted,
|
||||
requestsMedia,
|
||||
isHybridMediaAsk,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
@@ -360,6 +367,7 @@ export class ConversationAutomationService {
|
||||
const claim = this.claims.getClaim?.(conversation.roomId, incomingKey);
|
||||
if (claim?.status === 'pending') return true;
|
||||
if (claim?.status === 'sent') return false;
|
||||
if (claim?.status === 'superseded') return false;
|
||||
// On the first scan after startup, retain Matrix unread as the conservative
|
||||
// bootstrap signal so a deployment cannot revive historical read replies.
|
||||
// Later scans can recover fresh inbound events even if that flag cleared.
|
||||
@@ -376,11 +384,11 @@ export class ConversationAutomationService {
|
||||
conversationId: string,
|
||||
incomingKey: string,
|
||||
reply: string,
|
||||
isLegacy: boolean,
|
||||
requestsMedia: boolean,
|
||||
isHybridMediaAsk: boolean,
|
||||
): boolean {
|
||||
if (state.automation.lastIncomingKey !== incomingKey) {
|
||||
if (isLegacy || isHybridMediaAsk) state.askedForMedia = true;
|
||||
if (requestsMedia) state.askedForMedia = true;
|
||||
if (isHybridMediaAsk) state.automation.hybridPhase = 'post-media-ask';
|
||||
state.automation.turnsUsed += 1;
|
||||
state.automation.lastIncomingKey = incomingKey;
|
||||
@@ -405,33 +413,126 @@ export class ConversationAutomationService {
|
||||
incomingKey: string,
|
||||
reply: string,
|
||||
incomingAt: number,
|
||||
exhausted: boolean,
|
||||
requestsMedia: boolean,
|
||||
isHybridMediaAsk: boolean,
|
||||
signal?: AbortSignal,
|
||||
resumePending = false,
|
||||
): Promise<ConversationAutomationResult | null> {
|
||||
this.claims.markAttempt?.(conversation.roomId!, incomingKey);
|
||||
const sent = await this.rateLimiter.throttle(
|
||||
() => this.chat.sendChatMessage(conversation.url!, reply),
|
||||
(success) => success,
|
||||
const conversationId = conversation.roomId!;
|
||||
const attempt = await this.rateLimiter.throttle(
|
||||
async () => {
|
||||
const latestConversation = await this.loadConversationBeforeSend(conversationId, conversation);
|
||||
const latest = latestConversation ? latestInboundMessage(latestConversation.messages ?? []) : null;
|
||||
const latestKey = latest ? messageKey(latest) : null;
|
||||
const freshState = latestConversation
|
||||
? mergeConversationState(
|
||||
this.states.get(conversationId),
|
||||
buildConversationState((latestConversation.messages ?? []).map(({ sender, text, isSelf }) => ({ sender, text, isSelf }))),
|
||||
)
|
||||
: this.states.get(conversationId) ?? state;
|
||||
|
||||
const persistedIntentCanResume = resumePending
|
||||
&& freshState.automation.lastIncomingKey === incomingKey
|
||||
&& freshState.automation.status === 'human-required';
|
||||
if (!latestConversation || !latest || latestKey !== incomingKey
|
||||
|| (freshState.automation.status !== 'active' && !persistedIntentCanResume)) {
|
||||
this.claims.markSuperseded?.(conversationId, incomingKey);
|
||||
const result = latestConversation && latest
|
||||
? this.applyFreshHandoff(latestConversation, freshState, latest)
|
||||
: null;
|
||||
if (!result && latestConversation) this.states.save(conversationId, freshState);
|
||||
this.logger.info(
|
||||
`[automation] Superseded queued reply for u/${conversation.username}; a newer conversation state was observed before send`,
|
||||
);
|
||||
return { kind: 'superseded' as const, result, state: freshState };
|
||||
}
|
||||
|
||||
if (freshState.boundaries.length > 0 || hasReceivedImage(latestConversation.messages ?? [])) {
|
||||
this.claims.markSuperseded?.(conversationId, incomingKey);
|
||||
const result = this.applyFreshHandoff(latestConversation, freshState, latest);
|
||||
return { kind: 'superseded' as const, result, state: freshState };
|
||||
}
|
||||
|
||||
if (requestsMedia && freshState.askedForMedia
|
||||
&& !(resumePending && freshState.automation.lastIncomingKey === incomingKey)) {
|
||||
this.claims.markSuperseded?.(conversationId, incomingKey);
|
||||
const result = this.finish(
|
||||
latestConversation,
|
||||
conversationId,
|
||||
freshState,
|
||||
incomingKey,
|
||||
'human-required',
|
||||
'A media request has already been made; further autonomous requests are disabled.',
|
||||
);
|
||||
return { kind: 'superseded' as const, result, state: freshState };
|
||||
}
|
||||
|
||||
const exhausted = this.advanceStateForReply(
|
||||
freshState,
|
||||
conversationId,
|
||||
incomingKey,
|
||||
reply,
|
||||
requestsMedia,
|
||||
isHybridMediaAsk,
|
||||
);
|
||||
this.claims.markAttempt?.(conversationId, incomingKey);
|
||||
const sent = await this.chat.sendChatMessage(latestConversation.url!, reply);
|
||||
return { kind: sent ? 'sent' as const : 'failed' as const, exhausted, state: freshState };
|
||||
},
|
||||
(result) => result.kind === 'sent',
|
||||
signal,
|
||||
REPLY_SEND_PRIORITY,
|
||||
);
|
||||
if (!sent) return null;
|
||||
if (attempt.kind === 'superseded') return attempt.result;
|
||||
if (attempt.kind === 'failed') return null;
|
||||
const sentAt = this.now();
|
||||
this.claims.markSent(conversation.roomId!, incomingKey);
|
||||
this.lastReplySentAt = sentAt;
|
||||
this.lastReplyLatencyMs = Math.max(0, sentAt - incomingAt);
|
||||
this.logger.info(
|
||||
`[automation] Sent ${state.automation.policyMode} turn ${state.automation.turnsUsed}/${state.automation.maxAutomatedTurns} `
|
||||
`[automation] Sent ${attempt.state.automation.policyMode} turn ${attempt.state.automation.turnsUsed}/${attempt.state.automation.maxAutomatedTurns} `
|
||||
+ `to u/${conversation.username}; reply latency ${Math.round(this.lastReplyLatencyMs / 1_000)}s`,
|
||||
);
|
||||
return {
|
||||
conversationId: conversation.roomId!,
|
||||
username: conversation.username,
|
||||
kind: exhausted ? 'human-required' : 'sent',
|
||||
remainingTurns: remainingAutomationTurns(state),
|
||||
kind: attempt.exhausted ? 'human-required' : 'sent',
|
||||
remainingTurns: remainingAutomationTurns(attempt.state),
|
||||
};
|
||||
}
|
||||
|
||||
private async loadConversationBeforeSend(conversationId: string, observed: ActiveChat): Promise<ActiveChat | undefined> {
|
||||
if (this.beforeSendLoader) return this.beforeSendLoader(conversationId, observed);
|
||||
await this.chat.refreshTopChats(REPLY_REFRESH_LIMIT);
|
||||
return (await this.chat.getCachedConversations()).find((item) => item.roomId === conversationId);
|
||||
}
|
||||
|
||||
private applyFreshHandoff(
|
||||
conversation: ActiveChat,
|
||||
state: ConversationState,
|
||||
latest: ChatMessage,
|
||||
): ConversationAutomationResult | null {
|
||||
if (state.boundaries.length > 0) {
|
||||
return this.finish(conversation, conversation.roomId!, state, messageKey(latest), 'boundary', state.boundaries.join('; '));
|
||||
}
|
||||
if (hasReceivedImage(conversation.messages ?? [])) {
|
||||
return this.finish(conversation, conversation.roomId!, state, messageKey(latest), 'image-received', 'The user shared an image.');
|
||||
}
|
||||
const sellerSignal = findSellerSignal(latest.text, [...this.sellerKeywords], [...this.sellerDomains]);
|
||||
if (sellerSignal) {
|
||||
state.sellerDetected = true;
|
||||
return this.finish(
|
||||
conversation,
|
||||
conversation.roomId!,
|
||||
state,
|
||||
messageKey(latest),
|
||||
'human-required',
|
||||
`Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveConversationBehaviour(subreddit: string | undefined): SubredditConfig['conversation'] {
|
||||
const configured = subreddit
|
||||
? this.configuredSubreddits().find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase())
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
export interface BoundaryMessage {
|
||||
text: string;
|
||||
isSelf: boolean;
|
||||
}
|
||||
|
||||
const MEDIA_NOUN = String.raw`(?:pic|pics|picture|pictures|photo|photos|selfie|selfies|snap|snaps|image|images|video|videos|nude|nudes|content)`;
|
||||
const MEDIA_VERB = String.raw`(?:send(?:ing)?|shar(?:e|ing)|show(?:ing)?|giv(?:e|ing)|drop(?:ping)?|swap(?:ping)?|trad(?:e|ing))`;
|
||||
|
||||
const EXPLICIT_MEDIA_REFUSAL_PATTERNS = [
|
||||
new RegExp(String.raw`\b(?:no|not|never|won't|wouldn't|don't|do not|can't|cannot)\b[^.\n]{0,60}\b${MEDIA_NOUN}\b`, 'i'),
|
||||
new RegExp(String.raw`\b(?:won't|wouldn't|don't|do not|not going to|never)\b[^.\n]{0,40}\b${MEDIA_VERB}\b`, 'i'),
|
||||
new RegExp(String.raw`\b(?:zero|no) interest\b[^.\n]{0,60}\b(?:${MEDIA_VERB}|${MEDIA_NOUN}|anything|something)\b`, 'i'),
|
||||
new RegExp(String.raw`\b(?:rather not|not comfortable)\b[^.\n]{0,60}\b(?:${MEDIA_VERB}|${MEDIA_NOUN})\b`, 'i'),
|
||||
];
|
||||
|
||||
const CONTEXTUAL_MEDIA_REFUSAL_PATTERNS = [
|
||||
/^(?:no|nope|nah|no thanks|not really|rather not|pass)(?:,? thanks)?[.!\s]*$/i,
|
||||
/^(?:i(?:'m| am)|im|we(?:'re| are)|we're) good(?: thanks)?[.!\s]*$/i,
|
||||
/\b(?:zero interest|not interested|no interest)\b/i,
|
||||
/\b(?:not sending|won't send|wouldn't send|don't send|do not send)\b/i,
|
||||
];
|
||||
|
||||
const FULL_STOP_PATTERNS = [
|
||||
/\bstop\b/i,
|
||||
/\bleave me alone\b/i,
|
||||
/\b(?:do not|don't) message\b/i,
|
||||
/\bback off\b/i,
|
||||
/^(?:(?:i'm|i am|im)\s+)?not interested[.!\s]*$/i,
|
||||
/\b(?:creepy|uncomfortable|weirdest (?:reddit )?approach)\b/i,
|
||||
];
|
||||
|
||||
export function containsMediaRequest(text: string): boolean {
|
||||
return new RegExp(String.raw`\b${MEDIA_VERB}\b[^.!?\n]{0,60}\b${MEDIA_NOUN}\b`, 'i').test(text)
|
||||
|| new RegExp(String.raw`\b(?:can|could|would|will) you\b[^.!?\n]{0,60}\b(?:${MEDIA_VERB}\b[^.!?\n]{0,30})?\b${MEDIA_NOUN}\b`, 'i').test(text)
|
||||
|| new RegExp(String.raw`\bgot any (?:more )?${MEDIA_NOUN}\b`, 'i').test(text);
|
||||
}
|
||||
|
||||
export function isFullConversationStop(text: string): boolean {
|
||||
return FULL_STOP_PATTERNS.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
export function isMediaRefusal(text: string, mediaWasRequested: boolean): boolean {
|
||||
const normalized = text.replaceAll('’', "'");
|
||||
if (EXPLICIT_MEDIA_REFUSAL_PATTERNS.some((pattern) => pattern.test(normalized))) return true;
|
||||
return mediaWasRequested && CONTEXTUAL_MEDIA_REFUSAL_PATTERNS.some((pattern) => pattern.test(normalized.trim()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect recipient limits in sequence so short phrases such as "I'm good"
|
||||
* only become a media boundary when they directly follow a media request.
|
||||
*/
|
||||
export function detectConversationBoundaries(messages: readonly BoundaryMessage[]): string[] {
|
||||
const boundaries: string[] = [];
|
||||
let lastSelfRequestedMedia = false;
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.isSelf) {
|
||||
lastSelfRequestedMedia = containsMediaRequest(message.text);
|
||||
continue;
|
||||
}
|
||||
if (isMediaRefusal(message.text, lastSelfRequestedMedia)) {
|
||||
boundaries.push('does not want to send photos or videos');
|
||||
}
|
||||
if (isFullConversationStop(message.text)) {
|
||||
boundaries.push('does not want further sexual escalation');
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(boundaries)];
|
||||
}
|
||||
@@ -29,7 +29,12 @@ export function recordConversationReply(
|
||||
deps.outcomes.suppressRecipient(message.recipient, 'boundary');
|
||||
}
|
||||
|
||||
if (outcome === 'engaged' && !deps.successful.existsByMessage(message.message)) {
|
||||
const terminalNegative = outcome === 'boundary' || outcome === 'declined' || outcome === 'upsell';
|
||||
if (terminalNegative || (context.authoritativeTranscript && (outcome !== 'engaged' || !context.learningEligible))) {
|
||||
deps.successful.deleteBySentMessageId(message.id);
|
||||
}
|
||||
|
||||
if (outcome === 'engaged' && context.learningEligible && !deps.successful.existsByMessage(message.message)) {
|
||||
deps.successful.insert({
|
||||
sentMessageId: message.id,
|
||||
originalMessage: message.message,
|
||||
@@ -41,6 +46,8 @@ export function recordConversationReply(
|
||||
deps.logger.info(`Engaged response from u/${message.recipient}; stored for learning.`);
|
||||
} else if (outcome !== 'engaged') {
|
||||
deps.logger.info(`Response from u/${message.recipient} classified as ${outcome}; excluded from learning examples.`);
|
||||
} else if (!context.learningEligible) {
|
||||
deps.logger.info(`Response from u/${message.recipient} is not terminal; deferred from learning examples.`);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isFullConversationStop, isMediaRefusal } from './conversation-boundaries.js';
|
||||
|
||||
export const CONVERSATION_OUTCOMES = ['engaged', 'declined', 'boundary', 'upsell', 'unknown'] as const;
|
||||
export type ConversationOutcome = (typeof CONVERSATION_OUTCOMES)[number];
|
||||
|
||||
@@ -6,6 +8,16 @@ export interface OutcomeContext {
|
||||
messageCount?: number;
|
||||
/** True when both participants have sent at least two messages. */
|
||||
reciprocal?: boolean;
|
||||
/** All recipient messages in the attributed conversation, oldest first. */
|
||||
responses?: readonly string[];
|
||||
/** True only after automation has reached a terminal/human-owned state. */
|
||||
learningEligible?: boolean;
|
||||
/** A received image is an explicit positive outcome once the flow is complete. */
|
||||
receivedImage?: boolean;
|
||||
/** Whether the conversation had already asked the recipient for media. */
|
||||
mediaWasRequested?: boolean;
|
||||
/** True when responses contains the complete attributed recipient transcript. */
|
||||
authoritativeTranscript?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,10 +28,16 @@ export function classifyConversationOutcome(
|
||||
reply: string,
|
||||
context: OutcomeContext = {},
|
||||
): ConversationOutcome {
|
||||
const text = reply.toLowerCase();
|
||||
const responses = context.responses?.length ? context.responses : [reply];
|
||||
const text = responses.join('\n').toLowerCase();
|
||||
if (/\b(onlyfans|fansly|manyvids|link in bio|cash\.?app|venmo)\b/.test(text)) return 'upsell';
|
||||
if (/\b(stop|leave me alone|do not message|don't message)\b/.test(text)) return 'boundary';
|
||||
if (/\b(no thanks|not interested|nope|don't send|do not send)\b/.test(text)) return 'declined';
|
||||
if (context.reciprocal && (context.messageCount ?? 0) >= 4 && text.trim().length > 0) return 'engaged';
|
||||
if (responses.some(isFullConversationStop)) return 'boundary';
|
||||
if (responses.some((response) => isMediaRefusal(response, context.mediaWasRequested === true))
|
||||
|| /\b(no thanks|not interested|nope|nah|rather not|zero interest)\b/.test(text)) return 'declined';
|
||||
const explicitPositive = /\b(yes|yeah|yep|sure|absolutely|definitely|tell me more|sounds (?:good|fun)|i'd love to|would love to)\b/i.test(text);
|
||||
if (context.learningEligible
|
||||
&& (context.receivedImage || (context.reciprocal && (context.messageCount ?? 0) >= 4 && explicitPositive))) {
|
||||
return 'engaged';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { isSafeOutboundMessage } from './outbound-message-safety.js';
|
||||
import { containsMediaRequest, detectConversationBoundaries } from './conversation-boundaries.js';
|
||||
|
||||
export interface ConversationMessage {
|
||||
sender: string;
|
||||
@@ -38,7 +39,7 @@ export interface ConversationState {
|
||||
|
||||
export const LEGACY_AUTOMATED_CONVERSATION_TURNS = 3;
|
||||
export const DEFAULT_HYBRID_RAPPORT_TURNS = 3;
|
||||
export const CONVERSATION_POLICY_VERSION = 2;
|
||||
export const CONVERSATION_POLICY_VERSION = 3;
|
||||
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual';
|
||||
export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask';
|
||||
|
||||
@@ -103,11 +104,6 @@ export function buildConversationState(messages: ConversationMessage[]): Convers
|
||||
.filter((message) => !message.isSelf)
|
||||
.map((message) => message.text)
|
||||
.join('\n');
|
||||
const ownText = messages
|
||||
.filter((message) => message.isSelf)
|
||||
.map((message) => message.text)
|
||||
.join('\n');
|
||||
|
||||
const ageMatch = firstSelfMessage.match(/\b(3[0-5])\s*[mM]\b/);
|
||||
const persona: ConversationPersona = {
|
||||
age: ageMatch ? Number(ageMatch[1]) : EMPTY_PERSONA.age,
|
||||
@@ -115,20 +111,14 @@ export function buildConversationState(messages: ConversationMessage[]): Convers
|
||||
relationshipStatus: /\bmarried\b|\bwife\b/i.test(firstSelfMessage) ? 'married' : EMPTY_PERSONA.relationshipStatus,
|
||||
};
|
||||
|
||||
const boundaries: string[] = [];
|
||||
if (/\b(no|not)\b[^.\n]{0,50}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b|\bdon't send\b[^.\n]{0,30}\b(pic|photo|video|nudes?)\b/i.test(theirText)) {
|
||||
boundaries.push('does not want to send photos or videos');
|
||||
}
|
||||
if (/\bstop\b|\bleave me alone\b|\bnot interested\b/i.test(theirText)) {
|
||||
boundaries.push('does not want further sexual escalation');
|
||||
}
|
||||
const boundaries = detectConversationBoundaries(messages);
|
||||
|
||||
const topics = extractTopics(theirText);
|
||||
return {
|
||||
persona,
|
||||
boundaries,
|
||||
sellerDetected: false,
|
||||
askedForMedia: /\b(send|show|share)\b[^.\n]{0,40}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nudes?)\b/i.test(ownText),
|
||||
askedForMedia: messages.some((message) => message.isSelf && containsMediaRequest(message.text)),
|
||||
topics,
|
||||
automation: createConversationAutomationState(),
|
||||
};
|
||||
@@ -214,14 +204,13 @@ export function isSafeConversationReply(message: string): boolean {
|
||||
/** R4R automation may converse and flirt, but it must never solicit sexual media. */
|
||||
export function isSafeR4RReply(message: string): boolean {
|
||||
if (!isSafeConversationReply(message)) return false;
|
||||
return !/\b(send|show|share|swap|trade|drop|give|dm)\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes|content)\b/i.test(message)
|
||||
&& !/\b(can|could|would|will) you\b[^.!?\n]{0,60}\b(pic|pics|photo|photos|selfie|selfies|video|videos|nude|nudes)\b/i.test(message);
|
||||
return !containsMediaRequest(message);
|
||||
}
|
||||
|
||||
/** The hybrid policy gets one optional, non-explicit image request after rapport. */
|
||||
export function isSafeHybridMediaAsk(message: string): boolean {
|
||||
if (!isSafeConversationReply(message)) return false;
|
||||
if (!/\b(pic|pics|photo|photos|selfie|selfies|video|videos)\b/i.test(message)) return false;
|
||||
if (!containsMediaRequest(message)) return false;
|
||||
if (/\b(nude|nudes|naked|explicit|nsfw|sexy pic|sexual)\b/i.test(message)) return false;
|
||||
return !/\b(must|prove|dare|owe|have to|need to)\b/i.test(message);
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ async function buildRuntime(
|
||||
const chatService = new ChatService(
|
||||
matrixClient, logger, successfulMessagesRepo, mediaService,
|
||||
new MatrixTokenRefresher(), usernameResolver, chatCache,
|
||||
messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo,
|
||||
messagesRepo, redditUsersRepo, accountAuth, conversationOutcomesRepo, conversationStateRepo,
|
||||
);
|
||||
await chatService.loadCache();
|
||||
const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth);
|
||||
|
||||
@@ -94,6 +94,42 @@ describe('CandidateQueueRepository', () => {
|
||||
expect(queue.countActive()).toBe(2);
|
||||
});
|
||||
|
||||
it('admits, screens, and sends the newest posts before older higher-priority posts', () => {
|
||||
queue.enqueue([
|
||||
post('old-high-priority', 'alice', 100),
|
||||
post('new-low-priority', 'bob', 300),
|
||||
], 'image', 1, 1_000, 1);
|
||||
|
||||
expect(queue.listActive().map((candidate) => candidate.postId)).toEqual(['new-low-priority']);
|
||||
|
||||
queue.enqueue([post('old-high-priority', 'alice', 100)], 'image', 10, 1_001);
|
||||
const newestScreening = queue.claimForScreening('screen-new', 100, 1_010)!;
|
||||
expect(newestScreening.postId).toBe('new-low-priority');
|
||||
queue.markReady(newestScreening.postId, 'screen-new', prepared(newestScreening.post), 1_011);
|
||||
|
||||
const olderScreening = queue.claimForScreening('screen-old', 100, 1_012)!;
|
||||
queue.markReady(olderScreening.postId, 'screen-old', prepared(olderScreening.post), 1_013);
|
||||
|
||||
expect(queue.claimReady('account-a', 'send', 100, 1_014)?.postId).toBe('new-low-priority');
|
||||
});
|
||||
|
||||
it('promotes the newest due delivery retry when ready capacity is limited', () => {
|
||||
queue.enqueue([post('new-low-priority', 'bob', 300)], 'image', 1, 1_000);
|
||||
queue.enqueue([post('old-high-priority', 'alice', 100)], 'image', 10, 1_001);
|
||||
|
||||
for (const worker of ['screen-new', 'screen-old']) {
|
||||
const candidate = queue.claimForScreening(worker, 100, 1_010)!;
|
||||
queue.markReady(candidate.postId, worker, prepared(candidate.post), 1_011);
|
||||
}
|
||||
for (const [accountId, worker] of [['account-a', 'send-new'], ['account-b', 'send-old']] as const) {
|
||||
const candidate = queue.claimReady(accountId, worker, 100, 1_012)!;
|
||||
queue.deferDelivery(candidate.postId, worker, 'Matrix rate limited', 1_100, 1_013);
|
||||
}
|
||||
|
||||
expect(queue.promoteDueRetries(1, 1_100)).toBe(1);
|
||||
expect(queue.claimReady('account-a', 'retry', 100, 1_101)?.postId).toBe('new-low-priority');
|
||||
});
|
||||
|
||||
it('excludes posts and recipients already handled anywhere', () => {
|
||||
db.prepare(`
|
||||
INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at)
|
||||
@@ -140,13 +176,13 @@ describe('CandidateQueueRepository', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lists only active candidates in actionable stage order', () => {
|
||||
it('lists only active candidates in newest-post order across stages', () => {
|
||||
queue.enqueue([
|
||||
post('waiting', 'alice'),
|
||||
post('screening', 'bob'),
|
||||
post('ready', 'carol'),
|
||||
post('sending', 'dana'),
|
||||
post('rejected', 'erin'),
|
||||
post('waiting', 'alice', 100),
|
||||
post('screening', 'bob', 500),
|
||||
post('ready', 'carol', 400),
|
||||
post('sending', 'dana', 300),
|
||||
post('rejected', 'erin', 200),
|
||||
], 'image', 1, 1_000);
|
||||
queue.claimForScreening('worker-1', 1_000, 1_010);
|
||||
const ready = queue.claimForScreening('worker-2', 1_000, 1_020)!;
|
||||
@@ -155,8 +191,9 @@ describe('CandidateQueueRepository', () => {
|
||||
const rejected = queue.claimForScreening('worker-4', 1_000, 1_040)!;
|
||||
queue.reject(rejected.postId, 'worker-4', 'Seller signals detected', 1_041);
|
||||
|
||||
expect(queue.listActive().map((candidate) => candidate.status)).toEqual([
|
||||
'sending', 'screening', 'discovered', 'discovered',
|
||||
const active = queue.listActive();
|
||||
expect(active.map((candidate) => candidate.postId)).toEqual([
|
||||
'screening', 'ready', 'rejected', 'waiting',
|
||||
]);
|
||||
expect(queue.listActive().map((candidate) => candidate.postId)).not.toContain(rejected.postId);
|
||||
expect(queue.getOldestActiveAt()).toBe(1_000);
|
||||
|
||||
@@ -2,7 +2,8 @@ import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationOutcomesRepository } from '../src/database/repositories.js';
|
||||
import { ConversationOutcomesRepository, ConversationStateRepository } from '../src/database/repositories.js';
|
||||
import { buildConversationState } from '../src/services/conversation-state.js';
|
||||
import { ChatCache } from '../src/services/chat/cache.js';
|
||||
import { MatrixClient } from '../src/services/chat/matrix/client.js';
|
||||
import type { MatrixSyncResponse } from '../src/services/chat/matrix/types.js';
|
||||
@@ -46,6 +47,7 @@ describe('ChatService outcome ingestion', () => {
|
||||
expect(fixture.outcomes.get(fixture.sentMessageId)?.outcome).toBe('unknown');
|
||||
expect(fixture.repos.successfulMessagesRepo.count()).toBe(0);
|
||||
|
||||
fixture.markConversationComplete();
|
||||
await fixture.service.refreshTopChats();
|
||||
expect(fixture.outcomes.get(fixture.sentMessageId)?.outcome).toBe('engaged');
|
||||
expect(fixture.repos.successfulMessagesRepo.count()).toBe(1);
|
||||
@@ -67,6 +69,7 @@ describe('ChatService outcome ingestion', () => {
|
||||
],
|
||||
});
|
||||
fixture.repos.messagesRepo.markGotResponse(fixture.sentMessageId);
|
||||
fixture.markConversationComplete();
|
||||
fixture.matrix.sync = vi.fn(async () => syncResponse('s2', [])) as typeof fixture.matrix.sync;
|
||||
|
||||
await fixture.service.runStartupSync();
|
||||
@@ -94,6 +97,7 @@ async function createFixture(options: {
|
||||
openDatabases.push(db);
|
||||
const repos = createTestRepositories(db);
|
||||
const outcomes = new ConversationOutcomesRepository(db);
|
||||
const conversationStates = new ConversationStateRepository(db);
|
||||
const now = Date.now();
|
||||
const roomId = '!alice:example.test';
|
||||
|
||||
@@ -146,10 +150,19 @@ async function createFixture(options: {
|
||||
undefined,
|
||||
authenticatedAccountAuth,
|
||||
outcomes,
|
||||
conversationStates,
|
||||
);
|
||||
await service.loadCache();
|
||||
|
||||
return { db, repos, outcomes, now, roomId, sentMessageId, matrix, service };
|
||||
return {
|
||||
db, repos, outcomes, now, roomId, sentMessageId, matrix, service,
|
||||
markConversationComplete() {
|
||||
const state = buildConversationState([]);
|
||||
state.automation.status = 'human-required';
|
||||
state.automation.handoffReason = 'Conversation complete.';
|
||||
conversationStates.save(roomId, state);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function syncResponse(nextBatch: string, events: ReturnType<typeof messageEvent>[]): MatrixSyncResponse {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConversationAutomationService } from '../src/services/conversation-automation.js';
|
||||
import type { ConversationState } from '../src/services/conversation-state.js';
|
||||
import type { ActiveChat } from '../src/services/chat.js';
|
||||
|
||||
function harness(
|
||||
sellerKeywords: string[] = [],
|
||||
@@ -11,6 +12,7 @@ function harness(
|
||||
maxAutomatedTurns: number;
|
||||
rapportTurnsBeforeMediaAsk?: number;
|
||||
},
|
||||
beforeSendLoader?: (conversationId: string, observed: ActiveChat) => Promise<ActiveChat | undefined>,
|
||||
) {
|
||||
const saved = new Map<string, ConversationState>();
|
||||
let lastSaved: ConversationState | undefined;
|
||||
@@ -43,6 +45,7 @@ function harness(
|
||||
markAttempt: vi.fn(() => true),
|
||||
releasePending: vi.fn(() => true),
|
||||
markSent: vi.fn(() => true),
|
||||
markSuperseded: vi.fn(() => true),
|
||||
};
|
||||
const rateLimiter = {
|
||||
throttle: vi.fn(async (operation: () => Promise<boolean>) => operation()),
|
||||
@@ -59,6 +62,7 @@ function harness(
|
||||
}] : [],
|
||||
() => 1_000,
|
||||
0,
|
||||
beforeSendLoader ?? (async (_conversationId, observed) => observed),
|
||||
);
|
||||
return { service, chat, ai, states, openers, claims, rateLimiter, getSaved: () => lastSaved };
|
||||
}
|
||||
@@ -75,18 +79,17 @@ function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ original
|
||||
}
|
||||
|
||||
describe('conversation automation', () => {
|
||||
it('sends once per inbound turn and flags human handoff after the third send', async () => {
|
||||
it('allows only one autonomous media request and then hands off', async () => {
|
||||
const { service, chat, getSaved } = harness();
|
||||
|
||||
expect((await service.processConversation(incoming(1)))?.remainingTurns).toBe(2);
|
||||
expect((await service.processConversation(incoming(2)))?.remainingTurns).toBe(1);
|
||||
const third = await service.processConversation(incoming(3));
|
||||
const second = await service.processConversation(incoming(2));
|
||||
|
||||
expect(third).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
|
||||
expect(chat.sendChatMessage).toHaveBeenCalledTimes(3);
|
||||
expect(getSaved()?.automation).toMatchObject({ turnsUsed: 3, status: 'human-required' });
|
||||
expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(3);
|
||||
expect(await service.processConversation(incoming(3))).toBeNull();
|
||||
expect(second).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
|
||||
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
|
||||
expect(getSaved()?.automation).toMatchObject({ turnsUsed: 1, status: 'human-required' });
|
||||
expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(1);
|
||||
expect(await service.processConversation(incoming(2))).toBeNull();
|
||||
});
|
||||
|
||||
it('stops without sending when the user declines images', async () => {
|
||||
@@ -98,6 +101,63 @@ describe('conversation automation', () => {
|
||||
expect(getSaved()?.automation.status).toBe('boundary');
|
||||
});
|
||||
|
||||
it('hands off immediately when the opener already made the conversation media request', async () => {
|
||||
const fixture = harness();
|
||||
const conversation = incoming(1, 'Sure');
|
||||
conversation.messages[0].text = 'If you feel like it, send a pic where you are smiling.';
|
||||
|
||||
const result = await fixture.service.processConversation(conversation);
|
||||
|
||||
expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
|
||||
expect(fixture.ai.generateMediaAsk).not.toHaveBeenCalled();
|
||||
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
|
||||
expect(fixture.getSaved()?.automation.handoffReason).toContain('media request has already been made');
|
||||
});
|
||||
|
||||
it('supersedes a queued reply when a refusal arrives during the rate-limit wait', async () => {
|
||||
const tattooEvent = {
|
||||
eventId: 'tattoo-event', sender: 'alice',
|
||||
text: 'I edit out my tattoos for the sake of anonymity but that is the only filtering done.',
|
||||
timestamp: 30, isSelf: false,
|
||||
};
|
||||
const observed: ActiveChat = {
|
||||
username: 'JustProwling', roomName: 'JustProwling', roomId: 'room-alice', url: '/room/room-alice',
|
||||
lastMessageTime: 30, lastMessageSnippet: tattooEvent.text, isFromUs: false, unread: true,
|
||||
messages: [
|
||||
{ eventId: 'opener', sender: 'me', text: 'Send a pic where you are smiling.', timestamp: 10, isSelf: true },
|
||||
tattooEvent,
|
||||
],
|
||||
};
|
||||
const latest: ActiveChat = {
|
||||
...observed,
|
||||
lastMessageTime: 40,
|
||||
lastMessageSnippet: 'Zero interest in sending you anything lol',
|
||||
messages: [
|
||||
...observed.messages!,
|
||||
{
|
||||
eventId: 'refusal-event', sender: 'alice',
|
||||
text: 'Zero interest in sending you anything lol', timestamp: 40, isSelf: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fixture = harness([], [], 0, undefined, async () => latest);
|
||||
fixture.claims.getClaim.mockReturnValue({
|
||||
conversationId: 'room-alice', incomingKey: 'tattoo-event', status: 'pending',
|
||||
replyText: 'If you ever feel like sharing a quick snap, I am all eyes.', incomingAt: 30,
|
||||
claimedAt: 0, lastAttemptAt: null, attemptCount: 0, sentAt: null, supersededAt: null,
|
||||
} as never);
|
||||
|
||||
const result = await fixture.service.processConversation(observed);
|
||||
|
||||
expect(result?.kind).toBe('boundary');
|
||||
expect(fixture.claims.markSuperseded).toHaveBeenCalledWith('room-alice', 'tattoo-event');
|
||||
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
|
||||
expect(fixture.getSaved()).toMatchObject({
|
||||
boundaries: ['does not want to send photos or videos'],
|
||||
automation: { status: 'boundary', lastIncomingKey: 'refusal-event' },
|
||||
});
|
||||
});
|
||||
|
||||
it('flags a potential upsell for human handoff without generating a reply', async () => {
|
||||
const { service, chat, ai, getSaved } = harness(['onlyfans'], ['fansly.com']);
|
||||
const result = await service.processConversation(incoming(1, 'My Fansly is https://www.fansly.com/example'));
|
||||
@@ -323,7 +383,7 @@ describe('conversation automation', () => {
|
||||
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
|
||||
expect(chat.sendChatMessage).toHaveBeenCalledTimes(2);
|
||||
expect(getSaved()?.automation).toMatchObject({
|
||||
policyMode: 'r4r', policyVersion: 2, maxAutomatedTurns: 2, turnsUsed: 2,
|
||||
policyMode: 'r4r', policyVersion: 3, maxAutomatedTurns: 2, turnsUsed: 2,
|
||||
});
|
||||
expect(getSaved()?.askedForMedia).toBe(false);
|
||||
});
|
||||
@@ -396,7 +456,7 @@ describe('conversation automation', () => {
|
||||
expect(fixture.getSaved()?.askedForMedia).toBe(true);
|
||||
expect(fixture.getSaved()?.automation).toMatchObject({
|
||||
policyMode: 'r4r-media',
|
||||
policyVersion: 2,
|
||||
policyVersion: 3,
|
||||
rapportTurnsBeforeMediaAsk: 2,
|
||||
hybridPhase: 'post-media-ask',
|
||||
turnsUsed: 4,
|
||||
@@ -445,4 +505,14 @@ describe('conversation automation', () => {
|
||||
expect(result?.kind).toBe('human-required');
|
||||
expect(fixture.chat.sendChatMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects a coercive legacy media request', async () => {
|
||||
const fixture = harness();
|
||||
fixture.ai.generateMediaAsk.mockResolvedValue('Prove me wrong and send a pic.');
|
||||
|
||||
const result = await fixture.service.processConversation(incoming(1));
|
||||
|
||||
expect(result?.kind).toBe('human-required');
|
||||
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,11 @@ function message(): SentMessageRecord {
|
||||
function dependencies() {
|
||||
return {
|
||||
messages: { markGotResponse: vi.fn() } as unknown as MessagesRepository,
|
||||
successful: { insert: vi.fn(), existsByMessage: vi.fn(() => false) } as unknown as SuccessfulMessagesRepository,
|
||||
successful: {
|
||||
insert: vi.fn(),
|
||||
existsByMessage: vi.fn(() => false),
|
||||
deleteBySentMessageId: vi.fn(() => false),
|
||||
} as unknown as SuccessfulMessagesRepository,
|
||||
outcomes: { record: vi.fn(), suppressRecipient: vi.fn() } as unknown as ConversationOutcomesRepository,
|
||||
logger: createMockLogger(),
|
||||
};
|
||||
@@ -44,7 +48,9 @@ describe('recordConversationReply', () => {
|
||||
|
||||
it('stores only reciprocal engaged conversations as learning examples', () => {
|
||||
const deps = dependencies();
|
||||
expect(recordConversationReply(message(), 'Tell me more', { messageCount: 4, reciprocal: true }, deps)).toBe('engaged');
|
||||
expect(recordConversationReply(message(), 'Tell me more', {
|
||||
messageCount: 4, reciprocal: true, learningEligible: true,
|
||||
}, deps)).toBe('engaged');
|
||||
expect(deps.successful.insert).toHaveBeenCalledWith(expect.objectContaining({ sentMessageId: 1 }));
|
||||
});
|
||||
|
||||
@@ -53,5 +59,19 @@ describe('recordConversationReply', () => {
|
||||
expect(recordConversationReply(message(), 'Stop messaging me', { messageCount: 2 }, deps)).toBe('boundary');
|
||||
expect(deps.outcomes.suppressRecipient).toHaveBeenCalledWith('alice', 'boundary');
|
||||
expect(deps.successful.insert).not.toHaveBeenCalled();
|
||||
expect(deps.successful.deleteBySentMessageId).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('removes an earlier learning example when the full conversation later declines', () => {
|
||||
const deps = dependencies();
|
||||
expect(recordConversationReply(message(), "I'm good", {
|
||||
messageCount: 8,
|
||||
reciprocal: true,
|
||||
learningEligible: true,
|
||||
mediaWasRequested: true,
|
||||
responses: ['Sure', 'Zero interest in sending you anything lol', "I'm good"],
|
||||
}, deps)).toBe('declined');
|
||||
expect(deps.successful.deleteBySentMessageId).toHaveBeenCalledWith(1);
|
||||
expect(deps.successful.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,25 @@ describe('conversation outcome classification', () => {
|
||||
expect(classifyConversationOutcome('That sounds fun, tell me more', {
|
||||
messageCount: 4,
|
||||
reciprocal: true,
|
||||
learningEligible: true,
|
||||
})).toBe('engaged');
|
||||
});
|
||||
|
||||
it('lets a later refusal override earlier reciprocal engagement', () => {
|
||||
expect(classifyConversationOutcome("I'm good", {
|
||||
messageCount: 8,
|
||||
reciprocal: true,
|
||||
learningEligible: true,
|
||||
mediaWasRequested: true,
|
||||
responses: ['Sure, tell me more', 'Zero interest in sending you anything lol', "I'm good"],
|
||||
})).toBe('declined');
|
||||
});
|
||||
|
||||
it('does not promote a positive reciprocal thread while automation is active', () => {
|
||||
expect(classifyConversationOutcome('Sure, tell me more', {
|
||||
messageCount: 4,
|
||||
reciprocal: true,
|
||||
learningEligible: false,
|
||||
})).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,28 @@ describe('conversation state', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'Zero interest in sending you anything lol',
|
||||
"I'm good",
|
||||
'Rather not, thanks',
|
||||
])('treats a contextual media refusal as a boundary: %s', (refusal) => {
|
||||
const state = buildConversationState([
|
||||
message('Would you be comfortable sharing a selfie?', true),
|
||||
message(refusal),
|
||||
]);
|
||||
|
||||
expect(state.boundaries).toContain('does not want to send photos or videos');
|
||||
});
|
||||
|
||||
it('does not treat a conversational wellbeing answer as a media refusal', () => {
|
||||
const state = buildConversationState([
|
||||
message('How are you?', true),
|
||||
message("I'm good"),
|
||||
]);
|
||||
|
||||
expect(state.boundaries).toEqual([]);
|
||||
});
|
||||
|
||||
it('allows three progressive image-sharing turns before human handoff', () => {
|
||||
const state = buildConversationState([message('Hello', true), message('Hey')]);
|
||||
expect(remainingAutomationTurns(state)).toBe(3);
|
||||
@@ -75,7 +97,7 @@ describe('conversation state', () => {
|
||||
expect(state?.automation).toMatchObject({ turnsUsed: 0, status: 'active' });
|
||||
expect(state?.sellerDetected).toBe(false);
|
||||
expect(state?.automation).toMatchObject({
|
||||
policyMode: 'legacy', policyVersion: 2, maxAutomatedTurns: 3,
|
||||
policyMode: 'legacy', policyVersion: 3, maxAutomatedTurns: 3,
|
||||
rapportTurnsBeforeMediaAsk: 3, hybridPhase: null,
|
||||
});
|
||||
});
|
||||
|
||||
+41
-1
@@ -160,7 +160,7 @@ describe('Database Client', () => {
|
||||
DROP TABLE conversation_automation_claims;
|
||||
DROP TABLE ai_usage;
|
||||
`);
|
||||
db.pragma(`user_version = ${latestVersion - 5}`);
|
||||
db.pragma(`user_version = ${latestVersion - 6}`);
|
||||
runMigrations(db);
|
||||
|
||||
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
|
||||
@@ -170,6 +170,46 @@ describe('Database Client', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves pending reply claims when adding auditable superseded status', () => {
|
||||
runMigrations(db);
|
||||
const latestVersion = db.pragma('user_version', { simple: true }) as number;
|
||||
db.exec(`
|
||||
DROP TABLE conversation_automation_claims;
|
||||
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,
|
||||
reply_text TEXT,
|
||||
incoming_at INTEGER,
|
||||
last_attempt_at INTEGER,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (account_id, conversation_id, incoming_key),
|
||||
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO conversation_automation_claims (
|
||||
account_id, conversation_id, incoming_key, status, claimed_at,
|
||||
reply_text, incoming_at, last_attempt_at, attempt_count
|
||||
) VALUES (
|
||||
'__legacy_unassigned__', 'room', 'event', 'pending', 10,
|
||||
'durable reply', 5, 11, 1
|
||||
);
|
||||
`);
|
||||
db.pragma(`user_version = ${latestVersion - 1}`);
|
||||
|
||||
runMigrations(db);
|
||||
|
||||
expect(db.prepare(`
|
||||
SELECT status, reply_text, incoming_at, last_attempt_at, attempt_count, superseded_at
|
||||
FROM conversation_automation_claims
|
||||
`).get()).toEqual({
|
||||
status: 'pending', reply_text: 'durable reply', incoming_at: 5,
|
||||
last_attempt_at: 11, attempt_count: 1, superseded_at: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('processed_posts has correct schema', () => {
|
||||
runMigrations(db);
|
||||
const columns = db.prepare('PRAGMA table_info(processed_posts)').all() as { name: string; notnull: number }[];
|
||||
|
||||
@@ -121,13 +121,26 @@ describe('account-bound domain repositories', () => {
|
||||
expect(claims.getClaim('room', 'event')).toEqual({
|
||||
conversationId: 'room', incomingKey: 'event', status: 'pending',
|
||||
replyText: 'durable reply', incomingAt: 50, claimedAt: 60,
|
||||
lastAttemptAt: 70, attemptCount: 1, sentAt: null,
|
||||
lastAttemptAt: 70, attemptCount: 1, sentAt: null, supersededAt: null,
|
||||
});
|
||||
expect(claims.listPending()).toEqual([expect.objectContaining({ incomingKey: 'event' })]);
|
||||
expect(claims.releasePending('room', 'event')).toBe(true);
|
||||
expect(claims.listPending()).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps superseded reply claims as an auditable terminal record', () => {
|
||||
const claims = new ConversationAutomationClaimRepository(db, ACCOUNT_A);
|
||||
expect(claims.tryClaim('room', 'stale-event', 'stale reply', 50, 60)).toBe(true);
|
||||
|
||||
expect(claims.markSuperseded('room', 'stale-event', 80)).toBe(true);
|
||||
expect(claims.getClaim('room', 'stale-event')).toMatchObject({
|
||||
status: 'superseded',
|
||||
supersededAt: 80,
|
||||
sentAt: null,
|
||||
});
|
||||
expect(claims.listPending()).toEqual([]);
|
||||
});
|
||||
|
||||
it('claims recipients globally and blocks usernames with existing chat history', () => {
|
||||
db.prepare(`
|
||||
INSERT INTO reddit_conversations (
|
||||
|
||||
Reference in new issue
Block a user