Use when user explicitly requests Telegram bot development, Mini App integration, webhook/polling setup, or debugging Telegram-specific issues.
BEFORE using this skill, verify user needs Telegram integration:
DO NOT use this skill for:
Comprehensive skill for building Telegram bots and Mini Apps using Telegraf. Covers setup, development, debugging, and deployment with conditional workflows for local vs production environments.
Core principle: Use polling for local development, webhooks for production. Validate Mini App data server-side. Never mix polling and webhooks simultaneously.
ONLY after verifying Telegram work, use this skill when you see:
NEVER use this skill for:
telegram in file namesIf uncertain about Telegram involvement, verify first.
Start every Telegram task by routing to the correct workflow:
| What are you doing? | Workflow |
|---|---|
| Starting new Telegram integration | → NEW_PROJECT |
| Webhook/polling not working | → DEBUGGING |
| Adding commands/AI/Mini App | → FEATURE_ADDITION |
| Deploying to production | → DEPLOYMENT |
Use when: User wants to create a new Telegram bot or Mini App.
Create these todos when starting new project:
- [ ] Determine project type (bot only, mini app only, or both)
- [ ] Create bot via @BotFather, save token to .env.local
- [ ] Install Telegraf: npm install telegraf
- [ ] Choose environment setup (local polling or production webhook)
- [ ] Implement bot service with singleton pattern
- [ ] Add basic command handlers (/start, /help)
- [ ] Test bot responds to commands
@BotFather/newbot commandTELEGRAM_BOT_TOKEN=<token>/setdescription, /setabouttext)Choose ONE based on environment:
Use polling to avoid HTTPS requirements:
Create telegram-polling.js:
const fetch = require('node-fetch');
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
const WEBHOOK_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET || 'dev-secret';
const LOCAL_WEBHOOK_URL = 'http://localhost:3000/api/webhooks/telegram';
let offset = 0;
async function pollUpdates() {
try {
const response = await fetch(
`https://api.telegram.org/bot${BOT_TOKEN}/getUpdates`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ offset, timeout: 30 })
}
);
const data = await response.json();
if (data.ok && data.result.length > 0) {
for (const update of data.result) {
// Forward to local webhook
await fetch(LOCAL_WEBHOOK_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Telegram-Bot-Api-Secret-Token': WEBHOOK_SECRET
},
body: JSON.stringify(update)
});
offset = update.update_id + 1;
}
}
} catch (error) {
console.error('Polling error:', error);
}
setTimeout(pollUpdates, 1000);
}
console.log('Starting Telegram polling...');
pollUpdates();
Start polling: node telegram-polling.js
Use webhooks for event-driven updates:
Set webhook after deployment:
curl -X POST https://api.telegram.org/bot${TOKEN}/setWebhook \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/api/webhooks/telegram",
"secret_token": "YOUR_SECRET_TOKEN"
}'
Verify webhook:
curl https://api.telegram.org/bot${TOKEN}/getWebhookInfo
Create src/services/telegram/bot-service.ts:
import { Telegraf } from 'telegraf';
class TelegramBotService {
private static instance: Telegraf | null = null;
static getInstance(): Telegraf {
if (!this.instance) {
// Use dummy token for build, real token at runtime
const token = process.env.TELEGRAM_BOT_TOKEN || 'DUMMY_BUILD_TOKEN';
if (token === 'DUMMY_BUILD_TOKEN') {
console.warn('Using dummy token for build phase');
}
this.instance = new Telegraf(token);
this.registerCommands();
}
return this.instance;
}
private static registerCommands() {
const bot = this.instance!;
bot.command('start', (ctx) => {
ctx.reply('Welcome! Use /help to see available commands.');
});
bot.command('help', (ctx) => {
ctx.reply('Available commands:\n/start - Start bot\n/help - Show this message');
});
bot.on('text', async (ctx) => {
// Handle text messages
ctx.reply(`You said: ${ctx.message.text}`);
});
}
static async processUpdate(update: any) {
const bot = this.getInstance();
await bot.handleUpdate(update);
}
}
export default TelegramBotService;
Create src/app/api/webhooks/telegram/route.ts:
import { NextRequest } from 'next/server';
import TelegramBotService from '@/services/telegram/bot-service';
export async function POST(request: NextRequest) {
// Verify secret token
const secret = request.headers.get('x-telegram-bot-api-secret-token');
if (secret !== process.env.TELEGRAM_WEBHOOK_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const update = await request.json();
await TelegramBotService.processUpdate(update);
return Response.json({ ok: true });
} catch (error) {
console.error('Webhook error:', error);
return Response.json({ error: 'Internal error' }, { status: 500 });
}
}
// Force dynamic rendering (prevents caching issues)
export const dynamic = 'force-dynamic';
Use when: Telegram bot or webhook not working as expected.
Is webhook receiving updates?
├─ No → Check webhook configuration
│ ├─ Run: curl https://api.telegram.org/bot${TOKEN}/getWebhookInfo
│ ├─ Check: url, pending_update_count, last_error_date, last_error_message
│ └─ See: [Webhook Troubleshooting](#webhook-troubleshooting)
├─ Yes, but errors → Check platform logs
│ ├─ Vercel: Check deployment logs and function errors
│ ├─ Railway: railway logs
│ └─ See: [Platform-Specific Issues](#platform-specific-issues)
└─ Updates received, bot not responding
├─ Check bot service initialization
├─ Verify command handlers registered
└─ Check for errors in message processing
Common webhook issues and solutions:
| Symptom | Cause | Solution |
|---|---|---|
| 401/403 Unauthorized | Missing or wrong secret token | Verify X-Telegram-Bot-Api-Secret-Token header matches setWebhook |
| Webhook not receiving updates | Webhook not set or deleted | Run setWebhook with correct URL and secret |
| SSL certificate error | Non-HTTPS URL | Ensure webhook URL uses https:// (except test environment) |
| Pending updates growing | Webhook timing out | Reduce processing time or use edge functions (Vercel) |
| Duplicate message processing | Timeout <10s, Telegram retries | Increase timeout limit or optimize response time |
| getUpdates conflict error | Polling + webhook both active | Delete webhook OR stop polling (never both) |
Diagnostic Commands:
# Check webhook status
curl https://api.telegram.org/bot${TOKEN}/getWebhookInfo
# Delete webhook (switch to polling)
curl -X POST https://api.telegram.org/bot${TOKEN}/deleteWebhook
# Test bot token
curl https://api.telegram.org/bot${TOKEN}/getMe
Issue: Deployment Protection blocks webhooks
Issue: 10-second timeout on Hobby plan
export const runtime = 'edge'; // Longer timeout
Issue: Bot not receiving updates after deploy
Issue: Bot works locally, fails on Railway
Issue: Mini App buttons show old URL
TELEGRAM_MINI_APP_URL env varexport const dynamic = 'force-dynamic';
Issue: Environment variables not loaded
railway variables to verify, redeploy after changesIssue: Polling script not receiving updates
curl -X POST https://api.telegram.org/bot${TOKEN}/deleteWebhookIssue: Port conflicts
LOCAL_WEBHOOK_URL in polling scriptUse when: Adding features to existing Telegram bot.
Choose feature type:
| Feature | Guide |
|---|---|
| Commands (/start, /help, custom) | Adding Commands |
| AI Integration (ChatGPT, Claude) | AI Integration Pattern |
| Inline keyboards/buttons | Interactive UI |
| Mini App | Mini App Integration |
| File handling (photos, voice, documents) | File Handlers |
Pattern:
// In bot-service.ts registerCommands()
bot.command('mycommand', async (ctx) => {
// Command logic
await ctx.reply('Response text');
});
With parameters:
bot.command('search', async (ctx) => {
const query = ctx.message.text.split(' ').slice(1).join(' ');
if (!query) {
return ctx.reply('Usage: /search <query>');
}
const results = await searchFunction(query);
await ctx.reply(`Found: ${results}`);
});
Unified chat manager approach:
import { ChatManager } from '@/services/chat/chat-manager';
bot.on('text', async (ctx) => {
try {
// Get or create conversation for user
const conversation = await getOrCreateConversation(ctx.from.id);
// Process through AI
const aiResponse = await ChatManager.processMessage(
ctx.message.text,
{
platform: 'telegram',
userId: ctx.from.id,
context: conversation.pageContext
}
);
// Reply with markdown support
await ctx.reply(aiResponse, { parse_mode: 'Markdown' });
} catch (error) {
console.error('AI processing error:', error);
await ctx.reply('Sorry, I encountered an error processing your message.');
}
});
Streaming responses (for long AI outputs):
bot.on('text', async (ctx) => {
const statusMessage = await ctx.reply('Thinking...');
let fullResponse = '';
await ChatManager.streamMessage(ctx.message.text, {
onChunk: async (chunk) => {
fullResponse += chunk;
// Update message every 20 chunks to avoid rate limits
if (fullResponse.length % 100 === 0) {
await ctx.telegram.editMessageText(
ctx.chat.id,
statusMessage.message_id,
undefined,
fullResponse,
{ parse_mode: 'Markdown' }
);
}
},
onComplete: async () => {
await ctx.telegram.editMessageText(
ctx.chat.id,
statusMessage.message_id,
undefined,
fullResponse,
{ parse_mode: 'Markdown' }
);
}
});
});
Inline keyboard:
import { Markup } from 'telegraf';
bot.command('menu', async (ctx) => {
await ctx.reply(
'Choose an option:',
Markup.inlineKeyboard([
[Markup.button.callback('Option 1', 'option_1')],
[Markup.button.callback('Option 2', 'option_2')],
[Markup.button.url('Visit Website', 'https://example.com')]
])
);
});
// Handle button callbacks
bot.action('option_1', async (ctx) => {
await ctx.answerCbQuery();
await ctx.editMessageText('You selected Option 1');
});
Context switching with page state:
bot.command('training', async (ctx) => {
// Update conversation context
await updateConversationContext(ctx.from.id, 'training');
await ctx.reply(
'Switched to Training mode. Ask me anything about your workouts!',
Markup.inlineKeyboard([
[Markup.button.callback('View Plan', 'view_plan')],
[Markup.button.callback('Log Workout', 'log_workout')],
[Markup.button.callback('Back to Menu', 'main_menu')]
])
);
});
Photo handling:
bot.on('photo', async (ctx) => {
const photo = ctx.message.photo[ctx.message.photo.length - 1];
const fileLink = await ctx.telegram.getFileLink(photo.file_id);
// Download or process photo
const analysis = await analyzeImage(fileLink.href);
await ctx.reply(`Analysis: ${analysis}`);
});
Voice message handling:
bot.on('voice', async (ctx) => {
const voice = ctx.message.voice;
const fileLink = await ctx.telegram.getFileLink(voice.file_id);
// Transcribe voice (e.g., using Whisper API)
const transcript = await transcribeAudio(fileLink.href);
await ctx.reply(`You said: ${transcript}`);
});
Use when: Deploying Telegram bot to production.
Create these todos for production deployment:
- [ ] Determine deployment platform (Railway, Vercel, other)
- [ ] Set environment variables (TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET)
- [ ] Create webhook API route (/api/webhooks/telegram)
- [ ] [VERCEL] Disable Deployment Protection in settings
- [ ] [RAILWAY] Use dummy token for build, real token at runtime
- [ ] Deploy application
- [ ] Delete existing webhook or stop polling
- [ ] Set webhook with production URL
- [ ] Verify webhook: check getWebhookInfo
- [ ] Send test message to bot
- [ ] Monitor platform logs for errors
Requirements:
Steps:
Set environment variables in Vercel dashboard:
TELEGRAM_BOT_TOKENTELEGRAM_WEBHOOK_SECRETTELEGRAM_MINI_APP_URL (if using Mini Apps)Disable Deployment Protection:
Deploy:
vercel --prod
Set webhook:
curl -X POST https://api.telegram.org/bot${TOKEN}/setWebhook \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.vercel.app/api/webhooks/telegram",
"secret_token": "YOUR_SECRET"
}'
Test: Send message to bot, check Vercel logs
Requirements:
Steps:
Set environment variables:
railway variables --set "TELEGRAM_BOT_TOKEN=<token>"
railway variables --set "TELEGRAM_WEBHOOK_SECRET=<secret>"
Ensure bot service handles build phase:
const token = process.env.TELEGRAM_BOT_TOKEN || 'DUMMY_BUILD_TOKEN';
Deploy:
railway up
Set webhook using Railway domain:
# Get Railway URL from railway status
railway status
# Set webhook
curl -X POST https://api.telegram.org/bot${TOKEN}/setWebhook \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.up.railway.app/api/webhooks/telegram",
"secret_token": "YOUR_SECRET"
}'
Monitor deployment:
railway logs
When switching from local to production:
Stop polling script: Kill the telegram-polling.js process
Delete webhook (if any exists):
curl -X POST https://api.telegram.org/bot${TOKEN}/deleteWebhook
Deploy to production with webhook route
Set webhook with production URL (see platform steps above)
Verify switch:
curl https://api.telegram.org/bot${TOKEN}/getWebhookInfo
# Should show: url (your production URL), pending_update_count (should be 0 after test)
Test: Send message to bot, check production logs
Use when: User wants to add Telegram Mini App (web app within Telegram).
- [ ] Create Mini App via @BotFather: /newapp or /setmenubutton
- [ ] Create Next.js page for Mini App (e.g., /app/telegram-mini-app/page.tsx)
- [ ] Include Telegram Web App SDK script
- [ ] Implement client-side initialization
- [ ] Create server-side initData validation endpoint
- [ ] Set TELEGRAM_MINI_APP_URL environment variable
- [ ] Configure bot to launch Mini App (keyboard/inline button)
- [ ] Test Mini App opens from bot
- [ ] Verify theme integration
- [ ] Test data validation
Create Mini App:
@BotFather/myappshttps://your-app.com/telegram-mini-appOr create standalone app:
@BotFather/newappCreate app/telegram-mini-app/page.tsx:
'use client';
import { useEffect, useState } from 'react';
declare global {
interface Window {
Telegram?: {
WebApp: any;
};
}
}
export default function TelegramMiniApp() {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Initialize Telegram Web App
const tg = window.Telegram?.WebApp;
if (!tg) {
console.error('Telegram Web App SDK not loaded');
return;
}
// Notify Telegram app is ready
tg.ready();
// Get user data (UNSAFE - validate server-side)
const initDataUnsafe = tg.initDataUnsafe;
setUser(initDataUnsafe.user);
// Apply Telegram theme
document.body.style.backgroundColor = tg.themeParams.bg_color || '#ffffff';
document.body.style.color = tg.themeParams.text_color || '#000000';
// Listen for theme changes
tg.onEvent('themeChanged', () => {
document.body.style.backgroundColor = tg.themeParams.bg_color;
document.body.style.color = tg.themeParams.text_color;
});
setLoading(false);
// Enable closing confirmation for unsaved changes
tg.enableClosingConfirmation();
return () => {
tg.disableClosingConfirmation();
};
}, []);
const sendMessage = async (message: string) => {
const tg = window.Telegram?.WebApp;
// Validate on server with initData
const response = await fetch('/api/telegram-mini-app/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-telegram-init-data': tg.initData // For server validation
},
body: JSON.stringify({ message })
});
const data = await response.json();
return data;
};
if (loading) {
return <div>Loading...</div>;
}
return (
<div>
<h1>Welcome, {user?.first_name}!</h1>
{/* Your Mini App UI */}
</div>
);
}
Add SDK to layout:
// app/telegram-mini-app/layout.tsx
export default function TelegramMiniAppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<head>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body>{children}</body>
</html>
);
}
NEVER trust initDataUnsafe - always validate server-side.
Create validation utility:
// lib/telegram/validate-init-data.ts
import crypto from 'crypto';
export function validateTelegramInitData(
initData: string,
botToken: string
): boolean {
const urlParams = new URLSearchParams(initData);
const hash = urlParams.get('hash');
urlParams.delete('hash');
// Create data-check-string
const dataCheckArray = Array.from(urlParams.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`);
const dataCheckString = dataCheckArray.join('\n');
// Compute secret key
const secretKey = crypto
.createHmac('sha256', 'WebAppData')
.update(botToken)
.digest();
// Compute hash
const computedHash = crypto
.createHmac('sha256', secretKey)
.update(dataCheckString)
.digest('hex');
return computedHash === hash;
}
export function parseTelegramUser(initData: string) {
const urlParams = new URLSearchParams(initData);
const userJson = urlParams.get('user');
if (!userJson) return null;
return JSON.parse(userJson);
}
Use in API route:
// app/api/telegram-mini-app/chat/route.ts
import { validateTelegramInitData, parseTelegramUser } from '@/lib/telegram/validate-init-data';
export async function POST(request: Request) {
const initData = request.headers.get('x-telegram-init-data');
if (!initData) {
return Response.json({ error: 'Missing init data' }, { status: 400 });
}
// VALIDATE - critical for security
const isValid = validateTelegramInitData(
initData,
process.env.TELEGRAM_BOT_TOKEN!
);
if (!isValid) {
return Response.json({ error: 'Invalid init data' }, { status: 401 });
}
// NOW safe to use
const user = parseTelegramUser(initData);
const { message } = await request.json();
// Process message with validated user
const aiResponse = await processAIMessage(message, user.id);
return Response.json({ response: aiResponse });
}
Inline keyboard button:
import { Markup } from 'telegraf';
bot.command('app', async (ctx) => {
await ctx.reply(
'Open Mini App:',
Markup.inlineKeyboard([
[Markup.button.webApp(
'Launch App',
process.env.TELEGRAM_MINI_APP_URL || 'https://your-app.com/telegram-mini-app'
)]
])
);
});
Menu button (persistent): Set via @BotFather as shown above, or programmatically:
await bot.telegram.setChatMenuButton({
menu_button: {
type: 'web_app',
text: 'Open App',
web_app: {
url: process.env.TELEGRAM_MINI_APP_URL!
}
}
});
| Issue | Cause | Solution |
|---|---|---|
| Mini App won't load | Missing HTTPS | Use https:// in production (http:// only in test environment) |
| initData validation fails | Wrong bot token or hash computation | Verify bot token, check hash algorithm matches spec |
| Theme colors wrong | Hardcoded colors | Use tg.themeParams dynamically, listen for themeChanged |
| App closes unexpectedly | Missing ready() call |
Call tg.ready() early in initialization |
| Caching shows old version | Railway/Vercel cache | Force dynamic rendering: export const dynamic = 'force-dynamic' |
| Buttons disabled in production | Wrong URL in env var | Check TELEGRAM_MINI_APP_URL matches deployed URL |
Checklist:
curl https://api.telegram.org/bot${TOKEN}/getMecurl https://api.telegram.org/bot${TOKEN}/getWebhookInforailway logs, Vercel: dashboard)Problem: Polling not receiving updates
curl -X POST https://api.telegram.org/bot${TOKEN}/deleteWebhookProblem: Port 3000 already in use
Problem: Updates received but bot doesn't respond
Problem: Works locally, fails in production
Problem: Webhook returns 401
X-Telegram-Bot-Api-Secret-Token header matches setWebhook secretProblem: Vercel timeout errors
Problem: Railway caching issues
export const dynamic = 'force-dynamic'Problem: initDataUnsafe shows data but validation fails
initDataUnsafe instead of initData for validationinitData (raw string) server-side, never trust initDataUnsafeProblem: Mini App theme doesn't match Telegram
tg.themeParams and listen for themeChanged eventinitDataUnsafe is unsafe, validate initData/setcommands to show command list in UIgetWebhookInfo (check pending_update_count)CRITICAL: Diagnose before acting. Never guess under pressure.
getWebhookInfo shows errors, URL, pending updatesrailway logs, Vercel: deployment logscurl https://api.telegram.org/bot${TOKEN}/getMeFor webhook issues: verify → delete → redeploy → set → test For Mini App issues: validate server-side → check theme → verify URL