Sending text

Send text to Telegram, Discord, Slack, ntfy, Mastodon and Bluesky with one verb. Formatting differs per service, and every limit is checked before sending.

Every channel has send, and it always takes the text first and the options second. What differs is what the options are, because a Telegram message and a Bluesky post are not the same thing and pretending otherwise would only hide the parts that matter.

Copy this

server/api/deploy.post.ts
export default defineEventHandler(async () => {
  await telegram.send('Deploy failed on main')
  await discord.send('Deploy failed on main')
  await ntfy.send('Deploy failed on main')

  return { ok: true }
})

That runs. Every channel that has credentials in .env sends. The ones that do not throw a sentence telling you which variable is missing.

Sending to more than one at once

There is no fan out verb, and that is deliberate: it would be a wrapper around one line you can write better yourself.

const results = await Promise.allSettled([telegram.send(text), discord.send(text), ntfy.send(text)])

allSettled, not all. One dead channel must not take the other two with it, and it must not fail the request your user is waiting on.

Formatting is different everywhere

This is the part that surprises people, so it is worth one table:

ChannelBoldNotes
Telegram<b>x</b> or *x*Needs parseMode, otherwise the tags are literal
Discord**x**Always on, no flag
Slack*x*One asterisk. Two are literal
MastodonnonePlain text, the instance renders links
BlueskynonePlain text, and links need facets
ntfyMarkdownOff unless you ask for it
A @here copied from Discord into Slack does nothing. Slack writes it <!here>. There is no error, the text simply sits there and notifies nobody. The same is true for user mentions, which are <@U123> in Slack and <@123> in Discord.

Text that came from somewhere else

If the text contains anything a user typed, escape it. Every channel that has markup brings its own escaper, because the rules are not the same:

await telegram.send(telegram.escapeHtml(userInput), { parseMode: 'HTML' })
await discord.send(discord.escapeMarkdown(userInput))
await slack.send(slack.escapeMrkdwn(userInput))

Without it, a name like **Flo** arrives bold, and a stray < breaks a Telegram message entirely with a parse error rather than sending it plain.

Limits are checked here, not there

Every channel counts before sending, so you get a number instead of an API error:

ChannelLimitCounted as
Telegram4096, or 1024 with an imagecharacters
Discord2000characters
Slack40000 for text, 3000 per blockcharacters
ntfy4096bytes, so an umlaut costs two
Mastodon500 by default, the instance decidescharacters, a link always costs 23
Bluesky300 and 3000graphemes and bytes
Bluesky is the one to watch. '👨‍👩‍👧'.length is 8, its grapheme count is 1, and it weighs 18 bytes. All three numbers are different and only two of them matter.

What will cost you an hour

  • Telegram lowers its own limit when you add an image. 4096 for a message, 1024 for a caption. The same string can fit before an edit and be too long after one.
  • Mastodon's limit is not 500. It is whatever the instance says, from 500 into the thousands. The channel asks once and remembers.
  • A Mastodon link always costs 23, however long it is. Shortening a url in your text changes nothing about the count.
  • Slack's send gives you no message id unless you use a bot token. An incoming webhook answers with the plain text ok, so there is nothing to edit afterwards.

Read more