{
  "name": "TG Antispam Bot (ephemeral welcome)",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "seconds",
              "secondsInterval": 10
            }
          ]
        }
      },
      "id": "ef9f8a2c-e1e6-5550-8f25-bfa712ed50c6",
      "name": "Every 10s",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ],
      "notes": "ТРИГГЕР — расписание каждые 10 сек.\nЧТО: запускает короткий цикл опроса (getUpdates timeout=0), поэтому запуски не перекрываются.\nСам опрос — в ноде 'Get TG Updates', обработка — в 'Process Updates'.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "mode": "manual",
        "duplicateItem": false,
        "assignments": {
          "assignments": [
            {
              "id": "s1",
              "name": "botToken",
              "value": "PUT_BOT_TOKEN_HERE",
              "type": "string"
            },
            {
              "id": "s2",
              "name": "uptimeEnabled",
              "value": false,
              "type": "boolean"
            },
            {
              "id": "s3",
              "name": "uptimePushUrl",
              "value": "",
              "type": "string"
            },
            {
              "id": "s4",
              "name": "pingIntervalSec",
              "value": 300,
              "type": "number"
            },
            {
              "id": "s5",
              "name": "welcomeText",
              "value": "{mention}, гаф гаф!\nУ нас тут защита: чтобы отправлять сообщения — нажми кнопку.\nНе сработало? Пиши админу info@mail.com",
              "type": "string"
            },
            {
              "id": "s7",
              "name": "successText",
              "value": "Добро пожаловать и удачного самохостинга!",
              "type": "string"
            },
            {
              "id": "s9",
              "name": "silentWelcome",
              "value": true,
              "type": "boolean"
            },
            {
              "id": "s8",
              "name": "allowedChatIds",
              "value": "",
              "type": "string"
            },
            {
              "id": "s6",
              "name": "muteDurationSec",
              "value": 0,
              "type": "number"
            },
            {
              "id": "s10",
              "name": "deletePendingMessages",
              "value": true,
              "type": "boolean"
            },
            {
              "id": "s11",
              "name": "welcomeTtlSec",
              "value": 30,
              "type": "number"
            },
            {
              "id": "s12",
              "name": "captchaButtons",
              "value": 1,
              "type": "number"
            },
            {
              "id": "s13",
              "name": "captchaQuestion",
              "value": "Нажми на число: {answer}",
              "type": "string"
            },
            {
              "id": "s14",
              "name": "captchaWrongText",
              "value": "",
              "type": "string"
            },
            {
              "id": "s15",
              "name": "ephemeralWelcome",
              "value": true,
              "type": "boolean"
            }
          ]
        },
        "options": {}
      },
      "id": "79223209-a6aa-5cd3-8eb4-591a716629e8",
      "name": "Settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ],
      "notes": "НАСТРОЙКИ (Set-нода, правь в UI).\nЧитается дальше как $('Settings') в 'Config' и 'Heartbeat Gate'.\nПоля: botToken, uptimeEnabled, uptimePushUrl, pingIntervalSec, welcomeText ({mention}), successText, silentWelcome, allowedChatIds (ID чатов через запятую; пусто = любые чаты), muteDurationSec.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const s = $('Settings').first().json;\nconst sd = $getWorkflowStaticData('global');\nif (sd.updateOffset === undefined) sd.updateOffset = 0;\n\nconst botToken = (s.botToken && s.botToken !== 'PUT_BOT_TOKEN_HERE')\n  ? s.botToken\n  : String($env.TELEGRAM_BOT_TOKEN || '');\nif (!botToken) throw new Error('Укажи botToken в узле \"Settings\" (или env TELEGRAM_BOT_TOKEN)');\n\nconst uptimeEnabled = (s.uptimeEnabled === true) || (String($env.UPTIME_ENABLED || '').toLowerCase() === 'true');\nconst uptimePushUrl = String(s.uptimePushUrl || $env.UPTIME_KUMA_PUSH_URL || '').trim();\n\nconst allowedChatIds = String(s.allowedChatIds || '')\n  .split(',')\n  .map(function (x) { return x.trim(); })\n  .filter(Boolean);\n\nif (!sd.webhookCleared) {\n  try {\n    await this.helpers.httpRequest({\n      method: 'POST',\n      url: `https://api.telegram.org/bot${botToken}/deleteWebhook`,\n      body: { drop_pending_updates: false },\n      json: true,\n      timeout: 15000\n    });\n    sd.webhookCleared = true;\n  } catch (e) {}\n}\n\nreturn [{ json: Object.assign({}, s, { botToken, uptimeEnabled, uptimePushUrl, allowedChatIds, offset: sd.updateOffset }) }];"
      },
      "id": "c7da4656-5298-5b3d-8876-35fc92d2584f",
      "name": "Config",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        0
      ],
      "notes": "ПОДГОТОВКА (код). Стр.1: берёт $('Settings'); токен с фолбэком на env TELEGRAM_BOT_TOKEN.\ndeleteWebhook один раз — иначе getUpdates вернёт 409 Conflict. Заводит sd.updateOffset.\nНормализует allowedChatIds (строка 'id,id' -> массив). Отдаёт cfg дальше.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "method": "GET",
        "url": "=https://api.telegram.org/bot{{ $('Config').first().json.botToken }}/getUpdates?offset={{ $('Config').first().json.offset }}&timeout=0&allowed_updates=%5B%22message%22%2C%22callback_query%22%2C%22chat_member%22%2C%22my_chat_member%22%5D",
        "options": {
          "timeout": 15000
        }
      },
      "id": "a3f3a650-e2db-5f77-a121-1025c91b0c0e",
      "name": "Get TG Updates",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        0
      ],
      "onError": "continueRegularOutput",
      "notes": "ПОЛУЧЕНИЕ АПДЕЙТОВ (HTTP). GET getUpdates?offset(из Config)&timeout=0&allowed_updates=[message,callback_query,chat_member,my_chat_member].\nВЫВОД ЭТОЙ НОДЫ = СЫРОЙ ответ Telegram — здесь видно, что реально пришло.\nonError: continue (ошибка не валит воркфлоу, видна в выводе).",
      "notesInFlow": true
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const cfg = $('Config').first().json;\nconst token = cfg.botToken;\nconst base = `https://api.telegram.org/bot${token}`;\nconst sd = $getWorkflowStaticData('global');\nconst uptimeUrl = (cfg.uptimeEnabled && cfg.uptimePushUrl) ? cfg.uptimePushUrl : '';\nconst allowedChatIds = (cfg.allowedChatIds || []).map(String);\nconst chatAllowed = (id) => allowedChatIds.length === 0 || allowedChatIds.indexOf(String(id)) !== -1;\nconst deletePendingMessages = cfg.deletePendingMessages !== false;\nconst PENDING_TTL = 86400000;\nconst welcomeTtlSec = (cfg.welcomeTtlSec === undefined || cfg.welcomeTtlSec === null) ? 30 : Number(cfg.welcomeTtlSec);\nconst captchaButtons = Math.max(1, Math.min(9, parseInt(cfg.captchaButtons, 10) || 1));\n\nconst MUTE = {\n  can_send_messages: false,\n  can_send_audios: false,\n  can_send_documents: false,\n  can_send_photos: false,\n  can_send_videos: false,\n  can_send_video_notes: false,\n  can_send_voice_notes: false,\n  can_send_polls: false,\n  can_send_other_messages: false,\n  can_add_web_page_previews: false\n};\nconst UNMUTE = {\n  can_send_messages: true,\n  can_send_audios: true,\n  can_send_documents: true,\n  can_send_photos: true,\n  can_send_videos: true,\n  can_send_video_notes: true,\n  can_send_voice_notes: true,\n  can_send_polls: true,\n  can_send_other_messages: true,\n  can_add_web_page_previews: true\n};\n\nconst api = async (method, body) => {\n  return await this.helpers.httpRequest({\n    method: 'POST',\n    url: `${base}/${method}`,\n    body,\n    json: true,\n    timeout: 15000\n  });\n};\n\nconst statusCode = (e) => (e && ((e.response && e.response.status) || e.statusCode || e.httpCode || e.error_code)) || 0;\n\n// Новичок -> мьют + приветствие с кнопкой (new_chat_members ИЛИ chat_member).\nconst handleJoin = async (chatId, nm) => {\n  if (!nm || nm.is_bot) return;\n  try {\n    await api('restrictChatMember', { chat_id: chatId, user_id: nm.id, permissions: MUTE });\n  } catch (e) {}\n  const display = nm.username ? '@' + nm.username : (nm.first_name || 'гость');\n  const mention = `<a href=\"tg://user?id=${nm.id}\">${display}</a>`;\n  let text = String(cfg.welcomeText || '{mention}, гаф гаф!').replace(/{mention}/g, mention);\n  let keyboard;\n  if (captchaButtons >= 2) {\n    const pool = [];\n    while (pool.length < captchaButtons) {\n      const v = 1 + Math.floor(Math.random() * 9);\n      if (pool.indexOf(v) === -1) pool.push(v);\n    }\n    const answer = pool[Math.floor(Math.random() * pool.length)];\n    text += '\\n\\n' + String(cfg.captchaQuestion || 'Нажми на число: {answer}').replace(/{answer}/g, String(answer));\n    for (let i = pool.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const t = pool[i]; pool[i] = pool[j]; pool[j] = t; }\n    keyboard = { inline_keyboard: [ pool.map(function (v) { return { text: String(v), callback_data: `verify:${nm.id}:${v === answer ? 1 : 0}` }; }) ] };\n  } else {\n    keyboard = { inline_keyboard: [[{ text: 'Пройти проверку', callback_data: `verify:${nm.id}` }]] };\n  }\n  const sendBody = {\n    chat_id: chatId,\n    text,\n    parse_mode: 'HTML',\n    disable_notification: cfg.silentWelcome !== false,\n    reply_markup: keyboard\n  };\n  let wres = null;\n  if (cfg.ephemeralWelcome !== false) {\n    try {\n      wres = await api('sendMessage', Object.assign({}, sendBody, {\n        ephemeral_message_parameters: { receiver_user_id: nm.id }\n      }));\n    } catch (e) { wres = null; }\n  }\n  if (!wres) {\n    try { wres = await api('sendMessage', sendBody); } catch (e) {}\n  }\n  if (welcomeTtlSec > 0 && wres && wres.result && wres.result.message_id) {\n    sd.welcome = sd.welcome || {};\n    sd.welcome[chatId + ':' + nm.id] = { chat_id: chatId, message_id: wres.result.message_id, ts: Date.now() };\n  }\n  if (deletePendingMessages) {\n    sd.pending = sd.pending || {};\n    sd.pending[chatId + ':' + nm.id] = Date.now();\n  }\n};\n\nif (sd.polling && (Date.now() - sd.polling) < 60000) {\n  return [{ json: { ok: true, skipped: 'busy' } }];\n}\nsd.polling = Date.now();\n\ntry {\n  if (sd.pending) {\n    const nowMs = Date.now();\n    for (const k of Object.keys(sd.pending)) if (nowMs - sd.pending[k] > PENDING_TTL) delete sd.pending[k];\n  }\n  if (welcomeTtlSec > 0 && sd.welcome) {\n    const nowW = Date.now();\n    for (const k of Object.keys(sd.welcome)) {\n      const w = sd.welcome[k];\n      if (nowW - w.ts > welcomeTtlSec * 1000) {\n        try { await api('deleteMessage', { chat_id: w.chat_id, message_id: w.message_id }); } catch (e) {}\n        delete sd.welcome[k];\n      }\n    }\n  }\n  let resp = $json; // сырой ответ ноды \"Get TG Updates\"\n\n  // Если getUpdates не отдал ok (напр. 409) — пробуем снять вебхук и повторить.\n  if (!resp || resp.ok !== true) {\n    const errObj = (resp && resp.error) || resp || {};\n    const code = statusCode(resp) || statusCode(errObj) || (resp && resp.error_code) || 0;\n    const descr = String(errObj.description || errObj.message || 'getUpdates failed');\n    if (code === 409 || descr.indexOf('409') !== -1 || descr.indexOf('Conflict') !== -1) {\n      try { await api('deleteWebhook', { drop_pending_updates: false }); } catch (e2) {}\n      try {\n        resp = await api('getUpdates', { offset: sd.updateOffset || 0, timeout: 0, allowed_updates: ['message', 'callback_query', 'chat_member', 'my_chat_member'] });\n      } catch (e3) {\n        resp = null;\n      }\n      if (!resp || resp.ok !== true) {\n        let info = '';\n        try { const w = await api('getWebhookInfo', {}); info = JSON.stringify(w && w.result); } catch (e4) {}\n        throw new Error('409 Conflict: getUpdates занят (вебхук или второй поллер на этот токен). getWebhookInfo=' + info);\n      }\n    } else {\n      throw new Error('getUpdates error: ' + descr);\n    }\n  }\n\n  const updates = (resp && resp.result) || [];\n  let lastId = sd.updateOffset || 0;\n  const trace = [];\n\n  for (const u of updates) {\n    if (u.update_id + 1 > lastId) lastId = u.update_id + 1;\n    const msg = u.message;\n    const cq = u.callback_query;\n    const cm = u.chat_member;\n\n    // 0) карантин: сообщения ещё не проверенных новичков удаляем\n    let pendingDeleted = false;\n    if (deletePendingMessages && msg && msg.from && !Array.isArray(msg.new_chat_members) && msg.chat && sd.pending) {\n      const pkey = msg.chat.id + ':' + msg.from.id;\n      const pts = sd.pending[pkey];\n      if (pts && (Date.now() - pts) > PENDING_TTL) {\n        delete sd.pending[pkey];\n      } else if (pts) {\n        try { await api('deleteMessage', { chat_id: msg.chat.id, message_id: msg.message_id }); pendingDeleted = true; } catch (e) {}\n      }\n    }\n\n    // 1) сервисное сообщение new_chat_members (обычные группы)\n    if (msg && Array.isArray(msg.new_chat_members) && chatAllowed(msg.chat.id)) {\n      for (const nm of msg.new_chat_members) {\n        await handleJoin(msg.chat.id, nm);\n      }\n    }\n\n    // 2) chat_member: статус left/kicked -> member/restricted (супергруппы, бот админ)\n    if (cm && chatAllowed(cm.chat.id)) {\n      const nj = cm.new_chat_member || {};\n      const oj = cm.old_chat_member || {};\n      const nowIn = nj.is_member === true || nj.status === 'member';\n      const wasIn = oj.is_member === true || oj.status === 'member' || oj.status === 'administrator' || oj.status === 'creator';\n      const joined = nowIn && !wasIn && (nj.status === 'member' || nj.status === 'restricted');\n      if (joined) await handleJoin(cm.chat.id, nj.user);\n    }\n\n    // 3) кнопка проверки (captcha: одна из N верная)\n    let captchaResult;\n    if (cq && cq.message && chatAllowed(cq.message.chat.id) && typeof cq.data === 'string' && cq.data.indexOf('verify:') === 0) {\n      const parts = cq.data.split(':');\n      const targetId = parseInt(parts[1], 10);\n      const isCorrect = parts[2] === undefined ? true : parts[2] === '1';\n      if (cq.from && cq.from.id === targetId && isCorrect) {\n        captchaResult = 'correct';\n        try { await api('answerCallbackQuery', { callback_query_id: cq.id, text: String(cfg.successText || 'Добро пожаловать и удачного самохостинга!'), show_alert: true }); } catch (e) {}\n        try { await api('restrictChatMember', { chat_id: cq.message.chat.id, user_id: targetId, permissions: UNMUTE }); } catch (e) {}\n        try { await api('deleteMessage', { chat_id: cq.message.chat.id, message_id: cq.message.message_id }); } catch (e) {}\n        if (sd.pending) delete sd.pending[cq.message.chat.id + ':' + targetId];\n        if (sd.welcome) delete sd.welcome[cq.message.chat.id + ':' + targetId];\n      } else if (cq.from && cq.from.id === targetId) {\n        // неверная кнопка — то же самое, что не было ответа\n        captchaResult = 'wrong';\n        try { await api('answerCallbackQuery', { callback_query_id: cq.id, text: String(cfg.captchaWrongText || ''), show_alert: false }); } catch (e) {}\n      } else {\n        captchaResult = 'foreign';\n        try { await api('answerCallbackQuery', { callback_query_id: cq.id }); } catch (e) {}\n      }\n    }\n\n    const kind = (msg && Array.isArray(msg.new_chat_members)) ? 'new_chat_members' : (cm ? 'chat_member' : (cq ? 'callback_query' : 'other'));\n    const chatId = (msg && msg.chat && msg.chat.id) || (cm && cm.chat && cm.chat.id) || (cq && cq.message && cq.message.chat && cq.message.chat.id);\n    let users;\n    if (msg && Array.isArray(msg.new_chat_members)) users = msg.new_chat_members.map(function (m) { return m.id; });\n    else if (cm && cm.new_chat_member && cm.new_chat_member.user) users = [cm.new_chat_member.user.id];\n    const fromStatus = cm ? (cm.old_chat_member && cm.old_chat_member.status) : undefined;\n    const toStatus = cm ? (cm.new_chat_member && cm.new_chat_member.status) : undefined;\n    const wasInT = !!(cm && cm.old_chat_member && (cm.old_chat_member.is_member === true || cm.old_chat_member.status === 'member' || cm.old_chat_member.status === 'administrator' || cm.old_chat_member.status === 'creator'));\n    const nowInT = !!(cm && cm.new_chat_member && (cm.new_chat_member.is_member === true || cm.new_chat_member.status === 'member'));\n    const joined = !!(cm && nowInT && !wasInT && (cm.new_chat_member.status === 'member' || cm.new_chat_member.status === 'restricted'));\n    trace.push({ update_id: u.update_id, kind: kind, chat_id: chatId, allowed: chatAllowed(chatId), from_status: fromStatus, to_status: toStatus, joined: joined, deleted_pending: pendingDeleted, captcha_result: captchaResult, users: users });\n  }\n\n  sd.updateOffset = lastId;\n  return [{ json: { ok: true, updates: updates.length, offset: lastId, trace: trace.slice(-20) } }];\n} catch (err) {\n  if (uptimeUrl) {\n    try {\n      await this.helpers.httpRequest({\n        method: 'GET',\n        url: `${uptimeUrl}?status=down&msg=${encodeURIComponent(String(err && err.message ? err.message : err))}`,\n        timeout: 10000\n      });\n    } catch (e) {}\n  }\n  throw err;\n} finally {\n  sd.polling = 0;\n}"
      },
      "id": "5214225d-2bba-5073-9655-841a6ddb133e",
      "name": "Process Updates",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        0
      ],
      "notes": "ОБРАБОТКА (код). Читает сырой ответ ноды 'Get TG Updates'.\nhandleJoin(): new_chat_members ИЛИ chat_member (is_member false->true: вход; статус у замученных остаётся restricted). Приветствие шлётся как EPHEMERAL (видит только новичок), при неудаче — публичный фолбэк. -> restrictChatMember(MUTE)+sendMessage(welcome).\ncallback_query verify:ID -> answerCallbackQuery + UNMUTE + deleteMessage.\nchatAllowed() по allowedChatIds. 409 -> deleteWebhook + повтор. Пишет sd.updateOffset. ВЫХОД: {ok,updates,offset,trace}.",
      "notesInFlow": true
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const cfg = $('Config').first().json;\nconst sd = $getWorkflowStaticData('global');\nconst intervalSec = cfg.pingIntervalSec || 300;\nconst now = Date.now();\nconst last = sd.lastPingAt || 0;\nif (!cfg.uptimeEnabled || !cfg.uptimePushUrl || (now - last) < intervalSec * 1000) {\n  return [];\n}\nsd.lastPingAt = now;\nreturn [{ json: { url: cfg.uptimePushUrl } }];"
      },
      "id": "15dcad7f-7d98-5515-baaa-cf784caf2c72",
      "name": "Heartbeat Gate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        0
      ],
      "notes": "ТРОТТЛИНГ ХАРТБИТА (код). Пропускает пинг только если cfg.uptimeEnabled и не чаще cfg.pingIntervalSec.\nМетка sd.lastPingAt. Возвращает [{url}] либо [] (тогда пинг не идёт).",
      "notesInFlow": true
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.url }}",
        "queryParameters": {
          "parameters": [
            {
              "name": "status",
              "value": "up"
            },
            {
              "name": "msg",
              "value": "alive"
            }
          ]
        },
        "options": {
          "timeout": 10000
        }
      },
      "id": "40905158-675d-5b1e-aa66-a32601d13e98",
      "name": "Uptime Kuma ping",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1320,
        0
      ],
      "notes": "ХАРТБИТ. GET push-URL?status=up&msg=alive.\nПропуск пинга = сервис мёртв -> Uptime Kuma сама шлёт нотификацию (dead-man's switch).",
      "notesInFlow": true,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "content": "НАСТРОЙКА — всё в узле \"Settings\"\nbotToken          — токен бота (пусто -> env TELEGRAM_BOT_TOKEN)\nallowedChatIds    — ID чатов через запятую (напр. -1001234567890). Пусто = любые чаты\nuptimeEnabled     — вкл/выкл пинг в Uptime Kuma\nuptimePushUrl     — https://<kuma>/api/push/<token> (пусто -> env)\npingIntervalSec   — период хартбита, сек (напр. 300)\nwelcomeText       — текст новичку; {mention} = упоминание\nsuccessText       — текст после успешной проверки\nsilentWelcome     — приветствие без уведомления участников (true)\nmuteDurationSec   — 0 = бессрочный мьют\ndeletePendingMessages — удалять сообщения новичка до прохождения проверки (true)\nephemeralWelcome  — приветствие видит только вошедший новичок (true)\nwelcomeTtlSec    — удалить приветствие, если кнопку не нажали, сек (30; 0 = никогда)\ncaptchaButtons   — число кнопок-ответов: 1 = одна «Пройти проверку»; 2..9 = капча «нажми верное»\ncaptchaQuestion  — текст вопроса капчи, {answer} = верное число\ncaptchaWrongText — текст при неверном нажатии (пусто = без подсказки)\n\nID чата: добавь в группу @getidsbot / @userinfobot, или из getUpdates\n(у группы ID отрицательный, вида -100...).\n\nБот должен быть админом чата: restrict members, delete messages.\nПоток: Settings -> Config -> Get TG Updates (сырой ответ TG видно ЗДЕСЬ) -> Process Updates (мьют/приветствие) -> Heartbeat Gate -> Uptime Kuma ping.\n\nUptime Kuma: монитор типа Push, Heartbeat Interval ~= pingIntervalSec.",
        "height": 430,
        "width": 1620,
        "color": 4
      },
      "id": "9c2d8e73-6ec3-5d8f-a9e8-ad89f252baf3",
      "name": "Setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        560
      ]
    },
    {
      "parameters": {
        "content": "ТРИГГЕР\nРасписание: каждые 10 сек запускает короткий цикл опроса (getUpdates timeout=0).\nКороткий опрос = мгновенный вызов, запуски не накладываются (иначе Telegram отдаёт 409).",
        "height": 150,
        "width": 210,
        "color": 4
      },
      "id": "6c167bd2-f3da-5521-9349-abea58d700fb",
      "name": "card: Every 10s",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -55,
        200
      ]
    },
    {
      "parameters": {
        "content": "НАСТРОЙКИ — входные параметры, правятся без кода.\nКак читается: $('Settings').first().json\nПоля:\n• botToken\n• allowedChatIds — ID чатов через запятую (пусто = все)\n• welcomeText — {mention} = упоминание\n• successText — текст после проверки\n• silentWelcome — приветствие без уведомления (disable_notification)\n• muteDurationSec (0 = бессрочно)\n• deletePendingMessages — удалять сообщения новичка до проверки (true)\n• ephemeralWelcome — приветствие видит только новичок (true)\n• welcomeTtlSec — удалить приветствие, если не нажали кнопку, сек (30; 0=никогда)\n• captchaButtons — сколько кнопок-ответов (1=классика; 2..9=капча)\n• captchaQuestion — вопрос, {answer} = верное число\n• captchaWrongText — текст при неверном нажатии (пусто = без текста)\n• uptimeEnabled / uptimePushUrl / pingIntervalSec",
        "height": 280,
        "width": 210,
        "color": 4
      },
      "id": "b4e0c661-e64b-5746-9d29-0f2b5d4adb59",
      "name": "card: Settings",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        165,
        200
      ]
    },
    {
      "parameters": {
        "content": "ПОДГОТОВКА (код). Собирает cfg и проверяет токен.\nКоманды:\n• botToken = Settings.botToken, иначе $env.TELEGRAM_BOT_TOKEN (нет → throw)\n• deleteWebhook — один раз (флаг sd.webhookCleared); без него getUpdates → 409 Conflict\n• sd.updateOffset = 0 — счётчик прочитанных апдейтов\n• allowedChatIds: строка 'id,id' → массив",
        "height": 192,
        "width": 210,
        "color": 4
      },
      "id": "929f16c0-fe06-5334-8b96-ccfe65b150ee",
      "name": "card: Config",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        385,
        200
      ]
    },
    {
      "parameters": {
        "content": "ПОЛУЧЕНИЕ АПДЕЙТОВ (HTTP).\nКоманды:\n• GET getUpdates?offset=<из Config>&timeout=0\n• allowed_updates=[message, callback_query, chat_member, my_chat_member]\n• ВЫВОД = СЫРОЙ ответ Telegram (result[...]) — тут видно, что реально пришло\n• onError: continue — ошибка видна в выводе, не валит воркфлоу",
        "height": 192,
        "width": 210,
        "color": 4
      },
      "id": "259185b5-8791-5192-91d6-63e2213e273b",
      "name": "card: Get TG Updates",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        605,
        200
      ]
    },
    {
      "parameters": {
        "content": "ОБРАБОТКА (код).\nКоманды:\n• читает ответ ноды 'Get TG Updates'\n• handleJoin(): sendMessage с ephemeral_message_parameters{receiver_user_id} (видит только новичок), иначе публичный фолбэк\n• handleJoin(): new_chat_members ИЛИ chat_member (вход = is_member false→true; у замученных статус остаётся restricted) → restrictChatMember(MUTE)+sendMessage\n• Авто-удаление приветствия через welcomeTtlSec, если кнопку не нажали (sd.welcome)\n• КАРАНТИН: сообщения вошедших, но не прошедших проверку, удаляются (sd.pending, TTL 24ч)\n• callback 'verify:ID:ok/no' → верная кнопка: answerCallbackQuery+UNMUTE+deleteMessage; неверная: как будто не отвечал\n• chatAllowed() — фильтр по allowedChatIds\n• при 409 → deleteWebhook + повтор\n• пишет sd.updateOffset; вывод {ok,updates,offset,trace}",
        "height": 236,
        "width": 210,
        "color": 4
      },
      "id": "7fcf5781-1357-5e57-8093-f5b4dcd0209a",
      "name": "card: Process Updates",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        825,
        200
      ]
    },
    {
      "parameters": {
        "content": "ТРОТТЛИНГ ХАРТБИТА (код). Пускать пинг не чаще pingIntervalSec.\nКоманды:\n• если uptimeEnabled и now − sd.lastPingAt ≥ интервал → sd.lastPingAt = now, вернуть [{url}]\n• иначе [] — пинг пропускается",
        "height": 150,
        "width": 210,
        "color": 4
      },
      "id": "96cd3fc6-d9c5-5b45-9a0c-d3cff7f30e62",
      "name": "card: Heartbeat Gate",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1045,
        200
      ]
    },
    {
      "parameters": {
        "content": "ХАРТБИТ (HTTP). GET <uptimePushUrl>?status=up&msg=alive\nСмысл: пока пинги идут — сервис жив; пропуск = мёртв → Uptime Kuma шлёт нотификацию (dead-man's switch).",
        "height": 150,
        "width": 210,
        "color": 4
      },
      "id": "4ab0958a-dfdb-589e-a962-100794015e37",
      "name": "card: Uptime Kuma ping",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1265,
        200
      ]
    }
  ],
  "connections": {
    "Every 10s": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Get TG Updates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Heartbeat Gate": {
      "main": [
        [
          {
            "node": "Uptime Kuma ping",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get TG Updates": {
      "main": [
        [
          {
            "node": "Process Updates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Process Updates": {
      "main": [
        [
          {
            "node": "Heartbeat Gate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "a1b2c3d4-0000-4000-8000-000000000001",
  "meta": {},
  "tags": []
}