Небольшой православный телеграм-канал на ~4 тыс. чел., где я состою в числе редакторов, ежедневно атакуют спамеры. Ввиду его тематики к обычному спаму прибавляется еще и разный специфический, о чем я скажу позже. Поэтому без бота-модератора нам не обойтись.
Кстати, в Telegram довольно странно организованы комментарии к каналам. По сути они привязаны не к самим каналам, а к отдельным чатам, а уже эти чаты привязываются или отвязываются от каналов. Поэтому бот формально работает в чате, а не в канале, и туда же, в чат, он должен добавляться в качестве админа.
Первая попытка
Для начала я написала на aiogram простого бота, который удалял сообщения с гиперссылками, контактами для сбора денег и характерными для спама словами.
В этом боте было всего два фильтра для отсева спама:
CARD_PATTERN = re.compile(r'(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)')
PHONE_PATTERN = re.compile(
r'(?<!\d)(?:\+7|8)[ -]?(?:\(\d{3}\)|\d{3})[ -]?\d{3}[ -]?\d{2}[ -]?\d{2}(?!\d)'
)
def contains_url(message: Message) -> bool:
if message.entities:
for entity in message.entities:
if entity.type in ["url", "text_link"]:
return True
return False
def contains_spam(message: Message) -> bool:
spam_words = ["тинькофф", "10к в день", "выплаты каждый день", "выплатa ежеднeвнo",
"заработай бабок",
"хочешь заработать деньги", "хочешь зарабатывать", "рaбота с хopoшими условиями",
"ищу курьера", "требуются курьеры", "шабашк", "ищу сoтрyдников", "трeбуются coтрyдники",
"нeслoжныe задачи", "выплаты бeз задeржeк"]
text_to_process = None
if message.text:
text_to_process = message.text.lower()
elif message.caption:
text_to_process = message.caption.lower()
if text_to_process:
for spam_word in spam_words:
if spam_word in text_to_process:
return True
# номер банковской карты запрещен
if CARD_PATTERN.search(text_to_process):
return True
# номер телефона - тоже
if PHONE_PATTERN.search(text_to_process):
return True
return False
И еще один фильтр, отсеивающий сообщения от самого канала, чтобы бот их не тронул:
def is_not_channel_post(message: Message) -> bool:
return not (message.sender_chat and message.sender_chat.id == config.channel_id)
Для поиска гиперссылок здесь используется список сущностей message.entities
, содержащий специальные выделенные объекты в тексте: ссылки, упоминания, хэштеги, команды, жирный текст и т.д. Это проще, чем регулярные выражения.
Далее следовал обработчик сообщений с навешенными на него фильтрами:
@dp.message(lambda message: contains_url(message) and contains_spam(message) and is_not_channel_post(message))
async def delete_spam_message(message: Message):
username = "неизвестный пользователь"
try:
if message.from_user:
username = message.from_user.username or str(message.from_user.id)
warning_message = await message.answer(f"@{username}, сообщения с гиперссылками запрещены.")
await message.delete()
log_message = f"✔️ Удалили сообщение со ссылкой от пользователя {username} ({message.from_user.id}), который писал: {message.text[:100]}"
logging.info(log_message)
await send_log_to_admin(log_message)
await asyncio.create_task(delete_after_delay(warning_message, 30))
except Exception as e:
error_message = f"❌ Не удалось удалить сообщение со ссылкой от {username} в чате {message.chat.id}: {e}"
logging.error(error_message)
await send_log_to_admin(error_message)
Сообщения удаляются после 30-секундной задержки, чтобы дать пользователю осознать свою ошибку:
async def delete_after_delay(message: Message, delay: int):
await asyncio.sleep(delay)
await message.delete()
Этот бот работал не слишком точно. Фантазия спамеров оказалась неисчерпаема, поэтому список spam_words
приходилось ежедневно пополнять. Тогда я поняла, что пора подключать ChatGPT.
Вторая попытка
Прежде всего мне потребовался промпт. Например, такой:
prompt_template = (
"Определи, является ли следующий комментарий спамом, особенно с просьбой о переводе денег или явным или завуалированным предложением работы. "
"Примеры спама: 1) Нyжeн чeловек, готовый помoчь c нeскoлькими зaдачaми, пиши в ЛC, если зaинтeреcовало! "
"2) Плачу 8000 за пару легких заданий, пиши в лс. Всё это - спам. "
"Обоснуй свой ответ одним кратким предложением. "
"Верни ответ в формате JSON с двумя полями: "
"'is_spam' (значения: 'да' или 'нет') и 'reason' (обоснование). "
"Комментарий: {comment}"
)
Здесь {comment}
— это плейсхолдер, который затем заменится на конкретный текст сообщения.
Спойлер: оказалось, что с точки зрения нейросети многие спамерские предложения вакансий выглядят вполне честными. Пришлось дополнить:
"Честные предложения вакансий тоже считай спамом. "
Далее шаблонный код с получением ответа от OpenAI API:
client = OpenAI(
api_key=ЗДЕСЬ_МОГ_БЫТЬ_ВАШ_КЛЮЧ_API,
)
def get_openai_response(prompt_template: str, comment: str) -> dict:
try:
if not prompt_template or not comment:
logging.error("Неверные входные параметры: prompt_template или comment не заданы")
return {}
prompt = prompt_template.format(comment=sanitize_comment(comment))
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": prompt
}
],
max_tokens=1000,
)
result = response.choices[0].message.content.strip()
return parse_bot_response(result)
except ValueError as e:
logging.error(f"Ошибка в структуре ответа: {e}")
return {}
except Exception as e:
logging.error(f"Произошла ошибка при получении завершения чата: {e}")
return {}
Нейросеть возвращает ответ в виде json, как ее и просили в промпте:
{
"is_spam": "да",
"reason": "Явное предложение помощи в ЛС за деньги."
}
Поэтому его надо преобразовать в словарь:
def parse_bot_response(api_response: str) -> dict:
try:
json_str = extract_json(api_response)
if not json_str:
logging.error("Не удалось выделить JSON из ответа.")
return {}
data = json.loads(json_str)
is_spam = data.get("is_spam")
reason = data.get("reason")
return {"is_spam": is_spam, "reason": reason}
except json.JSONDecodeError as e:
logging.error("Ошибка декодирования JSON: %s", e)
except Exception as e:
logging.error("Ошибка при парсинге ответа: %s", e)
return {}
И еще один обработчик, на который навешено два фильтра: не пост от канала и текстовое сообщение (т.е. не видео и не картинка):
@dp.message(is_not_channel_post, F.text)
async def check_with_openai(message: Message):
if message is None:
logging.error("❌ Получено пустое сообщение.")
await send_log_to_admin("❌ Получено пустое сообщение.")
return
try:
text_to_process = None
if message.text:
text_to_process = message.text
elif message.caption:
text_to_process = message.caption
if text_to_process:
await send_log_to_admin("Отправляю запрос в OpenAI...")
result = get_openai_response(prompt_template, text_to_process)
if result == {}:
await send_log_to_admin(f"Ошибка обработки запроса!")
else:
verdict = result.get("is_spam", "нет").lower()
reason = result.get("reason", "Причина не указана")
pic = "✔️" if verdict == "нет" else "❌"
await send_log_to_admin(f"{pic} {reason}")
if verdict == "да" and message.from_user and message.chat:
username = message.from_user.username or str(message.from_user.id)
warning_message = await message.answer(
f"@{username}, ваше сообщение классифицировано как спам. {reason}")
await message.delete()
log_message = f"✔️ Удалили спам-сообщение от пользователя {username} ({message.from_user.id if message.from_user.id else 'неизвестно'}), который писал: {message.text[:100]}. Причина: {reason}"
logging.info(log_message)
await send_log_to_admin(log_message)
await asyncio.create_task(delete_after_delay(warning_message, 30))
except Exception as e:
error_message = f"❌ Не удалось удалить спам-сообщение от {message.from_user.username} в чате {message.chat.id}: {e}"
logging.error(error_message)
await send_log_to_admin(error_message)
Здесь sanitize_comment()
- это самописная функция, удаляющая спецсимволы, а format()
- стандартная строковая функция Python для подстановки значений в шаблон. С ее помощью плейсхолдер заменяется на конкретный текст комментария.
Этот обработчик удаляет каждое сообщение, для которого нейросеть вернула is_spam = true
, и объясняет автору, в чем он был неправ (warning_message)
. Через 30 секунд удаляет и свое объяснение, чтобы не засорять чат.

