Telegram bots that rely on a large language model to decide which action to take are becoming common for booking, support, or e‑commerce flows. The model receives the user’s text, chooses a tool such as get_order or create_booking, and the bot executes the corresponding Node.js function before returning a final answer. If the bot gives the model access to its Telegram token or skips validation of the tool calls, an attacker can manipulate the model to leak credentials or trigger unwanted actions. This guide walks through a complete Node.js implementation that keeps the token server‑side, defines a strict tool schema, and shows the failure cases that appear when those safeguards are omitted.
1. Project setup and the risky shortcut
Start with a fresh folder and install the dependencies we need: telegraf for the Telegram Bot API, openai (or any LLM provider) for chatting with the model, and dotenv to keep secrets out of source.
npm init -y
npm i telegraf openai dotenv
Create a .env file that holds only the values the server needs:
TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
OPENAI_API_KEY=sk-...
Now write a minimal bot that forwards every message to the model and blindly executes whatever tool name the model returns. This is the *failing* version: the bot puts the Telegram token into the system prompt so the model can “see” it, and it does not check that the tool name belongs to an allowed list.
// bot-unsafe.js
require('dotenv').config();
const { Telegraf } = require('telegraf');
const { Configuration, OpenAIApi } = require('openai');
const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN);
const openai = new OpenAIApi(new Configuration({ apiKey: process.env.OPENAI_API_KEY }));\n
// Dangerous: we give the model the bot token in the instructions
const SYSTEM_PROMPT = `You are a helpful assistant. You have access to two tools: get_order and create_booking.\nIf you need to call a tool, output a JSON object with the key \"tool_calls\" containing an array of objects. Each object must have \"name\" (the tool name) and \"arguments\" (a JSON string).\nYou also have the bot token: ${process.env.TELEGRAM_BOT_TOKEN}. Use it only if the tool requires it.`;
bot.on('text', async (ctx) => {
const userText = ctx.message.text;
try {
const completion = await openai.createChatCompletion({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userText }
],
temperature: 0
});
const reply = completion.data.choices[0].message.content;
// Assume the model returned a tool call in the format we described
let toolCall;
try {
toolCall = JSON.parse(reply);
} catch (_) {
await ctx.reply('I did not understand the request.');
return;
}
if (!toolCall.tool_calls || !Array.isArray(toolCall.tool_calls)) {
await ctx.reply('No tool call was returned.');
return;
}
for (const call of toolCall.tool_calls) {
// No validation of call.name – we just try to run it
if (call.name === 'get_order') {
const args = JSON.parse(call.arguments);
const result = await getOrder(args.order_id); // function defined later
await ctx.reply(JSON.stringify(result));
} else if (call.name === 'create_booking') {
const args = JSON.parse(call.arguments);
const result = await createBooking(args);
await ctx.reply(JSON.stringify(result));
} else {
// If the model hallucinated a tool name we still try to call it
// This is where an attacker could invoke arbitrary code
await ctx.reply(`Unknown tool: ${call.name}`);
}
}
} catch (err) {
console.error(err);
await ctx.reply('Something went wrong.');
}
});
bot.launch();
// Stub implementations – in a real app they would talk to a DB
async function getOrder(orderId) { return { order_id: orderId, status: 'shipped' }; }
async function createBooking(data) { return { booking_id: Math.random().toString(36).substr(2,9), ...data }; }
What goes wrong - The model receives the bot token in the system prompt. A clever user can ask the model to "repeat the token" or to use it in a fabricated tool call, leading to token leakage. - The bot does not verify that call.name is one of the allowed tools. If the model is prompted to output a name like sendMessage (a real Telegram method) the bot will try to execute it, potentially causing unwanted side effects. - No idempotency or error handling: a network glitch could cause the same tool to be run twice, creating duplicate bookings.
2. Defining a strict tool schema and keeping the token out of the model’s view
The fix is to treat the LLM as a *planner* only: it decides which tool to call and supplies the arguments, but it never sees the bot token or any internal secrets. We also give the model a JSON schema that limits the tool names and validates the argument shapes.
First, install zod for runtime validation (optional but helpful).
npm i zod
Now create a file tools.js that exports the schema and the handler functions.
// tools.js
const { z } = require('zod');
// ---------- Tool schemas ----------
const GetOrderSchema = z.object({
order_id: z.string().regex(/^[A-Z0-9]{6,12}$/i)
});
const CreateBookingSchema = z.object({
customer_name: z.string().min(1).max(100),
service: z.enum(['haircut', 'manicure', 'massage']),
datetime: z.string().datetime({ offset: true }) // ISO 8601 with timezone
});
// Expose a union that the LLM can pick from
const ToolDefinition = z.union([
z.object({ name: z.literal('get_order'), arguments: GetOrderSchema }),
z.object({ name: z.literal('create_booking'), arguments: CreateBookingSchema })
]);
// ---------- Handler implementations ----------
async function getOrder(args) {
// In production: query your DB, check ownership, etc.
// For demo we just return a static object
return { order_id: args.order_id, status: 'processed' };
}
async function createBooking(args) {
// Idempotency key: hash of the essential fields
const idempotencyKey = require('crypto')
.createHash('sha256')
.update(`${args.customer_name}|${args.service}|${args.datetime}`)
.digest('hex');
// Pretend we store it in Redis with a short TTL to avoid duplicates
// if (await redis.get(idempotencyKey)) { return { error: 'duplicate' }; }
// await redis.set(idempotencyKey, '1', 'EX', 60);
// Insert into DB here
return {
booking_id: require('crypto').randomBytes(7).toString('hex'),
...args
};
}
module.exports = { GetOrderSchema, CreateBookingSchema, ToolDefinition, getOrder, createBooking };
Why this prevents the earlier failure - The model never receives the bot token; it only sees a description of the two tools and their argument shapes. - The schema limits the name field to exactly get_order or create_booking. Any other string causes validation to fail before we attempt to execute anything. - Argument validation ensures that, for example, a datetime must be a proper ISO‑8601 string, stopping the model from injecting malformed data that could cause SQL injection or other errors.
3. Wiring the safe loop: from user message to model decision to tool execution
Now we rewrite the bot to use the schema. The flow is: 1. Receive a Telegram message. 2. Build a chat completion request that includes a short system prompt describing the tools (no token). 3. Ask the model to respond with a JSON object that matches ToolDefinition. 4. Validate the model’s output with Zod; if it fails, ask for clarification or reply with an error. 5. Run the appropriate handler function. 6. Feed the tool’s result back to the model as a "tool response" message so it can craft a final natural‑language reply. 7. Send that final reply to the user.
Here is the complete, production‑ready bot.
// bot-safe.js
require('dotenv').config();
const { Telegraf } = require('telegraf');
const { Configuration, OpenAIApi } = require('openai');
const { ToolDefinition, getOrder, createBooking } = require('./tools');
const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN);
const openai = new OpenAIApi(new Configuration({ apiKey: process.env.OPENAI_API_KEY }));
// System prompt that tells the model what tools exist, but hides the token
const SYSTEM_PROMPT = `You are a helpful booking assistant. You have access to two tools:\n- get_order: retrieves the status of an order. Argument: { order_id: string }\n- create_booking: creates a new salon booking. Arguments: {\n customer_name: string (1‑100 chars),\n service: one of \"haircut\", \"manicure\", \"massage\",\n datetime: ISO 8601 string with timezone\n }\nWhen you need to use a tool, reply with a JSON object that matches the following shape:\n{\n \"name\": \"get_order\" | \"create_booking\",\n \"arguments\": <object matching the tool\'s argument schema>\n}\nIf no tool is needed, answer the user directly in plain text.`;
// Helper to call the LLM and get a validated tool call
async function askModelForTool(userText) {
const completion = await openai.createChatCompletion({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userText }
],
temperature: 0,
// We ask the model to output JSON only
response_format: { type: 'json_object' }
});
const raw = completion.data.choices[0].message.content;
let parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
throw new Error('Model did not return valid JSON');
}
// Validate against our union schema
const result = ToolDefinition.safeParse(parsed);
if (!result.success) {
throw new Error(`Model output invalid: ${result.error.message}`);
}
return result.data; // { name, arguments }
}
bot.on('text', async (ctx) => {
const userText = ctx.message.text;
try {
const toolCall = await askModelForTool(userText);
let toolResult;
switch (toolCall.name) {
case 'get_order':
toolResult = await getOrder(toolCall.arguments);
break;
case 'create_booking':
toolResult = await createBooking(toolCall.arguments);
break;
default:
// This should never happen because of the schema check
throw new Error(`Unknown tool ${toolCall.name}`);
}
// Now ask the model to turn the tool result into a natural reply
const completion2 = await openai.createChatCompletion({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userText },
{ role: 'assistant', content: JSON.stringify({ name: toolCall.name, arguments: toolCall.arguments }) },
{ role: 'tool', content: JSON.stringify(toolResult) }
],
temperature: 0.7
});
const finalReply = completion2.data.choices[0].message.content;
await ctx.reply(finalReply, { parse_mode: 'HTML' });
} catch (err) {
console.error(err);
await ctx.reply('Sorry, I could not process that request. Please try again.');
}
});
bot.launch();
console.log('Bot is running');
Production notes - Token safety: The bot token appears only in the Telegraf constructor and in environment variables. It is never included in any prompt sent to the LLM. - Idempotency: For mutating tools like create_booking we generate a deterministic idempotency key from the essential fields and store it in Redis (or a short‑lived DB table) with a TTL. If the same request arrives again we return the stored result instead of creating a duplicate. - Replay protection: Store each incoming Telegram update_id in a set or database; ignore updates whose ID has already been processed. This prevents a network glitch from causing the same user message to be handled twice. - Rate limiting: Both Telegram and the LLM provider enforce limits. Implement a simple token‑bucket or use a library like p-limit to queue outgoing HTTP requests and respond with 429‑aware back‑off. - Error handling: Never return raw database or LLM error messages to the user. Log them internally and send a generic, user‑friendly message. - Input sanitisation: Although we validate arguments with Zod, any string that will be inserted into HTML (e.g., user‑provided names in a final reply) should be escaped. In the example we rely on Telegram’s default plain‑text mode; if you switch to parse_mode: 'HTML' run the result through escapeHtml or a similar utility. - Tool extensibility: When adding a new tool, extend the union in tools.js with a new Zod schema, implement the handler, and update the system prompt description. The validation step guarantees the model cannot call an undefined tool.
By keeping the bot token strictly server‑side, validating every tool call against a strict schema, and handling idempotency and replays, you obtain a reliable LLM‑driven Telegram agent that can safely take actions like fetching orders or creating bookings without exposing credentials or allowing unintended behavior.
BotCreator — studio that ships Telegram bots / Mini Apps. Further reading: