Files

159 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const denial = 'Административный доступ и закрытые данные сайта через чат пользователей не предоставляются. Если нужна помощь с учёбой или личным кабинетом, опишите проблему.';
const politeness = 'Пожалуйста, общайтесь вежливо и без нецензурных выражений.';
const banWarning = 'Пожалуйста, прекратите использовать нецензурные выражения. При дальнейшем нарушении доступ к чату может быть заблокирован.';
const adminTargets = [
/(?<![\p{L}\d])(?:админк[а-яё]*|админ(?:ск|истраторск)[а-яё]*|панел[а-яё]*\s+администрирования|суперпользовател[а-яё]*|суперадмин[а-яё]*)(?![\p{L}\d])/iu,
/административн[а-яё]*\s+(?:прав|доступ|полномочи)[а-яё]*|(?:прав|доступ|рол|полномочи)[а-яё]*\s+(?:(?:к|для|как\s+у|уровня)\s+)?(?:админ[а-яё]*|модератор[а-яё]*|панел[а-яё]*\s+управления)|полн[а-яё]*\s+доступ[а-яё]*\s+ко\s+всем\s+(?:разделам|функциям)\s+сайта/iu,
/\b(?:root|sudo|superuser|admin\s+(?:access|rights|privileges|role))\b/iu,
];
const adminRequests = [
/(?<![\p{L}\d])(?<!не )(?:дай(?:те)?|выдай(?:те)?|предоставь(?:те)?|открой(?:те)?|назначь(?:те)?|присвой(?:те)?|повысь(?:те)?|разреши(?:те)?|добавь(?:те)?|сделай(?:те)?|хочу|прошу|требую)(?![\p{L}\d])/iu,
/(?<![\p{L}\d])(?<!зачем )(?<!почему )(?<!не )нуж(?:ен|на|но|ны)(?![\p{L}\d])/iu,
/(?:как\s+(?:мне\s+)?(?:получить|стать|попасть|зайти|открыть)|где\s+(?:находится\s+)?админка|можно\s+(?:ли\s+)?(?:мне\s+)?(?:дать|выдать|получить|стать|зайти|в\s+админку|доступ|админск[а-яё]*|прав[а-яё]*)|могли\s+бы\s+(?:дать|выдать|предоставить))(?![\p{L}\d])/iu,
/\b(?:give|grant|assign|elevate|make)\b/iu,
];
const makeAdmin = /(?<![\p{L}\d])(?:сделай(?:те)?|назнач[а-яё]*|как\s+(?:мне\s+)?стать|хочу\s+стать)\s+(?:(?:мне|меня|себя)\s+)?админ(?:ом|истратором)(?![\p{L}\d])/iu;
const secretTargets = [
/(?<![\p{L}\d])(?:парол[а-яё]*|токен[а-яё]*|api[\s-]*(?:key|ключ)|ключ[а-яё]*\s+(?:api|доступа)|код[а-яё]*\s+(?:из\s+смс|доступа|подтверждения|авторизации)|системн[а-яё]*\s+промпт[а-яё]*|секрет[а-яё]*)(?![\p{L}\d])/iu,
/исходн[а-яё]*\s+код[а-яё]*|код[а-яё]*\s+(?:сайта|сервера)|баз[а-яё]*\s+(?:данных|пользовател[а-яё]*|ученик[а-яё]*|клиент[а-яё]*)|(?:лог[а-яё]*|журнал[а-яё]*)\s+(?:сервера|сайта|админки|приложения)/iu,
/(?:данн[а-яё]*|спис[а-яё]*|переписк[а-яё]*)\s+(?:(?:всех|других)\s+)?(?:пользовател[а-яё]*|ученик[а-яё]*|сотрудник[а-яё]*|клиент[а-яё]*)/iu,
/(?:закрыт|конфиденциальн|внутренн|приватн|непубличн|персональн|чуж)[а-яё]*\s+(?:данн|информаци|сведени|документ|файл|отч[её]т|статистик|переписк|лог|настройк|инструкц)[а-яё]*/iu,
/\b(?:(?:private|confidential|internal)\s+(?:data|information|logs|documents)|passwords?|api[\s-]*tokens?|system\s+prompt)\b/iu,
];
const secretRequests = [
/(?<![\p{L}\d])(?<!не )(?:дай(?:те)?|выдай(?:те)?|покажи(?:те)?|предоставь(?:те)?|пришли(?:те)?|вышли(?:те)?|отправь(?:те)?|выведи(?:те)?|раскрой(?:те)?|выгрузи(?:те)?|слей(?:те)?|скинь(?:те)?|сообщи(?:те)?|назови(?:те)?|поделись|поделитесь|открой(?:те)?|скачай(?:те)?|расскажи(?:те)?)(?![\p{L}\d])/iu,
/(?:хочу|можно(?:\s+ли)?|можете|могли\s+бы|прошу)\s+(?:мне\s+)?(?:дать|выдать|показать|предоставить|прислать|выслать|отправить|выгрузить|сообщить|поделиться|раскрыть|узнать|увидеть|получить|посмотреть|найти|скачать)(?![\p{L}\d])/iu,
/(?:как|где)\s+(?:мне\s+)?(?:узнать|увидеть|получить|посмотреть|найти|скачать|взять)(?![\p{L}\d])|\b(?:give|send|show|reveal|dump|expose)\b/iu,
/(?:хочу|мне\s+нуж(?:ен|на|но|ны))\s+(?:парол|токен|секрет|закрыт|конфиденциальн|приватн|внутренн|непубличн|баз)[а-яё]*/iu,
];
const ownData = /(?:(?:мо|сво)[ийяеюхм][а-яё]*\s+(?:персональн[а-яё]*\s+)?(?:данн[а-яё]*|профил[а-яё]*|аккаунт[а-яё]*)|(?:персональн[а-яё]*\s+)?данн[а-яё]*\s+(?:моего|своего)\s+(?:аккаунта|профиля))/giu;
const recoveryGuide = /(?:покажи(?:те)?|подскажи(?:те)?|расскажи(?:те)?|объясни(?:те)?)\s*[,—:]?\s*как\s+(?:мне\s+)?(?:восстановить|сбросить|сменить|поменять|изменить|настроить)(?![\p{L}\d])/giu;
const previousRequest = /^(?:пожалуйста\s+)?(?:дайте|выдайте|выдать|покажите|пришлите|поделитесь|предоставьте|откройте|отправьте|скиньте|сообщите|хочу получить|мне нужен|можно получить)(?:\s+(?:мне|нам|доступ|базу|данные|информацию)){0,3}$/iu;
const failure = /(?:не\s+(?:работ|открыва|загружа|мог|получа)|ошибк|сбой|недоступ)/iu;
const profanePatterns = [
/^бля(?:д(?:ь|и|ей|ям|ью|ск\p{L}*|ств\p{L}*)|т(?:ь|и|ей))?$/u,
/^(?:(?:а|на|по|ни|не|до|с|о|пере|за|от|рас|в|при))?ху(?:й|е|я|и)\p{L}*$/u,
/^(?:раз|рас|за|на|от|вы|под|по|сп|про)?пизд\p{L}*$/u,
/^(?:в|вы|вз|до|за|на|об|от|пере|под|по|про|раз|с|у)?[ъь]?еб(?:а\p{L}*|у(?:ч|т|щ)\p{L}*|н\p{L}*|ет\p{L}*|еш\p{L}*|л[яеи]\p{L}*|ис[ьт]\p{L}*|л)?$/u,
/^(?:долбоеб\p{L}*|сук(?:а|и|у|ой|ою|ин)|мудак\p{L}*|мудил[аоуы]|говн(?:о|а|у|е|ом|ян\p{L}*)|пид(?:ор|ар)\p{L}*)$/u,
/^(?:бл[*#@_]+(?:[дт][ьи])?|п[*#@_]+зд\p{L}*|(?:на|по)?х[*#@_]+[йеяи]\p{L}*|[*#@_]+бан\p{L}*)$/u,
/^(?:fuck(?:ing|er|ed|s)?|motherfuck(?:er|ing)|f[*#@_]+ck|(?:bull)?shit|bitch(?:es)?|asshole(?:s)?)$/u,
];
const abuseFiller = /^(?:(?:да|ну|эй|а|и|ты|вы|же|то|тебя|тебе|вас|вам|твоя|твой|ваш|ваша|сам|сама|сами|все|это|просто|полный|полная|иди|идите|пош[её]л|пошла|пошли|туп[а-яё]*|дебил[а-яё]*|урод[а-яё]*|мраз[а-яё]*|you|your|it|that|go|off|the|out|just)(?:\s+|$))*$/iu;
const keyboardRuns = [
'qwertyuiop', 'asdfghjkl', 'zxcvbnm', 'qazwsxedcrfvtgbyhnujm',
'йцукенгшщзхъ', 'фывапролджэ', 'ячсмитьбю', 'йфяцычувскамепинртгоьшлб',
].flatMap((run) => [run, [...run].reverse().join('')]);
const unlikelyCyrillicPair = /[жш]ы|[чщ]я|[чщ]ю|[ъь][ъьы]|[ыэ][ыэ]/gu;
const exactNoise = /^(?:абракадабр[а-я]*|асдф|(?:бла|ла|траля|лол|тесттест){2,})$/u;
const fillerWord = /^(?:ну|ээ+|эм+|мм+|бла|лол|blah|uh|um)$/u;
const emptyUtterance = /^(?:(?:просто )?(?:пишу|написал[аи]?|набираю) (?:просто так|что попало|что угодно|случайные (?:буквы|слова)|набор букв)|просто так|что попало|набор букв|(?:нет|без) (?:запроса|смысла)|(?:бла|blah)(?: (?:бла|blah))+|(?:ла ){2,}ла|(?:тест|лол|lol)(?: (?:тест|лол|lol))*)$/u;
const inanimateSubject = /^(?:котлет[а-я]*|холодильник[а-я]*|стол(?:ы)?|табурет[а-я]*|картошк[а-я]*|пельмен[а-я]*|банан[а-я]*|огур[а-я]*|помидор[а-я]*|кирпич[а-я]*|тапк[а-я]*|носк[а-я]*|облак[а-я]*|время|времена|радуг[а-я]*|солнц[а-я]*|луна|луны)$/u;
const incongruousVerb = /^(?:умножа[а-я]*|дел(?:ит|ят)|вычита[а-я]*|жу(?:ет|ют)|ест|едят|поеда[а-я]*|разговарива[а-я]*|по(?:ет|ют)|танцу[а-я]*|гавка[а-я]*|мяука[а-я]*|пиш(?:ет|ут)|чита[а-я]*|лета[а-я]*|леч(?:ит|ат)|звон(?:ит|ят))$/u;
const offTopicFragment = /^(?:син[а-я]*|красн[а-я]*|зелен[а-я]*|фиолетов[а-я]*|красив[а-я]*|вкусн[а-я]*) (?:котлет[а-я]*|кот[а-я]*|собак[а-я]*|дерев[а-я]*|пельмен[а-я]*|банан[а-я]*|огур[а-я]*|помидор[а-я]*|тап[а-я]*|носк[а-я]*)$/u;
const supportClue = /(?:вебинар|трансляци|задани|урок|экзамен|личн[а-я]* кабинет|ошибк|доступ|парол|профил|аккаунт|плеер|файл|загруз|не (?:могу|работает|открывается)|https?:\/\/)/u;
export function isAdvertising(text) {
return /рекламное предложение|купите базу|накрутк[аиу] подписчик|гарантированный заработок/iu.test(text);
}
function asksForRestrictedAccess(text) {
const restrictedDataRequest = text.replace(ownData, '').replace(recoveryGuide, '');
return (adminTargets.some((pattern) => pattern.test(text)) && adminRequests.some((pattern) => pattern.test(text)))
|| makeAdmin.test(text)
|| (secretTargets.some((pattern) => pattern.test(restrictedDataRequest))
&& secretRequests.some((pattern) => pattern.test(restrictedDataRequest)));
}
function isRestrictedRequest(text, history) {
if (asksForRestrictedAccess(text)) return true;
const previous = history.at(-1)?.trim().replace(/[.?!,:]+$/u, '');
if (!previous || text.split(/\s+/u).length > 7) return false;
const priorWasIncomplete = previousRequest.test(previous);
const currentIsIncomplete = previousRequest.test(text.replace(/[.?!,:]+$/u, ''));
if (!priorWasIncomplete && (!currentIsIncomplete || failure.test(previous))) return false;
return asksForRestrictedAccess(`${previous} ${text}`);
}
function isProfane(word) {
const normalized = word.toLowerCase().replaceAll('ё', 'е');
if (profanePatterns.some((pattern) => pattern.test(normalized))) return true;
// Check visually similar letters only inside a token already using Cyrillic.
if (!/[а-я]/u.test(normalized)) return false;
const lookalikes = { a: 'а', c: 'с', e: 'е', o: 'о', p: 'р', x: 'х', y: 'у' };
const cyrillic = normalized.replace(/[aceopxy]/gu, (letter) => lookalikes[letter]);
return cyrillic !== normalized && profanePatterns.some((pattern) => pattern.test(cyrillic));
}
function isNoiseWord(word) {
const lower = word.toLowerCase().replaceAll('ё', 'е');
const repeatedCluster = /^([\p{L}]{3,5})\1+$/u.exec(lower)?.[1];
if (exactNoise.test(lower) || /^([\p{L}]{1,4})\1{2,}$/u.test(lower)
|| (repeatedCluster && keyboardRuns.some((run) => run.includes(repeatedCluster)))
|| (lower.length >= 4 && keyboardRuns.some((run) => run.includes(lower)))
|| /(?:asdf|qwer|йцуке|фыва)\p{L}*/u.test(lower)) return true;
// Short uppercase abbreviations often identify formats, devices or departments.
if (word === word.toUpperCase() && lower.length <= 7) return false;
return (/^[а-я]{5,}$/u.test(lower) && (/[бвгджзйклмнпрстфхцчшщъь]{5}/u.test(lower)
|| /^[ъь]/u.test(lower) || [...lower.matchAll(unlikelyCyrillicPair)].length >= 2))
|| (/^[a-z]{6,}$/u.test(lower) && !/[aeiouy]/u.test(lower));
}
function isNonsense(text, hasContext) {
const cleaned = text.normalize('NFKC').replace(/[\u200B-\u200D\u2060\uFEFF]/gu, '');
if (/https?:\/\//iu.test(cleaned)) return false;
const codes = [...cleaned.matchAll(/\b([a-z]{2,})[-_]\d+\b/giu)];
if (/\b(?:err|error)\d+\b/iu.test(cleaned) || codes.some(([, prefix]) => !isNoiseWord(prefix))) return false;
const words = cleaned.match(/\p{L}+/gu) || [];
if (!words.length) {
return !/\p{N}/u.test(cleaned) && (!hasContext || !/^(?:[?!]{1,3}|\p{Extended_Pictographic}\p{Variation_Selector}?)$/u.test(cleaned));
}
const normalizedWords = words.map((word) => word.toLowerCase().replaceAll('ё', 'е'));
const phrase = normalizedWords.join(' ');
const noise = words.map(isNoiseWord);
if (noise.some(Boolean) && normalizedWords.every((word, index) => noise[index] || fillerWord.test(word))) return true;
if (/\p{N}/u.test(cleaned)) return false;
if (emptyUtterance.test(phrase) || (!hasContext && normalizedWords.every((word) => fillerWord.test(word)))) return true;
if (words.length >= 4 && normalizedWords.every((word) => word.length === 1)) {
return new Set(normalizedWords).size <= 2 || isNoiseWord(normalizedWords.join(''));
}
// Recognize a short incongruous clause while keeping requests that mention a lesson or a technical issue.
if (words.length > 8 || supportClue.test(phrase)) return false;
const predicate = normalizedWords.findIndex((word) => incongruousVerb.test(word));
const absurdClause = predicate > 0 && predicate < 4 && normalizedWords.slice(0, predicate).some((word) => inanimateSubject.test(word));
return absurdClause || (!hasContext && offTopicFragment.test(phrase));
}
export function moderateChatMessage({ actor, text, history = [], profanityCount = 0 }) {
const trimmed = text.trim();
let profanity = false;
const withoutProfanity = trimmed.replace(/[\p{L}*#@_]+/gu, (token) => {
if (!isProfane(token)) return token;
profanity = true;
return ' ';
});
const warning = profanity ? (profanityCount > 0 ? banWarning : politeness) : null;
const result = { block: false, reason: null, reply: warning, profanity, text: trimmed };
if (actor === 'student' && isRestrictedRequest(trimmed, history)) {
const reply = warning ? `${denial}\n\n${warning}` : denial;
return { ...result, block: true, reason: 'Запрос административного доступа или закрытой информации из чата пользователей.', reply };
}
if (isAdvertising(trimmed)) {
return { ...result, block: true, reason: 'Текст похож на явное рекламное предложение.' };
}
const remaining = withoutProfanity.replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
if (profanity && abuseFiller.test(remaining)) {
return { ...result, block: true, reason: 'Нецензурное сообщение без обращения за помощью.' };
}
if (isNonsense(withoutProfanity.trim(), history.length > 0)) {
return { ...result, block: true, reason: 'Сообщение не содержит осмысленного текста или деталей обращения.' };
}
return result;
}