Эффективность такого подхода налицо. ChatGPT улавливает спам там, где его не отсеял бы прежний бот, работающий по ключевым словам:

Модель gpt-4o неплохо справляется с этой задачей и притом не слишком прожорлива по части денег:

Причем это расходы двух моих ботов - модератора, о котором идет речь, и бота для удобного доступа к ChatGPT.
Вторая с половиной попытка
Но гонять каждый комментарий в API OpenAI все-таки показалось мне нерациональным. Поэтому я объединила обе версии бота и поставила обработчики, использующие простые эвристики (вроде отсева по ключевым словам или номерам карты) выше по коду, чтобы перекладывать на ИИ только сложные случаи.
Кроме того, полезными оказались такие функции бота:
мидлварь для ограничения количества сообщений от одного пользователя в сутки. Оказалось полезным для защиты не только от спамеров, но и просто от любителей болтать в чате часами. Счетчики сообщений храню в in-memory хранилище Redis;
фильтр для отсева запрещенных фотографий. Спамеры повадились присылать одну и ту же фотографию от разных пользователей с просьбой перевести денег на лечение. Тут даже компьютерного зрения не нужно, достаточно просто сравнить перцептивный хэш с помощью
imagehash.phash()
;удаление голосовых сообщений и стикеров, чтобы не засорять чат.
Цифры
Вот какие виды спама ловит бот:
вакансии - от почти легальных до откровенно противозаконных. Всех их объединяет обещание чрезвычайно высокого заработка - 6-10 тыс. в час;
же не манж па сис жур - просьбы помочь деньгами на лечение или на еду, купить продукты и т.д. В этой категории удивительное однообразие. Я бы и сама, пожалуй, поверила в эти просьбы, если бы они приходили не в виде одного и того же текста месяцами;
псевдорелигиозный спам - набор бессмысленных фраз, построенный на религиозных терминах. Тут нейросеть часто колеблется, потому что промпт явно не оговаривает такие случаи;
нелегальный контент - явные призывы к нарушению закона;
комментарии от ботов для рекламы других ботов - написаны нейросетью, которая читает текст поста и оставляет осмысленный на вид комментарий со ссылкой на бота. Выглядит такой текст очень солидно, но ИИ-модератор беспощаден:
И это тоже спам
Бот хранит логи всего 7 дней, поэтому за статистикой пришлось обратиться к логам, которые он отправляет админу в телеграме. Экспорт истории чата дал какие-то странноватые временные интервалы, но в целом помог подсчитать статистику.
За четыре месяца - с 17 февраля по 15 июня 2025 года - бот поймал и удалил 555 сообщений. Они распределяются так:

Впрочем, мне кажется, что делить на вакансии и нелегальный контент я начала где-то в середине подсчетов ;-)
Вот распределение по периодам (периоды соответствуют нелогичному разбиению истории чата Телеграма при экспорте):

К сожалению, бот логирует только те сообщения, которые он посчитал спамом. Поэтому по логам нельзя определить число ложноотрицательных срабатываний. Субъективно мне показалось, что таких были единицы.
Ложноположительных срабатываний, то есть пропусков хороших сообщений от реальных людей, у бота было всего 9 за 4 месяца. Из них:
3 случая, когда кто-то из участников чата оставил контактные данные;
5 случаев, когда участники из лучших побуждений предлагали устроить сбор на какое-нибудь хорошее дело;
1 случай, когда кто-то прислал текст молитвы на греческом языке. Отзыв нейросети: "Комментарий не содержит просьбы о переводе денег или предложения работы, однако он несет несвязный и странный религиозный контент, не являясь спамом по заданным критериям".
Таким образом, точность распознавания спама составила
По-моему, это отличный результат!
Другие мои проекты: