DEV Community

Cover image for Laravel Telegram Bot: How I Cleared My YouTube Watch Later with AI Summaries
Hafiz
Hafiz

Posted on • Originally published at hafiz.dev

Laravel Telegram Bot: How I Cleared My YouTube Watch Later with AI Summaries

Originally published at hafiz.dev


My YouTube Watch Later had 42 videos in it. I'd watched maybe ten. X bookmarks were worse because I couldn't even remember why I'd saved half of them. Every platform has a save button, and every save button leads to the same place: a list nobody ever opens again.

The obvious fix is "build something that syncs those lists and reminds you." I tried. That version is impossible, and knowing why shapes the whole design. So I built the opposite: a Telegram bot backed by a small Laravel app that replaces the save button instead of syncing it. I forward a link, it fetches the content, summarizes it with the AI SDK, and then, once a day, it forces me to decide: watch, snooze, or archive.

I've been running it for ten days. Here's the build, including the security bug that almost shipped and the fallback path that fires way more often than I expected.
A digest item marked as watched, with the video link unfurled below it in the chat

Why You Can't Sync Watch Later (So Stop Trying)

The dream version of this tool connects to your accounts and pulls your saved items. Two hard walls and one soft one:

YouTube's Data API deliberately excludes the Watch Later playlist. Access was removed years ago and never came back. You can read any playlist except that one.

Facebook's saved items have no API at all.

X bookmarks are the soft one. They do have an API, and since April 2026 reading your own data costs $0.001 per item, so pulling a thousand bookmarks runs about a dollar. But you still need a paid developer account with credits loaded and an OAuth flow to get there, and it only solves X. YouTube and Facebook stay shut either way.

This is why no existing tool does real sync. The workaround is to own the capture step: don't read their list, become the list. On mobile that's the native share sheet, which already has Telegram in it. Share, tap the bot, done. On desktop it's a bookmarklet that POSTs the current URL to the app. Either way, the platform's save button never gets touched again.

The Architecture

The stack is deliberately boring: Laravel 13, SQLite in WAL mode, the database queue driver, and the Telegram Bot API called through plain HTTP. No Telegram SDK package. The bot surface is four methods (sendMessage, editMessageText, answerCallbackQuery, setWebhook), and a thin service class wrapping Laravel's Http client covers them in about 60 lines. Zero extra infrastructure: no Redis, no Node process, no third-party queue.

There's also no frontend. Telegram is the UI, which means the tool works on mobile, desktop, and web from day one without me writing a single screen.

The flow from save to digest looks like this:

View the interactive diagram on hafiz.dev

The webhook does three jobs: it accepts new links, it answers commands like /stats, and it handles the button presses coming back from digest messages. Everything slow happens in a queued job, so the bot always answers in under a second with "Saving..." before the real confirmation arrives.

Cleaning URLs Is Half the Feature

The first thing the SaveUrl action does is canonicalize. The same video arrives as youtube.com/watch, youtu.be, a Shorts URL, or any of those with tracking junk attached (si=, utm_*, the X s= and t= params). All of them collapse to one canonical form, so saving the same video twice gets caught as a duplicate instead of cluttering the inbox. Timestamps survive the cleaning because "watch from 18:30" is information I actually want.

One small decision I like: re-saving something already watched or archived revives it back to the inbox. If I cared enough to save it twice, it's relevant again.

Fetching Content Without Scraping Anything

For YouTube, oEmbed gives me the title and channel for free, no API key. Duration comes from the Data API's videos.list if a key is present, and gets skipped when it's not. Transcripts are best-effort against the caption endpoints.

For X, the public oEmbed endpoint at publish.twitter.com returns the tweet text and author for any public tweet. No auth, no paid tier. That's the entire "X integration."

Everything else falls through to an article fetcher that grabs the title tag and meta description. Not glamorous, works fine.

Then the queue job hands whatever content it collected to the AI SDK with a schema-validated structured output request: a two-to-four sentence summary plus three to five lowercase tags, returned as strict JSON. If you haven't used structured outputs in the SDK yet, the smart assistant tutorial covers the pattern; it's the same idea here with a smaller schema.

The app runs DeepSeek by default because summaries are a cheap-model task, but the provider is configuration, not code. Two env vars switch it to Anthropic or OpenAI, and the same swap works for local models through Ollama if you want summaries that cost nothing. Since the SDK went stable, this kind of provider independence is the main reason I keep reaching for it in side projects.

The "Probably:" Fallback Fires More Than You'd Think

Here's the honest part. YouTube transcript fetching fails constantly. Captions are disabled, auto-captions aren't exposed, the endpoint shape shifts. I expected transcripts to be the normal path and the fallback to be rare. Ten days of real usage says it's closer to the other way around.

