Sending text
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
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:
| Channel | Bold | Notes |
|---|---|---|
| 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 |
| Mastodon | none | Plain text, the instance renders links |
| Bluesky | none | Plain text, and links need facets |
| ntfy | Markdown | Off unless you ask for it |
@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:
| Channel | Limit | Counted as |
|---|---|---|
| Telegram | 4096, or 1024 with an image | characters |
| Discord | 2000 | characters |
| Slack | 40000 for text, 3000 per block | characters |
| ntfy | 4096 | bytes, so an umlaut costs two |
| Mastodon | 500 by default, the instance decides | characters, a link always costs 23 |
| Bluesky | 300 and 3000 | graphemes and bytes |
'👨👩👧'.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
sendgives you no message id unless you use a bot token. An incoming webhook answers with the plain textok, so there is nothing to edit afterwards.
Read more
Credentials at runtime
Hand a channel its token from a database or a settings page instead of .env, and swap it while the process runs. The channel restarts on the new values, like docker down and up without the docker.
Sending images
Send images to Telegram, Discord, Slack, Mastodon, ntfy and Bluesky. Pass a url or raw bytes, and the upload, the size limits and spoilers are handled for you.