Files
RedditBot/src/services/off-platform-signals.ts
T
Jamie BruntonandClaude Opus 5 4404ae75ff 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>
2026-08-10 12:53:00 +01:00

218 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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}").`;
}
}