So the fallback is a first-class feature, not an error state. When there's no transcript, the model summarizes from the title and channel alone, and the summary is prefixed with "Probably:" so I know it's an educated guess rather than a digest of the actual content. Treating low confidence as something to label instead of hide turned out to be the right call. A guessed summary still answers the only question that matters at digest time: do I still care about this?

If I later want real transcripts, the escape hatch is downloading captions server-side per video. That's a v2 problem. For deciding watch-or-archive, "Probably:" is enough.

One Item a Day, Three Buttons

Every evening at 19:00 the scheduler picks exactly one item and sends it with its summary and three inline buttons: Watch, Snooze 7d, Archive.

The selection logic matters more than it looks. Due snoozes win first, oldest snooze first. Otherwise it picks from the inbox: never-surfaced items first, then whatever was surfaced longest ago. That rotation means ignoring a digest doesn't show you the same item every day forever, which is exactly the failure mode that makes reminder apps unbearable.

A digest item marked as watched, with the video link unfurled below it in the chat

The buttons edit the original message in place. Tap Watch and the message updates to show the plain URL so it unfurls and I can tap through. Tap Archive and it becomes one line. No app, no tab, a two-second decision inside a chat I already have open.

And because guilt is the actual product being fought here: anything untouched for 90 days gets auto-archived by a weekly job. The clock keys on updated_at, so snoozing counts as engagement and resets it. The list can only shrink.

A /stats command replaces the dashboard I refused to build. Inbox count, watched, archived, watch rate, age of the oldest item. One message. That's the whole confession.
The /stats command output showing inbox count, watched, archived, watch rate and age of the oldest saved item

The Bug That Almost Shipped: hash_equals('', '')

The first live smoke test caught something the 47 passing tests didn't. With a fresh .env where the secrets weren't filled in yet, every authenticated endpoint let everyone in.

The webhook check compared the configured secret against the header Telegram sends. The ingest endpoint compared the configured token against the bearer token. Both used hash_equals(). And hash_equals('', '') returns true, because an empty string does equal an empty string. Blank config plus absent header equals open endpoint, on a public VPS.

The tests never caught it because every test sets the secrets. Real config drift is exactly the case your test suite doesn't model.

The fix is a rule worth stealing for any personal tool: fail closed when config is blank. Every auth check now short-circuits to a 403 if the configured secret is empty, before any comparison runs, with regression tests pinning it. If a required secret isn't set, the correct behavior is "nothing works," never "everything works."

What I Deliberately Didn't Build

No user accounts, no dashboard, no Chrome extension, no Facebook support, no multi-tenancy. This is a single-user tool on my own VPS, and every one of those features would have turned a weekend build into a month of speculation about users who don't exist yet.

That's also my honest recommendation if you build one: resist the SaaS reflex. The value here is that it's yours, it's small, and it costs nothing to run. SQLite plus the database queue on a box you already have is the whole bill. If strangers start asking to use it, that's a different project and a different decision, and it should be made then, with real people asking, not upfront.

FAQ

Why can't it just read my existing YouTube Watch Later list?

Because the API doesn't allow it. YouTube removed Watch Later access from the Data API years ago, and no official way back exists. Any tool claiming to sync it is scraping, which breaks routinely. Replacing the save action is the reliable path.

What happens if I don't configure an AI provider?

Links still get saved with full metadata (title, channel, duration). The bot just says no summary is configured. The AI layer is optional, not a dependency.

Why Telegram instead of WhatsApp?

Telegram's Bot API is free, instant to set up, and supports inline buttons and message editing, which the digest depends on. WhatsApp's Business API costs money and requires approval. This isn't close.

Does it handle X bookmarks?

It replaces them rather than reading them. The bookmarks API is affordable now (roughly a dollar per thousand of your own items since April 2026), but it needs a funded developer account and an OAuth flow, and it only covers X. The public oEmbed endpoint returns any public tweet's text for free, so forwarding a tweet link gets you the same summarize-and-resurface treatment with no account at all.

How does it decide which item to resurface?

Due snoozes first, then never-surfaced inbox items oldest-first, then the item that's gone longest without being shown. Ignoring the digest rotates the backlog instead of repeating one item, and anything untouched for 90 days archives itself.

Watch Rate Over Library Size

Ten days in: 12 sitting in the inbox, 7 watched, 3 archived, one snoozed. That's a 70% watch rate on the things I've actually decided about, and I'll be honest that the number flatters me. These are still the early days and the sample is ten decisions. Ask again in a month when the novelty has worn off.

The whole thing is on GitHub at hzeeshan/watch-later if you want to run your own. It's a single-user tool by design, so expect to spend ten minutes on a bot token and a cron entry rather than a signup form.

Top comments (0)