Case study

Ping

Share a link in the chat without anyone leaving the chat.

2026 · A WhatsApp bot · Runs on your own server

Ping: a WhatsApp thread where a tweet link is answered with a rendered card of the tweet

Someone drops a tweet in the group chat and everybody has to leave to read it. You open X, wait for it to load, read four lines and come back. Reels and TikToks are worse, because half the time the app wants you signed in before it will show you anything at all.

Ping is a WhatsApp account I run that answers that problem in the chat it came up in. You send it a link and it replies with the thing itself. Sometimes that is the video or the photos. Sometimes it is a picture of the post with the avatar, the name, the text, the likes and the date still attached, so you can see who said it and when without going anywhere.

It runs in a Docker container on a VPS I already pay for, connected to a spare WhatsApp number. There is nothing to sign up for. It is one repo, and you can run your own copy on your own number.

What you send it

Ping reads your message, takes the first link in it and downloads what is behind that link. Everything else is a word you can put anywhere in the same message, in any order, because it looks for the words rather than parsing a command.

A link on its own gives you the media. A tweet with a video gives you the video. A post with several photos comes back as one album instead of five separate messages. Add mp3 and you get audio.

Add card and you get a picture of the post instead of what was in it.

a link on its own
The media behind it. Video by default
mp3
Audio instead of video
card
A picture of the post as it looks in the app
card mp4
The same card with the clip playing inside it
card mp4 quote
Play the quoted tweet's video
card mp4 parent
Play the video in the tweet being replied to
card mp4 og
Play the video in that tweet's own quote

A link on its own

card mp4

The same Instagram link sent twice. On the left, the reel on its own, which is what you get by default. On the right, the same link with card mp4 on the end, which keeps the account, the likes and the caption around it and plays the clip in place.

What a card is

A card is a drawing of the post rather than a screenshot of one. Nothing opens a browser and nothing takes a picture of a screen.

For tweets, the text, the media, the counts and the date come from the endpoint that the official embed widget uses. It is public, so there is no login and no account involved. Instagram goes through gallery-dl, which does need your own cookies. TikTok goes through yt-dlp, which does not need anything.

The drawing itself is done by Satori. You give it a tree of elements with flexbox rules on them and it hands back an SVG. resvg turns that SVG into a PNG at twice the size, and the PNG is what arrives in the chat. Because it is a layout engine rather than a template, a long tweet and a short one both come out looking right.

A tag, a link, and the word card. The reply is a PNG, so it behaves like any other photo in the chat: it can be forwarded, saved, or replied to.

Replies and quotes

A tweet is often only half of what is being shared, so the card draws the other half too.

If the tweet is a reply, the card puts the tweet it answers above it with the thread line running down the left, the way X shows it. If it quotes another post, the quoted post gets its own box inside the card. If it is a reply to a tweet that itself quotes something, all three appear in one picture, each one set in a narrower column than the one above so the nesting is easy to follow.

A reply

A quote

Left: a reply, with the tweet it answers drawn above it. Right: a quote, with the quoted post boxed inside the card.

Playing the video inside the card

A still card of a video post is a still of a video, which is only half useful. Adding mp4 to a card message gives you an mp4 instead, where the clip plays inside the card while the header, the text, the likes and the caption stay put around it.

The awkward part is knowing where in the finished picture the video belongs, because that position depends on how the layout turned out. So the layout answers the question itself. Where the video should go, it puts a solid rectangle in one colour that nothing else in the card uses. Once Satori has produced the SVG, the code finds the rectangle with that fill and reads its position and size straight off it. Then ffmpeg scales the clip to that box, crops it to fit, and lays it over the still card for as long as the clip runs. The measurement is never done twice, so a card whose layout shifts cannot get a video in the wrong place.

One tweet can hold up to four videos: its own, its quote's, the one it replies to, and that tweet's quote. Ping plays the first one it finds, in that order. If you want a different one, say which: quote, parent or og.

This tweet has no video of its own. The one playing belongs to the post it quotes, which is the first video Ping found and so the one it used.

Reels and TikToks

Instagram cards are built the same way as tweets: header, media, actions, likes, caption, date, with the clip dropped into the media slot.

TikTok is built the other way round, because the video is the whole frame there and the interface sits on top of it. So the card is rendered on a transparent background, and ffmpeg puts the clip underneath it rather than inside it. The rail, the caption and the sound line end up over the video, which is where they are on TikTok.

In a group, nothing happens until Ping is tagged, so the bot can sit in a chat without answering every link that goes past.

Who it is allowed to answer

The account is a real WhatsApp account, so it can see every chat the number is in. Two settings decide what it acts on.

PING_DMS covers direct messages, and is one of anyone, self for your own chat with yourself, or off. PING_GROUPS covers groups, and is one of mention, all or off. It ships on mention, which means the bot sits quietly in a group until someone tags it.

It also ignores anything sent before it started. WhatsApp delivers a backlog when a device reconnects, and without that check a restart would download every link from the last hour again.

PING_DMS
anyone, self, or off
PING_GROUPS
mention, all, or off
GALLERY_DL_COOKIES_B64
Login cookies, Instagram and YouTube
LINK_TOKEN
The secret that opens the pairing page
WA_AUTH_DIR
Where the WhatsApp session is kept

Pairing it without a terminal

WhatsApp links a device by QR code, which is easy on your laptop and annoying on a server. The first version printed the QR into the container logs, so relinking meant opening a shell on the box with a phone in the other hand.

Now the bot serves one page of its own. Set LINK_TOKEN to a secret and it listens on /link?token=..., giving the container a domain is enough to reach it. The page shows the current QR, refreshes itself every five seconds, and says “Linked” once the scan goes through. Without a token it serves nothing and falls back to the logs.

WhatsApp unlinks devices on its own sometimes. When that happens while the bot is running, it clears the dead session and goes back to showing a QR by itself, so relinking is opening a URL rather than redeploying anything. The one thing to get right is keeping the session folder on a volume, because a fresh container without it means pairing again after every deploy.

How it's built

Bun, TypeScript
One process, no build step
Baileys
Talks to WhatsApp as a linked device
yt-dlp
Video and audio, most sites
gallery-dl
Photos, and Instagram posts
ffmpeg
Merging, converting, compositing
Satori, resvg
Card layout to SVG to PNG
X syndication API
Tweet text, media, quotes, replies
Docker, GitHub Actions
Build on push, redeploy on the VPS

Running your own

It needs Bun, yt-dlp, gallery-dl and ffmpeg on the machine, or the Docker image, which has all four in it already. Copy .env.sample, run the container, open the link page once, and it is yours.

Instagram needs your cookies to download anything, and YouTube asks for them too when the request comes from a datacentre address. There is a script that takes a cookies.txt export, throws away every line that is not Instagram or YouTube, and encodes what is left into the env file. Use a throwaway Google account for the YouTube half, because cookies used from a server IP can get an account flagged.

The repo is at github.com/echoeyecodes/wa-ping-downloader.

Every card and clip on this page came out of the bot. The links were run through the command line version, and the files it wrote are the files playing here. Posts belong to their authors.