TL;DR: A Telegram bot that takes a URL, topic, or voice message and generates a ready-to-publish post with a unique AI cover image. Stack: Node.js, Telegraf, Google Gemini 2.5 Flash, Pollinations (Flux), Supabase. Infrastructure cost: $0.
The Problem
If you run a Telegram channel, you know the pain: you need content every single day.
A typical workflow:
- Find an interesting article — 15 min
- Rewrite it in your own voice — 30 min
- Find or create an image — 15 min
- Format and publish — 10 min
~1 hour per post. At 2-3 posts per day, it's a full-time job. Hiring an SMM manager costs $200-500/month — too much for a small channel.
I decided to automate this pipeline with AI.
What the Bot Does
Four input modes, one output — a ready-to-publish post with a cover image:
| Mode | Input | What it does |
|---|---|---|
| 🔗 URL → Post | Article link | Parses, analyzes, rewrites |
| 💬 Topic → Post | Text ("Bitcoin hits $100K") | Generates post from scratch |
| 🎤 Voice → Post | Voice message | Transcribes → generates post |
| 📅 Content Plan | Channel topic | 7 posts for the week |
Each post comes with 4 style options (Professional, Hype, Expert, Brief) and a "Try Again" button for regeneration.
Architecture
┌────────────────────────────────────────┐
│ Telegram User │
│ (URL / text / voice / /plan) │
└──────────────────┬─────────────────────┘
↓
┌────────────────────────────────────────┐
│ Telegraf (Webhook) │
│ Express.js Server │
│ Render (Free Tier) │
└───┬──────────┬──────────┬──────────────┘
↓ ↓ ↓
┌────────┐ ┌────────┐ ┌──────────┐
│ Parser │ │ AI │ │ DB │
│Cheerio │ │Service │ │ Service │
│ │ │ │ │ │
│• HTML │ │•Gemini │ │•Supabase │
│ parse │ │ 2.5 │ │ (PgSQL) │
│• OG │ │•Pollin-│ │•Limits │
│ tags │ │ ations │ │•Users │
└────────┘ └────────┘ └──────────┘
Parsing Articles: Cascade Strategy
My first naive attempt — $('p').text() — worked on 30% of websites. The rest returned garbage: navigation, ads, footers.
The solution — a cascade with fallbacks:
// Remove noise
$('script, style, nav, footer, header, aside, .ads, .sidebar, .comments').remove();
// Title: OG > H1 > title tag
const title = $('meta[property="og:title"]').attr('content')
|| $('h1').first().text().trim()
|| $('title').text().trim();
// Content: article > main > p > body
let content = '';
if ($('article').length) {
$('article p').each((i, el) => {
const text = $(el).text().trim();
if (text.length > 30) content += text + '\n\n';
});
}
if (!content && $('main').length) {
$('main p').each((i, el) => { /* same logic */ });
}
if (!content) {
$('p').each((i, el) => { /* fallback */ });
}
Key insight: filtering by length (text.length > 30). Short <p> tags are usually image captions, buttons, and navigation. Real article paragraphs are always longer.
Prompt Engineering: 3 Lessons Learned
Lesson 1: HTML, not Markdown
Telegram supports both formats, but Markdown is extremely fragile. One unclosed asterisk breaks the entire message. AI loves using * for emphasis, and Telegram interprets it as formatting.
// Before: ~40% formatting errors
ctx.reply(postText, { parse_mode: 'Markdown' });
// After: ~2% formatting errors
ctx.reply(postText, { parse_mode: 'HTML' });
In the prompt:
RULES:
1. Use ONLY HTML tags: <b>, <i>, <code>.
2. DO NOT use Markdown (no * or #).
Lesson 2: Length constraints
Without explicit limits, Gemini writes 5000+ character essays. Telegram posts are read on the go — optimal length is 800-2000 characters.
Lesson 3: Ban URLs
AI loves making up URLs. Especially "authoritative" links to non-existent studies. Just ban them:
Do not add any links.
I add the source link programmatically.
Image Generation: Two-Step Pipeline
Problem: asking AI to "draw an image about bitcoin" produces generic abstract backgrounds.
Solution: Gemini generates a visual prompt, then Flux renders it.
Step 1: Gemini creates the prompt
async function generateImagePrompt(title, postText) {
const prompt = `
Create an image description for an AI image generator.
RULES:
1. English only.
2. Describe CONCRETE objects (buildings, people, devices).
3. Avoid abstractions ("innovation", "justice").
4. Specify style, lighting, composition.
5. Output ONLY the prompt text (1-2 sentences).
`;
return await callGemini(prompt);
}
Step 2: Flux renders the image
async function generateImage(imagePrompt) {
const seed = Math.floor(Math.random() * 1000000);
const encoded = encodeURIComponent(imagePrompt);
const url = `https://image.pollinations.ai/prompt/${encoded}` +
`?width=1024&height=1024&nologo=true&model=flux&seed=${seed}`;
const response = await axios.get(url, {
responseType: 'arraybuffer', timeout: 60000
});
return Buffer.from(response.data, 'binary');
}
Pollinations + Flux — completely free API, no keys required. Quality comparable to Midjourney v4. Random seed ensures uniqueness.
Voice Transcription via Gemini
A non-obvious capability: Gemini 2.5 Flash handles audio natively. Telegram sends voice messages as OGG — Gemini accepts it directly:
async function transcribeVoice(audioBuffer) {
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
const result = await model.generateContent([
"Transcribe this voice recording. Output ONLY the spoken text.",
{
inlineData: {
data: audioBuffer.toString('base64'),
mimeType: 'audio/ogg'
}
}
]);
return result.response.text().trim();
}
Works with Russian speech, even with background noise. Speed: 2-3 seconds for a 30-second recording.
Auto-Failover for AI Models
Google periodically throttles model access by quota. To prevent downtime, I implemented a cascade:
const MODELS = ["gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-2.5-pro"];
async function callGemini(prompt) {
for (const modelName of MODELS) {
try {
const model = genAI.getGenerativeModel({ model: modelName });
const result = await model.generateContent(prompt);
return result.response.text();
} catch (e) {
continue; // Try next model
}
}
throw new Error('All models failed');
}
Zero downtime in a month of production use.
Freemium Monetization
- Free: 3 generations/day, all features
- Premium ($2/month): Unlimited generations, no watermark
const DAILY_LIMIT = 3;
async function checkUserLimit(userId) {
let { data: user } = await supabase
.from('users').select('*').eq('id', userId).single();
if (user?.is_premium) return { canGenerate: true };
const hours = (Date.now() - new Date(user.last_generation_at)) / 3600000;
if (hours > 24) {
// Reset daily counter
await supabase.from('users')
.update({ generations_count: 0 }).eq('id', userId);
return { canGenerate: true, remaining: DAILY_LIMIT };
}
return user.generations_count >= DAILY_LIMIT
? { canGenerate: false }
: { canGenerate: true, remaining: DAILY_LIMIT - user.generations_count };
}
Total Infrastructure Cost
| Component | Service | Cost |
|---|---|---|
| AI text | Gemini 2.5 Flash | $0 |
| AI images | Pollinations (Flux) | $0 |
| Database | Supabase Free | $0 |
| Hosting | Render Free | $0 |
| Total | $0/month |
Try It
The bot is free and runs 24/7: @generatortposBot
Landing page: project-3-411a.onrender.com
If you know Node.js and Telegram Bot API, you can build something like this in a weekend. The entire stack runs on free tiers.
Happy to answer questions in the comments!
Top comments (1)
The remaining judgment step is visual relevance. A unique cover can still be generic if it only mirrors keywords. I’d have the bot produce a one-line visual thesis first, then choose among literal scene, metaphor, or explanatory diagram, and reject covers too similar to the channel’s last 10 posts. The prompt, model, and source URL should travel with the asset for provenance.