AutomationFlowsAI & RAG › Query Expenses on Telegram with Gpt-4.1 and Google Sheets

Query Expenses on Telegram with Gpt-4.1 and Google Sheets

ByRobin @robinvm on n8n.io

Ask questions like “How much did I spend on food last month?” and get instant answers from your financial data — directly in Telegram.

Event trigger★★★★★ complexityAI-powered89 nodesTelegram TriggerOpenAIGoogle SheetsTelegramHTTP Request
AI & RAG Trigger: Event Nodes: 89 Complexity: ★★★★★ AI nodes: yes Added:

This workflow corresponds to n8n.io template #14051 — we link there as the canonical source.

This workflow follows the Google Sheets → HTTP Request recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "",
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "name": "Telegram Expense Tracker \u2014 Query Bot (Template)",
  "tags": [],
  "nodes": [
    {
      "id": "58535881-40c5-4d75-82c6-f0faa9443e1a",
      "name": "MSG | Telegram Inbound",
      "type": "n8n-nodes-base.telegramTrigger",
      "position": [
        2144,
        1088
      ],
      "parameters": {
        "updates": [
          "message",
          "callback_query"
        ],
        "additionalFields": {}
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "69f61c14-bd15-4c5a-b0b8-ada463bff839",
      "name": "LLM | Parse Intent",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        2880,
        1632
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-nano",
          "cachedResultName": "GPT-4.1-NANO"
        },
        "options": {},
        "messages": {
          "values": [
            {
              "role": "system",
              "content": "=You are a financial intent parser.\nYour ONLY task is to convert a natural language finance question into structured JSON.\nYou must:\n- Return ONLY valid JSON.\n- Do NOT include explanations.\n- Do NOT include markdown.\n- Do NOT answer the question.\n- Do NOT guess missing information.\n- Do NOT add extra fields.\n\nThe user's first name is: {{ $('MSG | Telegram Inbound').item.json.message.from.first_name }}\nThe current year is: {{ new Date().getFullYear() }}\nThe current date is: {{ new Date().toISOString().split('T')[0] }}\n\nAlways return the following JSON structure:\n{\n  \"intent\": \"spending_query\",\n  \"time_reference\": \"last_week, this_week, last_month, this_month, last_year, this_year, or null\",\n  \"explicit_start_date\": \"YYYY-MM-DD or null\",\n  \"explicit_end_date\": \"YYYY-MM-DD or null\",\n  \"person\": \"string or null\",\n  \"category\": [\"string\"] or null,\n  \"common_only\": true, false, or null,\n  \"comparison\": \"previous_period, previous_month, previous_year, or null\",\n  \"group_by\": \"category, person, or null\"\n}\n\nInterpretation Rules:\n1. Date Handling (IMPORTANT):\n- If the user mentions a specific month without a year (e.g. \"february\", \"januar\"),\n  assume the current year and set explicit_start_date and explicit_end_date accordingly.\n  Example: \"february\" \u2192 explicit_start_date: \"2026-02-01\", explicit_end_date: \"2026-02-28\"\n- If the user refers to relative time (e.g., last week, this month, last month, this year, etc.), \n  set the correct value in \"time_reference\".\n- DO NOT calculate actual dates for relative time references.\n- Leave explicit_start_date and explicit_end_date as null for relative time references.\n- If the user provides an explicit date range (e.g., 01.01.2026 - 31.01.2026),\n  set explicit_start_date and explicit_end_date accordingly,\n  and set time_reference to null.\n- If no time reference is mentioned \u2192 set all three fields to null.\n\n2. Person Handling:\n- If user says \"I\", \"me\", \"ich\", \"mir\" \u2192 use \"{{ $('MSG | Telegram Inbound').item.json.message.from.first_name }}\"\n- If user says \"we\", \"wir\", \"uns\", \"beide\" \u2192 null (refers to both persons, no filter needed).\n- If a specific name or nickname is mentioned that refers to another person \u2192 use that exact name as written.\n- If no person is mentioned \u2192 null.\n- NEVER invent or guess a person name.\n- The value for \"person\" must always be either the mentioned name/nickname, \"{{ $('MSG | Telegram Inbound').item.json.message.from.first_name }}\", or null.\n\n3. category:\n- If one category is mentioned, return it as a single-element array.\n- If multiple categories are mentioned, return all of them as an array.\n- Use lowercase strings.\n- If none mentioned \u2192 null.\n\n4. Common Expense Handling (VERY IMPORTANT):\nSet \"common_only\" to true if the user explicitly refers to shared expenses.\nThis includes words or phrases such as:\n- shared\n- common\n- joint\n- together\n- gemeinschaftlich\n- gemeinsam\n- gemeinsame ausgaben\n- geteilte ausgaben\n- f\u00fcr uns\n- zusammen\nIf the user explicitly says:\n- \"f\u00fcr gemeinsame Ausgaben\"\n- \"f\u00fcr gemeinsame Kosten\"\nyou MUST set \"common_only\" to true.\nSet \"common_only\" to false if the user explicitly refers to private expenses.\nThis includes:\n- private\n- pers\u00f6nlich\n- eigene ausgaben\n- nur f\u00fcr sich\n- privat\nIf shared/private is NOT explicitly mentioned \u2192 set \"common_only\" to null.\nNever assume.\n\n5. Comparison:\n- If user compares with previous month \u2192 \"previous_month\"\n- If compares with previous period \u2192 \"previous_period\"\n- If compares with previous year \u2192 \"previous_year\"\n- Otherwise \u2192 null\n\n6. Grouping:\n- If user asks for breakdown by category \u2192 \"category\"\n- If breakdown by person \u2192 \"person\"\n- Otherwise \u2192 null\n\nReturn ONLY JSON."
            },
            {
              "content": "={{ $json.message.text }}"
            }
          ]
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.4
    },
    {
      "id": "1f1984d4-cc31-4730-8354-69c6ec425bbe",
      "name": "JS | Extract Intent JSON",
      "type": "n8n-nodes-base.code",
      "position": [
        3232,
        1632
      ],
      "parameters": {
        "jsCode": "const rawContent = $input.first().json.message?.content || $input.first().json.choices?.[0]?.message?.content || '{}';\nlet parsed = {};\ntry {\n  const cleaned = rawContent.replace(/```json|```/g, '').trim();\n  parsed = JSON.parse(cleaned);\n} catch(e) {\n  parsed = { intent: 'spending_query', start_date: null, end_date: null, person: null, category: null, common_only: null, comparison: null, group_by: null };\n}\nreturn [{ json: parsed }];"
      },
      "typeVersion": 2
    },
    {
      "id": "4e830963-ede6-4d56-b328-7f3119040143",
      "name": "GS | Load Expenses",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        9824,
        1440
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "expenses"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "4271bf57-4b8d-4f68-8896-2d55f162161e",
      "name": "GS | Load Categories",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        9824,
        1152
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "expense_categories"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "21d72a8f-e9cd-4fac-b832-f0804777d270",
      "name": "JS | Filter & Aggregate",
      "type": "n8n-nodes-base.code",
      "position": [
        10576,
        1264
      ],
      "parameters": {
        "jsCode": "// =============================\n// Date Engine\n// =============================\n\nconst now = new Date();\n\nfunction normalizeDate(date) {\n  const d = new Date(date);\n  if (isNaN(d)) return null;\n  d.setHours(0,0,0,0);\n  return d;\n}\n\nfunction getDateRange(timeReference) {\n\n  switch (timeReference) {\n\n    case \"this_month\":\n      return {\n        start: normalizeDate(new Date(now.getFullYear(), now.getMonth(), 1)),\n        end: normalizeDate(now)\n      };\n\n    case \"last_month\":\n      return {\n        start: normalizeDate(new Date(now.getFullYear(), now.getMonth() - 1, 1)),\n        end: normalizeDate(new Date(now.getFullYear(), now.getMonth(), 0))\n      };\n\n    case \"this_year\":\n      return {\n        start: normalizeDate(new Date(now.getFullYear(), 0, 1)),\n        end: normalizeDate(now)\n      };\n\n    case \"last_year\":\n      return {\n        start: normalizeDate(new Date(now.getFullYear() - 1, 0, 1)),\n        end: normalizeDate(new Date(now.getFullYear() - 1, 11, 31))\n      };\n\n    case \"last_week\":\n      const day = now.getDay();\n      const diffToMonday = (day + 6) % 7;\n\n      const lastMonday = new Date(now);\n      lastMonday.setDate(now.getDate() - diffToMonday - 7);\n\n      const lastSunday = new Date(lastMonday);\n      lastSunday.setDate(lastMonday.getDate() + 6);\n\n      return {\n        start: normalizeDate(lastMonday),\n        end: normalizeDate(lastSunday)\n      };\n\n    case \"this_week\":\n      const currentDay = now.getDay();\n      const diff = (currentDay + 6) % 7;\n\n      const monday = new Date(now);\n      monday.setDate(now.getDate() - diff);\n\n      return {\n        start: normalizeDate(monday),\n        end: normalizeDate(now)\n      };\n\n    default:\n      return { start: null, end: null };\n  }\n}\n\nfunction formatDate(date) {\n  if (!date) return null;\n  return date.toISOString().split('T')[0];\n}\n\n// =============================\n// Retrieve Intent\n// =============================\n\nconst intent = $('JS | Extract Intent JSON').first().json;\nconst edit3 = $('SET | Assemble Resolved Intent').first().json;\n\n// IMPORTANT: overwrite values if edited\nintent.person = edit3.person || intent.person;\nintent.category = edit3.category || intent.category;\n\n// =============================\n// Load & Normalize Data\n// =============================\n\nconst expenseItems = $('GS | Load Expenses').all().map(i => ({\n  date: i.json.date,\n  amount: parseFloat(i.json.amount) || 0,\n  category: i.json.category?.toLowerCase() || null,\n  description: i.json.description,\n  common_expense: i.json.common_expense,\n  person: i.json.Person?.toLowerCase() || null\n}));\n\n// =============================\n// Date Handling\n// =============================\n\nlet startDate = null;\nlet endDate = null;\n\nif (intent.time_reference) {\n  const range = getDateRange(intent.time_reference);\n  startDate = range.start;\n  endDate = range.end;\n}\n\nif (intent.explicit_start_date) {\n  startDate = normalizeDate(intent.explicit_start_date);\n}\n\nif (intent.explicit_end_date) {\n  endDate = normalizeDate(intent.explicit_end_date);\n}\n\nconst formattedStart = formatDate(startDate);\nconst formattedEnd = formatDate(endDate);\n\n// =============================\n// Other Filters\n// =============================\n\nconst filterPerson = intent.person ? intent.person.toLowerCase() : null;\n\nconst filterCategories = (() => {\n  const c = intent.category;\n  if (!c) return [];\n  if (Array.isArray(c)) {\n    return c.map(x => String(x).trim().toLowerCase()).filter(Boolean);\n  }\n  return [String(c).trim().toLowerCase()].filter(Boolean);\n})();\n\nconst filterCommon = intent.common_only;\nconst groupBy = intent.group_by;\n\n// =============================\n// Apply Filters\n// =============================\n\nconst filtered = expenseItems.filter(row => {\n\n  if (!row.date) return false;\n\n  const rowDate = normalizeDate(row.date);\n  if (!rowDate) return false;\n\n  if (startDate && rowDate < startDate) return false;\n  if (endDate && rowDate > endDate) return false;\n\n  if (filterPerson && row.person !== filterPerson) return false;\n\n  if (filterCategories.length > 0) {\n    if (!row.category) return false;\n    if (!filterCategories.includes(row.category)) return false;\n  }\n\n  if (filterCommon !== null && filterCommon !== undefined) {\n\n    const isCommon =\n      String(row.common_expense).toLowerCase() === 'true' ||\n      row.common_expense === true ||\n      row.common_expense === 1;\n\n    if (filterCommon && !isCommon) return false;\n    if (!filterCommon && isCommon) return false;\n  }\n\n  return true;\n\n});\n\n// =============================\n// Aggregations\n// =============================\n\nconst total = filtered.reduce((sum, row) => sum + row.amount, 0);\n\n// Category breakdown\nlet categoryBreakdown = null;\n\nif (groupBy === \"category\") {\n\n  const breakdown = {};\n\n  filtered.forEach(row => {\n\n    const cat = row.category || \"uncategorized\";\n\n    breakdown[cat] = (breakdown[cat] || 0) + row.amount;\n\n  });\n\n  categoryBreakdown = breakdown;\n}\n\n// Person breakdown\nlet personBreakdown = null;\n\nif (groupBy === \"person\") {\n\n  const breakdown = {};\n\n  filtered.forEach(row => {\n\n    const person = row.person || \"unknown\";\n\n    breakdown[person] = (breakdown[person] || 0) + row.amount;\n\n  });\n\n  personBreakdown = breakdown;\n}\n\n// =============================\n// Return Result\n// =============================\n\nreturn [{\n  json: {\n    intent,\n    total: Math.round(total * 100) / 100,\n    count: filtered.length,\n    categoryBreakdown,\n    personBreakdown,\n    period: {\n      start: formattedStart,\n      end: formattedEnd\n    },\n    filters: {\n      person: intent.person,\n      category: intent.category,\n      common_only: intent.common_only\n    }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "b24a37e4-d82f-43e6-b343-e91f7f838d19",
      "name": "JS | Format Response Message",
      "type": "n8n-nodes-base.code",
      "position": [
        11056,
        1264
      ],
      "parameters": {
        "jsCode": "const data = $input.first().json;\nconst filters = data.filters || {};\nconst period = data.period || {};\n\nlet lines = [];\n\n// =============================\n// Header\n// =============================\n\nlines.push('\ud83d\udcca *Expense Summary*');\nlines.push('');\n\n// =============================\n// Period Display (NEW)\n// =============================\n\nif (period.start || period.end) {\n  const from = period.start || 'Beginning';\n  const to = period.end || 'Today';\n  lines.push(`\ud83d\udcc5 Period: ${from} \u2192 ${to}`);\n} else {\n  lines.push(`\ud83d\udcc5 Period: All time`);\n}\n\n// =============================\n// Applied filters\n// =============================\n\nif (filters.person) lines.push(`\ud83d\udc64 Person: ${filters.person}`);\nif (filters.category) lines.push(`\ud83c\udff7\ufe0f Category: ${filters.category}`);\n\nif (filters.common_only !== null && filters.common_only !== undefined) {\n  lines.push(`\ud83d\udd17 Common expenses only: ${filters.common_only ? 'Yes' : 'No'}`);\n}\n\nlines.push('');\nlines.push(`\ud83d\udcb0 *Total: \u20ac${data.total.toFixed(2)}*`);\nlines.push(`\ud83d\udcdd Transactions: ${data.count}`);\n\n// =============================\n// Category breakdown\n// =============================\n\nif (data.categoryBreakdown) {\n  lines.push('');\n  lines.push('*By Category:*');\n\n  const sorted = Object.entries(data.categoryBreakdown)\n    .sort((a, b) => b[1] - a[1]);\n\n  sorted.forEach(([cat, amt]) => {\n    const pct = data.total > 0\n      ? ((amt / data.total) * 100).toFixed(1)\n      : 0;\n\n    lines.push(`  \u2022 ${cat}: $${amt.toFixed(2)} (${pct}%)`);\n  });\n}\n\n// =============================\n// Person breakdown\n// =============================\n\nif (data.personBreakdown) {\n  lines.push('');\n  lines.push('*By Person:*');\n\n  const sorted = Object.entries(data.personBreakdown)\n    .sort((a, b) => b[1] - a[1]);\n\n  sorted.forEach(([person, amt]) => {\n    const pct = data.total > 0\n      ? ((amt / data.total) * 100).toFixed(1)\n      : 0;\n\n    lines.push(`  \u2022 ${person}: $${amt.toFixed(2)} (${pct}%)`);\n  });\n}\n\n// =============================\n// Comparison placeholder\n// =============================\n\nif (data.comparison) {\n  lines.push('');\n  lines.push(`\ud83d\udcc8 Comparison: [Feature coming soon]`);\n}\n\n// =============================\n// Empty state\n// =============================\n\nif (data.count === 0) {\n  lines.push('');\n  lines.push('_No expenses found for the given filters._');\n}\n\nreturn [{ json: { message: lines.join('\\n') } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "c7dc9c2c-8bc0-4b0a-a988-27a966b3f814",
      "name": "TG | Send Reply",
      "type": "n8n-nodes-base.telegram",
      "position": [
        11312,
        1264
      ],
      "parameters": {
        "text": "={{ $json.message }}",
        "chatId": "={{ $('MSG | Telegram Inbound').first().json.message.chat.id }}",
        "additionalFields": {
          "parse_mode": "Markdown",
          "appendAttribution": false,
          "reply_to_message_id": "={{ $('MSG | Telegram Inbound').first().json.message.message_id }}"
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "4876799b-af52-4218-80aa-7603258ff084",
      "name": "MERGE | Combine Expenses + Categories",
      "type": "n8n-nodes-base.merge",
      "position": [
        10144,
        1264
      ],
      "parameters": {
        "mode": "chooseBranch"
      },
      "typeVersion": 3.2
    },
    {
      "id": "db13a254-5fde-42e4-9e82-2060a04451f0",
      "name": "IF | User Authorized?",
      "type": "n8n-nodes-base.if",
      "position": [
        2528,
        1072
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "or",
          "conditions": [
            {
              "id": "0f85f7d5-8b7e-4bcd-ae10-d7a1785192c4",
              "operator": {
                "type": "number",
                "operation": "equals"
              },
              "leftValue": "={{ $json.message.chat.id }}",
              "rightValue": 1000000000
            },
            {
              "id": "2d18f8cf-2910-4e20-b2f3-16a9ef04e63e",
              "operator": {
                "type": "number",
                "operation": "equals"
              },
              "leftValue": "={{ $json.message.chat.id }}",
              "rightValue": 1000000001
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "7a76b7d6-f533-4785-b822-8d7ac464d883",
      "name": "SPLIT | Split Categories",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        4016,
        768
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "category"
      },
      "typeVersion": 1
    },
    {
      "id": "8e963faf-3079-447d-bfd9-89eff8da207e",
      "name": "GS | Read Category Mapping",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        4016,
        1072
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "categories_mapping"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "e992cd5e-ba4e-4ab5-b885-14c55546d0a3",
      "name": "MERGE | Join Categories with Mapping",
      "type": "n8n-nodes-base.merge",
      "position": [
        4272,
        768
      ],
      "parameters": {
        "mode": "combineBySql",
        "query": "SELECT * FROM input1 LEFT JOIN input2 ON input1.category = input2.find",
        "options": {}
      },
      "typeVersion": 3.2
    },
    {
      "id": "58c889ef-e918-43d4-a7a3-1a47bf890ffc",
      "name": "TG | Confirm Category Suggestion",
      "type": "n8n-nodes-base.telegram",
      "position": [
        6224,
        368
      ],
      "parameters": {
        "chatId": "={{ $('MSG | Telegram Inbound').item.json.message.chat.id }}",
        "message": "=I couldn\u2019t find the category \"{{ $('IF | Category Known?').item.json.category_new }}\".\n\nDid you mean \"{{ $json.content }}\"?\n\n\u2705 Yes  \n\u274c No",
        "options": {
          "appendAttribution": false
        },
        "operation": "sendAndWait",
        "approvalOptions": {
          "values": {
            "approvalType": "double",
            "approveLabel": "\u2705 Yes",
            "disapproveLabel": "\u274c No"
          }
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "fd9c3d1c-223a-44d8-afbf-4773a11d09bd",
      "name": "GS | Read Allowed Categories",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        4464,
        1072
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "expense_categories"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "2f1f1de8-cae3-4792-920c-ba490986019e",
      "name": "SET | Normalize Category",
      "type": "n8n-nodes-base.set",
      "position": [
        4464,
        768
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "8f458013-1006-4634-a1c8-aedff820a48f",
              "name": "category_new",
              "type": "string",
              "value": "={{ $json.replace ?? $json.category }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "7a819214-50e5-460a-bd4d-a546eb68dc20",
      "name": "MERGE | Check Category against Allowed",
      "type": "n8n-nodes-base.merge",
      "position": [
        4720,
        768
      ],
      "parameters": {
        "mode": "combineBySql",
        "query": "SELECT * FROM input1\n  \nLEFT JOIN input2 ON input1.category_new = input2.category",
        "options": {}
      },
      "typeVersion": 3.2,
      "alwaysOutputData": false
    },
    {
      "id": "ed0de8c5-8c55-49f2-96a0-973b49b95d6a",
      "name": "IF | Category Known?",
      "type": "n8n-nodes-base.if",
      "position": [
        4960,
        768
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "e329be08-8c46-4898-9480-5946735ad54d",
              "operator": {
                "type": "string",
                "operation": "notExists",
                "singleValue": true
              },
              "leftValue": "={{ $json.description }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3,
      "alwaysOutputData": false
    },
    {
      "id": "9ecd305a-5e42-43f1-91fa-21fbb0d89e53",
      "name": "LLM | Classify Category",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        5712,
        368
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-nano",
          "cachedResultName": "GPT-4.1-NANO"
        },
        "options": {},
        "messages": {
          "values": [
            {
              "role": "system",
              "content": "=You are a strict category classification system.\n\nYour task:\nMap the given input category to exactly ONE category from the allowed category list.\n\nRules:\n- You MUST select one category from the allowed list.\n- Do NOT invent new categories.\n- Do NOT return explanations.\n- Do NOT return multiple categories.\n- Output ONLY the exact category name from the allowed list.\n- Matching must be case-insensitive.\n- Choose the closest semantic match if there is no exact match.\n\nInput category:\n{{ $('IF | Category Known?').item.json.category_new }}\n\nAllowed categories:\n{{ $json.category }}"
            }
          ]
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.4
    },
    {
      "id": "73085ca0-da81-4f8f-a232-5680c7d88b88",
      "name": "MERGE | Combine Categories with List",
      "type": "n8n-nodes-base.merge",
      "position": [
        5216,
        672
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "combineBy": "combineAll"
      },
      "typeVersion": 3.2
    },
    {
      "id": "19fe45dc-435b-4e70-9b7a-cd7c0bedd41e",
      "name": "SET | Extract LLM Category",
      "type": "n8n-nodes-base.set",
      "position": [
        5968,
        368
      ],
      "parameters": {
        "mode": "raw",
        "options": {},
        "jsonOutput": "={{ $json.message }}"
      },
      "typeVersion": 3.4
    },
    {
      "id": "20b22d0e-35bf-476f-9aba-e2abb14f0377",
      "name": "IF | Category Suggestion Accepted?",
      "type": "n8n-nodes-base.if",
      "position": [
        6464,
        368
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "4fb343c7-9eb2-4491-b017-3637fb543564",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.data.approved }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "1d692432-6601-47f1-9bdd-cafd50aeef5c",
      "name": "GS | Save Category Mapping",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        7968,
        368
      ],
      "parameters": {
        "columns": {
          "value": {
            "find": "={{ $json.old }}",
            "replace": "={{ $json.new }}"
          },
          "schema": [
            {
              "id": "find",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "find",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "replace",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "replace",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "categories_mapping"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "750a9069-a57b-4b46-8725-42367602aad2",
      "name": "MERGE | Loop Entry (Category)",
      "type": "n8n-nodes-base.merge",
      "position": [
        5712,
        768
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "20498665-76a1-45b8-978f-e92e0fc19ed1",
      "name": "SET | Set Resolved Category",
      "type": "n8n-nodes-base.set",
      "position": [
        5968,
        768
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "47a00b45-f580-4568-9913-b2359e1c1d8c",
              "name": "category_new",
              "type": "string",
              "value": "={{ $json.replace ?? $('IF | Category Known?').item.json.category_new}}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "e6824c4c-a839-40d0-b334-40f19aad19d0",
      "name": "SET | Assemble Resolved Intent",
      "type": "n8n-nodes-base.set",
      "position": [
        9472,
        1264
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "dc8b496a-4e1a-4ba3-9180-61b77a616b99",
              "name": "intent",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.intent }}"
            },
            {
              "id": "1e76280f-f49b-4225-ba90-fccc0ba3d9f2",
              "name": "time_reference",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.time_reference }}"
            },
            {
              "id": "1b10b1a5-e35d-4e13-8a28-945271eb4cd6",
              "name": "explicit_start_date",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.explicit_start_date }}"
            },
            {
              "id": "51c694b2-c10b-485d-8e1e-dedd797308a9",
              "name": "explicit_end_date",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.explicit_end_date }}"
            },
            {
              "id": "56a2b084-547f-412f-b534-4c2a97ec52c8",
              "name": "person",
              "type": "string",
              "value": "={{ $json.person_new }}"
            },
            {
              "id": "aa1b02a7-6908-4ba0-b8c2-a74b15516b40",
              "name": "category",
              "type": "array",
              "value": "={{ $json.category_new }}"
            },
            {
              "id": "4bd911ba-a8e3-423c-aa2e-eab59e16a33a",
              "name": "common_only",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.common_only }}"
            },
            {
              "id": "720e91ea-fc42-48b4-af78-1853736d8720",
              "name": "comparison",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.comparison }}"
            },
            {
              "id": "27656405-d197-4f6e-b1d8-f90a72351f20",
              "name": "group_by",
              "type": "string",
              "value": "={{ $('JS | Extract Intent JSON').item.json.group_by }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "cc31446d-2ffb-4411-afd7-64e69182b448",
      "name": "AGG | Aggregate Category List",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        4720,
        1072
      ],
      "parameters": {
        "options": {},
        "fieldsToAggregate": {
          "fieldToAggregate": [
            {
              "fieldToAggregate": "category"
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "6f2ace60-279f-40ce-8459-53a13a603748",
      "name": "LOOP | Iterate Categories",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        5472,
        768
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "165edc36-f6bd-4231-a1e4-bd7c7cd4ecea",
      "name": "SET | Set New+Old Category (LLM)",
      "type": "n8n-nodes-base.set",
      "position": [
        7216,
        368
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "5f52894c-c285-46f0-822d-44665a58555f",
              "name": "new",
              "type": "string",
              "value": "={{ $('SET | Extract LLM Category').item.json.content }}"
            },
            {
              "id": "e02f6b7f-c27d-4620-84e4-346dcb5f26f0",
              "name": "old",
              "type": "string",
              "value": "={{ $('LOOP | Iterate Categories').item.json.category_new }}"
            }
          ]
        }
      },
      "typeVersion": 3.4,
      "alwaysOutputData": true
    },
    {
      "id": "8294d338-81df-4799-8d83-335699eb38d9",
      "name": "JS | Build Category Inline Buttons",
      "type": "n8n-nodes-base.code",
      "position": [
        6720,
        176
      ],
      "parameters": {
        "jsCode": "const items = $('AGG | Aggregate Category List').first().json.category;\nif (!Array.isArray(items)) {\n  throw new Error('Expected category to be an array');\n}\n\nconst buttons = items.map(item => ([{\n  text: String(item),\n  callback_data: String(item)\n}]));\n\nreturn [{\n  json: {\n    inline_keyboard: buttons,\n    resumeUrl: $execution.resumeUrl\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "e7a92e17-a097-4e67-8ba0-aa2073aeefb1",
      "name": "WAIT | Wait for Category Selection",
      "type": "n8n-nodes-base.wait",
      "position": [
        7216,
        176
      ],
      "parameters": {
        "resume": "webhook",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 1.1
    },
    {
      "id": "d5c9afc3-2245-4a93-9c66-38edc4c0f4a2",
      "name": "HTTP | Send Category Selection Message",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        6960,
        176
      ],
      "parameters": {
        "url": "https://api.telegram.org/bot{{YOUR_BOT_TOKEN}}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": \"{{ $('MSG | Telegram Inbound').item.json.message.chat.id }}\",\n  \"text\": \"Bitte w\u00e4hle eine Kategorie:<a href='{{ $json.resumeUrl }}'>\u200b</a>\",\n  \"parse_mode\": \"HTML\",\n  \"reply_markup\": {{ JSON.stringify({ \"inline_keyboard\": $json.inline_keyboard }) }}\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.3
    },
    {
      "id": "33dba25d-9b17-4da1-8463-92dbad2c0ff3",
      "name": "SET | Read Callback Body (Cat.)",
      "type": "n8n-nodes-base.set",
      "position": [
        7472,
        176
      ],
      "parameters": {
        "mode": "raw",
        "options": {},
        "jsonOutput": "={{ $json.body}}"
      },
      "typeVersion": 3.4
    },
    {
      "id": "e11d27e9-66ea-4cde-a108-3c38b5fea75b",
      "name": "SET | Set New+Old Category (Selection)",
      "type": "n8n-nodes-base.set",
      "position": [
        7472,
        368
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "e34ab1c9-3985-432b-9637-6a695fbf5931",
              "name": "new",
              "type": "string",
              "value": "={{ $json.category }}"
            },
            {
              "id": "b3f6ce98-c6bc-4fea-a581-d1abcd087df4",
              "name": "old",
              "type": "string",
              "value": "={{ $('LOOP | Iterate Categories').item.json.category_new }}"
            }
          ]
        }
      },
      "typeVersion": 3.4,
      "alwaysOutputData": true
    },
    {
      "id": "e765fbf0-81a6-4117-9fbf-8c5c4d0f6c90",
      "name": "MERGE | Combine Category Mapping Entries",
      "type": "n8n-nodes-base.merge",
      "position": [
        7712,
        368
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "dada58e6-2abc-4cec-84c2-4bd92994e796",
      "name": "HTTP | Forward Category Selection",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3312,
        496
      ],
      "parameters": {
        "url": "={{ $json.resumeUrl }}",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"category\": \"{{ $json.category }}\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.3
    },
    {
      "id": "76197f7e-8f0f-46d5-84b4-c9c6bb735668",
      "name": "TG | Confirm Category Selection",
      "type": "n8n-nodes-base.telegram",
      "position": [
        3504,
        496
      ],
      "parameters": {
        "text": "=Du hast \" {{ $('MSG | Telegram Inbound').item.json.callback_query.data }}\" ausgew\u00e4hlt\"",
        "chatId": "={{ $('MSG | Telegram Inbound').item.json.callback_query.message.chat.id }}",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "883bf86d-3fac-461a-a9b7-dcc0018eb20f",
      "name": "JS | Read Category Callback",
      "type": "n8n-nodes-base.code",
      "position": [
        3104,
        496
      ],
      "parameters": {
        "jsCode": "const callbackData = $json.callback_query.data;\nconst text = $json.callback_query.message.text;\nconst entities = $json.callback_query.message.entities;\n\n// URL aus den message entities auslesen\nconst urlEntity = entities.find(e => e.type === 'text_link');\nconst resumeUrl = urlEntity.url;\n\nreturn [{\n  json: {\n    category: callbackData,\n    resumeUrl: resumeUrl\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "0e0c0fc1-c577-4e31-a62e-2bef9ef22815",
      "name": "IF | Message or Callback?",
      "type": "n8n-nodes-base.if",
      "position": [
        2352,
        1088
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "69655a65-f86e-4666-8f8f-d9cc2fda74b2",
              "operator": {
                "type": "object",
                "operation": "exists",
                "singleValue": true
              },
              "leftValue": "={{ $json.message }}",
              "rightValue": "="
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "4233a9ba-e3a9-49c7-8f07-1d36fa3fec81",
      "name": "IF | Category Present?",
      "type": "n8n-nodes-base.if",
      "position": [
        3824,
        976
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "b875ecb6-7b43-4083-8591-bec82fb6a22b",
              "operator": {
                "type": "string",
                "operation": "notExists",
                "singleValue": true
              },
              "leftValue": "={{ $json.category[0] }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "449ced93-3de6-4bf4-8e71-cb0acb48a43e",
      "name": "MERGE | Combine Category + Person",
      "type": "n8n-nodes-base.merge",
      "position": [
        8832,
        1248
      ],
      "parameters": {
        "numberInputs": 3
      },
      "typeVersion": 3.2
    },
    {
      "id": "f98b58b7-483e-495b-95c1-aa5a9fa457c4",
      "name": "AGG | Aggregate Resolved Categories",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        6224,
        768
      ],
      "parameters": {
        "options": {},
        "fieldsToAggregate": {
          "fieldToAggregate": [
            {
              "fieldToAggregate": "category_new"
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "50f4587d-754a-4bb3-888b-b6e084ee6237",
      "name": "IF | Person Present?",
      "type": "n8n-nodes-base.if",
      "position": [
        3760,
        2096
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "b875ecb6-7b43-4083-8591-bec82fb6a22b",
              "operator": {
                "type": "string",
                "operation": "notExists",
                "singleValue": true
              },
              "leftValue": "={{ $json.person }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "85f203b8-bdf7-4346-aceb-30a8388dc4cf",
      "name": "SPLIT | Split Persons",
      "type": "n8n-nodes-base.splitOut",
      "position": [
        3968,
        2064
      ],
      "parameters": {
        "options": {},
        "fieldToSplitOut": "person"
      },
      "typeVersion": 1
    },
    {
      "id": "7657a339-c559-4327-bc25-5fda098783f3",
      "name": "MERGE | Join Persons with Mapping",
      "type": "n8n-nodes-base.merge",
      "position": [
        4224,
        2064
      ],
      "parameters": {
        "mode": "combineBySql",
        "query": "SELECT * FROM input1 LEFT JOIN input2 ON input1.category = input2.find",
        "options": {}
      },
      "typeVersion": 3.2
    },
    {
      "id": "286c2311-d948-4dd0-92bb-81231cb895ea",
      "name": "SET | Normalize Person",
      "type": "n8n-nodes-base.set",
      "position": [
        4416,
        2064
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "8f458013-1006-4634-a1c8-aedff820a48f",
              "name": "person_new",
              "type": "string",
              "value": "={{ $json.replace ?? $json.person }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "fa32c88e-fb58-483a-8159-bc68edbeebcd",
      "name": "MERGE | Check Person against Allowed",
      "type": "n8n-nodes-base.merge",
      "position": [
        4704,
        2048
      ],
      "parameters": {
        "mode": "combineBySql",
        "query": "SELECT * FROM input1\n  \nLEFT JOIN input2 ON input1.person_new = input2.person",
        "options": {}
      },
      "typeVersion": 3.2,
      "alwaysOutputData": false
    },
    {
      "id": "8ed005b0-aa2a-4052-868a-424d6df8d83e",
      "name": "IF | Person Known?",
      "type": "n8n-nodes-base.if",
      "position": [
        4944,
        2048
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "e329be08-8c46-4898-9480-5946735ad54d",
              "operator": {
                "type": "string",
                "operation": "notExists",
                "singleValue": true
              },
              "leftValue": "={{ $json.description }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3,
      "alwaysOutputData": false
    },
    {
      "id": "2f3cf76a-d852-4479-a91a-4d93bdcb8e28",
      "name": "MERGE | Combine Persons with List",
      "type": "n8n-nodes-base.merge",
      "position": [
        5200,
        2048
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "combineBy": "combineAll"
      },
      "typeVersion": 3.2
    },
    {
      "id": "9db7a679-849e-46a4-8c12-9c9296e00d4c",
      "name": "AGG | Aggregate Person List",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        4912,
        2352
      ],
      "parameters": {
        "options": {},
        "fieldsToAggregate": {
          "fieldToAggregate": [
            {
              "fieldToAggregate": "person"
            }
          ]
        }
      },
      "typeVersion": 1
    },
    {
      "id": "82d59267-2594-498f-863b-f35116802032",
      "name": "GS | Read Person Mapping",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        3968,
        2368
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "person_mapping"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "16747ce4-9d18-438d-a8df-df00508908c1",
      "name": "GS | Read Allowed Persons",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        4416,
        2368
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 1157427829,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "list_persons"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "person_mapping"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "fa8b6b35-8747-40b8-9639-5131f279c0cb",
      "name": "TG | Confirm Person Suggestion",
      "type": "n8n-nodes-base.telegram",
      "position": [
        6208,
        2448
      ],
      "parameters": {
        "chatId": "={{ $('MSG | Telegram Inbound').item.json.message.chat.id }}",
        "message": "=I couldn\u2019t find the person \"{{ $('IF | Person Known?').item.json.person_new }}\".\n\nDid you mean \"{{ $json.content }}\"?\n\n\u2705 Yes  \n\u274c No",
        "options": {
          "appendAttribution": false
        },
        "operation": "sendAndWait",
        "approvalOptions": {
          "values": {
            "approvalType": "double",
            "approveLabel": "\u2705 Yes",
            "disapproveLabel": "\u274c No"
          }
        }
      },
      "credentials": {
        "telegramApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "fa9fc8ee-c15e-4f4f-9af2-8a5dd626d788",
      "name": "LLM | Classify Person",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        5696,
        2448
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-nano",
          "cachedResultName": "GPT-4.1-NANO"
        },
        "options": {},
        "messages": {
          "values": [
            {
              "role": "system",
              "content": "=You are a strict person classification system.\n\nYour task:\nMap the given input name to exactly ONE person from the allowed person list.\n\nRules:\n\n* You MUST select one person from the allowed list.\n* Do NOT invent new persons.\n* Do NOT return explanations.\n* Do NOT return multiple persons.\n* Output ONLY the exact person name from the allowed list.\n* Matching must be case-insensitive.\n* Choose the closest semantic match if there is no exact match.\n\nInput name:\n{{ $('IF | Person Known?').item.json.person_new }}\n\nAllowed persons:\n{{ $json.person }}\n"
            }
          ]
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.4
    },
    {
      "id": "ef710337-260f-495c-97aa-7ccb5420b7dc",
      "name": "SET | Extract LLM Person",
      "type": "n8n-nodes-base.set",
      "position": [
        5952,
        2448
      ],
      "parameters": {
        "mode": "raw",
        "options": {},
        "jsonOutput": "={{ $json.message }}"
      },
      "typeVersion": 3.4
    },
    {
      "id": "fd5713f9-9501-420f-9d12-bb8b84fda620",
      "name": "IF | Person Suggestion Accepted?",
      "type": "n8n-nodes-base.if",
      "position": [
        6448,
        2448
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "4fb343c7-9eb2-4491-b017-3637fb543564",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.data.approved }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "262227e8-5564-4caf-a833-edc83bd36894",
      "name": "GS | Save Person Mapping",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        8176,
        1904
      ],
      "parameters": {
        "columns": {
          "value": {
            "find": "={{ $json.old }}",
            "replace": "={{ $json.new }}"
          },
          "schema": [
            {
              "id": "find",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "find",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "replace",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "replace",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "Sheet1"
        },
        "documentId": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_SPREADSHEET_ID",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID",
          "cachedResultName": "person_mapping"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "e6850bcd-ec85-4a14-ae06-73878206a278",
      "name": "LOOP | Iterate Persons",
      "type": "n8n-nodes-base.splitInBatches",
      "position": [
        5456,
        2048
      ],
      "parameters": {
        "options": {}
      },
      "typeVersion": 3
    },
    {
      "id": "741e158c-14ed-4ff1-aba3-27b0f648ab20",
      "name": "SET | Set New+Old Person (LLM)",
      "type": "n8n-nodes-base.set",
      "position": [
        7664,
        2304
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "5f52894c-c285-46f0-822d-44665a58555f",
              "name": "new",
              "type": "string",
              "value": "={{ $('SET | Extract LLM Person').item.json.content }}"
            },
            {
              "id": "e02f6b7f-c27d-4620-84e4-346dcb5f26f0",
              "name": "old",
              "type": "string",
              "value": "={{ $('LOOP | Iterate Persons').item.json.person_new }}"
            }
          ]
        }
      },
      "typeVersion": 3.4,
      "alwaysOutputData": true
    },
    {
      "id": "41280f1e-9534-4947-908a-41a7caef3397",
      "name": "JS | Build Person Inline Buttons",
      "type": "n8n-nodes-base.code",
      "position": [
        6672,
        1712
      ],
      "parameters": {
        "jsCode": "const items = $('AGG | Aggregate Person List').first().json.person;\n\nif (!Array.isArray(items)) {\n  throw new Error('Expected person to be an array');\n}\n\nconst buttons = items.map(item => ([{\n  text: String(item),\n  callback_data: String(item)\n}]));\n\nreturn [{\n  json: {\n    inline_keyboard: buttons,\n    resumeUrl: $execution.resumeUrl\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "5f9c0563-ee99-4147-9081-f499af2bb507",
      "name": "WAIT | Wait for Person Selection",
      "type": "n8n-nodes-base.wait",
      "position": [
        7168,
        1712
      ],
      "parameters": {
        "resume": "webhook",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 1.1
    },
    {
      "id": "5b86154e-57b0-4b2d-a7d7-1e11cc878b67",
      "name": "HTTP | Send Person Selection Message",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        6912,
        1712
      ],
      "parameters": {
        "url": "https://api.telegram.org/bot{{YOUR_BOT_TOKEN}}/sendMessage",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"chat_id\": \"{{ $('MSG | Telegram Inbound').item.json.message.chat.id }}\",\n  \"text\": \"Bitte w\u00e4hle eine Person:<a href='{{ $json.resumeUrl }}'>\u200b</a>\",\n  \"parse_mode\": \"HTML\",\n  \"reply_markup\": {{ JSON.stringify({ \"inline_keyboard\": $json.inline_keyboard }) }}\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.3
    },
    {
      "id": "b879f1c5-ffb3-45b6-b7f5-1b772b431643",
      "name": "SET | Read Callback Body (Person)",
      "type": "n8n-nodes-base.set",
      "position": [
        7424,
        1712
      ],
      "parameters": {
        "mode": "raw",
        "options": {},
        "jsonOutput": "={{ $json.body}}"
      },
      "typeVersion": 3.4
    },
    {
      "id": "1a9aa8de-b20f-4cac-9fde-9d889caee19e",
      "name": "SET | Set New+Old Person (Selection)",
      "type": "n8n-nodes-base.set",
      "position": [
        7664,
        1712
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "e34ab1c9-3985-432b-9637-6a695fbf5931",
              "name": "new",
              "type": "string",
              "value": "={{ $json.person }}"
            },
            {
              "id": "b3f6ce98-c6bc-4fea-a581-d1abcd087df4",
              "name": "old",
              "type": "string",
              "value": "={{ $('LOOP | Iterate Persons').item.json.person_new }}"
            }
          ]
        }
      },
      "typeVersion": 3.4,
      "alwaysOutputData": true
    },
    {
      "id": "205a6d3a-9108-4b8b-9698-5ba8db844f88",
      "name": "MERGE | Combine Person Mapping Entries",
      "type": "n8n-nodes-base.merge",
      "position": [
        7920,
        1904
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "eaee6930-3da6-40a9-ba0f-84ab8543b451",
      "name": "MERGE | Loop Entry (Person)",
      "type": "n8n-nodes-base.merge",
      "position": [
        5696,
        2256
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "e2241bb1-eae6-4311-909c-a67bb2bd52fe",
      "name": "SET | Set Resolved Person",
      "type": "n8n-nodes-base.set",
      "position": [
        6208,
        2256
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "47a00b45-f580-4568-9913-b2359e1c1d8c",
              "name": "person_new",
              "type": "string",
              "value": "={{ $json.replace ?? $('IF | Person Known?').item.json.person_new}}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "9a37179d-2d3c-48e3-9ce1-1624b626b6fd",
      "name": "MERGE | Combine Person Mapping",
      "type": "n8n-nodes-base.merge",
      "position": [
        6448,
        2048
      ],
      "parameters": {},
      "typeVersion": 3.2
    },
    {
      "id": "183d2e80-d18b-403e-a3f0-86d41fd0c0b1",
      "name": "IF | Category or Person Callback?",
      "type": "n8n-nodes-base.if",
      "position": [
        2816,
        576
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "a6650d78-63a0-4bc4-ac46-e076eba14def",
              "operator": {
                "type": "string",
                "operation": "contains"
              },
              "leftValue": "{{json.message.text}}",
              "rightValue": "Kategorie"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "0ac9e7b3-a794-4a42-b48b-1d42ba317bbc",
      "name": "HTTP | Forward Person Selection",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3312,
        688
      ],
      "parameters": {
        "url": "={{ $json.resumeUrl }}",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"person\": \"{{ $json.person }}\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.3
    },
    {
      "id": "36db9da8-b32c-4b48-b7d4-6871eed49cb0",
      "name": "TG | Confirm Person Selection",
      "type": "n8n-nodes-base.telegram",
      "position": [
        3504,
        688
      ],
      "parameters"

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

How this works

This workflow lets you ask natural-language questions about your spending and receive clear answers straight in Telegram. It pulls live data from Google Sheets, uses GPT-4.1 to interpret the query and decide which rows to analyse, then returns a concise summary or breakdown. The core step is the intent-parsing call to OpenAI that turns your message into structured instructions for the subsequent filtering and aggregation.

Use it for quick personal or small-team checks on ad-hoc queries rather than scheduled reporting. Skip it if you need audited totals or complex multi-sheet joins, as those are better handled in dedicated dashboards. A common variation replaces the Sheets source with another ledger while keeping the same Telegram front end.

About this workflow

Ask questions like “How much did I spend on food last month?” and get instant answers from your financial data — directly in Telegram.

Source: https://n8n.io/workflows/14051/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

&gt; ⚠️ Disclaimer: This workflow uses Community Nodes and must be run on a self-hosted instance of n8n.

HTTP Request, Telegram Trigger, Telegram +2
AI & RAG

Viral Tik Tok Clone Finder. Uses httpRequest, telegramTrigger, openAi, googleSheets. Event-driven trigger; 41 nodes.

HTTP Request, Telegram Trigger, OpenAI +2
AI & RAG

This workflow is designed for content creators, agencies, influencers, and automation builders who want to transform viral videos into personalized avatar-based edits — and automatically publish them

Telegram Trigger, Telegram, HTTP Request +3
AI & RAG

Send a target niche and location via Telegram message Workflow discovers businesses via Google Maps API AI enriches contacts with email and LinkedIn data via Serper GPT-4o scores and qualifies each le

Telegram Trigger, OpenAI, Google Sheets +3
AI & RAG

💥 Automate YouTube thumbnail creation from video links -vide. Uses telegramTrigger, httpRequest, googleDrive, gmail. Event-driven trigger; 25 nodes.

Telegram Trigger, HTTP Request, Google Drive +6