Утром 27 августа мне скинули скриншот из ВКонтакте. Под одним комментарием висело пять ответов от нашего бота. Одному и тому же человеку, с интервалом ровно в час, каждый раз как будто впервые.
У бота есть дедупликация. Есть суточный лимит. Обе проверки написаны, покрыты условиями, лежат в коде уже месяц. И обе не работали ни разу за всё время.
Дальше — разбор, почему. Ошибка оказалась в том месте, куда я не смотрел вообще: в семантике String.prototype.replace.
Как устроена запись в базу
Скрипты у нас ходят в Postgres не напрямую, а через вебхук n8n: маленькая обёртка mk-db-exec, которой шлёшь JSON с полем sql, а она выполняет запрос нодой Postgres. Сделано так исторически — база живёт в закрытом контуре, а скрипты крутятся на другой машине.
Внутри ноды запрос подставляется выражением:
{{ $json.body.sql }}
Скрипт после ответа пишет строку в журнал активности:
meta = json.dumps({"who": name, "src": "vk_comment"}, ensure_ascii=False) db(f"INSERT INTO mk_max.activity_log(kind, meta, done) " f"VALUES('vk_reply', $${meta}$$::jsonb, true)")
Долларовые кавычки здесь взялись из здравого смысла: в JSON полно одинарных кавычек и апострофов, экранировать их руками не хотелось. $$...$$ в Postgres как раз для этого и придуман.
Именно эта строчка ничего в базу не записывала.
Что происходит на самом деле
Нода подставляет значение в шаблон, и подстановка идёт через JS-замену. А в строке замены $ — служебный символ. Там свой мини-язык:
Последовательность | Во что превращается |
|---|---|
| один |
| всё совпадение целиком |
| текст до совпадения |
| текст после совпадения |
| первая группа захвата |
То есть мои $$ схлопывались в один $ ещё до того, как SQL доходил до Postgres. Проверил живым запросом:
отправлено: SELECT $${"who":"Тест"}$$::jsonb выполнено: SELECT ${"who":"Тест"}$::jsonb ответ: unterminated dollar-quoted string at or near "${"
Запрос был синтаксически убит на подлёте. Каждый раз, всегда, для всех скриптов.
Почему это не было видно полтора месяца
Тут вторая половина истории, и она неприятнее первой.
n8n на ошибку SQL отвечает HTTP 200. Тело ответа содержит описание ошибки, но код — двухсотый. А наша функция db() в скриптах на сервере возвращала тело ответа как есть, не разбирая, данные там или текст ошибки.
Получилось вот что:
def db(sql): r = requests.post(URL, json={"sql": sql}, timeout=30) return r.json() # 200 — значит всё хорошо, да?
Вызывающий код смотрел на «ответ есть» и шёл дальше. В лог падало бодрое ✅ replied. Мониторинг молчал, потому что мониторить было нечего: ошибок нет, HTTP-коды двухсотые, скрипт отработал штатно.
А проверка дедупликации выглядела так:
seen = db(f"SELECT 1 FROM mk_max.activity_log " f"WHERE kind='vk_reply' AND meta->>'cid'='{cid}' LIMIT 1") if seen: return # уже отвечали
Записи в activity_log не появлялось никогда, поэтому seen был пуст всегда, и «уже отвечали» не срабатывало ни разу. Суточный лимит считался по той же таблице и по той же причине всегда показывал ноль.
Две независимые защиты, обе завязанные на одну таблицу, в которую никто не писал.
Масштаб
Когда стало понятно, в чём дело, я прошёл grep’ом по всем скриптам. Дырка нашлась в семи местах: ответчик на комментарии, проактивная личка, добавление в друзья, вступление в группы во ВКонтакте и в Одноклассниках, приём заявок. Везде одна и та же конструкция с $$, скопированная из первого скрипта.
Человек с пятью ответами — самый заметный симптом, но не единственный. Просто остальные последствия никто не считал: заявки принимались повторно, в группы бот пытался вступать заново.
Чиним
Первым делом — быстрая заплатка по всем семи файлам: обычные одинарные кавычки с удвоением внутри.
meta = json.dumps(d, ensure_ascii=False).replace("'", "''") db(f"INSERT INTO mk_max.activity_log(kind, meta, done) " f"VALUES('vk_reply', '{meta}', true)")
Работает, но это лечение симптома: следующий человек, который напишет $$ по привычке, наступит туда же. Поэтому вечером починил сам вебхук.
В mk-db-exec появилось второе поле — sql_raw. Запрос в нём экранирует сам воркфлоу, вызывающему думать не нужно. Старое поле sql оставил работать как раньше, чтобы не переписывать 26 уже пропатченных скриптов разом.
Проверял не глазами, а прогоном через оба контракта. Отправил строки 100$, $&, $`, $, $$ — в базу они доехали дословно, без удвоений и потерь. За двое суток в таблице накопилось восемь сообщений со знаком доллара, все целые.
Третья правка — в db():
def db(sql): r = requests.post(URL, json={"sql_raw": sql}, timeout=30) body = r.json() if isinstance(body, dict) and body.get("error"): log.error("SQL failed: %s", str(body["error"])[:300]) return None # не данные, а сбой return body
И проверки дедупа переведены в fail-closed: если база не ответила, робот не действует. Раньше было наоборот — нет ответа, значит записей нет, значит можно писать. Логика «отсутствие данных = разрешение» в защитных проверках означает, что любая поломка базы превращается в спам-машину.
Что не помогло
Логи. Они говорили ✅ replied, и это было правдой: ответ действительно отправлялся. Врали не логи, а то, что я записал в них успехом отправку сообщения, а не полный цикл «отправил и запомнил».
Мониторинг HTTP-кодов тоже оказался бесполезен — все коды были двухсотые.
Единственное, что действительно вскрыло проблему, — сверка внешнего результата с внутренним состоянием: пять сообщений в ВК против нуля строк в activity_log. Такую сверку я теперь гоняю раз в сутки по всем роботам, у которых есть понятие «уже делал».
Осадок
Обиднее всего, что $$ я выбрал ровно из соображений безопасности — чтобы не городить экранирование кавычек в JSON. Аккуратность на одном слое обернулась тихой поломкой на другом, потому что между моим скриптом и Postgres стоял ещё один интерпретатор строк, о котором я не подумал.
Если у вас между кодом и базой есть промежуточный слой с шаблонами — n8n, Zapier, самописный прокси, что угодно, — стоит потратить пять минут и прогнать через него строку $$, $& и $`. Проверка дешёвая, а тишина на той стороне может стоить полутора месяцев.

