Утром 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-замену. А в строке замены $ — служебный символ. Там свой мини-язык:

Последовательность

Во что превращается

$$

один $

$&

всё совпадение целиком

$`

текст до совпадения

$'

текст после совпадения

$1

первая группа захвата

То есть мои $$ схлопывались в один $ ещё до того, как 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, самописный прокси, что угодно, — стоит потратить пять минут и прогнать через него строку $$, $& и $`. Проверка дешёвая, а тишина на той стороне может стоить полутора месяцев.