Limits and errors

Limits checked before sending, errors that never carry a token, and refusals that say what to do. Safe to log, and useful when they land in your tracker.

Two things decide whether an error is useful: whether it says what to do, and whether it is safe to log. Both are handled here rather than passed on.

Copy this

server/api/hello.get.ts
export default defineEventHandler(async () => {
  try {
    await telegram.send('x'.repeat(5000))
  } catch (error) {
    return { failed: (error as Error).message }
  }
})

That runs, and it answers:

Telegram rejects messages over 4096 characters, got 5000

Nothing was sent. The count happened here, so you get a number instead of an API error after a round trip.

No error carries a token

For Telegram the token sits in the url. For Discord and Slack the webhook url is the credential. A raw fetch error puts the full url in its message, and from there it goes into your log, your error tracker and your ticket.

So the errors are rebuilt:

Discord responded 401
Telegram sendMessage failed: chat not found
Slack chat.postMessage failed: missing_scope, needs the scope `chat:write`

The target is named, the address never is.

The original is still attached as cause, so nothing is lost:
catch (error) {
  console.error(error.message)          // safe to log
  console.error(error.cause?.status)    // 401
  console.error(error.cause?.data)      // what the service actually said
}

What is checked before sending

CheckChannel
Text lengthall, each with its own counting
Caption length, 1024Telegram, when there is an image
Grapheme and byte countBluesky
Attachment countDiscord 10, Bluesky 4, Mastodon instance dependent
File sizeDiscord 10 MB, Bluesky about 1 MB
Missing credentialsall, naming the variable

A missing variable says which one:

Telegram bot token is not defined. Set PIGEON_TELEGRAM_BOT_TOKEN

Services that fail with a 200

Two of them, and both would otherwise pass for success:

Slack answers 200 with { ok: false, error: 'missing_scope' }. The module throws, and names the scope when Slack says which one is needed.

Bluesky accepts a putRecord on a post with a 200 and then ignores it. There is nothing to throw on, so the channel does not offer edit at all.

Refusals that are not errors

Where a service cannot do something, the method is missing from the type, so it is a red line while typing rather than a surprise in production:

ntfy.edit(message, 'new')
//   ^^^^ Property 'edit' does not exist

Called anyway from plain JavaScript, it explains itself instead of failing as undefined is not a function:

slack cannot edit through an incoming webhook: it answers with the plain text `ok`
and gives no message id, so there is nothing to address. Set PIGEON_SLACK_BOT_TOKEN,
and give the app the `chat:write` scope.

The wording separates two cases on purpose: the service cannot do it at all, or it can but needs other credentials than the ones you configured.

Warnings

One case is neither an error nor silence. Bluesky carries one embed per post, so images and a link card cannot both go. Refusing the whole post would be worse than sending a slightly reduced one, but it must not happen quietly:

[nuxt-pigeon] bluesky: a post carries one embed, so the link card wins.
Your first image became its thumbnail.

That goes to the server log. It is the only place the module writes a warning rather than throwing, and the rule behind it is: throw when we cannot send what you asked for, warn when we send it differently.

What will cost you an hour

  • One dead channel must not kill the request. Promise.allSettled, never Promise.all, when you send to several.
  • Message contents are never logged. They can be personal data. Errors name the channel and the reason, not what you were sending.
  • A limit can be fine before an edit and too long after it. Telegram drops from 4096 to 1024 the moment there is an image.
  • Mastodon's limit is not 500. It is whatever the instance says, and the module asks once and remembers.

Read more