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.

Some services fetch a url for you. Some refuse urls and want the file itself. One turns the file into the message. You write the same thing for all of them.

Copy this

server/api/screenshot.post.ts
export default defineEventHandler(async () => {
  await telegram.send('The failing step', {
    media: [{ url: 'https://example.com/shot.png', alt: 'The failing step' }],
  })

  return { ok: true }
})

That runs. Telegram hands the url on and fetches it itself. Mastodon and Bluesky cannot do that, so for them the module downloads it and uploads it for you. You do not choose, and you do not have to know which.

Two shapes, and only two

{ url: 'https://…/shot.png', alt: 'A screenshot', filename: 'shot.png' }
{ data: bytes,                alt: 'A screenshot', filename: 'shot.png', type: 'image/png' }

data takes a Blob, a Uint8Array or an ArrayBuffer. Everything else is optional.

No file paths, and no bare base64. A path would pull node:fs into the module, and that does not exist on Cloudflare Workers. Reading the file is two lines you write, and then it is bytes like everything else.
const data = await readFile('shot.png')
await telegram.send('Look', { media: [{ data }] })

Why both shapes exist everywhere

A url only version would force you to publish a private screenshot on the internet so that we can download it again. That is absurd, and in some places it is a data protection problem.

A bytes only version would drop Discord spoilers, which work exclusively on real attachments.

So both work everywhere, and each channel picks the cheaper route:

ChannelA urlBytes
Telegramhanded over untouched, Telegram fetches itmultipart
ntfyhanded over as attach, ntfy fetches itPUT, the body is the file
Discordwe fetch it, an attachment takes no urlmultipart
Mastodonwe fetch it, /api/v2/media takes a file onlynative
Blueskywe fetch it, uploadBlob wants raw bytesnative
Slackwe fetch it, three step uploadnative

Limits, and they are not close to each other

ChannelPer fileHow many
Discord10 MB10
Blueskyabout 1 MB4
Mastodoninstance decides, often 10 MB4, instance decides
TelegramTelegram's own, generousone album
Bluesky's 1 MB is the one that will hit you. A normal screenshot is three to five times that. Official clients quietly resize before uploading, and this module does not: resizing is a decision about your content and would mean a dependency. You get the real number in the refusal instead, before anything is sent.

What will cost you an hour

  • Telegram's caption limit is 1024, not 4096. Adding an image lowers the limit of the text you already wrote.
  • A Discord spoiler only works on an attachment, through a SPOILER_ filename prefix. On an embed.image.url it is ignored, no error, just not hidden.
  • Mastodon answers 202 while it is still processing a large upload, and posting before it finishes is refused. The module waits for you.
  • Slack needs the bot in the channel to upload. chat:write.public covers messages but not files, so /invite it once.
  • A Slack upload has no message timestamp. The file becomes the message and Slack answers without a ts, so it can be deleted but never edited.

Read more