tg-tracker / src /lib /pattern-engine.ts
iq7se2's picture
Upload 102 files
e03f82d verified
Raw History Blame Contribute Delete
10.8 kB
/**
* Pattern Matching Engine
*
* DSL syntax:
* - lowercase letters a-z: variable placeholder. Same letter = same character.
* Different letters = independent variables (may coincidentally be equal).
* Matches [a-z0-9].
* - digits 0-9: variable placeholder (same rules as letters). Matches [a-z0-9].
* - `#`: identical-run marker. A maximal run of `#` matches a run of identical
* characters. The first `#` starts a new group, subsequent `#`s backreference it.
* Matches [a-z0-9] (valid Telegram username chars excluding literal `_`).
* - `_`: literal underscore.
* - `\X`: literal character X (escape for when you need a literal a-z / 0-9 / #).
* - `[literal]`: a literal block — everything between `[` and `]` is treated as
* literal text (no variable interpretation). Useful for fixed words like
* `[vip]ab` which matches "vip" + two variables (vipdd, vipx1, vipab).
* - any other character: treated as a literal (regex-escaped).
*
* Examples (all match the user's listed usernames):
* - "a_b_0" -> ^([a-z0-9])_([a-z0-9])_([a-z0-9])$ matches e_y_3, e_e_h, a_k_v
* - "ab###" -> ^([a-z0-9])([a-z0-9])([a-z0-9])\3\3$ matches av000
* - "###ab" -> ^([a-z0-9])\1\1([a-z0-9])([a-z0-9])$ matches tttxy
* - "a###a" -> ^([a-z0-9])([a-z0-9])\2\2\1$ matches u000u
* - "a###b" -> ^([a-z0-9])([a-z0-9])\2\2([a-z0-9])$ matches u000q, kjjjf
* - "aa###" -> ^([a-z0-9])\1([a-z0-9])\3\3$ matches aaccc
* - "###bb" -> ^([a-z0-9])\1\1([a-z0-9])\4$ matches gggbb
* - "[vip]ab" -> ^vip([a-z0-9])([a-z0-9])$ matches vipdd, vipx1
*/
export interface CompiledPattern {
regex: string;
flags: string;
variableCount: number;
length: number;
}
export interface PatternCompileError {
error: string;
}
const USERNAME_CHAR = "[a-z0-9]";
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Compile a DSL expression into an anchored regex source string.
*/
export function compilePattern(expr: string): CompiledPattern {
if (!expr || expr.length === 0) {
throw new Error("Pattern expression cannot be empty");
}
if (expr.length < 3) {
throw new Error("Pattern is too short (Telegram usernames are 5-32 chars, patterns should be at least 3)");
}
if (expr.length > 32) {
throw new Error("Pattern is too long (Telegram usernames are max 32 chars)");
}
let regex = "";
const varGroups = new Map<string, number>();
let groupCount = 0;
let i = 0;
while (i < expr.length) {
const ch = expr[i];
// Escape sequence -> literal
if (ch === "\\" && i + 1 < expr.length) {
regex += escapeRegex(expr[i + 1]);
i += 2;
continue;
}
// Literal block [....] -> everything inside is literal text
if (ch === "[") {
const end = expr.indexOf("]", i + 1);
if (end === -1) {
throw new Error("Unclosed [ literal block — missing ]");
}
const literalContent = expr.slice(i + 1, end);
if (literalContent.length === 0) {
throw new Error("Empty [ ] literal block");
}
regex += escapeRegex(literalContent);
i = end + 1;
continue;
}
// Identical run of #
if (ch === "#") {
let runLen = 0;
while (i < expr.length && expr[i] === "#") {
runLen++;
i++;
}
groupCount++;
const groupNum = groupCount;
regex += `(${USERNAME_CHAR})`;
for (let r = 1; r < runLen; r++) {
regex += `\\${groupNum}`;
}
continue;
}
// Variable (letter or digit)
if (/[a-z0-9]/.test(ch)) {
if (varGroups.has(ch)) {
regex += `\\${varGroups.get(ch)}`;
} else {
groupCount++;
varGroups.set(ch, groupCount);
regex += `(${USERNAME_CHAR})`;
}
i++;
continue;
}
// Literal
regex += escapeRegex(ch);
i++;
}
return {
regex: `^${regex}$`,
flags: "i",
variableCount: groupCount,
length: expr.length,
};
}
/**
* Build a RegExp from a compiled pattern.
*/
export function buildRegExp(compiled: CompiledPattern): RegExp {
return new RegExp(compiled.regex, compiled.flags);
}
/**
* Test whether a username (without @) matches a compiled pattern.
*/
export function matchesPattern(username: string, compiled: CompiledPattern): boolean {
const re = buildRegExp(compiled);
return re.test(username);
}
/**
* Validate a Telegram username (basic rules).
* - 5-32 chars
* - a-z, 0-9, _ only
* - must start with a letter
* - no leading/trailing underscore
* - no consecutive underscores
*/
export function isValidTelegramUsername(username: string): boolean {
if (!username) return false;
if (username.length < 5 || username.length > 32) return false;
if (!/^[a-z]/.test(username)) return false;
if (!/^[a-z0-9_]+$/.test(username)) return false;
if (username.startsWith("_") || username.endsWith("_")) return false;
if (username.includes("__")) return false;
return true;
}
/**
* Generate example usernames that match a pattern (for preview in the dashboard).
* Returns up to `count` examples.
*/
export function generateExamples(expr: string, count = 5): string[] {
let compiled: CompiledPattern;
try {
compiled = compilePattern(expr);
} catch {
return [];
}
const re = buildRegExp(compiled);
const examples: string[] = [];
const seen = new Set<string>();
// Deterministic generation: try a set of known-matching seeds + random attempts.
// We brute-force over the pattern structure by building candidates from the DSL.
const candidates = generateCandidates(expr);
for (const c of candidates) {
if (seen.has(c)) continue;
seen.add(c);
if (re.test(c) && isValidTelegramUsername(c)) {
examples.push(c);
if (examples.length >= count) break;
}
}
// Random fill if not enough.
let attempts = 0;
while (examples.length < count && attempts < 2000) {
attempts++;
const candidate = randomUsernameForPattern(expr);
if (candidate && !seen.has(candidate) && re.test(candidate) && isValidTelegramUsername(candidate)) {
seen.add(candidate);
examples.push(candidate);
}
}
return examples;
}
/**
* Build a small set of deterministic candidate usernames from the DSL expression
* by substituting concrete values for each variable / run.
*/
function generateCandidates(expr: string): string[] {
// Identify variable slots and run slots.
const varNames = new Set<string>();
const runs: { start: number; len: number; varName: string }[] = [];
let i = 0;
const structure: { type: "var" | "run" | "literal"; value: string; runLen?: number; varName?: string }[] = [];
while (i < expr.length) {
const ch = expr[i];
if (ch === "\\" && i + 1 < expr.length) {
structure.push({ type: "literal", value: expr[i + 1] });
i += 2;
continue;
}
if (ch === "[") {
const end = expr.indexOf("]", i + 1);
if (end === -1) break;
structure.push({ type: "literal", value: expr.slice(i + 1, end) });
i = end + 1;
continue;
}
if (ch === "#") {
let runLen = 0;
let j = i;
while (j < expr.length && expr[j] === "#") {
runLen++;
j++;
}
const varName = `__run${runs.length}`;
runs.push({ start: i, len: runLen, varName });
varNames.add(varName);
structure.push({ type: "run", value: "", runLen, varName });
i = j;
continue;
}
if (/[a-z0-9]/.test(ch)) {
varNames.add(ch);
structure.push({ type: "var", value: "", varName: ch });
i++;
continue;
}
structure.push({ type: "literal", value: ch });
i++;
}
const vars = Array.from(varNames);
// Letter pool for variables (avoid _ for vars since _ is literal).
const letterPool = "abcdefghijklmnopqrstuvwxyz".split("");
// For runs we allow letters and digits.
const runPool = "abcdefghijklmnopqrstuvwxyz0123456789".split("");
const results: string[] = [];
// Generate a few combinations.
const combos: Record<string, string>[] = [];
const maxCombos = 8;
const pick = <T>(arr: T[], n: number): T => arr[n % arr.length];
for (let n = 0; n < maxCombos; n++) {
const combo: Record<string, string> = {};
vars.forEach((v, idx) => {
const isRun = v.startsWith("__run");
const pool = isRun ? runPool : letterPool;
combo[v] = pick(pool, n + idx * 3);
});
combos.push(combo);
}
for (const combo of combos) {
let s = "";
for (const part of structure) {
if (part.type === "literal") {
s += part.value;
} else if (part.type === "var") {
s += combo[part.varName!];
} else if (part.type === "run") {
s += combo[part.varName!].repeat(part.runLen!);
}
}
results.push(s);
}
return results;
}
function randomUsernameForPattern(expr: string): string | null {
const structure: { type: "var" | "run" | "literal"; value: string; runLen?: number; varName?: string }[] = [];
const varNames = new Set<string>();
let i = 0;
let runIdx = 0;
while (i < expr.length) {
const ch = expr[i];
if (ch === "\\" && i + 1 < expr.length) {
structure.push({ type: "literal", value: expr[i + 1] });
i += 2;
continue;
}
if (ch === "[") {
const end = expr.indexOf("]", i + 1);
if (end === -1) break;
structure.push({ type: "literal", value: expr.slice(i + 1, end) });
i = end + 1;
continue;
}
if (ch === "#") {
let runLen = 0;
let j = i;
while (j < expr.length && expr[j] === "#") {
runLen++;
j++;
}
const varName = `__run${runIdx++}`;
varNames.add(varName);
structure.push({ type: "run", value: "", runLen, varName });
i = j;
continue;
}
if (/[a-z0-9]/.test(ch)) {
varNames.add(ch);
structure.push({ type: "var", value: "", varName: ch });
i++;
continue;
}
structure.push({ type: "literal", value: ch });
i++;
}
const letters = "abcdefghijklmnopqrstuvwxyz";
const alnum = "abcdefghijklmnopqrstuvwxyz0123456789";
const rand = <T>(arr: T[]) => arr[Math.floor(Math.random() * arr.length)];
const combo: Record<string, string> = {};
for (const v of varNames) {
const pool = v.startsWith("__run") ? alnum : letters;
combo[v] = rand(pool.split(""));
}
let s = "";
for (const part of structure) {
if (part.type === "literal") s += part.value;
else if (part.type === "var") s += combo[part.varName!];
else if (part.type === "run") s += combo[part.varName!].repeat(part.runLen!);
}
return s;
}