/** * 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 = { '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}").`; } }