Seller detection on replies was a case-insensitive substring match over the configured keyword list, and every hit landed in `human-required` alongside genuine prospects that need a person. Off-platform pushes were only checked for r4r policy modes, as were the under-18 signals. Three layers now: - New `off-platform-signals` module matches on normalized text (unicode folding, zero-width stripping, leet mapping, separator removal), so `0nlyf4ns`, `t3l3gr4m` and `t e l e g r a m` are caught. Ambiguous terms need context: `snap` on its own stays a photo request, never a kill. - New terminal `killed` automation status. It records the evidence, sets the durable seller flag, supersedes the pending claim, and resolves triage to `done`, so killed conversations leave every actionable queue. Off-platform kills are independent of `reddit.filterSellers`. - Optional AI screening of inbound replies behind `conversations.aiReplyScreening` (off by default). Runs only after the deterministic gates pass, memoizes per inbound event, and fails open. Under-18 and in-person/contact-detail signals now apply to every policy mode rather than r4r only, and remain human handoffs rather than kills. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
253 lines
9.3 KiB
TypeScript
253 lines
9.3 KiB
TypeScript
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.
|
|
*/
|
|
export type ConversationFilter =
|
|
| 'all'
|
|
| 'needs-reply'
|
|
| 'unread'
|
|
| 'awaiting-reply'
|
|
| 'manual'
|
|
| 'received-images'
|
|
| 'account-deleted'
|
|
| 'snoozed'
|
|
| 'done';
|
|
|
|
export type ConversationSort = 'recent' | 'oldest-unanswered';
|
|
|
|
export interface ConversationFilters {
|
|
search: string;
|
|
filter: ConversationFilter;
|
|
showSellers: boolean;
|
|
sort: ConversationSort;
|
|
/** Reddit account id to narrow to, or `all`. */
|
|
account: string;
|
|
}
|
|
|
|
export const DEFAULT_CONVERSATION_FILTERS: ConversationFilters = {
|
|
search: '',
|
|
filter: 'all',
|
|
showSellers: 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: 'snoozed', label: 'Snoozed' },
|
|
{ value: 'done', label: 'Done' },
|
|
];
|
|
|
|
export const CONVERSATION_SORTS: ReadonlyArray<{ value: ConversationSort; label: string }> = [
|
|
{ value: 'recent', label: 'Newest activity' },
|
|
{ value: 'oldest-unanswered', label: 'Oldest unanswered' },
|
|
];
|
|
|
|
export function isConversationFilter(value: string | null): value is ConversationFilter {
|
|
return CONVERSATION_FILTERS.some((option) => option.value === value);
|
|
}
|
|
|
|
export function isConversationSort(value: string | null): value is ConversationSort {
|
|
return CONVERSATION_SORTS.some((option) => option.value === value);
|
|
}
|
|
|
|
export function triageStatus(conversation: ActiveChat): 'open' | 'snoozed' | 'done' {
|
|
return conversation.triageStatus ?? 'open';
|
|
}
|
|
|
|
/**
|
|
* True when the newest message is inbound and newer than the read watermark.
|
|
*
|
|
* Falls back to Matrix's `unread` only when the server has not supplied read
|
|
* state at all, so an older backend keeps behaving as before rather than
|
|
* showing an inbox with nothing unread.
|
|
*/
|
|
export function isUnread(conversation: ActiveChat): boolean {
|
|
if (conversation.hasUnreadInbound !== undefined) return conversation.hasUnreadInbound;
|
|
return conversation.unread;
|
|
}
|
|
|
|
export function needsReply(conversation: ActiveChat): boolean {
|
|
if (conversation.recipientAccountStatus === 'deleted') return false;
|
|
if (conversation.needsReply !== undefined) return conversation.needsReply;
|
|
return !conversation.isFromUs && triageStatus(conversation) === 'open';
|
|
}
|
|
|
|
/** We have replied and are waiting on them. */
|
|
export function isWaitingOnThem(conversation: ActiveChat): boolean {
|
|
return conversation.recipientAccountStatus !== 'deleted'
|
|
&& conversation.isFromUs
|
|
&& triageStatus(conversation) === 'open';
|
|
}
|
|
|
|
/**
|
|
* True when the reply loop is no longer sending on its own, so every further
|
|
* message has to be written by an operator: either the subreddit's policy was
|
|
* manual from the start, or automation handed the conversation over (a manual
|
|
* reply was sent, an image arrived, a boundary or turn limit was hit).
|
|
*
|
|
* Conversations with no recorded automation state are not manual — they have
|
|
* simply never been through the reply loop.
|
|
*/
|
|
export function isManualFlow(conversation: ActiveChat): boolean {
|
|
if (conversation.automationPolicyMode === 'manual') return true;
|
|
return conversation.automationStatus !== undefined && conversation.automationStatus !== 'active';
|
|
}
|
|
|
|
/** Short label for why a conversation is being handled by hand. */
|
|
export function manualFlowLabel(conversation: ActiveChat): string | null {
|
|
if (!isManualFlow(conversation)) return null;
|
|
if (conversation.automationPolicyMode === 'manual' && conversation.automationStatus === 'active') {
|
|
return 'Manual by policy';
|
|
}
|
|
switch (conversation.automationStatus) {
|
|
case 'killed':
|
|
return 'Killed — seller';
|
|
case 'user-overridden':
|
|
return 'Manual — you replied';
|
|
case 'image-received':
|
|
return 'Manual — image received';
|
|
case 'boundary':
|
|
return 'Manual — boundary';
|
|
case 'human-required':
|
|
return 'Manual — handed over';
|
|
default:
|
|
return 'Manual';
|
|
}
|
|
}
|
|
|
|
export function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
|
|
if (conversation.recipientAccountStatus === 'deleted') return filter === 'account-deleted';
|
|
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';
|
|
}
|
|
}
|
|
|
|
export function matchesSearch(conversation: ActiveChat, search: string): boolean {
|
|
const normalized = search.trim().toLowerCase();
|
|
if (normalized.length === 0) return true;
|
|
if (conversation.username.toLowerCase().includes(normalized)) return true;
|
|
if (conversation.lastMessageSnippet.toLowerCase().includes(normalized)) return true;
|
|
// Message text is only present for conversations whose history has been
|
|
// loaded, so this is a bonus rather than a guarantee.
|
|
return (conversation.messages ?? []).some((message) => message.text.toLowerCase().includes(normalized));
|
|
}
|
|
|
|
function matchesAccount(conversation: ActiveChat, account: string): boolean {
|
|
return account === 'all' || conversation.owner?.accountId === account;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* `oldest-unanswered` puts the longest-waiting conversation that needs a reply
|
|
* first — the order you actually want when draining a queue — and keeps
|
|
* everything else in newest-first order behind it.
|
|
*/
|
|
function compareConversations(sort: ConversationSort) {
|
|
return (left: ActiveChat, right: ActiveChat): number => {
|
|
if (sort === 'oldest-unanswered') {
|
|
const leftNeeds = needsReply(left);
|
|
const rightNeeds = needsReply(right);
|
|
if (leftNeeds !== rightNeeds) return leftNeeds ? -1 : 1;
|
|
if (leftNeeds && rightNeeds) return left.lastMessageTime - right.lastMessageTime;
|
|
}
|
|
return right.lastMessageTime - left.lastMessageTime;
|
|
};
|
|
}
|
|
|
|
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 { visible: visible.sort(compareConversations(filters.sort)), hiddenSellerCount };
|
|
}
|
|
|
|
/**
|
|
* Per-bucket counts for the filter pills. Counts respect search, account and
|
|
* the seller toggle so a pill never promises rows the list will not show.
|
|
*/
|
|
export function filterCounts(
|
|
conversations: readonly ActiveChat[],
|
|
filters: ConversationFilters,
|
|
): Record<ConversationFilter, number> {
|
|
const candidates = conversations.filter((conversation) =>
|
|
matchesSearch(conversation, filters.search)
|
|
&& matchesAccount(conversation, filters.account)
|
|
&& (filters.showSellers || conversation.sellerDetected !== true));
|
|
const counts = {} as Record<ConversationFilter, number>;
|
|
for (const { value } of CONVERSATION_FILTERS) {
|
|
counts[value] = candidates.filter((conversation) => matchesFilter(conversation, value)).length;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
export interface ConversationAccountOption {
|
|
accountId: string;
|
|
redditUsername: string;
|
|
}
|
|
|
|
/** Distinct owning accounts present in the list, for the account switcher. */
|
|
export function accountOptions(conversations: readonly ActiveChat[]): ConversationAccountOption[] {
|
|
const seen = new Map<string, string>();
|
|
for (const conversation of conversations) {
|
|
if (conversation.owner) seen.set(conversation.owner.accountId, conversation.owner.redditUsername);
|
|
}
|
|
return [...seen]
|
|
.map(([accountId, redditUsername]) => ({ accountId, redditUsername }))
|
|
.sort((left, right) => left.redditUsername.localeCompare(right.redditUsername));
|
|
}
|
|
|
|
export const SNOOZE_OPTIONS: ReadonlyArray<{ label: string; ms: number }> = [
|
|
{ label: '1 hour', ms: 60 * 60_000 },
|
|
{ label: 'Tomorrow', ms: 24 * 60 * 60_000 },
|
|
{ label: '3 days', ms: 3 * 24 * 60 * 60_000 },
|
|
{ label: '1 week', ms: 7 * 24 * 60 * 60_000 },
|
|
];
|