A Cloudflare Worker that fetches top Hacker News stories daily, uses Gemini AI to pick the funniest one, and posts it to Telegram chats/channels in any language with a customizable writing tone.
- A Cloudflare account (free tier works)
- Node.js + npm installed (
npx wranglerrequires it) - A Telegram bot token from @BotFather
- A Gemini API key from Google AI Studio
- Your Telegram User ID (to gate the bot to you only)
- Open Telegram and message @BotFather.
- Send
/newbot, give it a name (e.g. "FunnyNewsBot") and a username ending inbot(e.g.funnynews_bot). - Save the token BotFather gives you. Format:
123456789:ABCdefGhIJKlmnOPQRstUVwxyz
- Message @userinfobot in Telegram.
- Copy the numeric
Idit returns (e.g.123456789). This is yourOWNER_TELEGRAM_USER_ID.
npm install -g wranglerVerify:
npx wrangler --versionnpx wrangler loginThe bot uses KV to store per-chat language and tone settings.
npx wrangler kv namespace create "FUNNYNEWS_KV"You'll see output like:
Add the following to your wrangler.toml:
kv_namespaces = [
{ binding = "KV", id = "abc123def456..." }
]
Copy that ID and update wrangler.toml — replace PLACEHOLDER_KV_ID with the real ID:
kv_namespaces = [
{ binding = "KV", id = "abc123def456..." }
]These are encrypted at rest — never put them in wrangler.toml.
npx wrangler secret put GEMINI_API_KEY
# paste your Gemini key, press Enter
npx wrangler secret put TELEGRAM_BOT_TOKEN
# paste your bot token, press Enter
npx wrangler secret put OWNER_TELEGRAM_USER_ID
# paste your numeric Telegram User ID from @userinfobot, press EnterImportant: Only the user with this ID can issue commands to the bot (
/setlang,/settone,/now,/status). The cron job still posts to all registered chats regardless of ownership.
The default model is gemini-3.1-flash-lite. To use a different Gemini model, update wrangler.toml:
[vars]
MODEL = "gemini-2.5-flash" # or gemini-2.5-pro, gemini-2.0-flash, etc.From the project directory (E:\funnynewsbot):
npx wrangler deployWrangler will output your worker URL, e.g.:
https://funnynewsbot.your-subdomain.workers.dev
Visit your worker's /init route once:
https://funnynewsbot.your-subdomain.workers.dev/init
This calls Telegram's setWebhook API to point all bot updates to your worker's /webhook route. You should see:
{"ok":true,"result":true,"description":"Webhook was set"}Open Telegram, find your bot, and send:
/start
You should see the help message. Then:
/setlang Japanese
/settone deadpan and dry
/now
The bot will fetch HN stories, call Gemini, and post the result directly to you in Japanese with a deadpan tone.
- Create a Telegram channel (or use an existing one).
- Add your bot as an administrator (needs "Post messages" permission).
- In that channel (as the owner user), send:
/setlang English /settone over-the-top sensationalist /now - The bot will post the funny news in the channel with that tone.
Important: The bot must be an admin in the channel to send messages via the Bot API. Commands only work when sent by the owner user (the one whose
OWNER_TELEGRAM_USER_IDyou set).
The worker's wrangler.toml is configured to run at 07:10 AM UTC daily:
[triggers]
crons = ["10 7 * * *"]How Cron Triggers work (important): A
[triggers]cron entry does NOT make an HTTP request to your worker. Cloudflare invokes ascheduled(event, env, ctx)handler on the worker's default export. The worker exports bothfetch(for HTTP/webhooks) andscheduled(for the cron);scheduledruns the samerunPipelinethe/cronroute uses, wrapped inctx.waitUntil()so the runtime doesn't terminate it early. Do NOT remove thescheduledhandler — if youexport default apponly, the cron will silently stop firing. Seeworkers.jsat the bottom for the export shape.
The cron expression uses UTC (no timezone override). Change it to any 5-field cron expression:
# 9 AM UTC:
crons = ["0 9 * * *"]
# Every 6 hours (at minute 0):
crons = ["0 */6 * * *"]
# Twice a day (9 AM + 9 PM UTC):
crons = ["0 9,21 * * *"]
# 8:30 AM UTC, weekdays only:
crons = ["30 8 * * 1-5"]Redeploy after changing:
npx wrangler deployDeploying updates does NOT require re-registering channels. Cloudflare KV is separate from the worker code —
npx wrangler deployreplaces the worker but leaves KV data untouched. Channels you registered with/setlangstay registered and will continue receiving the daily post. You only need to re-run/setlang//settonewhen:
- Adding the bot to a new channel (to register it in KV), or
- Changing the language/tone for an existing channel, or
- Recreating the KV namespace with a different ID in
wrangler.toml(this wipes all registrations — don't do this unless intended).
You don't need to wait for 07:10 UTC to verify the schedule works. Two ways to trigger a run immediately:
Option A — Wrangler local cron trigger (tests the scheduled handler directly):
# Fires the first cron entry (index 0) immediately in the local dev runtime
npx wrangler triggers cron 0This invokes your scheduled handler exactly as Cloudflare would at 07:10 UTC, so it's the most faithful test. If runPipeline posts to your registered channels, the cron path is working.
Option B — Hit the /cron HTTP route (tests the same runPipeline via fetch):
https://funnynewsbot.kianooshshakeri99.workers.dev/cron
Open it in a browser, or:
curl https://funnynewsbot.kianooshshakeri99.workers.dev/cronThe response includes the chosen headline, URL, and the number of chats it was sent to. This route is a manual trigger kept for convenience — the daily run goes through scheduled, not this route.
After npx wrangler deploy, confirm the new code is running before tomorrow's cron fires. Either method above exercising runPipeline successfully confirms the deploy is live. You can also check the deploy hash in Cloudflare's dashboard (Workers → your worker → Deployments) against the hash wrangler deploy printed.
| Command | Where | What it does |
|---|---|---|
/start |
DM / channel | Shows welcome + help |
/help |
DM / channel | Shows all commands |
/setlang French |
Any chat | Sets output language for that chat |
/settone deadpan |
Any chat | Sets writing tone/style for that chat |
/now |
Any chat | Forces immediate news run (owner only) |
/test |
Any chat | Same as /now (owner only) |
/status |
Any chat | Shows current language, tone, chat ID (owner only) |
Owner-only:
/now,/test,/status,/setlang,/settoneonly respond when sent by the user whose ID matchesOWNER_TELEGRAM_USER_ID. Other users are silently ignored.
| Tone string | Effect |
|---|---|
funny and sarcastic |
Default — witty, irreverent |
deadpan and dry |
Understated, matter-of-fact |
over-the-top sensationalist |
ALL CAPS, EXCLAMATION POINTS!!! |
Gen Z internet slang |
"no cap", "skrrt", "iykyk" |
Shakespearean drama |
"Hark! Good sir, this tidings doth..." |
passive-aggressive corporate memo |
"Per my last email regarding the absurdity..." |
enthusiastic tabloid |
"SHOCKING: You WON'T BELIEVE what happened!" |
Any free-form description works — it's passed directly to Gemini as a style instruction.
Send /setlang English in the chat first (as the owner). This registers the chat in KV so the cron knows about it.
Make sure the bot is a channel admin with "Post messages" permission.
- Verify your
OWNER_TELEGRAM_USER_IDsecret is set correctly (must be numeric string). - Only that specific Telegram user can issue commands.
- Check your
GEMINI_API_KEYsecret:npx wrangler secret list - Verify the
MODELname inwrangler.toml— must be a valid Gemini model likegemini-3.1-flash-lite - Check Gemini API quota at Google AI Studio
Visit /init again. Check https://api.telegram.org/bot<TOKEN>/getWebhookInfo in a browser.
The worker fetches HN top stories with a concurrency limit of 5. If it still times out, reduce TOP_STORIES_LIMIT in workers.js (line 20).
- Cloudflare Workers: Free tier = 100,000 requests/day
- Cloudflare KV: Free tier = 1,000 reads + 1,000 writes/day
- Gemini API:
gemini-3.1-flash-lite/gemini-2.5-flashfree up to ~1,500 requests/day on Google AI Studio. Two calls per daily run = ~60/month. - Hacker News API: Free, no limits
Total cost: $0/month if you stay within free tiers.