Make conversation queues mutually exclusive

This commit is contained in:
Jamie Brunton committed 2026-08-14 09:25:45 +01:00
1 parent 3bb1dda6bb
commit 2447563c60
20 files changed
+386 -220

No files matched your search

@@ -15,7 +15,9 @@ export function ConversationRoute() {
const sortValue = searchParams.get('sort');
const filters: ConversationFilters = {
search: searchParams.get('q') ?? '',
showSellers: searchParams.get('showSellers') === 'true',
unreadOnly: searchParams.get('unread') === 'true',
manualOnly: searchParams.get('manual') === 'true',
imagesOnly: searchParams.get('images') === 'true',
filter: isConversationFilter(filterValue) ? filterValue : DEFAULT_CONVERSATION_FILTERS.filter,
sort: isConversationSort(sortValue) ? sortValue : DEFAULT_CONVERSATION_FILTERS.sort,
account: searchParams.get('account') ?? DEFAULT_CONVERSATION_FILTERS.account,
@@ -33,8 +35,10 @@ export function ConversationRoute() {
function updateFilters(next: ConversationFilters) {
const params = new URLSearchParams(searchParams);
if (next.search) params.set('q', next.search); else params.delete('q');
if (next.filter !== 'all') params.set('filter', next.filter); else params.delete('filter');
if (next.showSellers) params.set('showSellers', 'true'); else params.delete('showSellers');
if (next.filter !== DEFAULT_CONVERSATION_FILTERS.filter) params.set('filter', next.filter); else params.delete('filter');
if (next.unreadOnly) params.set('unread', 'true'); else params.delete('unread');
if (next.manualOnly) params.set('manual', 'true'); else params.delete('manual');
if (next.imagesOnly) params.set('images', 'true'); else params.delete('images');
if (next.sort !== DEFAULT_CONVERSATION_FILTERS.sort) params.set('sort', next.sort); else params.delete('sort');
if (next.account !== 'all') params.set('account', next.account); else params.delete('account');
setSearchParams(params, { replace: true });
@@ -1,21 +1,16 @@
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
/**
* Inbox buckets. These are the operator's actual work queues, derived from
* application read state rather than Matrix's unread count.
*
* `all` deliberately excludes resolved and snoozed conversations — the default
* view is "what is still on my plate", and non-actionable conversations have
* dedicated pills so nothing becomes unreachable.
* Mutually exclusive inbox workflow queues. Read state, manual ownership, and
* images are secondary filters so they cannot make a row appear to occupy two
* competing workflow states.
*/
export type ConversationFilter =
| 'all'
| 'needs-reply'
| 'unread'
| 'awaiting-reply'
| 'manual'
| 'received-images'
| 'sellers'
| 'account-deleted'
| 'archived'
| 'snoozed'
| 'done';
@@ -24,7 +19,9 @@ export type ConversationSort = 'recent' | 'oldest-unanswered';
export interface ConversationFilters {
search: string;
filter: ConversationFilter;
showSellers: boolean;
unreadOnly: boolean;
manualOnly: boolean;
imagesOnly: boolean;
sort: ConversationSort;
/** Reddit account id to narrow to, or `all`. */
account: string;
@@ -32,22 +29,22 @@ export interface ConversationFilters {
export const DEFAULT_CONVERSATION_FILTERS: ConversationFilters = {
search: '',
filter: 'all',
showSellers: false,
filter: 'needs-reply',
unreadOnly: false,
manualOnly: false,
imagesOnly: false,
sort: 'recent',
account: 'all',
};
export const CONVERSATION_FILTERS: ReadonlyArray<{ value: ConversationFilter; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'needs-reply', label: 'Needs reply' },
{ value: 'unread', label: 'Unread' },
{ value: 'awaiting-reply', label: 'Waiting on them' },
{ value: 'manual', label: 'Manual' },
{ value: 'received-images', label: 'Images' },
{ value: 'account-deleted', label: 'Account deleted' },
{ value: 'needs-reply', label: 'Needs attention' },
{ value: 'awaiting-reply', label: 'Waiting' },
{ value: 'sellers', label: 'Sellers' },
{ value: 'snoozed', label: 'Snoozed' },
{ value: 'done', label: 'Done' },
{ value: 'account-deleted', label: 'Deleted' },
{ value: 'archived', label: 'Archived' },
];
export const CONVERSATION_SORTS: ReadonlyArray<{ value: ConversationSort; label: string }> = [
@@ -87,9 +84,7 @@ export function needsReply(conversation: ActiveChat): boolean {
/** We have replied and are waiting on them. */
export function isWaitingOnThem(conversation: ActiveChat): boolean {
return conversation.recipientAccountStatus !== 'deleted'
&& conversation.isFromUs
&& triageStatus(conversation) === 'open';
return conversationQueue(conversation) === 'awaiting-reply';
}
/**
@@ -103,7 +98,9 @@ export function isWaitingOnThem(conversation: ActiveChat): boolean {
*/
export function isManualFlow(conversation: ActiveChat): boolean {
if (conversation.automationPolicyMode === 'manual') return true;
return conversation.automationStatus !== undefined && conversation.automationStatus !== 'active';
return conversation.automationStatus !== undefined
&& conversation.automationStatus !== 'active'
&& conversation.automationStatus !== 'killed';
}
/** Short label for why a conversation is being handled by hand. */
@@ -113,8 +110,6 @@ export function manualFlowLabel(conversation: ActiveChat): string | null {
return 'Manual by policy';
}
switch (conversation.automationStatus) {
case 'killed':
return 'Killed — seller';
case 'user-overridden':
return 'Manual — you replied';
case 'image-received':
@@ -128,29 +123,23 @@ export function manualFlowLabel(conversation: ActiveChat): string | null {
}
}
export function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
if (conversation.recipientAccountStatus === 'deleted') return filter === 'account-deleted';
/**
* One authoritative workflow queue per conversation. Data-ownership and
* terminal safety outcomes take precedence over message direction so archived,
* deleted, and seller rows can never leak into actionable queues.
*/
export function conversationQueue(conversation: ActiveChat): ConversationFilter {
if (conversation.readOnly) return 'archived';
if (conversation.recipientAccountStatus === 'deleted') return 'account-deleted';
if (conversation.sellerDetected === true || conversation.automationStatus === 'killed') return 'sellers';
const status = triageStatus(conversation);
switch (filter) {
case 'needs-reply':
return needsReply(conversation);
case 'unread':
return status === 'open' && isUnread(conversation);
case 'awaiting-reply':
return isWaitingOnThem(conversation);
case 'manual':
return status === 'open' && isManualFlow(conversation);
case 'received-images':
return status === 'open' && conversation.hasReceivedImages === true;
case 'account-deleted':
return false;
case 'snoozed':
return status === 'snoozed';
case 'done':
return status === 'done';
default:
return status === 'open';
}
if (status === 'snoozed') return 'snoozed';
if (status === 'done') return 'done';
return needsReply(conversation) ? 'needs-reply' : 'awaiting-reply';
}
export function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
return conversationQueue(conversation) === filter;
}
export function matchesSearch(conversation: ActiveChat, search: string): boolean {
@@ -170,8 +159,12 @@ function matchesAccount(conversation: ActiveChat, account: string): boolean {
export interface ConversationListPartition {
/** Conversations passing every active filter, in the requested order. */
visible: ActiveChat[];
/** How many were removed purely by the seller toggle, so the UI can say so. */
hiddenSellerCount: number;
}
function matchesSecondaryFilters(conversation: ActiveChat, filters: ConversationFilters): boolean {
return (!filters.unreadOnly || isUnread(conversation))
&& (!filters.manualOnly || isManualFlow(conversation))
&& (!filters.imagesOnly || conversation.hasReceivedImages === true);
}
/**
@@ -195,18 +188,13 @@ export function partitionConversations(
conversations: readonly ActiveChat[],
filters: ConversationFilters,
): ConversationListPartition {
let hiddenSellerCount = 0;
const visible = conversations.filter((conversation) => {
if (!matchesSearch(conversation, filters.search)) return false;
if (!matchesFilter(conversation, filters.filter)) return false;
if (!matchesAccount(conversation, filters.account)) return false;
if (!filters.showSellers && conversation.sellerDetected === true) {
hiddenSellerCount += 1;
return false;
}
return true;
return matchesSecondaryFilters(conversation, filters);
});
return { visible: visible.sort(compareConversations(filters.sort)), hiddenSellerCount };
return { visible: visible.sort(compareConversations(filters.sort)) };
}
/**
@@ -220,7 +208,7 @@ export function filterCounts(
const candidates = conversations.filter((conversation) =>
matchesSearch(conversation, filters.search)
&& matchesAccount(conversation, filters.account)
&& (filters.showSellers || conversation.sellerDetected !== true));
&& matchesSecondaryFilters(conversation, filters));
const counts = {} as Record<ConversationFilter, number>;
for (const { value } of CONVERSATION_FILTERS) {
counts[value] = candidates.filter((conversation) => matchesFilter(conversation, value)).length;
@@ -21,12 +21,12 @@ import {
CONVERSATION_SORTS,
DEFAULT_CONVERSATION_FILTERS,
accountOptions,
conversationQueue,
filterCounts,
isUnread,
manualFlowLabel,
needsReply,
partitionConversations,
triageStatus,
type ConversationFilter,
type ConversationFilters,
type ConversationSort,
@@ -87,16 +87,19 @@ function sellerScanSummary(result: SellerScanResponse): string {
return `${parts.join(' · ')}.`;
}
/** Independent state chips. A conversation can be several of these at once. */
/** One workflow chip plus independent presentation/ownership badges. */
function conversationChips(conversation: ActiveChat, now: number): ConversationChip[] {
const chips: ConversationChip[] = [];
if (conversation.recipientAccountStatus === 'deleted') {
chips.push({ label: 'Account deleted' });
return chips;
switch (conversationQueue(conversation)) {
case 'needs-reply': chips.push({ label: 'Needs attention' }); break;
case 'awaiting-reply': chips.push({ label: 'Waiting' }); break;
case 'sellers': chips.push({ label: 'Seller' }); break;
case 'snoozed': chips.push({ label: 'Snoozed' }); break;
case 'done': chips.push({ label: 'Done' }); break;
case 'account-deleted': chips.push({ label: 'Account deleted' }); break;
case 'archived': chips.push({ label: 'Archived' }); break;
}
if (isUnread(conversation)) chips.push({ label: 'Unread' });
if (needsReply(conversation)) chips.push({ label: 'Needs reply' });
else if (conversation.isFromUs && triageStatus(conversation) === 'open') chips.push({ label: 'Waiting on them' });
const manualLabel = manualFlowLabel(conversation);
if (manualLabel) {
chips.push({
@@ -107,10 +110,8 @@ function conversationChips(conversation: ActiveChat, now: number): ConversationC
: manualLabel,
});
}
const status = triageStatus(conversation);
if (status === 'snoozed') chips.push({ label: 'Snoozed' });
if (status === 'done') chips.push({ label: 'Done' });
if (needsReply(conversation) && conversationAge(conversation.lastMessageTime, now) !== 'fresh') {
if (conversationQueue(conversation) === 'needs-reply'
&& conversationAge(conversation.lastMessageTime, now) !== 'fresh') {
chips.push({ label: 'Overdue' });
}
return chips;
@@ -133,7 +134,6 @@ function ConversationRow({
redditUsernameLabel(conversation.username),
...chips.map((chip) => chip.hint ?? chip.label),
relativeTime,
...(conversation.sellerDetected ? ['Seller'] : []),
...(conversation.hasReceivedImages ? ['Image received'] : []),
...(conversation.readOnly ? ['Removed account, read-only'] : []),
];
@@ -186,7 +186,6 @@ function ConversationRow({
Read-only
</span>
) : null}
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
<span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span>
</span>
@@ -235,7 +234,7 @@ export function ConversationsPage({
const allConversations = conversationsQuery.data ?? [];
const { visible: liveConversations, hiddenSellerCount } = useMemo(
const { visible: liveConversations } = useMemo(
() => partitionConversations(allConversations, activeFilters),
[allConversations, activeFilters],
);
@@ -514,11 +513,27 @@ export function ConversationsPage({
) : null}
<label className="conversation-seller-toggle">
<input
checked={activeFilters.showSellers}
onChange={(event) => updateFilters({ ...activeFilters, showSellers: event.target.checked })}
checked={activeFilters.unreadOnly}
onChange={(event) => updateFilters({ ...activeFilters, unreadOnly: event.target.checked })}
type="checkbox"
/>
Show sellers
Unread only
</label>
<label className="conversation-seller-toggle">
<input
checked={activeFilters.manualOnly}
onChange={(event) => updateFilters({ ...activeFilters, manualOnly: event.target.checked })}
type="checkbox"
/>
Manual only
</label>
<label className="conversation-seller-toggle">
<input
checked={activeFilters.imagesOnly}
onChange={(event) => updateFilters({ ...activeFilters, imagesOnly: event.target.checked })}
type="checkbox"
/>
Has images
</label>
</div>
{visibleConversations.length > 0 ? (
@@ -577,9 +592,6 @@ export function ConversationsPage({
<div className="conversation-list-state">
<strong>No matching conversations.</strong>
<span>Try a different search or filter.</span>
{hiddenSellerCount > 0 ? (
<span>{hiddenSellerCount} hidden by the seller filter.</span>
) : null}
</div>
) : (
<>
@@ -605,9 +617,6 @@ export function ConversationsPage({
})}
</ul>
<p className="conversation-list-footer">
{hiddenSellerCount > 0
? `${hiddenSellerCount} hidden by the seller filter.`
: null}
<span className="conversation-shortcut-hint">
j/k move · Enter opens · u toggles unread · e marks done · / searches
</span>
+3 -1
View File
@@ -76,6 +76,8 @@ Explicit bot clients can send to a username through `POST /api/v1/messages` or t
Each account runtime refreshes recent chats and processes actionable inbound conversations oldest-first before it can accept new outreach. After the initial conservative startup scan, the persisted latest Matrix message event plus the worker's prior-scan watermark is authoritative: a newly observed inbound event newer than the confirmed opener remains actionable even if Matrix's presentation-only unread notification count has already returned to zero. On startup, read-marked rooms without a pending reply claim are not revived. Durable event claims, conversation policy state, and a later outbound event prevent duplicate replies.
Every running-mode reply scan also applies the shared deterministic seller/off-platform rules to the complete cached inbound transcript before actionability is calculated. This retrospective pass catches Telegram, Snapchat, other contact-platform pushes, payment requests, verification scams, paid-content funnels, and configured seller evidence that arrived before a restart or after an operator took ownership. It uses an in-process transcript watermark, spends no classifier tokens, persists the seller/terminal state, and moves killed conversations to Done. Scan mode remains non-mutating. The operator-forced seller scan uses the same deterministic decision before its optional batched AI fallback.
Outgoing Matrix events are attributed against the recorded opener, durable automated-reply fingerprints, and confirmed exchange-library event records. If synchronization observes an unmatched outgoing message, the conversation becomes operator-owned before another reply can be generated. An unmatched outgoing message also supersedes, rather than confirms, a pending automated claim; only the claimed reply text can reconcile that claim as sent.
```mermaid
@@ -100,7 +102,7 @@ flowchart TD
SEND --> MARK["Mark claim sent and record latency"]
```
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.
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, the shared seller/off-platform decision, policy status, and the one-media-request limit. Newer state makes the old claim terminally `superseded`; it is never sent. If the replacement event is a seller or off-platform push, the same revalidation ends and classifies the conversation instead of leaving it for a later pass. 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.
General R4R generation explicitly permits consensual adult NSFW conversation. A model response that softly refuses, moralizes, or diverts solely because the topic is sexual is rejected before send and falls through to the next configured chat model. General replies may offer a generic approved saved picture without that offer being miscounted as a request for recipient media.
+1 -1
View File
@@ -78,7 +78,7 @@ Exchange-library uploads require an explicit consent checkbox before the raw ima
Conversation reply composers include a collapsed saved-image picker. It lists only server-approved assets for the latest inbound request, keeps thumbnails blurred until revealed, and requires selection plus a dedicated confirmation dialog before sending. The server remains authoritative and may reject a choice if the conversation changes before the rate-limited send executes.
The conversation inbox has a system-derived **Account deleted** bucket alongside the operator-maintained open, snoozed, and done queues. A deleted recipient is excluded from the default, unread, needs-reply, waiting, manual, and image buckets and is labelled in both the list and detail views. Suspended profiles are not treated as deleted.
The conversation inbox assigns every row to exactly one primary workflow queue: **Needs attention**, **Waiting**, **Sellers**, **Snoozed**, **Done**, **Deleted**, or **Archived**. Removed-account history belongs only to Archived, deleted recipients belong only to Deleted, and seller/off-platform outcomes belong only to Sellers, so none can leak into actionable work. Unread state, manual ownership, and received images are independent badges and secondary toggles rather than competing queues. The default queue is Needs attention. Suspended profiles are not treated as deleted.
## Feature structure
+1 -1
View File
@@ -54,7 +54,7 @@ The snapshot matters: changing configuration later must not silently rewrite the
Within those boundaries, R4R replies are expected to participate naturally in consensual adult NSFW conversation instead of moralizing, declining the topic, or diverting ordinary flirting into generic small talk. In-character soft refusals are treated like provider refusals and fall through to the next configured chat model. This does not weaken recipient boundaries, the under-18 handoff, seller/off-platform controls, location and meeting prohibitions, or the separate media-request rules.
Two of those stops are terminal rather than handoffs. A reply that pushes the conversation onto another messaging app, asks for contact details, solicits payment, points at a paid-content funnel, or runs the age/bot "verification" scam sets the automation status to `killed`, marks the durable seller flag, and resolves the operator's triage state, so the conversation leaves every actionable queue instead of collecting in the manual one. Detection normalizes the text first — unicode folding, zero-width stripping, leet mapping, separator removal — because these words are routinely obfuscated (`0nlyfans`, `t3l3gram`, `t e l e g r a m`). Ambiguous terms need context: `snap` on its own is a photo request in this product and never a kill on its own. Off-platform kills are a safety control and stay on independently of `reddit.filterSellers`; a configured seller keyword or domain also kills unless `conversations.killOnSellerKeyword` is disabled. Under-18 disclosures and in-person/contact-detail asks remain human handoffs, and now apply to every policy mode rather than the R4R ones only.
Two of those stops are terminal rather than handoffs. A reply that pushes the conversation onto another messaging app, asks for contact details, solicits payment, points at a paid-content funnel, or runs the age/bot "verification" scam sets the automation status to `killed`, marks the durable seller flag, and resolves the operator's triage state, so the conversation leaves every actionable queue instead of collecting in the manual one. The same deterministic decision runs against cached inbound history during each running-mode reply scan and during the operator-forced seller scan, including conversations that are too old to revive for an automated reply or have already become operator-owned. Detection normalizes the text first — unicode folding, zero-width stripping, leet mapping, separator removal — because these words are routinely obfuscated (`0nlyfans`, `t3l3gram`, `t e l e g r a m`). Ambiguous terms need context: `snap` on its own is a photo request in this product and never a kill on its own. Off-platform kills are a safety control and stay on independently of `reddit.filterSellers`; a configured seller keyword or domain also kills unless `conversations.killOnSellerKeyword` is disabled. Under-18 disclosures and in-person/contact-detail asks remain human handoffs, and now apply to every policy mode rather than the R4R ones only.
Optional second-stage AI screening (`conversations.aiReplyScreening`) runs only when the deterministic rules come up clean, classifies the inbound side of the conversation as genuine, seller, off-platform, scam, bot, or unclear, and kills at or above `killConfidence` while handing off at or above `flagConfidence`. It fails open: a classifier error or outage never blocks a reply, and each inbound event is screened at most once per process. Short refusals are interpreted in sequence: phrases such as “I’m good” become media boundaries only when they answer a media request.
+3 -1
View File
@@ -57,8 +57,10 @@ Verification is the safety mechanism that matters most. Posted comments are swep
| `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/recipient-account-status.ts` | Periodic cached-conversation recipient profile checks | Only definitive Reddit results replace durable state; checks are authentication-bound and must not block the reply loop. |
| `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-automation.ts` | Reply-first policy, cached-history deterministic risk screening, claims, retries, send-time refresh, handoffs, turn budgets | Snapshot policy; apply the shared risk decision before actionability and send; 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-risk.ts` | Shared deterministic seller, funnel, contact-platform, payment, and scam decision | Live, revalidation, automatic history, and forced-sweep paths must not diverge. |
| `services/seller-sweep.ts` | Operator-forced deterministic and batched-AI seller screening | Use complete cached inbound text; deterministic off-platform outcomes remain terminal; unchanged clean AI screens use the durable watermark. |
| `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. |
+1 -1
View File
@@ -173,7 +173,7 @@ export const SellerScanFlagSchema = z.strictObject({
conversationId: z.string(),
username: z.string(),
/** Which rule settled it. Everything but `ai` costs no tokens. */
source: z.enum(['keyword', 'domain', 'funnel', 'ai']),
source: z.enum(['keyword', 'domain', 'funnel', 'off-platform', 'ai']),
reason: z.string(),
/** Present for AI verdicts only. */
confidence: z.number().min(0).max(1).optional(),
+99 -39
View File
@@ -3,7 +3,6 @@ import type { SubredditConfig } from '../config/index.js';
import type { ConversationStateRepository, SentMessageRecord } from '../database/repositories.js';
import type { Logger } from '../utils/logger.js';
import type { RateLimiter } from '../utils/rate-limiter.js';
import { findSellerSignal } from '../reddit/api.js';
import type { ActiveChat, ChatMessage } from './chat/types.js';
import type { ChatService } from './chat/service.js';
import type { ConversationAutomationClaimRecord } from '../database/repositories.js';
@@ -22,7 +21,7 @@ import {
type ConversationState,
} from './conversation-state.js';
import { containsMediaRequest } from './conversation-boundaries.js';
import { findOffPlatformSignal, offPlatformSignalReason } from './off-platform-signals.js';
import { findDeterministicConversationRisk } from './conversation-risk.js';
import type { MediaLibraryExchangeService } from './media-library-exchange.js';
import type { ConversationReplyScreening } from '../ai/openrouter.js';
import type { LearningConfig } from '../config/index.js';
@@ -121,6 +120,8 @@ export class ConversationAutomationService {
private lastScanAt: number | null = null;
private lastReplySentAt: number | null = null;
private lastReplyLatencyMs: number | null = null;
/** Avoid rescanning unchanged cached transcripts every 30 seconds. */
private readonly deterministicScreeningKeys = new Map<string, string>();
constructor(
private readonly chat: ChatService,
@@ -161,6 +162,7 @@ export class ConversationAutomationService {
await this.chat.refreshTopChats(REPLY_REFRESH_LIMIT);
conversations = await this.chat.getCachedConversations();
this.reconcilePendingClaims(conversations);
const deterministicResults = scanOnly ? [] : this.screenCachedConversationRisks(conversations);
const actionable = this.actionableConversations(conversations, previousScanAt);
this.actionableBacklog = actionable.length;
this.oldestPendingAt = actionable[0]?.messages?.at(-1)?.timestamp ?? null;
@@ -183,7 +185,7 @@ export class ConversationAutomationService {
return [];
}
const results: ConversationAutomationResult[] = [];
const results: ConversationAutomationResult[] = [...deterministicResults];
for (const conversation of actionable) {
const result = await this.processConversation(conversation, signal);
this.actionableBacklog = Math.max(0, this.actionableBacklog - 1);
@@ -325,22 +327,17 @@ export class ConversationAutomationService {
);
}
// Off-platform pushes end the conversation outright: there is nothing for an
// operator to salvage, and leaving them in the manual queue only buries the
// conversations that do need a human.
if (this.options.offPlatformKill !== false) {
const offPlatform = findOffPlatformSignal(latest.text);
if (offPlatform) {
return this.kill(conversation, conversationId, state, incomingKey, offPlatformSignalReason(offPlatform));
}
}
const sellerSignal = findSellerSignal(latest.text, [...this.sellerKeywords], [...this.sellerDomains]);
if (sellerSignal) {
const deterministicRisk = findDeterministicConversationRisk(
latest.text,
this.sellerKeywords,
this.sellerDomains,
{ includeOffPlatform: this.options.offPlatformKill !== false },
);
if (deterministicRisk) {
state.sellerDetected = true;
const reason = `Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`;
return this.options.killOnSellerKeyword === false
? this.finish(conversation, conversationId, state, incomingKey, 'human-required', reason)
: this.kill(conversation, conversationId, state, incomingKey, reason);
return deterministicRisk.terminal || this.options.killOnSellerKeyword !== false
? this.kill(conversation, conversationId, state, incomingKey, deterministicRisk.reason)
: this.finish(conversation, conversationId, state, incomingKey, 'human-required', deterministicRisk.reason);
}
// Under-18 and in-person signals are safety handoffs rather than kills, and
// they apply to every policy mode, not just the R4R ones.
@@ -368,19 +365,14 @@ export class ConversationAutomationService {
if (refreshedState.boundaries.length > 0 || hasReceivedImage(refreshed.messages ?? [])) {
return { kind: 'human-required' as const, reason: 'The conversation became ineligible before the image could be sent.' };
}
const refreshedOffPlatform = this.options.offPlatformKill !== false
? findOffPlatformSignal(refreshedLatest.text)
: null;
if (refreshedOffPlatform) {
return { kind: 'killed' as const, reason: offPlatformSignalReason(refreshedOffPlatform) };
}
const refreshedSellerSignal = findSellerSignal(
const refreshedRisk = findDeterministicConversationRisk(
refreshedLatest.text,
[...this.sellerKeywords],
[...this.sellerDomains],
this.sellerKeywords,
this.sellerDomains,
{ includeOffPlatform: this.options.offPlatformKill !== false },
);
if (refreshedSellerSignal) {
return { kind: 'killed' as const, reason: 'Potential upsell appeared before the image could be sent.' };
if (refreshedRisk) {
return { kind: 'killed' as const, reason: refreshedRisk.reason };
}
if (messageKey(refreshedLatest) !== incomingKey) return { kind: 'superseded' as const };
const refreshedPreviousSelfText = previousSelfMessageBefore(
@@ -570,6 +562,67 @@ export class ConversationAutomationService {
}
}
/**
* Retrospectively applies the same deterministic rules as the live reply
* path to cached inbound history. This catches conversations first observed
* before a restart, after an operator reply, or outside the automation
* actionability window without spending classifier tokens.
*/
private screenCachedConversationRisks(
conversations: ActiveChat[],
): ConversationAutomationResult[] {
const results: ConversationAutomationResult[] = [];
for (const conversation of conversations) {
if (!conversation.roomId) continue;
const inbound = (conversation.messages ?? []).filter((message) => !message.isSelf);
const latest = inbound.at(-1);
if (!latest) continue;
const inboundText = inbound.map((message) => message.text).join('\n');
const screeningKey = `${messageKey(latest)}:${conversationMessageFingerprint(inboundText)}`;
if (this.deterministicScreeningKeys.get(conversation.roomId) === screeningKey) continue;
this.deterministicScreeningKeys.set(conversation.roomId, screeningKey);
const savedState = this.states.get(conversation.roomId);
if (savedState?.sellerDetected || savedState?.automation.status === 'killed') continue;
const risk = findDeterministicConversationRisk(
inboundText,
this.sellerKeywords,
this.sellerDomains,
{ includeOffPlatform: this.options.offPlatformKill !== false },
);
if (!risk) continue;
const mapped = (conversation.messages ?? []).map(({ sender, text, isSelf }) => ({ sender, text, isSelf }));
const state = mergeConversationState(savedState, buildConversationState(mapped));
if (!savedState) {
const opener = this.openers.getByChatRoomId(conversation.roomId);
const behaviour = this.resolveConversationBehaviour(opener?.subreddit);
const maxTurns = behaviour.mode === 'r4r' || behaviour.mode === 'r4r-media'
? behaviour.maxAutomatedTurns
: LEGACY_AUTOMATED_CONVERSATION_TURNS;
state.automation = createConversationAutomationState(
behaviour.mode,
maxTurns,
behaviour.rapportTurnsBeforeMediaAsk,
);
}
state.sellerDetected = true;
results.push(
risk.terminal || this.options.killOnSellerKeyword !== false
? this.kill(conversation, conversation.roomId, state, messageKey(latest), risk.reason)
: this.finish(
conversation,
conversation.roomId,
state,
messageKey(latest),
'human-required',
risk.reason,
),
);
}
return results;
}
private isActionableConversation(conversation: ActiveChat, observedAfter: number | null): boolean {
if (!conversation.roomId || !conversation.url) return false;
const latest = latestInboundMessage(conversation.messages ?? []);
@@ -738,17 +791,24 @@ export class ConversationAutomationService {
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) {
const risk = findDeterministicConversationRisk(
latest.text,
this.sellerKeywords,
this.sellerDomains,
{ includeOffPlatform: this.options.offPlatformKill !== false },
);
if (risk) {
state.sellerDetected = true;
return this.finish(
conversation,
conversation.roomId!,
state,
messageKey(latest),
'human-required',
`Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`,
);
return risk.terminal || this.options.killOnSellerKeyword !== false
? this.kill(conversation, conversation.roomId!, state, messageKey(latest), risk.reason)
: this.finish(
conversation,
conversation.roomId!,
state,
messageKey(latest),
'human-required',
risk.reason,
);
}
return null;
}
+43
View File
@@ -0,0 +1,43 @@
import { findSellerSignal } from '../reddit/api.js';
import { findOffPlatformSignal, offPlatformSignalReason } from './off-platform-signals.js';
export type DeterministicConversationRiskSource = 'keyword' | 'domain' | 'funnel' | 'off-platform';
export interface DeterministicConversationRisk {
source: DeterministicConversationRiskSource;
reason: string;
/** Off-platform and seller-funnel signals are terminal safety outcomes. */
terminal: boolean;
}
/**
* Shared deterministic seller/off-platform decision used by live replies,
* send-time revalidation, automatic cached-history screening, and the manual
* inbox scan. Keeping one decision here prevents those paths from disagreeing.
*/
export function findDeterministicConversationRisk(
text: string,
sellerKeywords: readonly string[],
sellerDomains: readonly string[],
{ includeOffPlatform = true }: { includeOffPlatform?: boolean } = {},
): DeterministicConversationRisk | null {
if (includeOffPlatform) {
const offPlatform = findOffPlatformSignal(text);
if (offPlatform) {
return {
source: offPlatform.kind === 'seller-link' ? 'funnel' : 'off-platform',
reason: offPlatformSignalReason(offPlatform),
terminal: true,
};
}
}
const configured = findSellerSignal(text, [...sellerKeywords], [...sellerDomains]);
return configured
? {
source: configured.kind,
reason: `Potential upsell detected (${configured.kind}: ${configured.value}).`,
terminal: false,
}
: null;
}
@@ -514,6 +514,8 @@ export class AccountRuntimeLoop {
? `Human follow-up required for u/${result.username}`
: result.kind === 'image-received'
? `Image received from u/${result.username}`
: result.kind === 'killed'
? `Seller or off-platform conversation ended for u/${result.username}`
: `Automation stopped for u/${result.username} after a boundary`,
recipient: result.username,
});
+22 -19
View File
@@ -1,5 +1,7 @@
import { findSellerSignal } from '../reddit/api.js';
import { findOffPlatformSignal, offPlatformSignalReason } from './off-platform-signals.js';
import {
findDeterministicConversationRisk,
type DeterministicConversationRiskSource,
} from './conversation-risk.js';
import {
buildConversationState,
mergeConversationState,
@@ -38,7 +40,7 @@ export interface SellerSweepConversation {
watermark: string;
}
export type SellerSweepSource = 'keyword' | 'domain' | 'funnel' | 'ai';
export type SellerSweepSource = DeterministicConversationRiskSource | 'ai';
export interface SellerSweepFlag {
conversationId: string;
@@ -97,6 +99,8 @@ export interface SellerSweepOptions {
flagConfidence: number;
/** Mirrors `conversations.killOnSellerKeyword` for deterministic hits. */
killOnSellerKeyword: boolean;
/** Mirrors `conversations.offPlatformKill` for app/contact/payment pushes. */
offPlatformKill?: boolean;
/** Conversations per classifier request. */
batchSize?: number;
/** Newest messages per conversation sent to the classifier. */
@@ -144,21 +148,20 @@ function inboundText(messages: ConversationMessage[]): string {
function deterministicVerdict(
text: string,
options: SellerSweepOptions,
): { source: SellerSweepSource; reason: string } | null {
const configured = findSellerSignal(text, [...options.sellerKeywords], [...options.sellerDomains]);
if (configured) {
return {
source: configured.kind,
reason: `Potential upsell detected (${configured.kind}: ${configured.value}).`,
};
}
const signal = findOffPlatformSignal(text);
// Only the seller funnel patterns belong to a seller sweep. The other
// off-platform kinds are the live reply loop's business, not this one's.
if (signal?.kind === 'seller-link') {
return { source: 'funnel', reason: offPlatformSignalReason(signal) };
}
return null;
): { source: SellerSweepSource; reason: string; kill: boolean } | null {
const risk = findDeterministicConversationRisk(
text,
options.sellerKeywords,
options.sellerDomains,
{ includeOffPlatform: options.offPlatformKill !== false },
);
return risk
? {
source: risk.source,
reason: risk.reason,
kill: risk.terminal || options.killOnSellerKeyword,
}
: null;
}
export async function sweepConversationsForSellers(
@@ -238,7 +241,7 @@ export async function sweepConversationsForSellers(
const deterministic = deterministicVerdict(text, options);
if (deterministic) {
summary.deterministic += 1;
flag(conversation, state, deterministic.source, deterministic.reason, options.killOnSellerKeyword);
flag(conversation, state, deterministic.source, deterministic.reason, deterministic.kill);
continue;
}
// Deterministically clean and unchanged since the last sweep: the
+1
View File
@@ -834,6 +834,7 @@ export function createConversationsRouter(ctx: WebContext): Router {
killConfidence: ctx.config.conversations.aiReplyScreening.killConfidence,
flagConfidence: ctx.config.conversations.aiReplyScreening.flagConfidence,
killOnSellerKeyword: ctx.config.conversations.killOnSellerKeyword,
offPlatformKill: ctx.config.conversations.offPlatformKill,
};
}
+41
View File
@@ -223,6 +223,32 @@ describe('conversation automation', () => {
});
});
it('kills instead of resuming a queued reply when an off-platform push arrives', async () => {
const observed = incoming(1, 'how is your week going?');
const latest: ActiveChat = {
...observed,
lastMessageTime: 40,
lastMessageSnippet: 'add me on telegram instead',
messages: [
...observed.messages!,
{ eventId: 'telegram-event', sender: 'alice', text: 'add me on telegram instead', timestamp: 40, isSelf: false },
],
};
const fixture = harness([], [], 0, undefined, async () => latest);
fixture.claims.getClaim.mockReturnValue({
conversationId: 'room-alice', incomingKey: 'in-1', status: 'pending',
replyText: 'Mine has been good, thanks.', incomingAt: 3,
claimedAt: 0, lastAttemptAt: null, attemptCount: 0, sentAt: null, supersededAt: null,
} as never);
const result = await fixture.service.processConversation(observed);
expect(result?.kind).toBe('killed');
expect(fixture.claims.markSuperseded).toHaveBeenCalledWith('room-alice', 'in-1');
expect(fixture.chat.sendChatMessage).not.toHaveBeenCalled();
expect(fixture.getSaved()).toMatchObject({ sellerDetected: true, automation: { status: 'killed' } });
});
it('kills a conversation that pushes a seller funnel', async () => {
const { service, chat, ai, triage, getSaved } = harness(['onlyfans'], ['fansly.com']);
const result = await service.processConversation(incoming(1, 'My Fansly is https://www.fansly.com/example'));
@@ -619,6 +645,21 @@ describe('conversation automation', () => {
expect(chat.sendChatMessage).not.toHaveBeenCalled();
});
it('automatically ends a historical read conversation with an off-platform push', async () => {
const { service, chat, triage, getSaved } = harness();
chat.getCachedConversations.mockResolvedValue([{
...incoming(1, 'add me on telegram, I barely use reddit'),
unread: false,
}]);
const results = await service.processPendingConversations();
expect(results).toEqual([expect.objectContaining({ kind: 'killed', conversationId: 'room-alice' })]);
expect(getSaved()).toMatchObject({ sellerDetected: true, automation: { status: 'killed' } });
expect(triage.setStatus).toHaveBeenCalledWith('room-alice', 'done', null, 1_000);
expect(chat.sendChatMessage).not.toHaveBeenCalled();
});
it('retries the same durable reply after an interrupted send', async () => {
const { service, chat, ai, claims } = harness();
chat.sendChatMessage.mockRejectedValueOnce(new Error('interrupted'));
+38 -52
View File
@@ -3,6 +3,7 @@ import type { ActiveChat } from '../packages/api-contracts/src/index.js';
import {
DEFAULT_CONVERSATION_FILTERS,
accountOptions,
conversationQueue,
filterCounts,
isConversationFilter,
isConversationSort,
@@ -91,7 +92,7 @@ describe('conversation triage', () => {
expect(manualFlowLabel(chat({ username: 'c', automationStatus: 'active', automationPolicyMode: 'manual' })))
.toBe('Manual by policy');
expect(manualFlowLabel(chat({ username: 'd', automationStatus: 'killed' })))
.toBe('Killed — seller');
.toBeNull();
expect(manualFlowLabel(chat({ username: 'e', automationStatus: 'active' }))).toBeNull();
});
});
@@ -101,44 +102,32 @@ describe('conversation triage', () => {
const replied = chat({ username: 'replied', isFromUs: true });
const snoozed = chat({ username: 'snoozed', triageStatus: 'snoozed' });
const done = chat({ username: 'done', triageStatus: 'done' });
const withImage = chat({ username: 'image', hasReceivedImages: true });
const deleted = chat({ username: 'deleted', recipientAccountStatus: 'deleted' });
const archived = chat({ username: 'archived', readOnly: true });
const seller = chat({ username: 'seller', sellerDetected: true });
it('excludes snoozed and resolved conversations from the default view', () => {
expect(matchesFilter(open, 'all')).toBe(true);
expect(matchesFilter(replied, 'all')).toBe(true);
expect(matchesFilter(snoozed, 'all')).toBe(false);
expect(matchesFilter(done, 'all')).toBe(false);
});
it('keeps snoozed and resolved conversations reachable through their own buckets', () => {
it('assigns every conversation to one workflow queue', () => {
expect(conversationQueue(open)).toBe('needs-reply');
expect(conversationQueue(replied)).toBe('awaiting-reply');
expect(matchesFilter(snoozed, 'snoozed')).toBe(true);
expect(matchesFilter(done, 'done')).toBe(true);
expect(matchesFilter(seller, 'sellers')).toBe(true);
expect(matchesFilter(archived, 'archived')).toBe(true);
});
it('moves deleted accounts out of actionable buckets into their own bucket', () => {
expect(matchesFilter(deleted, 'all')).toBe(false);
it('moves deleted and archived accounts out of actionable queues', () => {
expect(matchesFilter(deleted, 'needs-reply')).toBe(false);
expect(matchesFilter(deleted, 'account-deleted')).toBe(true);
expect(matchesFilter(archived, 'needs-reply')).toBe(false);
expect(matchesFilter(archived, 'archived')).toBe(true);
expect(needsReply(deleted)).toBe(false);
});
it('scopes unread and image buckets to open conversations', () => {
expect(matchesFilter(open, 'unread')).toBe(true);
expect(matchesFilter(chat({ username: 'x', hasUnreadInbound: true, triageStatus: 'done' }), 'unread')).toBe(false);
expect(matchesFilter(withImage, 'received-images')).toBe(true);
expect(matchesFilter(chat({ username: 'y', hasReceivedImages: true, triageStatus: 'done' }), 'received-images')).toBe(false);
});
it('collects conversations the operator is driving into the manual bucket', () => {
const manual = chat({ username: 'manual', automationStatus: 'user-overridden' });
expect(matchesFilter(manual, 'manual')).toBe(true);
expect(matchesFilter(open, 'manual')).toBe(false);
// Resolved work leaves the manual queue the same way it leaves the others.
expect(matchesFilter(
chat({ username: 'z', automationStatus: 'user-overridden', triageStatus: 'done' }),
'manual',
)).toBe(false);
it('gives seller and archive outcomes precedence over status and direction', () => {
expect(conversationQueue(chat({ username: 's', sellerDetected: true, isFromUs: true }))).toBe('sellers');
expect(conversationQueue(chat({ username: 'a', readOnly: true, sellerDetected: true }))).toBe('archived');
expect(conversationQueue(chat({ username: 'd', recipientAccountStatus: 'deleted', sellerDetected: true })))
.toBe('account-deleted');
});
it('matches search against username, snippet and loaded message text', () => {
@@ -169,33 +158,31 @@ describe('conversation triage', () => {
it('puts the longest-waiting unanswered conversation first when sorting by oldest unanswered', () => {
const result = partitionConversations([
chat({ username: 'recent-inbound', lastMessageTime: 300 }),
chat({ username: 'replied-old', lastMessageTime: 50, isFromUs: true }),
chat({ username: 'stale-inbound', lastMessageTime: 100 }),
], filters({ sort: 'oldest-unanswered' }));
// Unanswered first, oldest of those leading; everything else newest-first.
expect(result.visible.map((item) => item.username))
.toEqual(['stale-inbound', 'recent-inbound', 'replied-old']);
.toEqual(['stale-inbound', 'recent-inbound']);
});
it('reports how many rows the seller filter removed', () => {
it('keeps sellers in their dedicated queue', () => {
const result = partitionConversations([
chat({ username: 'alice' }),
chat({ username: 'seller-one', sellerDetected: true }),
chat({ username: 'seller-two', sellerDetected: true }),
], filters());
], filters({ filter: 'sellers' }));
expect(result.visible.map((item) => item.username)).toEqual(['alice']);
expect(result.hiddenSellerCount).toBe(2);
expect(result.visible.map((item) => item.username)).toEqual(['seller-one', 'seller-two']);
});
it('includes sellers without counting them as hidden once revealed', () => {
it('applies unread, manual and image filters as secondary modifiers', () => {
const result = partitionConversations([
chat({ username: 'seller', sellerDetected: true }),
], filters({ showSellers: true }));
chat({ username: 'match', hasUnreadInbound: true, automationStatus: 'user-overridden', hasReceivedImages: true }),
chat({ username: 'read', hasUnreadInbound: false, automationStatus: 'user-overridden', hasReceivedImages: true }),
chat({ username: 'automatic', hasUnreadInbound: true, automationStatus: 'active', hasReceivedImages: true }),
], filters({ unreadOnly: true, manualOnly: true, imagesOnly: true }));
expect(result.visible).toHaveLength(1);
expect(result.hiddenSellerCount).toBe(0);
expect(result.visible.map((item) => item.username)).toEqual(['match']);
});
it('narrows to a single owning account', () => {
@@ -219,25 +206,25 @@ describe('conversation triage', () => {
});
describe('filterCounts', () => {
it('counts each bucket independently', () => {
it('counts mutually exclusive workflow queues', () => {
const counts = filterCounts([
chat({ username: 'unread-inbound', hasUnreadInbound: true }),
chat({ username: 'read-inbound', hasUnreadInbound: false }),
chat({ username: 'replied', isFromUs: true }),
chat({ username: 'snoozed', triageStatus: 'snoozed' }),
chat({ username: 'done', triageStatus: 'done' }),
chat({ username: 'manual', isFromUs: true, automationStatus: 'user-overridden' }),
chat({ username: 'seller', sellerDetected: true }),
chat({ username: 'deleted', recipientAccountStatus: 'deleted' }),
chat({ username: 'archived', readOnly: true }),
], filters());
expect(counts.all).toBe(4);
expect(counts.unread).toBe(1);
expect(counts['needs-reply']).toBe(2);
expect(counts['awaiting-reply']).toBe(2);
expect(counts.manual).toBe(1);
expect(counts['awaiting-reply']).toBe(1);
expect(counts.sellers).toBe(1);
expect(counts.snoozed).toBe(1);
expect(counts.done).toBe(1);
expect(counts['account-deleted']).toBe(1);
expect(counts.archived).toBe(1);
});
it('respects the search term so a pill never promises rows the list will not show', () => {
@@ -246,17 +233,16 @@ describe('conversation triage', () => {
chat({ username: 'bob', hasUnreadInbound: true }),
], filters({ search: 'alice' }));
expect(counts.unread).toBe(1);
expect(counts.all).toBe(1);
expect(counts['needs-reply']).toBe(1);
});
it('excludes hidden sellers from the counts', () => {
it('applies secondary filters to every queue count', () => {
const counts = filterCounts([
chat({ username: 'alice', hasUnreadInbound: true }),
chat({ username: 'seller', hasUnreadInbound: true, sellerDetected: true }),
], filters());
chat({ username: 'read', hasUnreadInbound: false }),
], filters({ unreadOnly: true }));
expect(counts.unread).toBe(1);
expect(counts['needs-reply']).toBe(1);
});
});
@@ -440,17 +440,21 @@ describe('application-level read state', () => {
expect(await screen.findByRole('button', { name: /u\/carol/ })).toBeInTheDocument();
});
it('explains rows hidden by the seller filter instead of silently dropping them', async () => {
it('keeps seller rows reachable through their dedicated queue', async () => {
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'alice' }),
chatFixture({ username: 'seller', sellerDetected: true }),
])),
);
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('1 hidden by the seller filter.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Sellers, 1' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Sellers, 1' }));
expect(screen.getByRole('button', { name: /u\/seller/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/alice/ })).not.toBeInTheDocument();
});
it('flags an unanswered conversation that has gone stale', async () => {
+13 -10
View File
@@ -148,10 +148,12 @@ describe('ConversationsPage', () => {
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
await user.type(screen.getByRole('searchbox', { name: 'Search conversations' }), 'bob');
expect(screen.queryByRole('button', { name: /u\/alice/ })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^Waiting/ }));
expect(screen.getByRole('button', { name: /u\/bob/ })).toBeInTheDocument();
await user.clear(screen.getByRole('searchbox', { name: 'Search conversations' }));
await user.click(screen.getByRole('button', { name: /^Unread/ }));
await user.click(screen.getByRole('button', { name: /^Needs attention/ }));
await user.click(screen.getByRole('checkbox', { name: 'Unread only' }));
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
@@ -185,7 +187,7 @@ describe('ConversationsPage', () => {
expect(previewTrigger).toHaveFocus();
});
it('hides detected sellers by default and reveals marked conversations on request', async () => {
it('keeps detected sellers in their dedicated queue', async () => {
const seller = chatFixture({ username: 'seller', roomId: 'room-seller', sellerDetected: true });
server.use(http.get('/api/conversations', () => HttpResponse.json([alice, seller])));
const user = userEvent.setup();
@@ -194,7 +196,7 @@ describe('ConversationsPage', () => {
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/seller/ })).not.toBeInTheDocument();
await user.click(screen.getByRole('checkbox', { name: 'Show sellers' }));
await user.click(screen.getByRole('button', { name: /^Sellers/ }));
expect(screen.getByRole('button', { name: /u\/seller.*Seller/ })).toBeInTheDocument();
expect(screen.getByText('Seller')).toBeInTheDocument();
});
@@ -261,7 +263,7 @@ describe('ConversationsPage', () => {
const aliceItem = await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('Image received')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^Images/ }));
await user.click(screen.getByRole('checkbox', { name: 'Has images' }));
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
@@ -288,13 +290,14 @@ describe('ConversationsPage', () => {
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /^Waiting/ }));
await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('Manual', { selector: '.conversation-chip' })).toHaveAttribute(
'title',
'Manual — you replied — A manual reply was sent in this conversation.',
);
await user.click(screen.getByRole('button', { name: 'Manual, 1' }));
await user.click(screen.getByRole('checkbox', { name: 'Manual only' }));
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
});
@@ -501,12 +504,12 @@ describe('ConversationsPage', () => {
const aliceButton = await screen.findByRole('button', { name: /u\/alice/ });
// alice is inbound and unread; bob is a conversation we replied to last.
expect(screen.getByRole('button', { name: 'Unread, 1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Needs reply, 1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Waiting on them, 1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'All, 2' })).toHaveAttribute('aria-pressed', 'true');
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, Needs reply, .+ago/);
expect(screen.getByRole('button', { name: 'Needs attention, 1' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Waiting, 1' })).toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: 'Unread only' })).not.toBeChecked();
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Needs attention, Unread, .+ago/);
expect(aliceButton.closest('li')).toHaveClass('is-unread');
await userEvent.setup().click(screen.getByRole('button', { name: 'Waiting, 1' }));
expect(screen.getByRole('button', { name: /u\/bob/ }).closest('li')).not.toHaveClass('is-unread');
});
+7 -5
View File
@@ -33,20 +33,22 @@ describe('route-friendly filters', () => {
server.use(http.get('/api/conversations', () => HttpResponse.json([])));
const user = userEvent.setup();
renderRoute(
'/conversations?q=alice&filter=received-images&showSellers=true&sort=oldest-unanswered',
'/conversations?q=alice&filter=awaiting-reply&unread=true&manual=true&images=true&sort=oldest-unanswered',
<ConversationRoute />,
);
const search = await screen.findByRole('searchbox', { name: 'Search conversations' });
expect(search).toHaveValue('alice');
expect(screen.getByRole('button', { name: /^Images/ })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: /^Waiting/ })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('combobox', { name: 'Sort' })).toHaveValue('oldest-unanswered');
expect(screen.getByRole('checkbox', { name: 'Show sellers' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'Unread only' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'Manual only' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'Has images' })).toBeChecked();
await user.clear(search);
await user.type(search, 'bob');
expect(screen.getByLabelText('Current URL')).toHaveTextContent('q=bob');
await user.click(screen.getByRole('button', { name: /^Needs reply/ }));
expect(screen.getByLabelText('Current URL')).toHaveTextContent('filter=needs-reply');
await user.click(screen.getByRole('button', { name: /^Needs attention/ }));
expect(screen.getByLabelText('Current URL')).not.toHaveTextContent('filter=');
});
it('hydrates and writes media filters', async () => {
+19 -3
View File
@@ -15,6 +15,7 @@ const OPTIONS: SellerSweepOptions = {
killConfidence: 0.8,
flagConfidence: 0.6,
killOnSellerKeyword: true,
offPlatformKill: true,
};
function conversation(overrides: Partial<SellerSweepConversation> = {}): SellerSweepConversation {
@@ -68,7 +69,7 @@ describe('sweepConversationsForSellers', () => {
expect(ai.screenConversationsForSellers).not.toHaveBeenCalled();
expect(summary.deterministic).toBe(2);
expect(summary.aiRequests).toBe(0);
expect(summary.flagged.map((flag) => flag.source)).toEqual(['keyword', 'domain']);
expect(summary.flagged.map((flag) => flag.source)).toEqual(['funnel', 'funnel']);
expect(summary.flagged.every((flag) => flag.killed)).toBe(true);
expect(states.saved.get('room-keyword')?.sellerDetected).toBe(true);
expect(states.saved.get('room-keyword')?.automation.status).toBe('killed');
@@ -87,13 +88,28 @@ describe('sweepConversationsForSellers', () => {
expect(summary.flagged[0].source).toBe('funnel');
});
it('treats contact-platform and payment pushes as deterministic seller outcomes', async () => {
const states = stateStore();
const summary = await sweepConversationsForSellers([
conversation({ conversationId: 'telegram', messages: [{ sender: 'alice', text: 'add me on telegram', isSelf: false }] }),
conversation({ conversationId: 'snap', messages: [{ sender: 'alice', text: 'my snapchat is seller123', isSelf: false }] }),
conversation({ conversationId: 'cash', messages: [{ sender: 'alice', text: 'cash app is $seller123', isSelf: false }] }),
], OPTIONS, { states });
expect(summary.flagged.map((flag) => flag.source)).toEqual([
'off-platform', 'off-platform', 'off-platform',
]);
expect(summary.flagged.every((flag) => flag.killed)).toBe(true);
});
it('hands the deterministic kill decision to the configured policy', async () => {
const states = stateStore();
const triage = { setStatus: vi.fn() };
const summary = await sweepConversationsForSellers(
[conversation({ messages: [{ sender: 'alice', text: 'see my onlyfans', isSelf: false }] })],
{ ...OPTIONS, killOnSellerKeyword: false },
[conversation({ messages: [{ sender: 'alice', text: 'you could spoil me', isSelf: false }] })],
{ ...OPTIONS, sellerKeywords: ['spoil me'], killOnSellerKeyword: false },
{ states, triage },
);
+1 -1
View File
@@ -599,7 +599,7 @@ describe('conversations route', () => {
expect(body.flagged).toEqual([expect.objectContaining({
// Flags carry the owned id so the dashboard can match its list rows.
conversationId: `${encodeURIComponent('@t2_one:reddit.com')}~room-alice`,
username: 'alice', source: 'keyword', killed: true,
username: 'alice', source: 'funnel', killed: true,
})]);
// The keyword hit never reaches the model; only the clean row is screened.
expect(ai.screenConversationsForSellers.mock.calls[0][0]).toEqual([