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
+379 -213

No files matched your search

@@ -15,7 +15,9 @@ export function ConversationRoute() {
const sortValue = searchParams.get('sort'); const sortValue = searchParams.get('sort');
const filters: ConversationFilters = { const filters: ConversationFilters = {
search: searchParams.get('q') ?? '', 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, filter: isConversationFilter(filterValue) ? filterValue : DEFAULT_CONVERSATION_FILTERS.filter,
sort: isConversationSort(sortValue) ? sortValue : DEFAULT_CONVERSATION_FILTERS.sort, sort: isConversationSort(sortValue) ? sortValue : DEFAULT_CONVERSATION_FILTERS.sort,
account: searchParams.get('account') ?? DEFAULT_CONVERSATION_FILTERS.account, account: searchParams.get('account') ?? DEFAULT_CONVERSATION_FILTERS.account,
@@ -33,8 +35,10 @@ export function ConversationRoute() {
function updateFilters(next: ConversationFilters) { function updateFilters(next: ConversationFilters) {
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
if (next.search) params.set('q', next.search); else params.delete('q'); 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.filter !== DEFAULT_CONVERSATION_FILTERS.filter) params.set('filter', next.filter); else params.delete('filter');
if (next.showSellers) params.set('showSellers', 'true'); else params.delete('showSellers'); 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.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'); if (next.account !== 'all') params.set('account', next.account); else params.delete('account');
setSearchParams(params, { replace: true }); setSearchParams(params, { replace: true });
@@ -1,21 +1,16 @@
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js'; import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
/** /**
* Inbox buckets. These are the operator's actual work queues, derived from * Mutually exclusive inbox workflow queues. Read state, manual ownership, and
* application read state rather than Matrix's unread count. * images are secondary filters so they cannot make a row appear to occupy two
* * competing workflow states.
* `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.
*/ */
export type ConversationFilter = export type ConversationFilter =
| 'all'
| 'needs-reply' | 'needs-reply'
| 'unread'
| 'awaiting-reply' | 'awaiting-reply'
| 'manual' | 'sellers'
| 'received-images'
| 'account-deleted' | 'account-deleted'
| 'archived'
| 'snoozed' | 'snoozed'
| 'done'; | 'done';
@@ -24,7 +19,9 @@ export type ConversationSort = 'recent' | 'oldest-unanswered';
export interface ConversationFilters { export interface ConversationFilters {
search: string; search: string;
filter: ConversationFilter; filter: ConversationFilter;
showSellers: boolean; unreadOnly: boolean;
manualOnly: boolean;
imagesOnly: boolean;
sort: ConversationSort; sort: ConversationSort;
/** Reddit account id to narrow to, or `all`. */ /** Reddit account id to narrow to, or `all`. */
account: string; account: string;
@@ -32,22 +29,22 @@ export interface ConversationFilters {
export const DEFAULT_CONVERSATION_FILTERS: ConversationFilters = { export const DEFAULT_CONVERSATION_FILTERS: ConversationFilters = {
search: '', search: '',
filter: 'all', filter: 'needs-reply',
showSellers: false, unreadOnly: false,
manualOnly: false,
imagesOnly: false,
sort: 'recent', sort: 'recent',
account: 'all', account: 'all',
}; };
export const CONVERSATION_FILTERS: ReadonlyArray<{ value: ConversationFilter; label: string }> = [ export const CONVERSATION_FILTERS: ReadonlyArray<{ value: ConversationFilter; label: string }> = [
{ value: 'all', label: 'All' }, { value: 'needs-reply', label: 'Needs attention' },
{ value: 'needs-reply', label: 'Needs reply' }, { value: 'awaiting-reply', label: 'Waiting' },
{ value: 'unread', label: 'Unread' }, { value: 'sellers', label: 'Sellers' },
{ value: 'awaiting-reply', label: 'Waiting on them' },
{ value: 'manual', label: 'Manual' },
{ value: 'received-images', label: 'Images' },
{ value: 'account-deleted', label: 'Account deleted' },
{ value: 'snoozed', label: 'Snoozed' }, { value: 'snoozed', label: 'Snoozed' },
{ value: 'done', label: 'Done' }, { value: 'done', label: 'Done' },
{ value: 'account-deleted', label: 'Deleted' },
{ value: 'archived', label: 'Archived' },
]; ];
export const CONVERSATION_SORTS: ReadonlyArray<{ value: ConversationSort; label: string }> = [ 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. */ /** We have replied and are waiting on them. */
export function isWaitingOnThem(conversation: ActiveChat): boolean { export function isWaitingOnThem(conversation: ActiveChat): boolean {
return conversation.recipientAccountStatus !== 'deleted' return conversationQueue(conversation) === 'awaiting-reply';
&& conversation.isFromUs
&& triageStatus(conversation) === 'open';
} }
/** /**
@@ -103,7 +98,9 @@ export function isWaitingOnThem(conversation: ActiveChat): boolean {
*/ */
export function isManualFlow(conversation: ActiveChat): boolean { export function isManualFlow(conversation: ActiveChat): boolean {
if (conversation.automationPolicyMode === 'manual') return true; 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. */ /** 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'; return 'Manual by policy';
} }
switch (conversation.automationStatus) { switch (conversation.automationStatus) {
case 'killed':
return 'Killed — seller';
case 'user-overridden': case 'user-overridden':
return 'Manual — you replied'; return 'Manual — you replied';
case 'image-received': 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); const status = triageStatus(conversation);
switch (filter) { if (status === 'snoozed') return 'snoozed';
case 'needs-reply': if (status === 'done') return 'done';
return needsReply(conversation); return needsReply(conversation) ? 'needs-reply' : 'awaiting-reply';
case 'unread': }
return status === 'open' && isUnread(conversation);
case 'awaiting-reply': export function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
return isWaitingOnThem(conversation); return conversationQueue(conversation) === filter;
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';
}
} }
export function matchesSearch(conversation: ActiveChat, search: string): boolean { export function matchesSearch(conversation: ActiveChat, search: string): boolean {
@@ -170,8 +159,12 @@ function matchesAccount(conversation: ActiveChat, account: string): boolean {
export interface ConversationListPartition { export interface ConversationListPartition {
/** Conversations passing every active filter, in the requested order. */ /** Conversations passing every active filter, in the requested order. */
visible: ActiveChat[]; 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[], conversations: readonly ActiveChat[],
filters: ConversationFilters, filters: ConversationFilters,
): ConversationListPartition { ): ConversationListPartition {
let hiddenSellerCount = 0;
const visible = conversations.filter((conversation) => { const visible = conversations.filter((conversation) => {
if (!matchesSearch(conversation, filters.search)) return false; if (!matchesSearch(conversation, filters.search)) return false;
if (!matchesFilter(conversation, filters.filter)) return false; if (!matchesFilter(conversation, filters.filter)) return false;
if (!matchesAccount(conversation, filters.account)) return false; if (!matchesAccount(conversation, filters.account)) return false;
if (!filters.showSellers && conversation.sellerDetected === true) { return matchesSecondaryFilters(conversation, filters);
hiddenSellerCount += 1;
return false;
}
return true;
}); });
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) => const candidates = conversations.filter((conversation) =>
matchesSearch(conversation, filters.search) matchesSearch(conversation, filters.search)
&& matchesAccount(conversation, filters.account) && matchesAccount(conversation, filters.account)
&& (filters.showSellers || conversation.sellerDetected !== true)); && matchesSecondaryFilters(conversation, filters));
const counts = {} as Record<ConversationFilter, number>; const counts = {} as Record<ConversationFilter, number>;
for (const { value } of CONVERSATION_FILTERS) { for (const { value } of CONVERSATION_FILTERS) {
counts[value] = candidates.filter((conversation) => matchesFilter(conversation, value)).length; counts[value] = candidates.filter((conversation) => matchesFilter(conversation, value)).length;
@@ -21,12 +21,12 @@ import {
CONVERSATION_SORTS, CONVERSATION_SORTS,
DEFAULT_CONVERSATION_FILTERS, DEFAULT_CONVERSATION_FILTERS,
accountOptions, accountOptions,
conversationQueue,
filterCounts, filterCounts,
isUnread, isUnread,
manualFlowLabel, manualFlowLabel,
needsReply, needsReply,
partitionConversations, partitionConversations,
triageStatus,
type ConversationFilter, type ConversationFilter,
type ConversationFilters, type ConversationFilters,
type ConversationSort, type ConversationSort,
@@ -87,16 +87,19 @@ function sellerScanSummary(result: SellerScanResponse): string {
return `${parts.join(' · ')}.`; 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[] { function conversationChips(conversation: ActiveChat, now: number): ConversationChip[] {
const chips: ConversationChip[] = []; const chips: ConversationChip[] = [];
if (conversation.recipientAccountStatus === 'deleted') { switch (conversationQueue(conversation)) {
chips.push({ label: 'Account deleted' }); case 'needs-reply': chips.push({ label: 'Needs attention' }); break;
return chips; 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 (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); const manualLabel = manualFlowLabel(conversation);
if (manualLabel) { if (manualLabel) {
chips.push({ chips.push({
@@ -107,10 +110,8 @@ function conversationChips(conversation: ActiveChat, now: number): ConversationC
: manualLabel, : manualLabel,
}); });
} }
const status = triageStatus(conversation); if (conversationQueue(conversation) === 'needs-reply'
if (status === 'snoozed') chips.push({ label: 'Snoozed' }); && conversationAge(conversation.lastMessageTime, now) !== 'fresh') {
if (status === 'done') chips.push({ label: 'Done' });
if (needsReply(conversation) && conversationAge(conversation.lastMessageTime, now) !== 'fresh') {
chips.push({ label: 'Overdue' }); chips.push({ label: 'Overdue' });
} }
return chips; return chips;
@@ -133,7 +134,6 @@ function ConversationRow({
redditUsernameLabel(conversation.username), redditUsernameLabel(conversation.username),
...chips.map((chip) => chip.hint ?? chip.label), ...chips.map((chip) => chip.hint ?? chip.label),
relativeTime, relativeTime,
...(conversation.sellerDetected ? ['Seller'] : []),
...(conversation.hasReceivedImages ? ['Image received'] : []), ...(conversation.hasReceivedImages ? ['Image received'] : []),
...(conversation.readOnly ? ['Removed account, read-only'] : []), ...(conversation.readOnly ? ['Removed account, read-only'] : []),
]; ];
@@ -186,7 +186,6 @@ function ConversationRow({
Read-only Read-only
</span> </span>
) : null} ) : null}
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null} {conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
<span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span> <span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span>
</span> </span>
@@ -235,7 +234,7 @@ export function ConversationsPage({
const allConversations = conversationsQuery.data ?? []; const allConversations = conversationsQuery.data ?? [];
const { visible: liveConversations, hiddenSellerCount } = useMemo( const { visible: liveConversations } = useMemo(
() => partitionConversations(allConversations, activeFilters), () => partitionConversations(allConversations, activeFilters),
[allConversations, activeFilters], [allConversations, activeFilters],
); );
@@ -514,11 +513,27 @@ export function ConversationsPage({
) : null} ) : null}
<label className="conversation-seller-toggle"> <label className="conversation-seller-toggle">
<input <input
checked={activeFilters.showSellers} checked={activeFilters.unreadOnly}
onChange={(event) => updateFilters({ ...activeFilters, showSellers: event.target.checked })} onChange={(event) => updateFilters({ ...activeFilters, unreadOnly: event.target.checked })}
type="checkbox" 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> </label>
</div> </div>
{visibleConversations.length > 0 ? ( {visibleConversations.length > 0 ? (
@@ -577,9 +592,6 @@ export function ConversationsPage({
<div className="conversation-list-state"> <div className="conversation-list-state">
<strong>No matching conversations.</strong> <strong>No matching conversations.</strong>
<span>Try a different search or filter.</span> <span>Try a different search or filter.</span>
{hiddenSellerCount > 0 ? (
<span>{hiddenSellerCount} hidden by the seller filter.</span>
) : null}
</div> </div>
) : ( ) : (
<> <>
@@ -605,9 +617,6 @@ export function ConversationsPage({
})} })}
</ul> </ul>
<p className="conversation-list-footer"> <p className="conversation-list-footer">
{hiddenSellerCount > 0
? `${hiddenSellerCount} hidden by the seller filter.`
: null}
<span className="conversation-shortcut-hint"> <span className="conversation-shortcut-hint">
j/k move · Enter opens · u toggles unread · e marks done · / searches j/k move · Enter opens · u toggles unread · e marks done · / searches
</span> </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. 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. 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 ```mermaid
@@ -100,7 +102,7 @@ flowchart TD
SEND --> MARK["Mark claim sent and record latency"] 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. 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. 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 ## 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. 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. 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/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/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/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-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/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/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/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(), conversationId: z.string(),
username: z.string(), username: z.string(),
/** Which rule settled it. Everything but `ai` costs no tokens. */ /** 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(), reason: z.string(),
/** Present for AI verdicts only. */ /** Present for AI verdicts only. */
confidence: z.number().min(0).max(1).optional(), confidence: z.number().min(0).max(1).optional(),
+93 -33
View File
@@ -3,7 +3,6 @@ import type { SubredditConfig } from '../config/index.js';
import type { ConversationStateRepository, SentMessageRecord } from '../database/repositories.js'; import type { ConversationStateRepository, SentMessageRecord } from '../database/repositories.js';
import type { Logger } from '../utils/logger.js'; import type { Logger } from '../utils/logger.js';
import type { RateLimiter } from '../utils/rate-limiter.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 { ActiveChat, ChatMessage } from './chat/types.js';
import type { ChatService } from './chat/service.js'; import type { ChatService } from './chat/service.js';
import type { ConversationAutomationClaimRecord } from '../database/repositories.js'; import type { ConversationAutomationClaimRecord } from '../database/repositories.js';
@@ -22,7 +21,7 @@ import {
type ConversationState, type ConversationState,
} from './conversation-state.js'; } from './conversation-state.js';
import { containsMediaRequest } from './conversation-boundaries.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 { MediaLibraryExchangeService } from './media-library-exchange.js';
import type { ConversationReplyScreening } from '../ai/openrouter.js'; import type { ConversationReplyScreening } from '../ai/openrouter.js';
import type { LearningConfig } from '../config/index.js'; import type { LearningConfig } from '../config/index.js';
@@ -121,6 +120,8 @@ export class ConversationAutomationService {
private lastScanAt: number | null = null; private lastScanAt: number | null = null;
private lastReplySentAt: number | null = null; private lastReplySentAt: number | null = null;
private lastReplyLatencyMs: number | null = null; private lastReplyLatencyMs: number | null = null;
/** Avoid rescanning unchanged cached transcripts every 30 seconds. */
private readonly deterministicScreeningKeys = new Map<string, string>();
constructor( constructor(
private readonly chat: ChatService, private readonly chat: ChatService,
@@ -161,6 +162,7 @@ export class ConversationAutomationService {
await this.chat.refreshTopChats(REPLY_REFRESH_LIMIT); await this.chat.refreshTopChats(REPLY_REFRESH_LIMIT);
conversations = await this.chat.getCachedConversations(); conversations = await this.chat.getCachedConversations();
this.reconcilePendingClaims(conversations); this.reconcilePendingClaims(conversations);
const deterministicResults = scanOnly ? [] : this.screenCachedConversationRisks(conversations);
const actionable = this.actionableConversations(conversations, previousScanAt); const actionable = this.actionableConversations(conversations, previousScanAt);
this.actionableBacklog = actionable.length; this.actionableBacklog = actionable.length;
this.oldestPendingAt = actionable[0]?.messages?.at(-1)?.timestamp ?? null; this.oldestPendingAt = actionable[0]?.messages?.at(-1)?.timestamp ?? null;
@@ -183,7 +185,7 @@ export class ConversationAutomationService {
return []; return [];
} }
const results: ConversationAutomationResult[] = []; const results: ConversationAutomationResult[] = [...deterministicResults];
for (const conversation of actionable) { for (const conversation of actionable) {
const result = await this.processConversation(conversation, signal); const result = await this.processConversation(conversation, signal);
this.actionableBacklog = Math.max(0, this.actionableBacklog - 1); 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 const deterministicRisk = findDeterministicConversationRisk(
// operator to salvage, and leaving them in the manual queue only buries the latest.text,
// conversations that do need a human. this.sellerKeywords,
if (this.options.offPlatformKill !== false) { this.sellerDomains,
const offPlatform = findOffPlatformSignal(latest.text); { includeOffPlatform: this.options.offPlatformKill !== false },
if (offPlatform) { );
return this.kill(conversation, conversationId, state, incomingKey, offPlatformSignalReason(offPlatform)); if (deterministicRisk) {
}
}
const sellerSignal = findSellerSignal(latest.text, [...this.sellerKeywords], [...this.sellerDomains]);
if (sellerSignal) {
state.sellerDetected = true; state.sellerDetected = true;
const reason = `Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`; return deterministicRisk.terminal || this.options.killOnSellerKeyword !== false
return this.options.killOnSellerKeyword === false ? this.kill(conversation, conversationId, state, incomingKey, deterministicRisk.reason)
? this.finish(conversation, conversationId, state, incomingKey, 'human-required', reason) : this.finish(conversation, conversationId, state, incomingKey, 'human-required', deterministicRisk.reason);
: this.kill(conversation, conversationId, state, incomingKey, reason);
} }
// Under-18 and in-person signals are safety handoffs rather than kills, and // Under-18 and in-person signals are safety handoffs rather than kills, and
// they apply to every policy mode, not just the R4R ones. // 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 ?? [])) { 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.' }; return { kind: 'human-required' as const, reason: 'The conversation became ineligible before the image could be sent.' };
} }
const refreshedOffPlatform = this.options.offPlatformKill !== false const refreshedRisk = findDeterministicConversationRisk(
? findOffPlatformSignal(refreshedLatest.text)
: null;
if (refreshedOffPlatform) {
return { kind: 'killed' as const, reason: offPlatformSignalReason(refreshedOffPlatform) };
}
const refreshedSellerSignal = findSellerSignal(
refreshedLatest.text, refreshedLatest.text,
[...this.sellerKeywords], this.sellerKeywords,
[...this.sellerDomains], this.sellerDomains,
{ includeOffPlatform: this.options.offPlatformKill !== false },
); );
if (refreshedSellerSignal) { if (refreshedRisk) {
return { kind: 'killed' as const, reason: 'Potential upsell appeared before the image could be sent.' }; return { kind: 'killed' as const, reason: refreshedRisk.reason };
} }
if (messageKey(refreshedLatest) !== incomingKey) return { kind: 'superseded' as const }; if (messageKey(refreshedLatest) !== incomingKey) return { kind: 'superseded' as const };
const refreshedPreviousSelfText = previousSelfMessageBefore( 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 { private isActionableConversation(conversation: ActiveChat, observedAfter: number | null): boolean {
if (!conversation.roomId || !conversation.url) return false; if (!conversation.roomId || !conversation.url) return false;
const latest = latestInboundMessage(conversation.messages ?? []); const latest = latestInboundMessage(conversation.messages ?? []);
@@ -738,16 +791,23 @@ export class ConversationAutomationService {
if (hasReceivedImage(conversation.messages ?? [])) { if (hasReceivedImage(conversation.messages ?? [])) {
return this.finish(conversation, conversation.roomId!, state, messageKey(latest), 'image-received', 'The user shared an image.'); 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]); const risk = findDeterministicConversationRisk(
if (sellerSignal) { latest.text,
this.sellerKeywords,
this.sellerDomains,
{ includeOffPlatform: this.options.offPlatformKill !== false },
);
if (risk) {
state.sellerDetected = true; state.sellerDetected = true;
return this.finish( return risk.terminal || this.options.killOnSellerKeyword !== false
? this.kill(conversation, conversation.roomId!, state, messageKey(latest), risk.reason)
: this.finish(
conversation, conversation,
conversation.roomId!, conversation.roomId!,
state, state,
messageKey(latest), messageKey(latest),
'human-required', 'human-required',
`Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`, risk.reason,
); );
} }
return null; 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}` ? `Human follow-up required for u/${result.username}`
: result.kind === 'image-received' : result.kind === 'image-received'
? `Image received from u/${result.username}` ? `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`, : `Automation stopped for u/${result.username} after a boundary`,
recipient: result.username, recipient: result.username,
}); });
+21 -18
View File
@@ -1,5 +1,7 @@
import { findSellerSignal } from '../reddit/api.js'; import {
import { findOffPlatformSignal, offPlatformSignalReason } from './off-platform-signals.js'; findDeterministicConversationRisk,
type DeterministicConversationRiskSource,
} from './conversation-risk.js';
import { import {
buildConversationState, buildConversationState,
mergeConversationState, mergeConversationState,
@@ -38,7 +40,7 @@ export interface SellerSweepConversation {
watermark: string; watermark: string;
} }
export type SellerSweepSource = 'keyword' | 'domain' | 'funnel' | 'ai'; export type SellerSweepSource = DeterministicConversationRiskSource | 'ai';
export interface SellerSweepFlag { export interface SellerSweepFlag {
conversationId: string; conversationId: string;
@@ -97,6 +99,8 @@ export interface SellerSweepOptions {
flagConfidence: number; flagConfidence: number;
/** Mirrors `conversations.killOnSellerKeyword` for deterministic hits. */ /** Mirrors `conversations.killOnSellerKeyword` for deterministic hits. */
killOnSellerKeyword: boolean; killOnSellerKeyword: boolean;
/** Mirrors `conversations.offPlatformKill` for app/contact/payment pushes. */
offPlatformKill?: boolean;
/** Conversations per classifier request. */ /** Conversations per classifier request. */
batchSize?: number; batchSize?: number;
/** Newest messages per conversation sent to the classifier. */ /** Newest messages per conversation sent to the classifier. */
@@ -144,21 +148,20 @@ function inboundText(messages: ConversationMessage[]): string {
function deterministicVerdict( function deterministicVerdict(
text: string, text: string,
options: SellerSweepOptions, options: SellerSweepOptions,
): { source: SellerSweepSource; reason: string } | null { ): { source: SellerSweepSource; reason: string; kill: boolean } | null {
const configured = findSellerSignal(text, [...options.sellerKeywords], [...options.sellerDomains]); const risk = findDeterministicConversationRisk(
if (configured) { text,
return { options.sellerKeywords,
source: configured.kind, options.sellerDomains,
reason: `Potential upsell detected (${configured.kind}: ${configured.value}).`, { includeOffPlatform: options.offPlatformKill !== false },
}; );
return risk
? {
source: risk.source,
reason: risk.reason,
kill: risk.terminal || options.killOnSellerKeyword,
} }
const signal = findOffPlatformSignal(text); : null;
// 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;
} }
export async function sweepConversationsForSellers( export async function sweepConversationsForSellers(
@@ -238,7 +241,7 @@ export async function sweepConversationsForSellers(
const deterministic = deterministicVerdict(text, options); const deterministic = deterministicVerdict(text, options);
if (deterministic) { if (deterministic) {
summary.deterministic += 1; summary.deterministic += 1;
flag(conversation, state, deterministic.source, deterministic.reason, options.killOnSellerKeyword); flag(conversation, state, deterministic.source, deterministic.reason, deterministic.kill);
continue; continue;
} }
// Deterministically clean and unchanged since the last sweep: the // 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, killConfidence: ctx.config.conversations.aiReplyScreening.killConfidence,
flagConfidence: ctx.config.conversations.aiReplyScreening.flagConfidence, flagConfidence: ctx.config.conversations.aiReplyScreening.flagConfidence,
killOnSellerKeyword: ctx.config.conversations.killOnSellerKeyword, 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 () => { it('kills a conversation that pushes a seller funnel', async () => {
const { service, chat, ai, triage, getSaved } = harness(['onlyfans'], ['fansly.com']); 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')); 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(); 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 () => { it('retries the same durable reply after an interrupted send', async () => {
const { service, chat, ai, claims } = harness(); const { service, chat, ai, claims } = harness();
chat.sendChatMessage.mockRejectedValueOnce(new Error('interrupted')); 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 { import {
DEFAULT_CONVERSATION_FILTERS, DEFAULT_CONVERSATION_FILTERS,
accountOptions, accountOptions,
conversationQueue,
filterCounts, filterCounts,
isConversationFilter, isConversationFilter,
isConversationSort, isConversationSort,
@@ -91,7 +92,7 @@ describe('conversation triage', () => {
expect(manualFlowLabel(chat({ username: 'c', automationStatus: 'active', automationPolicyMode: 'manual' }))) expect(manualFlowLabel(chat({ username: 'c', automationStatus: 'active', automationPolicyMode: 'manual' })))
.toBe('Manual by policy'); .toBe('Manual by policy');
expect(manualFlowLabel(chat({ username: 'd', automationStatus: 'killed' }))) expect(manualFlowLabel(chat({ username: 'd', automationStatus: 'killed' })))
.toBe('Killed — seller'); .toBeNull();
expect(manualFlowLabel(chat({ username: 'e', automationStatus: 'active' }))).toBeNull(); expect(manualFlowLabel(chat({ username: 'e', automationStatus: 'active' }))).toBeNull();
}); });
}); });
@@ -101,44 +102,32 @@ describe('conversation triage', () => {
const replied = chat({ username: 'replied', isFromUs: true }); const replied = chat({ username: 'replied', isFromUs: true });
const snoozed = chat({ username: 'snoozed', triageStatus: 'snoozed' }); const snoozed = chat({ username: 'snoozed', triageStatus: 'snoozed' });
const done = chat({ username: 'done', triageStatus: 'done' }); const done = chat({ username: 'done', triageStatus: 'done' });
const withImage = chat({ username: 'image', hasReceivedImages: true });
const deleted = chat({ username: 'deleted', recipientAccountStatus: 'deleted' }); 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', () => { it('assigns every conversation to one workflow queue', () => {
expect(matchesFilter(open, 'all')).toBe(true); expect(conversationQueue(open)).toBe('needs-reply');
expect(matchesFilter(replied, 'all')).toBe(true); expect(conversationQueue(replied)).toBe('awaiting-reply');
expect(matchesFilter(snoozed, 'all')).toBe(false);
expect(matchesFilter(done, 'all')).toBe(false);
});
it('keeps snoozed and resolved conversations reachable through their own buckets', () => {
expect(matchesFilter(snoozed, 'snoozed')).toBe(true); expect(matchesFilter(snoozed, 'snoozed')).toBe(true);
expect(matchesFilter(done, 'done')).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', () => { it('moves deleted and archived accounts out of actionable queues', () => {
expect(matchesFilter(deleted, 'all')).toBe(false);
expect(matchesFilter(deleted, 'needs-reply')).toBe(false); expect(matchesFilter(deleted, 'needs-reply')).toBe(false);
expect(matchesFilter(deleted, 'account-deleted')).toBe(true); 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); expect(needsReply(deleted)).toBe(false);
}); });
it('scopes unread and image buckets to open conversations', () => { it('gives seller and archive outcomes precedence over status and direction', () => {
expect(matchesFilter(open, 'unread')).toBe(true); expect(conversationQueue(chat({ username: 's', sellerDetected: true, isFromUs: true }))).toBe('sellers');
expect(matchesFilter(chat({ username: 'x', hasUnreadInbound: true, triageStatus: 'done' }), 'unread')).toBe(false); expect(conversationQueue(chat({ username: 'a', readOnly: true, sellerDetected: true }))).toBe('archived');
expect(matchesFilter(withImage, 'received-images')).toBe(true); expect(conversationQueue(chat({ username: 'd', recipientAccountStatus: 'deleted', sellerDetected: true })))
expect(matchesFilter(chat({ username: 'y', hasReceivedImages: true, triageStatus: 'done' }), 'received-images')).toBe(false); .toBe('account-deleted');
});
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('matches search against username, snippet and loaded message text', () => { 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', () => { it('puts the longest-waiting unanswered conversation first when sorting by oldest unanswered', () => {
const result = partitionConversations([ const result = partitionConversations([
chat({ username: 'recent-inbound', lastMessageTime: 300 }), chat({ username: 'recent-inbound', lastMessageTime: 300 }),
chat({ username: 'replied-old', lastMessageTime: 50, isFromUs: true }),
chat({ username: 'stale-inbound', lastMessageTime: 100 }), chat({ username: 'stale-inbound', lastMessageTime: 100 }),
], filters({ sort: 'oldest-unanswered' })); ], filters({ sort: 'oldest-unanswered' }));
// Unanswered first, oldest of those leading; everything else newest-first.
expect(result.visible.map((item) => item.username)) 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([ const result = partitionConversations([
chat({ username: 'alice' }), chat({ username: 'alice' }),
chat({ username: 'seller-one', sellerDetected: true }), chat({ username: 'seller-one', sellerDetected: true }),
chat({ username: 'seller-two', sellerDetected: true }), chat({ username: 'seller-two', sellerDetected: true }),
], filters()); ], filters({ filter: 'sellers' }));
expect(result.visible.map((item) => item.username)).toEqual(['alice']); expect(result.visible.map((item) => item.username)).toEqual(['seller-one', 'seller-two']);
expect(result.hiddenSellerCount).toBe(2);
}); });
it('includes sellers without counting them as hidden once revealed', () => { it('applies unread, manual and image filters as secondary modifiers', () => {
const result = partitionConversations([ const result = partitionConversations([
chat({ username: 'seller', sellerDetected: true }), chat({ username: 'match', hasUnreadInbound: true, automationStatus: 'user-overridden', hasReceivedImages: true }),
], filters({ showSellers: 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.visible.map((item) => item.username)).toEqual(['match']);
expect(result.hiddenSellerCount).toBe(0);
}); });
it('narrows to a single owning account', () => { it('narrows to a single owning account', () => {
@@ -219,25 +206,25 @@ describe('conversation triage', () => {
}); });
describe('filterCounts', () => { describe('filterCounts', () => {
it('counts each bucket independently', () => { it('counts mutually exclusive workflow queues', () => {
const counts = filterCounts([ const counts = filterCounts([
chat({ username: 'unread-inbound', hasUnreadInbound: true }), chat({ username: 'unread-inbound', hasUnreadInbound: true }),
chat({ username: 'read-inbound', hasUnreadInbound: false }), chat({ username: 'read-inbound', hasUnreadInbound: false }),
chat({ username: 'replied', isFromUs: true }), chat({ username: 'replied', isFromUs: true }),
chat({ username: 'snoozed', triageStatus: 'snoozed' }), chat({ username: 'snoozed', triageStatus: 'snoozed' }),
chat({ username: 'done', triageStatus: 'done' }), 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: 'deleted', recipientAccountStatus: 'deleted' }),
chat({ username: 'archived', readOnly: true }),
], filters()); ], filters());
expect(counts.all).toBe(4);
expect(counts.unread).toBe(1);
expect(counts['needs-reply']).toBe(2); expect(counts['needs-reply']).toBe(2);
expect(counts['awaiting-reply']).toBe(2); expect(counts['awaiting-reply']).toBe(1);
expect(counts.manual).toBe(1); expect(counts.sellers).toBe(1);
expect(counts.snoozed).toBe(1); expect(counts.snoozed).toBe(1);
expect(counts.done).toBe(1); expect(counts.done).toBe(1);
expect(counts['account-deleted']).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', () => { 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 }), chat({ username: 'bob', hasUnreadInbound: true }),
], filters({ search: 'alice' })); ], filters({ search: 'alice' }));
expect(counts.unread).toBe(1); expect(counts['needs-reply']).toBe(1);
expect(counts.all).toBe(1);
}); });
it('excludes hidden sellers from the counts', () => { it('applies secondary filters to every queue count', () => {
const counts = filterCounts([ const counts = filterCounts([
chat({ username: 'alice', hasUnreadInbound: true }), chat({ username: 'alice', hasUnreadInbound: true }),
chat({ username: 'seller', hasUnreadInbound: true, sellerDetected: true }), chat({ username: 'read', hasUnreadInbound: false }),
], filters()); ], 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(); 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( server.use(
http.get('/api/conversations', () => HttpResponse.json([ http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'alice' }), chatFixture({ username: 'alice' }),
chatFixture({ username: 'seller', sellerDetected: true }), chatFixture({ username: 'seller', sellerDetected: true }),
])), ])),
); );
const user = userEvent.setup();
renderConversations(); renderConversations();
await screen.findByRole('button', { name: /u\/alice/ }); 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 () => { 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(); expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
await user.type(screen.getByRole('searchbox', { name: 'Search conversations' }), 'bob'); await user.type(screen.getByRole('searchbox', { name: 'Search conversations' }), 'bob');
expect(screen.queryByRole('button', { name: /u\/alice/ })).not.toBeInTheDocument(); 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(); expect(screen.getByRole('button', { name: /u\/bob/ })).toBeInTheDocument();
await user.clear(screen.getByRole('searchbox', { name: 'Search conversations' })); 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.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
@@ -185,7 +187,7 @@ describe('ConversationsPage', () => {
expect(previewTrigger).toHaveFocus(); 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 }); const seller = chatFixture({ username: 'seller', roomId: 'room-seller', sellerDetected: true });
server.use(http.get('/api/conversations', () => HttpResponse.json([alice, seller]))); server.use(http.get('/api/conversations', () => HttpResponse.json([alice, seller])));
const user = userEvent.setup(); const user = userEvent.setup();
@@ -194,7 +196,7 @@ describe('ConversationsPage', () => {
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/seller/ })).not.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.getByRole('button', { name: /u\/seller.*Seller/ })).toBeInTheDocument();
expect(screen.getByText('Seller')).toBeInTheDocument(); expect(screen.getByText('Seller')).toBeInTheDocument();
}); });
@@ -261,7 +263,7 @@ describe('ConversationsPage', () => {
const aliceItem = await screen.findByRole('button', { name: /u\/alice/ }); const aliceItem = await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('Image received')).toBeInTheDocument(); 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.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
@@ -288,13 +290,14 @@ describe('ConversationsPage', () => {
const user = userEvent.setup(); const user = userEvent.setup();
renderConversations(); renderConversations();
await user.click(await screen.findByRole('button', { name: /^Waiting/ }));
await screen.findByRole('button', { name: /u\/alice/ }); await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('Manual', { selector: '.conversation-chip' })).toHaveAttribute( expect(screen.getByText('Manual', { selector: '.conversation-chip' })).toHaveAttribute(
'title', 'title',
'Manual — you replied — A manual reply was sent in this conversation.', '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.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.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/ }); const aliceButton = await screen.findByRole('button', { name: /u\/alice/ });
// alice is inbound and unread; bob is a conversation we replied to last. // 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 attention, 1' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Needs reply, 1' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Waiting, 1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Waiting on them, 1' })).toBeInTheDocument(); expect(screen.getByRole('checkbox', { name: 'Unread only' })).not.toBeChecked();
expect(screen.getByRole('button', { name: 'All, 2' })).toHaveAttribute('aria-pressed', 'true'); expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Needs attention, Unread, .+ago/);
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, Needs reply, .+ago/);
expect(aliceButton.closest('li')).toHaveClass('is-unread'); 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'); 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([]))); server.use(http.get('/api/conversations', () => HttpResponse.json([])));
const user = userEvent.setup(); const user = userEvent.setup();
renderRoute( 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 />, <ConversationRoute />,
); );
const search = await screen.findByRole('searchbox', { name: 'Search conversations' }); const search = await screen.findByRole('searchbox', { name: 'Search conversations' });
expect(search).toHaveValue('alice'); 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('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.clear(search);
await user.type(search, 'bob'); await user.type(search, 'bob');
expect(screen.getByLabelText('Current URL')).toHaveTextContent('q=bob'); expect(screen.getByLabelText('Current URL')).toHaveTextContent('q=bob');
await user.click(screen.getByRole('button', { name: /^Needs reply/ })); await user.click(screen.getByRole('button', { name: /^Needs attention/ }));
expect(screen.getByLabelText('Current URL')).toHaveTextContent('filter=needs-reply'); expect(screen.getByLabelText('Current URL')).not.toHaveTextContent('filter=');
}); });
it('hydrates and writes media filters', async () => { it('hydrates and writes media filters', async () => {
+19 -3
View File
@@ -15,6 +15,7 @@ const OPTIONS: SellerSweepOptions = {
killConfidence: 0.8, killConfidence: 0.8,
flagConfidence: 0.6, flagConfidence: 0.6,
killOnSellerKeyword: true, killOnSellerKeyword: true,
offPlatformKill: true,
}; };
function conversation(overrides: Partial<SellerSweepConversation> = {}): SellerSweepConversation { function conversation(overrides: Partial<SellerSweepConversation> = {}): SellerSweepConversation {
@@ -68,7 +69,7 @@ describe('sweepConversationsForSellers', () => {
expect(ai.screenConversationsForSellers).not.toHaveBeenCalled(); expect(ai.screenConversationsForSellers).not.toHaveBeenCalled();
expect(summary.deterministic).toBe(2); expect(summary.deterministic).toBe(2);
expect(summary.aiRequests).toBe(0); 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(summary.flagged.every((flag) => flag.killed)).toBe(true);
expect(states.saved.get('room-keyword')?.sellerDetected).toBe(true); expect(states.saved.get('room-keyword')?.sellerDetected).toBe(true);
expect(states.saved.get('room-keyword')?.automation.status).toBe('killed'); expect(states.saved.get('room-keyword')?.automation.status).toBe('killed');
@@ -87,13 +88,28 @@ describe('sweepConversationsForSellers', () => {
expect(summary.flagged[0].source).toBe('funnel'); 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 () => { it('hands the deterministic kill decision to the configured policy', async () => {
const states = stateStore(); const states = stateStore();
const triage = { setStatus: vi.fn() }; const triage = { setStatus: vi.fn() };
const summary = await sweepConversationsForSellers( const summary = await sweepConversationsForSellers(
[conversation({ messages: [{ sender: 'alice', text: 'see my onlyfans', isSelf: false }] })], [conversation({ messages: [{ sender: 'alice', text: 'you could spoil me', isSelf: false }] })],
{ ...OPTIONS, killOnSellerKeyword: false }, { ...OPTIONS, sellerKeywords: ['spoil me'], killOnSellerKeyword: false },
{ states, triage }, { states, triage },
); );
+1 -1
View File
@@ -599,7 +599,7 @@ describe('conversations route', () => {
expect(body.flagged).toEqual([expect.objectContaining({ expect(body.flagged).toEqual([expect.objectContaining({
// Flags carry the owned id so the dashboard can match its list rows. // Flags carry the owned id so the dashboard can match its list rows.
conversationId: `${encodeURIComponent('@t2_one:reddit.com')}~room-alice`, 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. // The keyword hit never reaches the model; only the clean row is screened.
expect(ai.screenConversationsForSellers.mock.calls[0][0]).toEqual([ expect(ai.screenConversationsForSellers.mock.calls[0][0]).toEqual([