How receiving works
Sending looks the same everywhere. Receiving does not, and that is the interesting part of this module.
Telegram pushes to a url. Bluesky has no push at all and has to be asked. Slack pushes too, but wants an answer within three seconds. You write the same five lines for all of them.
Copy this
Turn receiving on
export default defineNuxtConfig({
modules: ['nuxt-pigeon'],
nuxtPigeon: {
channels: {
telegram: { receive: true },
},
},
})
Write a listener
A Nitro plugin, so it is registered once at startup rather than per request.
export default defineNitroPlugin(() => {
telegram.listen(async (update) => {
const message = update.message
if (!message?.text) return
await telegram.send(`You said: ${message.text}`, { chatId: message.chat.id })
})
})
Let Telegram reach you
pnpm dev --tunnel
Copy the public address it prints, then register it once:
export default defineEventHandler(async () => {
return telegram.setWebhook('https://your-tunnel.trycloudflare.com')
})
Open that route once in the browser.
Write to your bot
Your bot answers.
What just happened
listenreturned a function. Calling it unregisters. That matters in development: without it a hot reload stacks a second copy of the same handler on top of the first.- The update arrived untouched.
updateis exactly what Telegram sent, every field, nothing renamed. What one service calls an event type and another calls an action stays where it was. - The signature was checked before your handler ran. Telegram signs with a secret token in a header, and a request without it never reaches you.
The three transports
You do not choose this. The channel does, and it is the reason listen can look the
same everywhere.
| Channel | How it arrives | Works on serverless |
|---|---|---|
| Telegram | Webhook, a route the module registers | ✅ |
| Slack | Events API, same idea, plus a signature and a deadline | ✅ |
| Any webhook | Your own route, for GitHub, Stripe, n8n, anything | ✅ |
| Mastodon | Polling, there is no webhook for your own account | ❌ |
| Bluesky | Polling, there is no webhook at all | ❌ |
What will cost you an hour
- A hot reload leaves the old listener running unless you unregister. The module
keeps its pollers in a global symbol and stops them on
closefor exactly this reason. With Telegram, two pollers at once earn you a 409 Conflict. - Slack wants an acknowledgement within three seconds, or it retries the delivery.
The module answers first and runs your handler afterwards, so a slow handler cannot
cause a duplicate. You will still see retries if Slack itself times out, and they
arrive with
x-slack-retry-numset. - Slack sends you your own messages. Every message in the channel produces an event,
including the one your app just posted. Answer it and you have built a loop that runs
until Slack rate limits you. Yours carry
bot_id:slack.listen((event) => { if (event.event?.bot_id) return // that was us }) - Mastodon and Bluesky never notify you about your own doing. Testing needs a second account, or a friend.
Read more
- Development with a tunnel - why
localhostis not reachable, and the one flag that fixes it - In the browser - getting what arrived into your frontend without polling
- Telegram getting updates
- Slack Events API
Changing and removing
Edit and delete messages you already sent on Telegram, Discord, Slack, ntfy and Mastodon. The result of send is the handle, and the types say who cannot.
Development with a tunnel
Telegram and Slack cannot reach localhost. Nuxt dev --tunnel fixes that, and one surprise about Cloudflare quick tunnels will cost you an evening otherwise.