nuxt-pigeon
v1.0.0-beta.1
Published
Send and receive messages across Telegram, Slack, Discord and any webhook.
Downloads
32
Maintainers
Readme
// server/api/contact.post.ts
await telegram.send('New contact request from Flo')That is the whole setup. No client to construct, no token to pass around, no import to write.
What you get
Four verbs, seven services. send, listen, edit, delete. They look the same everywhere, even though one service wants multipart, the next wants an upload first, and the third wants a blob reference.
Receiving does not care how. Telegram delivers by webhook, Bluesky has to be polled, Slack has a three second deadline to answer. You write one handler and get the same shape back.
What arrives in the frontend, arrives. One composable, no polling loop of your own.
Nothing is swallowed. Every send hands back the service's own answer, body and headers included. Where a service cannot do something, you get a sentence that says why, not silence.
Setup
npx nuxt module add nuxt-pigeon// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-pigeon'],
nuxtPigeon: {
channels: {
telegram: { receive: true },
discord: true,
},
},
})# .env
PIGEON_TELEGRAM_BOT_TOKEN=123456:ABC…
PIGEON_TELEGRAM_CHAT_ID=987654321
PIGEON_DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/…Secrets live in .env, never in nuxt.config.ts. Every value can also be set in the config if it is not a secret, and every call can override it.
Sending
Each channel is auto imported on the server. No import, no setup.
// server/api/deploy.post.ts
export default defineEventHandler(async () => {
await discord.send('Deploy failed on `main`', {
embeds: [{ title: 'Build #42', color: 0xd4262a }],
media: [{ url: 'https://example.com/log.png', alt: 'The failing step' }],
})
})Everything the service accepts is reachable. Discord embeds, Slack blocks, Telegram keyboards, ntfy priorities and actions, Mastodon content warnings, Bluesky link cards. We add the parts that are easy to get wrong: character limits counted the way the service counts them, escaping, byte offsets, media fetched and uploaded for the services that take no url.
Images
The same option everywhere, either a url or bytes:
await mastodon.post('Release 1.0', { media: [{ url: 'https://…/shot.png', alt: 'The new panel' }] })
await telegram.send('Look', { media: [{ data: pngBytes, filename: 'shot.png' }] })Underneath these are four different mechanisms. Telegram is handed the url and fetches it itself, Discord gets multipart, Mastodon uploads first and waits for processing, Bluesky wants raw bytes, Slack needs three requests. You do not have to know that.
Changing and removing
Sending returns a handle. Pass it back:
const msg = await discord.send('Deploy running…')
await discord.edit(msg, 'Deploy finished')
await discord.delete(msg)The handle is a small plain object (id, and whatever else the service needs), so you can store it in your database and rebuild it tomorrow.
Receiving
Register once at startup, from a Nitro plugin:
// server/plugins/pigeon.ts
export default defineNitroPlugin((nitro) => {
const stop = telegram.listen(async (update) => {
if (update.message?.text === '/status') {
await telegram.send('running', { chatId: update.message.chat.id })
}
})
nitro.hooks.hook('close', stop)
})The route, the signature check and the transport are handled. Telegram verifies its secret token, Slack its signing secret and answers the challenge within its deadline, Mastodon and Bluesky are polled by a hot reload safe poller.
Your handler gets the service's own payload, fully typed and with nothing removed.
In the browser
<script setup>
const { messages, connected } = usePigeon()
</script>
<template>
<p v-for="m in messages" :key="m.at">{{ m.channel }}: {{ m.text }}</p>
</template>A server sent event stream, one connection, closed automatically when the component goes away. Normalised fields (text, from, conversation) sit next to the untouched raw and body.
What each channel can do
| | send | media | edit | delete | receive | | -------- | ---- | ----- | ---- | ------ | ------------- | | Telegram | ✅ | ✅ | ✅ | ✅ | ✅ webhook | | Discord | ✅ | ✅ | ✅ | ✅ | — | | Slack | ✅ | ✅ ¹ | ✅ ¹ | ✅ ¹ | ✅ Events API | | ntfy | ✅ | ✅ | ✅ ² | ✅ ² | — | | Mastodon | ✅ | ✅ | ✅ | ✅ | ✅ polling | | Bluesky | ✅ | ✅ | ❌ ³ | ✅ | ✅ polling | | Webhook | ✅ | — | — ⁴ | — ⁴ | ✅ route |
¹ needs a bot token. With only an incoming webhook Slack answers ok and no message id, so there is nothing to point at afterwards.
² needs an ntfy server of 2.16.0 or newer.
³ Bluesky has no post editing. putRecord answers with a 200 and the appview ignores the change, so an edit would look like it worked and do nothing. It is not offered rather than offered and broken.
⁴ you decide what the receiver is, so send carries method and url and a PATCH or DELETE is one call.
Where a channel cannot do something, the method is missing from its type, so you find out while typing rather than in production. Calling it anyway gets a sentence naming the reason.
Generic webhooks
Point it at anything and it sends what you give it, adding nothing:
nuxtPigeon: {
webhook: {
endpoints: {
n8n: { headers: { 'X-Source': 'nuxt' } }, // url from PIGEON_WEBHOOK_N8N_URL
},
},
}await webhook.send({ event: 'deploy', ok: true }, { to: 'n8n' })With a secret it signs to Standard Webhooks, so the other side can verify with an existing library instead of reading our docs. Incoming requests are verified the same way.
Reliability
Retry with growing delays for 5xx, Retry-After respected where the service sends one, and read from the body where a service puts it there instead (Telegram does). Network errors are not retried by default: without an answer there is no way to know whether the message already arrived, and sending it twice is worse than not knowing.
Errors never carry a token. For Discord and Slack the url itself is the credential, so errors name the target instead of the address.
Things that will cost you an hour if nobody tells you
- Mentions are written differently everywhere. Discord
@here, Slack<!here>. A@herecopied from Discord into Slack sits there as dead text and notifies nobody, without an error. - Telegram lowers its own limit with an image. 4096 characters for text, 1024 for a caption. The same string can fit before an edit and be too long after one.
- Bluesky counts graphemes and bytes. 300 and 3000, and neither is
String.length. A blob is limited to about 1 MB, which a normal screenshot exceeds. - Discord drops attachments on an edit that does not name them. We send them again unless you say
media: []. - Server sent events do not arrive through a Cloudflare quick tunnel. The connection opens and stays empty, which looks exactly like a broken server. Use the tunnel for the incoming side and open the page on
localhost.
Nuxt compatibility
Nuxt 4, Node 22 or newer. The server side runs on Nitro, the composable is the only part in the browser.
Contributing
pnpm install
pnpm dev # the playground, with every channel to click through
pnpm test # vitest, no network
pnpm test:types
pnpm lint