|
| 1 | +package com.sysadmindoc.callshield.data |
| 2 | + |
| 3 | +/** |
| 4 | + * Reply bait: a text that opens a conversation and carries no link, the way |
| 5 | + * "Hi mum, this is my new number" and "Is this Sarah?" scams wait for an |
| 6 | + * answer before they ask for money or move the chat to WhatsApp. It only |
| 7 | + * means something in a stranger's first text, which the caller decides; this |
| 8 | + * reads the words. |
| 9 | + * |
| 10 | + * Measured on the IMC 2025 smishing sample in SmsEvaluationCorpusTest. Most |
| 11 | + * wrong-number openers ("How are you doing", "Is the house still for sale?") |
| 12 | + * read exactly like a real first text, so only the unambiguous ones count. |
| 13 | + * Word edges are \p{L} lookarounds and spaces include \p{Zs}, as in |
| 14 | + * [SmsContentAnalyzer], because the JVM and Android's ICU disagree on \b. |
| 15 | + */ |
| 16 | +internal object SmsReplyBait { |
| 17 | + /** A real first text that asks "is this Sarah?" says why it's writing, so the opener counts only when it's short. */ |
| 18 | + private const val MAX_OPENER_LENGTH = 80 |
| 19 | + |
| 20 | + /** How far back from a parent word to look for "Maya's" or "my". */ |
| 21 | + private const val POSSESSIVE_WINDOW = 24 |
| 22 | + |
| 23 | + private const val START = "(?<![\\p{L}\\p{N}])" |
| 24 | + private const val END = "(?![\\p{L}\\p{N}])" |
| 25 | + private const val SPACE = "[\\s\\p{Zs}]" |
| 26 | + private const val APOSTROPHE = "['’]" |
| 27 | + |
| 28 | + private val chatLinkHosts = setOf("wa.me", "api.whatsapp.com", "chat.whatsapp.com") |
| 29 | + |
| 30 | + // A parent the text speaks to. |
| 31 | + private val parent = |
| 32 | + Regex( |
| 33 | + "(?iu)$START(?:mum|mom|mam|mama|mamma|mammy|mummy|mommy|dad|daddy|papa|pap|pappa|m[aã]e|mam[aã]e|papai" + |
| 34 | + "|mam[aá]|pap[aá]|mami|papi|mutti|vati|maman|ayah|bunda)$END|엄마|아빠|妈妈|爸爸|お母さん|お父さん", |
| 35 | + ) |
| 36 | + |
| 37 | + // "mam" is also ma'am and "pap" is also porridge, so they count with a lost |
| 38 | + // or new phone but not with a request to use WhatsApp alone. |
| 39 | + private val ambiguousParents = setOf("mam", "pap") |
| 40 | + |
| 41 | + // Whose parent: "Maya's mom", "my dad", "la mamá de Lucía", "le papa de Léo". |
| 42 | + private val possessive = |
| 43 | + Regex( |
| 44 | + "(?iu)(?:$APOSTROPHE(?:s)?|s$APOSTROPHE|$START(?:my|your|his|her|their|our|the|a|la|el|lo|los|las|su|sus|mi|mis|tu" + |
| 45 | + "|der|die|das|dem|den|meine?|deine?|seine?|ihre?|unsere?|het|mijn|jouw|je|zijn|haar|onze" + |
| 46 | + "|le|les|mon|ma|ton|ta|son|sa|notre|il|gli|mio|mia|tuo|tua|suo|sua" + |
| 47 | + "|o|os|as|meu|minha|teu|seu|nossa|de|van|von|di|du|des|do|da|della|del))$SPACE*$", |
| 48 | + ) |
| 49 | + |
| 50 | + // "This is your mum", "It's your son". |
| 51 | + private val parentSignsOff = |
| 52 | + Regex( |
| 53 | + "(?iu)^[^\\p{L}\\p{N}]*(?:(?:hi|hello|hey)[^\\p{L}\\p{N}]+)?(?:this is|it$APOSTROPHE?s|it is)$SPACE+" + |
| 54 | + "(?:your$SPACE+)?(?:mum|mom|mother|dad|father|son|daughter)$END", |
| 55 | + ) |
| 56 | + |
| 57 | + // A lost, broken or new phone, in the languages the reports use. |
| 58 | + private val newPhone = |
| 59 | + Regex( |
| 60 | + "(?iu)$START(?:" + |
| 61 | + "new (?:mobile |phone |cell |cellphone |whatsapp )?(?:number|phone|no\\.?$END|nr\\.?$END)" + |
| 62 | + "|temporary (?:number|phone)|(?:save|store) (?:this|my|the) (?:new )?number" + |
| 63 | + "|(?:lost|broke|smashed|dropped|cracked|damaged) my (?:old )?(?:phone|mobile|cell|sim)" + |
| 64 | + "|my (?:old )?(?:phone|mobile|cell)[^.!?\\n]{0,20}(?:broken|broke|smashed|lost|dead|damaged|cracked|stolen" + |
| 65 | + "|won$APOSTROPHE?t (?:turn on|work)|doesn$APOSTROPHE?t (?:turn on|work))" + |
| 66 | + "|friend$APOSTROPHE?s phone|using a spare|water damage" + |
| 67 | + "|(?:nuevo|otro) n[uú]mero|n[uú]mero (?:nuevo|temporal)" + |
| 68 | + "|(?:tel[eé]fono|m[oó]vil|celular)[^.!?\\n]{0,15}(?:roto|rompi|ca[ií]d|perd[ií])" + |
| 69 | + "|neue[sn]? (?:handy)?nummer|nummer gewechselt|nummer (?:ein)?speichern" + |
| 70 | + "|(?:handy|telefon)[^.!?\\n]{0,15}(?:kaputt|verloren|defekt|gestohlen|zertr)" + |
| 71 | + "|nieuwe? (?:mobiele |telefoon|gsm |06[- ]?)?(?:nummer|nr$END)|leentoestel" + |
| 72 | + "|(?:telefoon|mobiel|gsm)[^.!?\\n]{0,15}(?:kapot|kwijt|gevallen|gestolen)" + |
| 73 | + "|(?:novo|outro) n[uú]mero|n[uú]mero novo" + |
| 74 | + "|(?:telem[oó]vel|celular|telefone|ecr[aã])[^.!?\\n]{0,15}(?:avariad|partid|quebrad|estragad|perdi)" + |
| 75 | + "|nuov[oi] numero|(?:telefono|cellulare)[^.!?\\n]{0,15}(?:rott|cadut|pers[oa])" + |
| 76 | + "|nouveau num[eé]ro|(?:t[eé]l[eé]phone|portable)[^.!?\\n]{0,15}(?:cass[eé]|perdu|tomb[eé])" + |
| 77 | + "|nomor baru|hp[^.!?\\n]{0,10}rusak" + |
| 78 | + ")|폰이? ?고장|새 번호|手机坏|换号|新号码|携帯[がを]?壊|新しい番号", |
| 79 | + ) |
| 80 | + |
| 81 | + private val chatApp = Regex("(?iu)${START}whats ?app") |
| 82 | + |
| 83 | + private val wrongNumber = |
| 84 | + Regex( |
| 85 | + "(?iu)$START(?:wrong (?:number|person)|do you (?:still )?remember me" + |
| 86 | + "|did(?:n$APOSTROPHE?t| not) you saved? my number|long time no (?:see|talk|chat|speak))$END", |
| 87 | + ) |
| 88 | + |
| 89 | + // "Hi, is this Sarah?" The name has to be capitalized: "is this the |
| 90 | + // number for the flyer?" is someone with a reason to write. |
| 91 | + private val isThisName = |
| 92 | + Regex( |
| 93 | + "^[^\\p{L}\\p{N}]*(?:(?iu:hi|hello|hey|good (?:morning|afternoon|evening))[^\\p{L}\\p{N}]+)?" + |
| 94 | + "(?iu:is this|are you|is that)$SPACE+(?iu:you$SPACE+)?(?:(?iu:mr|mrs|ms|miss|dr)\\.?$SPACE+)?" + |
| 95 | + "\\p{Lu}\\p{Ll}+(?:${APOSTROPHE}s)?(?:$SPACE+(?iu:number|phone|cell))?$SPACE*\\?", |
| 96 | + ) |
| 97 | + |
| 98 | + /** |
| 99 | + * True when [text] fishes for a reply. [urls] are the lowercase links it |
| 100 | + * carries: any link but a WhatsApp chat link makes it a link scam instead. |
| 101 | + * [visibleLength] is its length without invisible characters. |
| 102 | + */ |
| 103 | + fun matches( |
| 104 | + text: String, |
| 105 | + urls: List<String>, |
| 106 | + visibleLength: Int, |
| 107 | + ): Boolean { |
| 108 | + if (urls.any { !isChatLink(it) }) return false |
| 109 | + val parents = parentsAddressed(text) |
| 110 | + val signsOff = parentSignsOff.containsMatchIn(text) |
| 111 | + if ((parents.isNotEmpty() || signsOff) && newPhone.containsMatchIn(text)) return true |
| 112 | + if ((signsOff || parents.any { it !in ambiguousParents }) && chatApp.containsMatchIn(text)) return true |
| 113 | + return wrongNumber.containsMatchIn(text) || (visibleLength <= MAX_OPENER_LENGTH && isThisName.containsMatchIn(text)) |
| 114 | + } |
| 115 | + |
| 116 | + /** The parent words [text] speaks to, skipping ones that name someone's parent. */ |
| 117 | + private fun parentsAddressed(text: String): List<String> = |
| 118 | + parent |
| 119 | + .findAll(text) |
| 120 | + .filterNot { match -> |
| 121 | + val start = match.range.first |
| 122 | + possessive.containsMatchIn(text.substring((start - POSSESSIVE_WINDOW).coerceAtLeast(0), start)) |
| 123 | + }.map { it.value.lowercase() } |
| 124 | + .toList() |
| 125 | + |
| 126 | + private fun isChatLink(url: String): Boolean { |
| 127 | + val host = |
| 128 | + url |
| 129 | + .substringAfter("://") |
| 130 | + .substringBefore('/') |
| 131 | + .substringBefore('?') |
| 132 | + .removePrefix("www.") |
| 133 | + return host in chatLinkHosts |
| 134 | + } |
| 135 | +} |
0 commit comments