feat: kill conversations on off-platform and seller pushes
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>
This commit is contained in:
1 parent
ac12a3146a
commit
4404ae75ff
18 files changed
+907
-31
No files matched your search
@@ -17,16 +17,20 @@ import {
|
||||
mergeConversationState,
|
||||
r4rReplySafetyFailure,
|
||||
remainingAutomationTurns,
|
||||
type ConversationMessage,
|
||||
type ConversationState,
|
||||
} from './conversation-state.js';
|
||||
import { containsMediaRequest } from './conversation-boundaries.js';
|
||||
import { findOffPlatformSignal, offPlatformSignalReason } from './off-platform-signals.js';
|
||||
import type { MediaLibraryExchangeService } from './media-library-exchange.js';
|
||||
import type { ConversationReplyScreening } from '../ai/openrouter.js';
|
||||
|
||||
export type ConversationAutomationResultKind =
|
||||
| 'sent'
|
||||
| 'human-required'
|
||||
| 'boundary'
|
||||
| 'image-received';
|
||||
| 'image-received'
|
||||
| 'killed';
|
||||
|
||||
export interface ConversationAutomationResult {
|
||||
conversationId: string;
|
||||
@@ -54,6 +58,37 @@ export type ConversationBeforeSendLoader = (
|
||||
observed: ActiveChat,
|
||||
) => Promise<ActiveChat | undefined>;
|
||||
|
||||
/** Writes the operator-facing triage status, so killed conversations leave the inbox. */
|
||||
export interface ConversationTriageWriter {
|
||||
setStatus(conversationId: string, status: 'open' | 'snoozed' | 'done', snoozedUntil?: number | null, now?: number): void;
|
||||
}
|
||||
|
||||
/** Narrow view of the AI client used for second-stage reply screening. */
|
||||
export interface ConversationReplyScreener {
|
||||
screenConversationReply(
|
||||
conversationHistory: { sender: string; text: string; isSelf: boolean }[],
|
||||
username: string,
|
||||
): Promise<ConversationReplyScreening>;
|
||||
}
|
||||
|
||||
export interface ConversationScreeningOptions {
|
||||
enabled: boolean;
|
||||
/** At or above this confidence, a seller/off-platform/scam verdict kills the conversation. */
|
||||
killConfidence: number;
|
||||
/** At or above this confidence, the same verdict hands off to a human instead. */
|
||||
flagConfidence: number;
|
||||
}
|
||||
|
||||
export interface ConversationAutomationOptions {
|
||||
/** Off-platform kills are a safety control, so they default on regardless of `filterSellers`. */
|
||||
offPlatformKill?: boolean;
|
||||
/** Whether a configured seller keyword or domain also ends the conversation. */
|
||||
killOnSellerKeyword?: boolean;
|
||||
triage?: ConversationTriageWriter;
|
||||
screener?: ConversationReplyScreener;
|
||||
screening?: ConversationScreeningOptions;
|
||||
}
|
||||
|
||||
export interface ReplyPriorityStatus {
|
||||
processing: boolean;
|
||||
actionableBacklog: number;
|
||||
@@ -63,6 +98,9 @@ export interface ReplyPriorityStatus {
|
||||
lastReplyLatencyMs: number | null;
|
||||
}
|
||||
|
||||
const SCREENING_CACHE_LIMIT = 500;
|
||||
const SCREENING_REJECT_CLASSIFICATIONS = new Set(['seller', 'off-platform', 'scam', 'bot']);
|
||||
|
||||
const REPLY_REFRESH_LIMIT = 25;
|
||||
const REPLY_SEND_PRIORITY = 100;
|
||||
const PENDING_REPLY_RETRY_DELAY_MS = 2 * 60_000;
|
||||
@@ -96,8 +134,12 @@ export class ConversationAutomationService {
|
||||
private readonly pendingReplyRetryDelayMs = PENDING_REPLY_RETRY_DELAY_MS,
|
||||
private readonly beforeSendLoader?: ConversationBeforeSendLoader,
|
||||
private readonly mediaExchange?: MediaLibraryExchangeService,
|
||||
private readonly options: ConversationAutomationOptions = {},
|
||||
) {}
|
||||
|
||||
/** In-process memo so a claim retry does not pay for the same verdict twice. */
|
||||
private readonly screeningCache = new Map<string, ConversationReplyScreening>();
|
||||
|
||||
async processPendingConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
|
||||
this.processing = true;
|
||||
let conversations: ActiveChat[] = [];
|
||||
@@ -254,17 +296,28 @@ 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) {
|
||||
state.sellerDetected = true;
|
||||
return this.finish(
|
||||
conversation,
|
||||
conversationId,
|
||||
state,
|
||||
incomingKey,
|
||||
'human-required',
|
||||
`Potential upsell detected (${sellerSignal.kind}: ${sellerSignal.value}).`,
|
||||
);
|
||||
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);
|
||||
}
|
||||
// Under-18 and in-person signals are safety handoffs rather than kills, and
|
||||
// they apply to every policy mode, not just the R4R ones.
|
||||
const safetyReason = safetyHandoffReason(latest.text);
|
||||
if (safetyReason) {
|
||||
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', safetyReason);
|
||||
}
|
||||
if (hasReceivedImage(messages)) {
|
||||
return this.finish(conversation, conversationId, state, incomingKey, 'image-received', 'The user shared an image.');
|
||||
@@ -272,12 +325,6 @@ export class ConversationAutomationService {
|
||||
if (state.boundaries.length > 0) {
|
||||
return this.finish(conversation, conversationId, state, incomingKey, 'boundary', state.boundaries.join('; '));
|
||||
}
|
||||
if (state.automation.policyMode === 'r4r' || state.automation.policyMode === 'r4r-media') {
|
||||
const safetyReason = r4rSafetyHandoffReason(latest.text);
|
||||
if (safetyReason) {
|
||||
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', safetyReason);
|
||||
}
|
||||
}
|
||||
if (this.mediaExchange?.detect(conversationId, latest.text)) {
|
||||
const exchangeAttempt = await this.rateLimiter.throttle(
|
||||
async () => {
|
||||
@@ -291,13 +338,19 @@ 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(
|
||||
refreshedLatest.text,
|
||||
[...this.sellerKeywords],
|
||||
[...this.sellerDomains],
|
||||
);
|
||||
if (refreshedSellerSignal) {
|
||||
return { kind: 'human-required' as const, reason: 'Potential upsell appeared before the image could be sent.' };
|
||||
return { kind: 'killed' as const, reason: 'Potential upsell appeared before the image could be sent.' };
|
||||
}
|
||||
if (messageKey(refreshedLatest) !== incomingKey) return { kind: 'superseded' as const };
|
||||
return this.mediaExchange!.handle(conversationId, incomingKey, refreshedLatest.text);
|
||||
@@ -318,6 +371,9 @@ export class ConversationAutomationService {
|
||||
remainingTurns: remainingAutomationTurns(state),
|
||||
};
|
||||
}
|
||||
if (exchange.kind === 'killed') {
|
||||
return this.kill(conversation, conversationId, state, incomingKey, exchange.reason);
|
||||
}
|
||||
if (exchange.kind === 'human-required') {
|
||||
return this.finish(conversation, conversationId, state, incomingKey, 'human-required', exchange.reason);
|
||||
}
|
||||
@@ -335,6 +391,14 @@ export class ConversationAutomationService {
|
||||
);
|
||||
}
|
||||
|
||||
const screeningOutcome = await this.screenReply(conversationId, incomingKey, mapped, conversation.username);
|
||||
if (screeningOutcome) {
|
||||
state.sellerDetected = true;
|
||||
return screeningOutcome.kind === 'killed'
|
||||
? this.kill(conversation, conversationId, state, incomingKey, screeningOutcome.reason)
|
||||
: this.finish(conversation, conversationId, state, incomingKey, 'human-required', screeningOutcome.reason);
|
||||
}
|
||||
|
||||
const { isLegacy, isHybridMediaAsk } = replyPlan(state);
|
||||
if (isLegacy && state.askedForMedia) {
|
||||
return this.finish(
|
||||
@@ -626,6 +690,82 @@ export class ConversationAutomationService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the optional second-stage classifier. Returns null when the reply
|
||||
* should proceed — including on any classifier failure, because an
|
||||
* OpenRouter outage must not stall the reply path.
|
||||
*/
|
||||
private async screenReply(
|
||||
conversationId: string,
|
||||
incomingKey: string,
|
||||
messages: ConversationMessage[],
|
||||
username: string,
|
||||
): Promise<{ kind: 'killed' | 'human-required'; reason: string } | null> {
|
||||
const screening = this.options.screening;
|
||||
const screener = this.options.screener;
|
||||
if (!screening?.enabled || !screener) return null;
|
||||
|
||||
const cacheKey = `${conversationId}:${incomingKey}`;
|
||||
let verdict = this.screeningCache.get(cacheKey);
|
||||
if (!verdict) {
|
||||
try {
|
||||
verdict = await screener.screenConversationReply(messages, username);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[automation] Reply screening unavailable for u/${username}; proceeding: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (this.screeningCache.size >= SCREENING_CACHE_LIMIT) {
|
||||
const oldest = this.screeningCache.keys().next().value;
|
||||
if (oldest !== undefined) this.screeningCache.delete(oldest);
|
||||
}
|
||||
this.screeningCache.set(cacheKey, verdict);
|
||||
}
|
||||
|
||||
if (!SCREENING_REJECT_CLASSIFICATIONS.has(verdict.classification)) return null;
|
||||
const reason = `AI screening classified this conversation as ${verdict.classification}`
|
||||
+ ` (confidence ${verdict.confidence.toFixed(2)})`
|
||||
+ (verdict.evidence ? `: ${verdict.evidence}` : '.');
|
||||
if (verdict.confidence >= screening.killConfidence) return { kind: 'killed', reason };
|
||||
if (verdict.confidence >= screening.flagConfidence) return { kind: 'human-required', reason };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a conversation permanently. Unlike a handoff this also resolves the
|
||||
* operator's triage state, so a killed conversation drops out of every
|
||||
* actionable queue while staying reachable through the seller and done views.
|
||||
*/
|
||||
private kill(
|
||||
conversation: ActiveChat,
|
||||
conversationId: string,
|
||||
state: ConversationState,
|
||||
incomingKey: string,
|
||||
reason: string,
|
||||
): ConversationAutomationResult {
|
||||
state.sellerDetected = true;
|
||||
state.automation.status = 'killed';
|
||||
state.automation.lastIncomingKey = incomingKey;
|
||||
state.automation.handoffReason = reason;
|
||||
this.states.save(conversationId, state);
|
||||
this.claims.markSuperseded?.(conversationId, incomingKey);
|
||||
try {
|
||||
this.options.triage?.setStatus(conversationId, 'done', null, this.now());
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`[automation] Killed u/${conversation.username} but could not resolve triage state: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
this.logger.info(`[automation] Killed conversation with u/${conversation.username}: ${reason}`);
|
||||
return {
|
||||
conversationId,
|
||||
username: conversation.username,
|
||||
kind: 'killed',
|
||||
remainingTurns: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private finish(
|
||||
conversation: ActiveChat,
|
||||
conversationId: string,
|
||||
@@ -701,13 +841,18 @@ function replyPlan(state: ConversationState): { isLegacy: boolean; isHybridMedia
|
||||
return { isLegacy, isHybridMediaAsk };
|
||||
}
|
||||
|
||||
function r4rSafetyHandoffReason(message: string): string | null {
|
||||
/**
|
||||
* Handoffs that need a person rather than a kill, and that apply to every
|
||||
* policy mode. Messaging-app names are deliberately absent: those are handled
|
||||
* earlier by the off-platform kill, which runs before this check.
|
||||
*/
|
||||
function safetyHandoffReason(message: string): string | null {
|
||||
if (/\b(?:i(?:'m| am)|im|age(?:d)?|turning)\s*(?:1[0-7]|[0-9])\b/i.test(message)
|
||||
|| /\b(?:1[0-7]|[0-9])\s*[fm]\b/i.test(message)
|
||||
|| /\b(?:minor|underage)\b/i.test(message)) {
|
||||
return 'The participant may be under 18.';
|
||||
}
|
||||
if (/\b(?:meet|meetup|meet up|come over|address|phone number|whatsapp|telegram|snapchat)\b/i.test(message)) {
|
||||
if (/\b(?:meet|meetup|meet up|come over|address|phone number)\b/i.test(message)) {
|
||||
return 'The conversation reached contact details or an in-person meeting.';
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -43,12 +43,19 @@ export const CONVERSATION_POLICY_VERSION = 3;
|
||||
export type ConversationPolicyMode = 'legacy' | 'r4r' | 'r4r-media' | 'manual';
|
||||
export type HybridConversationPhase = 'rapport' | 'media-ask' | 'post-media-ask';
|
||||
|
||||
/**
|
||||
* `killed` is terminal and distinct from `human-required`: the conversation was
|
||||
* ended because the other party is selling or pulling the conversation off
|
||||
* Reddit, so there is nothing for an operator to pick up. Everything else is a
|
||||
* handoff that still expects a human.
|
||||
*/
|
||||
export type ConversationAutomationStatus =
|
||||
| 'active'
|
||||
| 'image-received'
|
||||
| 'human-required'
|
||||
| 'boundary'
|
||||
| 'user-overridden';
|
||||
| 'user-overridden'
|
||||
| 'killed';
|
||||
|
||||
export interface ConversationAutomationState {
|
||||
policyMode: ConversationPolicyMode;
|
||||
@@ -296,7 +303,8 @@ function parseAutomationState(value: unknown): ConversationAutomationState {
|
||||
? Math.max(1, Math.min(20, Math.trunc(parsed.maxAutomatedTurns)))
|
||||
: LEGACY_AUTOMATED_CONVERSATION_TURNS;
|
||||
const status: ConversationAutomationStatus =
|
||||
parsed.status === 'image-received' || parsed.status === 'human-required' || parsed.status === 'boundary' || parsed.status === 'user-overridden'
|
||||
parsed.status === 'image-received' || parsed.status === 'human-required' || parsed.status === 'boundary'
|
||||
|| parsed.status === 'user-overridden' || parsed.status === 'killed'
|
||||
? parsed.status
|
||||
: 'active';
|
||||
const rapportTurnsBeforeMediaAsk = typeof parsed.rapportTurnsBeforeMediaAsk === 'number'
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Detection for attempts to move a conversation off Reddit, plus the
|
||||
* transactional funnels that usually accompany them.
|
||||
*
|
||||
* This is deliberately separate from the configured `sellerKeywords` list in
|
||||
* `findSellerSignal`: that list is a lead-quality filter an operator can turn
|
||||
* off, whereas these signals are the ones that end a conversation outright. It
|
||||
* also matches on a normalized form of the text, because sellers obfuscate the
|
||||
* exact words Reddit itself filters on (`0nlyfans`, `t3l3gram`,
|
||||
* `t e l e g r a m`, zero-width joiners between letters).
|
||||
*/
|
||||
|
||||
export type OffPlatformSignalKind =
|
||||
| 'contact-platform'
|
||||
| 'handle-ask'
|
||||
| 'payment'
|
||||
| 'seller-link'
|
||||
| 'verification-scam';
|
||||
|
||||
export interface OffPlatformSignal {
|
||||
kind: OffPlatformSignalKind;
|
||||
/** Stable label used in logs and the durable handoff reason. */
|
||||
label: string;
|
||||
/** The fragment that matched, kept as evidence in the audit trail. */
|
||||
matched: string;
|
||||
}
|
||||
|
||||
const ZERO_WIDTH = /[-]/g;
|
||||
const COMBINING_MARKS = /[̀-ͯ]/g;
|
||||
|
||||
const LEET_MAP: Record<string, string> = {
|
||||
'0': 'o',
|
||||
'1': 'i',
|
||||
'3': 'e',
|
||||
'4': 'a',
|
||||
'5': 's',
|
||||
'7': 't',
|
||||
'8': 'b',
|
||||
'9': 'g',
|
||||
'@': 'a',
|
||||
$: 's',
|
||||
'!': 'i',
|
||||
'|': 'i',
|
||||
'£': 'e',
|
||||
};
|
||||
|
||||
/**
|
||||
* Lowercased, unicode-folded text with separators intact. Word-boundary rules
|
||||
* run against this so short tokens cannot match inside unrelated words.
|
||||
*/
|
||||
export function normalizeSignalText(text: string): string {
|
||||
return text
|
||||
.normalize('NFKD')
|
||||
.replace(COMBINING_MARKS, '')
|
||||
.replace(ZERO_WIDTH, '')
|
||||
.toLowerCase()
|
||||
.replace(/[’‘`]/g, "'")
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggressively folded text: leet characters mapped back to letters, every
|
||||
* separator removed, and long runs collapsed. `t.me` becomes `tme`, `0nly
|
||||
* f4ns` becomes `onlyfans`, `snaaaapchat` becomes `snapchat`. Only tokens long
|
||||
* enough to be unambiguous are matched here — see `FOLDED_TOKENS`.
|
||||
*/
|
||||
export function foldSignalText(text: string): string {
|
||||
const normalized = normalizeSignalText(text);
|
||||
let folded = '';
|
||||
for (const character of normalized) {
|
||||
folded += LEET_MAP[character] ?? character;
|
||||
}
|
||||
return folded
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.replace(/(.)\1{2,}/g, '$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens of six characters or more that carry no innocent reading in this
|
||||
* context. Matched against the folded text, so obfuscated spellings are caught.
|
||||
*/
|
||||
const FOLDED_TOKENS: ReadonlyArray<{ kind: OffPlatformSignalKind; label: string; token: string }> = [
|
||||
{ kind: 'contact-platform', label: 'Telegram', token: 'telegram' },
|
||||
{ kind: 'contact-platform', label: 'WhatsApp', token: 'whatsapp' },
|
||||
{ kind: 'contact-platform', label: 'Snapchat', token: 'snapchat' },
|
||||
{ kind: 'contact-platform', label: 'Discord', token: 'discord' },
|
||||
{ kind: 'contact-platform', label: 'Wickr', token: 'wickr' },
|
||||
{ kind: 'contact-platform', label: 'Skype', token: 'skype' },
|
||||
{ kind: 'contact-platform', label: 'Viber', token: 'viber' },
|
||||
{ kind: 'contact-platform', label: 'Instagram', token: 'instagram' },
|
||||
{ kind: 'seller-link', label: 'OnlyFans', token: 'onlyfans' },
|
||||
{ kind: 'seller-link', label: 'Fansly', token: 'fansly' },
|
||||
{ kind: 'seller-link', label: 'Fanvue', token: 'fanvue' },
|
||||
{ kind: 'seller-link', label: 'LoyalFans', token: 'loyalfans' },
|
||||
{ kind: 'seller-link', label: 'ManyVids', token: 'manyvids' },
|
||||
{ kind: 'seller-link', label: 'JustForFans', token: 'justforfans' },
|
||||
{ kind: 'seller-link', label: 'FanCentro', token: 'fancentro' },
|
||||
{ kind: 'seller-link', label: 'AdmireMe', token: 'admireme' },
|
||||
{ kind: 'seller-link', label: 'Linktree', token: 'linktree' },
|
||||
{ kind: 'seller-link', label: 'AllMyLinks', token: 'allmylinks' },
|
||||
{ kind: 'seller-link', label: 'Beacons', token: 'beacons' },
|
||||
{ kind: 'seller-link', label: 'Chaturbate', token: 'chaturbate' },
|
||||
{ kind: 'seller-link', label: 'MyFreeCams', token: 'myfreecams' },
|
||||
{ kind: 'seller-link', label: 'premium Snapchat', token: 'premiumsnap' },
|
||||
{ kind: 'seller-link', label: 'Throne', token: 'throne' },
|
||||
{ kind: 'payment', label: 'Cash App', token: 'cashapp' },
|
||||
{ kind: 'payment', label: 'Venmo', token: 'venmo' },
|
||||
{ kind: 'payment', label: 'PayPal', token: 'paypal' },
|
||||
{ kind: 'payment', label: 'Zelle', token: 'zelle' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Rules for short or ambiguous tokens. These need surrounding context because
|
||||
* the bare word has an innocent meaning in this domain — `snap` is a photo,
|
||||
* `signal` is a sign, `sc` and `ig` appear inside ordinary words.
|
||||
*/
|
||||
const CONTEXT_RULES: ReadonlyArray<{ kind: OffPlatformSignalKind; label: string; pattern: RegExp }> = [
|
||||
// Link shorteners for the messaging apps, which appear without the app name.
|
||||
{ kind: 'contact-platform', label: 'Telegram', pattern: /\bt\.me\b/ },
|
||||
{ kind: 'contact-platform', label: 'Telegram', pattern: /\btelegra?\.ph\b/ },
|
||||
{ kind: 'contact-platform', label: 'WhatsApp', pattern: /\bwa\.me\b/ },
|
||||
{ kind: 'contact-platform', label: 'Telegram', pattern: /\btg\b[^.\n]{0,12}\b(?:is|handle|user|username|@)/ },
|
||||
{ kind: 'contact-platform', label: 'Telegram', pattern: /\b(?:add|my|your|on)\b[^.\n]{0,12}\btg\b/ },
|
||||
{ kind: 'contact-platform', label: 'Kik', pattern: /\bkik\b/ },
|
||||
// `snap` alone is a photo in this context, so only handle-shaped uses count.
|
||||
{ kind: 'contact-platform', label: 'Snapchat', pattern: /\bsnap\s*(?:chat)?\s*(?:code|handle|id|user|username)\b/ },
|
||||
{ kind: 'contact-platform', label: 'Snapchat', pattern: /\bmy snap\s+(?:is|=|:)/ },
|
||||
{ kind: 'contact-platform', label: 'Snapchat', pattern: /\b(?:add|find|message|msg|dm)\s+me\s+(?:up\s+)?on\s+(?:the\s+)?snap\b/ },
|
||||
{ kind: 'contact-platform', label: 'Snapchat', pattern: /\bwhat'?s? (?:your|ur) snap\b/ },
|
||||
{ kind: 'contact-platform', label: 'Snapchat', pattern: /\bsc\b[^.\n]{0,10}\b(?:is|handle|user|username)\b/ },
|
||||
{ kind: 'contact-platform', label: 'Instagram', pattern: /\binsta\b/ },
|
||||
{ kind: 'contact-platform', label: 'Instagram', pattern: /\big\b[^.\n]{0,10}\b(?:is|handle|user|username)\b/ },
|
||||
{ kind: 'contact-platform', label: 'Instagram', pattern: /\b(?:add|dm|message|msg|find)\s+me\s+(?:up\s+)?on\s+(?:the\s+)?ig\b/ },
|
||||
{ kind: 'contact-platform', label: 'Signal', pattern: /\b(?:on|to|via|use|using|my|get)\s+signal\b/ },
|
||||
{ kind: 'contact-platform', label: 'Signal', pattern: /\bsignal\s+(?:app|handle|username|number)\b/ },
|
||||
// Generic "continue this somewhere else" asks, whatever the platform.
|
||||
{
|
||||
kind: 'handle-ask',
|
||||
label: 'off-platform contact request',
|
||||
pattern: /\b(?:add|find|message|msg|text|dm|hit)\s+me\s+(?:up\s+)?(?:on|at|@)\b(?![^.\n]{0,10}\breddit\b)/,
|
||||
},
|
||||
{
|
||||
kind: 'handle-ask',
|
||||
label: 'off-platform contact request',
|
||||
pattern: /\bwhat'?s? (?:your|ur)\s+(?:@|handle|username|user name)\b/,
|
||||
},
|
||||
{ kind: 'handle-ask', label: 'off-platform contact request', pattern: /\bmy\s+(?:@|handle|username)\s+(?:is|=|:)/ },
|
||||
{
|
||||
kind: 'handle-ask',
|
||||
label: 'off-platform contact request',
|
||||
pattern: /\b(?:let'?s|lets|wanna|want to|shall we|we should|can we)\s+(?:chat|talk|continue|move|take this|carry on)\b[^.\n]{0,20}\b(?:elsewhere|off|somewhere else|another app|different app)\b/,
|
||||
},
|
||||
{
|
||||
kind: 'handle-ask',
|
||||
label: 'off-platform contact request',
|
||||
pattern: /\b(?:take|move|moving|continue|chat|talk|talking|go|get)\b[^.\n]{0,20}\boff (?:of )?reddit\b/,
|
||||
},
|
||||
{ kind: 'handle-ask', label: 'off-platform contact request', pattern: /\breddit (?:chat )?(?:is )?(?:sucks|glitch(?:y|es)|broken|keeps? (?:crashing|deleting))\b[^.\n]{0,40}\b(?:instead|another|other) (?:app|platform)\b/ },
|
||||
// Money rails. Cash App cashtags are matched on un-folded text so `$` survives.
|
||||
{ kind: 'payment', label: 'Cash App cashtag', pattern: /(?:^|[\s(])\$[a-z][a-z0-9_]{2,}\b/ },
|
||||
{ kind: 'payment', label: 'payment request', pattern: /\bpay\s*pal\b/ },
|
||||
{ kind: 'payment', label: 'payment request', pattern: /\bcash\s*app\b/ },
|
||||
{ kind: 'payment', label: 'payment request', pattern: /\bgift\s*card\b/ },
|
||||
// Link-in-bio funnels. "check my profile" is deliberately absent: genuine
|
||||
// users say it too, and it is not itself a move off the platform.
|
||||
{ kind: 'seller-link', label: 'link-in-bio funnel', pattern: /\b(?:link|links)\s+(?:is\s+|are\s+)?in\s+(?:my\s+|the\s+)?(?:bio|profile|about)\b/ },
|
||||
{ kind: 'seller-link', label: 'link-in-bio funnel', pattern: /\bbio\s+link\b/ },
|
||||
// The age/bot "verification" scam, which always ends on a third-party site.
|
||||
{ kind: 'verification-scam', label: 'verification scam', pattern: /\b(?:age|id|identity)\s*verif(?:y|ied|ication)\b/ },
|
||||
{
|
||||
kind: 'verification-scam',
|
||||
label: 'verification scam',
|
||||
pattern: /\bverify\s+(?:that\s+)?(?:you|your|yourself|u)\b[^.\n]{0,25}\b(?:not a bot|human|real|age|18|adult)\b/,
|
||||
},
|
||||
{ kind: 'verification-scam', label: 'verification scam', pattern: /\bverification\s+(?:site|link|page|process|code|card)\b/ },
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the first signal that a conversation is being pulled off Reddit, or
|
||||
* null. Order is stable: unambiguous platform and funnel names first, then the
|
||||
* context-dependent rules, so the recorded evidence names the strongest match.
|
||||
*/
|
||||
export function findOffPlatformSignal(text: string): OffPlatformSignal | null {
|
||||
if (!text || !text.trim()) return null;
|
||||
const normalized = normalizeSignalText(text);
|
||||
const folded = foldSignalText(text);
|
||||
|
||||
for (const rule of FOLDED_TOKENS) {
|
||||
if (folded.includes(rule.token)) {
|
||||
return { kind: rule.kind, label: rule.label, matched: rule.token };
|
||||
}
|
||||
}
|
||||
for (const rule of CONTEXT_RULES) {
|
||||
const match = normalized.match(rule.pattern);
|
||||
if (match) {
|
||||
return { kind: rule.kind, label: rule.label, matched: match[0].trim() };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Human-readable reason recorded against a killed conversation. */
|
||||
export function offPlatformSignalReason(signal: OffPlatformSignal): string {
|
||||
switch (signal.kind) {
|
||||
case 'contact-platform':
|
||||
return `Off-platform contact push detected (${signal.label}: "${signal.matched}").`;
|
||||
case 'handle-ask':
|
||||
return `Off-platform contact push detected ("${signal.matched}").`;
|
||||
case 'payment':
|
||||
return `Payment solicitation detected (${signal.label}: "${signal.matched}").`;
|
||||
case 'seller-link':
|
||||
return `Seller funnel detected (${signal.label}: "${signal.matched}").`;
|
||||
case 'verification-scam':
|
||||
return `Verification scam detected ("${signal.matched}").`;
|
||||
}
|
||||
}
|
||||
@@ -257,6 +257,17 @@ async function buildRuntime(
|
||||
undefined,
|
||||
undefined,
|
||||
mediaExchange,
|
||||
{
|
||||
offPlatformKill: accountConfig.conversations.offPlatformKill,
|
||||
killOnSellerKeyword: accountConfig.conversations.killOnSellerKeyword,
|
||||
triage: conversationReadStateRepo,
|
||||
screener: aiClient,
|
||||
screening: {
|
||||
enabled: accountConfig.conversations.aiReplyScreening.enabled,
|
||||
killConfidence: accountConfig.conversations.aiReplyScreening.killConfidence,
|
||||
flagConfidence: accountConfig.conversations.aiReplyScreening.flagConfidence,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const resources: RedditAccountRuntimeResources = {
|
||||
|
||||
Reference in new issue
Block a user