{
  "id": "",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "GitHub Knowledge Base to Telegram Bot",
  "tags": [],
  "nodes": [
    {
      "id": "093210cf-5e40-45ab-8be4-432cfab436d2",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -368,
        -736
      ],
      "parameters": {
        "width": 688,
        "height": 1456,
        "content": "## GitHub Knowledge Base \u2192 Telegram RAG Bot\n\nTurn a plain JSON file on GitHub into a Telegram chatbot with retrieval-augmented generation. No vector DB, no embeddings, no extra subscription.\n\nUser sends /ask <question> \u2192 bot pulls KB from GitHub \u2192 keyword match the top 2 chunks \u2192 send context + question to LLM \u2192 reply with a grounded answer.\n\n### How it works\n\n1. **Telegram Trigger** \u2014 Listens for messages starting with /ask.\n2. **Input Validation** \u2014 Rejects messages shorter than 7 characters (e.g. just \"/ask\" with no question). Replies with a usage hint so the user learns the format on the first try. Prevents unnecessary API calls downstream.\n3. **GitHub File Fetch** \u2014 Pulls kb.json from a GitHub repo using a Fine-grained Personal Access Token (read-only, scoped to the repo). If the file is missing, the token is expired, or the repo is unreachable, the user gets a clear error message instead of a silent crash.\n4. **Binary Decode & Parse** \u2014 Reads raw binary from the GitHub node, decodes to UTF-8, parses the JSON array. Each entry needs a \"text\" field (also accepts \"content\", \"answer\", \"title\").\n5. **Rough Keyword Match** \u2014 Splits the question into words, scores every KB entry by keyword overlap, returns the top 2. Intentionally simple \u2014 works well for small-to-medium KBs (up to a few hundred entries). No embedding model, no vector math, zero extra cost.\n6. **LLM Call (Qwen 3 via OpenRouter)** \u2014 Sends matched context + question to the model. Prompt instructs it to answer strictly from context. If nothing relevant is found, the model says so instead of guessing.\n7. **Output Cleanup** \u2014 Strips Qwen 3 thinking tags and any leftover XML. Falls back to a friendly message if the model returns empty.\n8. **Telegram Reply** \u2014 Sends the answer as a reply to the original message. Enforces Telegram 4000-char limit. Appends a branded footer.\n\n### Setup (5\u201310 min)\n\n- [ ] Create a Telegram Bot via @BotFather. Copy the token.\n- [ ] Create a GitHub Fine-grained PAT: Settings \u2192 Developer settings \u2192 Fine-grained tokens. Scope: read-only, select the repo that holds your KB file.\n- [ ] In n8n, add credentials: Telegram Bot API + GitHub API + OpenRouter (or OpenAI-compatible endpoint).\n- [ ] Edit the \"Fetch GitHub File\" node: set owner, repository, and filePath to match your repo.\n- [ ] Prepare your KB as a JSON array:\n```json\n[\n  { \"text\": \"your knowledge entry here\" },\n  { \"text\": \"another entry\" }\n]\n```\n- [ ] Activate the workflow. Send /ask <question> to your bot.\n\n### Customization\n\n- **Swap the LLM**: Change the model in the \"OpenAI Qwen Model\" node to any OpenRouter-supported model (GPT-4o, Claude, Gemini, etc.) or point to a self-hosted model by changing the base URL.\n- **More context**: Edit \"Perform Rough Match\" to return top 3 or top 5 chunks instead of 2. More chunks = better answers but higher token cost.\n- **Live updates**: Edit kb.json on GitHub. The workflow fetches fresh data on every request \u2014 no redeploy needed.\n- **Multi-language**: Works with any language in the KB. The LLM responds in the same language as the question.\n\n### What's next\n\nThis is a foundation. Here's where it can grow:\n- **Vector search** \u2014 Replace keyword match with embedding retrieval (OpenAI embeddings + Qdrant/Pinecone) for larger KBs.\n- **Multi-source** \u2014 Pull from multiple GitHub files, Notion DB, or Google Sheets.\n- **Memory** \u2014 Add a short-term conversation buffer so the bot remembers the last few messages.\n- **Web chat** \u2014 Add a Webhook trigger alongside Telegram to serve a chat widget on any website.\n- **Auto-sync** \u2014 Watch the GitHub file for changes and rebuild the index instead of fetching on every request.\n\nSimple means it runs cheap, breaks rarely, and is easy to debug. Complexity can be added when the use case demands it."
      },
      "typeVersion": 1
    },
    {
      "id": "90513a58-dc86-4271-8cfd-f7af0f598219",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        368,
        -736
      ],
      "parameters": {
        "color": 7,
        "width": 256,
        "height": 448,
        "content": "## Telegram Input\n\nWatches for any message that starts with /ask. The filter is set in the trigger conditions \u2014 only matching messages enter the workflow. Everything else is ignored silently.\n\nCredential needed: Telegram Bot API (same token across all Telegram nodes in this workflow)."
      },
      "typeVersion": 1
    },
    {
      "id": "754968f4-4054-4447-8410-e8a93bed3242",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        688,
        -736
      ],
      "parameters": {
        "color": 7,
        "width": 256,
        "height": 448,
        "content": "## Fetch GitHub File\n\nPulls kb.json from the configured repo using a Fine-grained Personal Access Token (read-only, scoped to this repo only).\n\nSet these fields:\n- **Owner**: your GitHub username (e.g. dothanhvinh17)\n- **Repository**: repo name (e.g. rag-kb)\n- **File Path**: kb.json (or path/to/kb.json if nested)\n\nThe node outputs binary data. If the file doesn't exist or the token is invalid, the error branch fires and the user gets a clear message instead of a silent failure."
      },
      "typeVersion": 1
    },
    {
      "id": "b2437c0c-18f7-41d7-a40d-a1bdd34064f7",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        720,
        384
      ],
      "parameters": {
        "color": 7,
        "width": 288,
        "height": 336,
        "content": "## Handle GitHub Errors\n\nWhen the GitHub node fails (token expired, file not found, repo deleted, network timeout), this branch catches it.\n\n\"Handle GitHub Error\" builds a user-friendly message with the chat ID and reply-to ID. \"Send GitHub Error\" delivers it to Telegram. The user always gets a response \u2014 never a silent crash."
      },
      "typeVersion": 1
    },
    {
      "id": "54f27d52-4c96-42a9-b1a8-a97dadd259bb",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        976,
        -736
      ],
      "parameters": {
        "color": 7,
        "width": 816,
        "height": 320,
        "content": "## Prepare AI Input\n\nTwo code nodes that turn raw GitHub binary into something the LLM can work with.\n\n**Initialize Variables** \u2014 Reads the binary output from GitHub, decodes it to UTF-8, parses the JSON array, and extracts the user's question from the Telegram message. If decoding or parsing fails, returns an error object so the user gets a message instead of a crash.\n\n**Perform Rough Match** \u2014 Splits the question into words, scores every KB entry by counting keyword overlap, sorts by score, and returns the top 2 matches as a single context string. Words shorter than 3 characters are skipped (filters out \"is\", \"a\", \"the\", etc.). This is a cheap retrieval method \u2014 no embeddings, no vector DB, no extra API calls. Works well for focused KBs under a few hundred entries."
      },
      "typeVersion": 1
    },
    {
      "id": "aa507b64-2b03-4acf-af13-d2a71539e0a9",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1872,
        -608
      ],
      "parameters": {
        "color": 7,
        "width": 560,
        "height": 272,
        "content": "## Clean AI Output\n\nThree nodes between the LLM call and the final Telegram send.\n\n**Format AI Output** \u2014 Strips Qwen 3 thinking tags ( &lt;think&gt;...&lt;/think&gt; ), removes any leftover XML-like tags, and falls back to a friendly message if the model returned nothing.\n\n**Check AI Output** \u2014 IF node. If the cleaned output is not empty \u2192 proceed to formatting. If empty \u2192 send an error message to the user via Telegram.\n\n**Send Telegram Error** \u2014 Delivers error messages from multiple failure points (LLM empty response, input too short). All error paths converge here so the user always gets feedback."
      },
      "typeVersion": 1
    },
    {
      "id": "058fa8b4-013d-4117-a465-d7cceb345c72",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2480,
        -608
      ],
      "parameters": {
        "color": 7,
        "width": 432,
        "height": 272,
        "content": "## Format & Send to Telegram\n\n**Build Telegram Message** \u2014 Takes the cleaned AI output, enforces the 3800-character limit (Telegram caps at 4096, buffer keeps it safe), capitalizes the first letter, and appends a branded footer.\n\n**Send Telegram Message** \u2014 Delivers the final answer as a reply to the user's original message so conversations stay threaded. appendAttribution is turned off to keep the message clean."
      },
      "typeVersion": 1
    },
    {
      "id": "68e40c61-7ab1-451c-b2b4-c34f201788ee",
      "name": "When Message Received",
      "type": "n8n-nodes-base.telegramTrigger",
      "position": [
        384,
        -272
      ],
      "parameters": {
        "updates": [
          {
            "trigger": "message",
            "conditions": {
              "string": [
                {
                  "value1": "/ask",
                  "operation": "startsWith"
                }
              ]
            }
          }
        ],
        "additionalFields": {}
      },
      "typeVersion": 1.2
    },
    {
      "id": "1ab24334-d04c-49e3-a1f1-9bb0731e0692",
      "name": "Validate Input",
      "type": "n8n-nodes-base.if",
      "position": [
        560,
        -272
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "check_len",
              "operator": {
                "type": "number",
                "operation": "largerThan"
              },
              "leftValue": "={{ $('When Message Received').item.json.message.text.length }}",
              "rightValue": 6
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "8dbd71ef-22be-403f-b3bd-a69a348bcd3d",
      "name": "Send Telegram Error",
      "type": "n8n-nodes-base.telegram",
      "position": [
        1008,
        0
      ],
      "parameters": {
        "text": "\u26a0\ufe0f Please use: /ask <your question>",
        "chatId": "={{ $('When Message Received').item.json.message.chat.id }}",
        "additionalFields": {}
      },
      "typeVersion": 1.2
    },
    {
      "id": "b48bf05d-8b67-4f14-a281-f886b3365768",
      "name": "Handle GitHub Error",
      "type": "n8n-nodes-base.code",
      "position": [
        752,
        208
      ],
      "parameters": {
        "jsCode": "let msg = 'Sorry, I could not load the knowledge base right now. Please try again later.';\nlet chatId = $('When Message Received').item.json.message.chat.id;\nlet replyTo = $('When Message Received').item.json.message.message_id;\nreturn [{ json: { err_msg: msg, chatId: chatId, replyTo: replyTo } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "b22b4dec-ae21-4590-a438-5d23505814c8",
      "name": "Send GitHub Error",
      "type": "n8n-nodes-base.telegram",
      "position": [
        1008,
        208
      ],
      "parameters": {
        "text": "={{ $json.err_msg }}",
        "chatId": "={{ $json.chatId }}",
        "additionalFields": {}
      },
      "typeVersion": 1.2
    },
    {
      "id": "cffd7406-b00a-4292-bb6d-53d9b60dc55b",
      "name": "Fetch GitHub File",
      "type": "n8n-nodes-base.github",
      "onError": "continueErrorOutput",
      "position": [
        768,
        -272
      ],
      "parameters": {
        "owner": {
          "__rl": true,
          "mode": "list",
          "value": "YOUR_GITHUB_USERNAME"
        },
        "filePath": "kb.json",
        "resource": "file",
        "operation": "get",
        "repository": {
          "__rl": true,
          "mode": "list",
          "value": "rag-kb"
        },
        "additionalParameters": {}
      },
      "typeVersion": 1.1
    },
    {
      "id": "56598892-6cfc-4133-a08c-dc05bfa2cdd6",
      "name": "Initialize Variables",
      "type": "n8n-nodes-base.code",
      "position": [
        1088,
        -400
      ],
      "parameters": {
        "jsCode": "const item = $input.first();\nlet content = '';\n\ntry {\n  const buf = await this.helpers.getBinaryDataBuffer(0, 'data');\n  content = buf.toString('utf8');\n} catch(e) {\n  return [{ json: { err_msg: 'binary read fail: ' + e.message } }];\n}\n\nlet kbArr = [];\ntry {\n  const parsed = JSON.parse(content);\n  kbArr = Array.isArray(parsed) ? parsed : [];\n} catch(e) {\n  return [{ json: { err_msg: 'json parse fail: ' + e.message } }];\n}\n\nconst rawText = $('When Message Received').item.json.message.text || '';\nconst question = rawText.replace('/ask ', '').trim();\n\nreturn [{ json: { kb_data: kbArr, question: question } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "22056c04-8ed8-42e9-9b85-1816c1827c82",
      "name": "Perform Rough Match",
      "type": "n8n-nodes-base.code",
      "position": [
        1280,
        -400
      ],
      "parameters": {
        "jsCode": "const input = $input.first().json;\n\nif (!input.question || !input.kb_data) {\n  return [{ json: { context: 'no data available', question: '' } }];\n}\n\nconst q = input.question.toLowerCase().split(' ');\nconst kb = input.kb_data;\n\nif (!Array.isArray(kb) || kb.length === 0) {\n  return [{ json: { context: 'empty db', question: input.question } }];\n}\n\nlet scored = kb.map(chunk => {\n  let score = 0;\n  let txt = (chunk.text || chunk.content || '').toLowerCase();\n\n  q.forEach(word => {\n    if (txt.includes(word) && word.length > 2) score++;\n  });\n\n  return { txt: txt, score: score };\n});\n\nscored.sort((a, b) => b.score - a.score);\n\nlet top = scored.slice(0, 2).map(s => s.txt).join('\\n---\\n');\n\nif (!top) top = 'no match';\n\nreturn [{ json: { context: top, question: input.question } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "79885bca-4d82-465e-986d-8a3444dfbabd",
      "name": "OpenAI Qwen Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        1488,
        -64
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "qwen/qwen3-235b-a22b-2507",
          "cachedResultName": "qwen3-235b"
        },
        "options": {},
        "builtInTools": {}
      },
      "typeVersion": 1.3
    },
    {
      "id": "767f32b2-51f1-41c9-bfcd-e34db31c9546",
      "name": "Request AI Assistance",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "onError": "continueErrorOutput",
      "position": [
        1488,
        -400
      ],
      "parameters": {
        "text": "=Answer the question using the context below. Be direct and concise. If the context has relevant information, use it.\n\nContext:\n{{ $json.context }}\n\nQuestion: {{ $json.question }}\n\nAnswer:",
        "batching": {},
        "promptType": "define"
      },
      "typeVersion": 1.9
    },
    {
      "id": "bae0e721-3a55-4baa-a2a4-b3abcb46f22f",
      "name": "Format AI Output",
      "type": "n8n-nodes-base.code",
      "position": [
        1984,
        -304
      ],
      "parameters": {
        "jsCode": "let output = '';\n\ntry {\n  output = $input.first().json.output || $input.first().json.text || '';\n} catch(e) {\n  output = '';\n}\n\noutput = output.replace(/&lt;think&gt;[\\s\\S]*?<\\/think>/gi, '').trim();\noutput = output.replace(/<[^>]+>/g, '').trim();\n\nif (!output) {\n  output = 'I could not generate an answer. Please try rephrasing your question.';\n}\n\nreturn [{ json: { output: output } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "f3a14f44-66c8-4e99-8878-0409e9a7e33b",
      "name": "Check AI Output",
      "type": "n8n-nodes-base.if",
      "position": [
        2192,
        -304
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "check_null",
              "operator": {
                "type": "string",
                "operation": "notEmpty"
              },
              "leftValue": "={{ $json.output }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "0b58d18e-35cf-4295-b738-1727935bd9e9",
      "name": "Build Telegram Message",
      "type": "n8n-nodes-base.code",
      "position": [
        2512,
        -320
      ],
      "parameters": {
        "jsCode": "let txt = $input.first().json.output || '';\ntxt = txt.replace(/<thinking>[\\s\\S]*?<\\/thinking>/gi, '').trim();\ntxt = txt.replace(/<\\/?[^>]+(>|$)/g, '').trim();\n\nif (txt.length > 3800) {\n  txt = txt.substring(0, 3797) + '...';\n}\n\nif (txt.length > 0) {\n  txt = txt.charAt(0).toUpperCase() + txt.slice(1);\n}\n\ntxt += '\\n\\n\u2014\\nPowered by Vinh Automation | vinhautomation.com/en';\n\nreturn [{ json: { final_text: txt } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "7f4cb898-c121-497c-8312-7d0d6531fb07",
      "name": "Send Telegram Message",
      "type": "n8n-nodes-base.telegram",
      "position": [
        2752,
        -320
      ],
      "parameters": {
        "text": "={{ $json.final_text }}",
        "chatId": "={{ $('When Message Received').item.json.message.chat.id }}",
        "additionalFields": {
          "appendAttribution": false
        }
      },
      "typeVersion": 1.2
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "",
  "connections": {
    "Validate Input": {
      "main": [
        [
          {
            "node": "Fetch GitHub File",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send Telegram Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check AI Output": {
      "main": [
        [
          {
            "node": "Build Telegram Message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send Telegram Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format AI Output": {
      "main": [
        [
          {
            "node": "Check AI Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch GitHub File": {
      "main": [
        [
          {
            "node": "Initialize Variables",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Handle GitHub Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Qwen Model": {
      "ai_languageModel": [
        [
          {
            "node": "Request AI Assistance",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Handle GitHub Error": {
      "main": [
        [
          {
            "node": "Send GitHub Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Perform Rough Match": {
      "main": [
        [
          {
            "node": "Request AI Assistance",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Initialize Variables": {
      "main": [
        [
          {
            "node": "Perform Rough Match",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Request AI Assistance": {
      "main": [
        [
          {
            "node": "Format AI Output",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send Telegram Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Message Received": {
      "main": [
        [
          {
            "node": "Validate Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Telegram Message": {
      "main": [
        [
          {
            "node": "Send Telegram Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}