A Telegram read-later bot on Supabase. Send it links, it sends three back every morning.
Named because I have the 7-second memory of a goldfish.
Why this exists
I save so many articles everywhere but I hardly read them. I wanted to be able to put them in one place and handed to me like a little article butler.
I also thought it would be fun try out and experiment with telegram bots and databases, so why not.
What it does
- Capture. Send a link. It saves the URL, title, meta description and domain. Claude Haiku assigns 1 to 3 tags from a fixed list stored in the database.
- Daily digest. At 08:00 SGT, three unread articles get sent out, one message each. Weighted random favouring older saves, never two from the same domain.
- Backoff. If the last two digests went untouched, skip the send entirely.
- Read / Skip buttons. One way. A skipped article never comes back.
- Notes. Reply to an article message and your reply is saved as a note. Your words only. The bot never writes summaries for you. Reply
/tag <name>to tag it by hand. - Search and browse.
/search <query>covers titles, descriptions and your notes./tagsbrowses by tag./statscounts unread, read and saved this week./helplists the lot. - PDFs. Stored in a private bucket, re-sendable, excluded from the daily feed.
How it fits together
| Layer | Choice |
|---|---|
| Database | Supabase Postgres |
| Compute | Supabase Edge Functions (Deno) |
| Scheduling | Supabase Cron (pg_cron + pg_net) |
| File storage | Supabase Storage, private bucket |
| Telegram client | Raw fetch, no framework |
| Tagging | Anthropic API, Claude Haiku |
Two functions. telegram-webhook handles every inbound update. daily-digest runs
once a day from cron. Everything shared lives in supabase/functions/_shared/.
Read SPEC.md first
SPEC.md is the more useful half of this repo. It holds 38 pinned decisions written before any code, each with the reasoning that produced it, plus the complete schema,
the full input table and the build order.
A few of those decisions exist because writing them down caught a bug the design already had:
- The first selection query used
distinct on (domain), which forces ordering by domain and quietly destroys the age weighting. Replaced with Efraimidis-Spirakis weighted sampling and a logarithmic age weight. - Nothing in the original schema mapped an outbound Telegram message back to an article, which made both the buttons and reply-to-note unresolvable. Fixed with a
sent_messagestable. - Telegram resends an update if your webhook is slow, so a slow save silently became a duplicate save. Fixed by acking
200immediately and processing inEdgeRuntime.waitUntil, deduped onupdate_id.
Running your own
This is written for one person: you. See Single-user by design before you start.
You need a Supabase project, a Telegram bot from @BotFather, an Anthropic API key, Docker (for local development) and the Supabase CLI.
-
Clone and link.
git clone https://github.com/bookwormsuf/goldfish.git cd goldfish supabase link --project-ref <your-project-ref> -
Set the digest URL.
supabase/migrations/0003_cron.sqlcontains a<YOUR_PROJECT_REF>placeholder in the cron job's target URL. Replace it with your own project ref before pushing. Hosted Supabase will not let you set a custom database GUC, so this one value has to be literal in the migration. -
Store the cron auth token in Vault. In the dashboard, add a secret named
digest_cron_secret. Any random 32+ character string. The cron job looks it up by name and sends it as a bearer token, anddaily-digestchecks the header itself. -
Push the schema.
supabase db push -
Set the function secrets.
SUPABASE_URLandSUPABASE_SERVICE_ROLE_KEYare injected for you and must not be set by hand.Name Purpose TELEGRAM_BOT_TOKENBot API auth, from BotFather TELEGRAM_SECRET_TOKENWebhook header secret, any random 32+ char string ALLOWED_CHAT_IDThe only chat the bot will answer ANTHROPIC_API_KEYTag assignment Copy
.env.exampleto.env.localfor local runs..env.localis gitignored and must stay that way. -
Deploy and point Telegram at it.
supabase functions deploy curl "https://api.telegram.org/bot<TOKEN>/setWebhook" \ -d "url=https://<your-project-ref>.supabase.co/functions/v1/telegram-webhook" \ -d "secret_token=<TELEGRAM_SECRET_TOKEN>" -
Send it a link. Then check the
articlestable in the Supabase Table Editor, which is the entire admin UI.
The cron schedule is UTC, so the 0 0 * * * in 0003_cron.sql is 08:00 Singapore.
Change it for your own timezone.
Making it yours
- Tags are seeded in
0001_init.sql. The statement there readsinsert into topics, because the table was only renamed totagslater in0005. Tags are read live from the database, so the LLM can only ever pick from that list. Edit the seed before your first push, or edit the table afterwards. Mine are design and engineering heavy; yours will not be. - Every user-facing string lives in
supabase/functions/_shared/copy.ts. - Digest size, weighting and backoff are in
daily-digest/index.ts, with the reasoning for each in SPEC.md.
Single-user by design
There is one bot token, one allowed chat ID, one cron row, and no row-level security. Edge Functions talk to Postgres with the service role key.
That is a deliberate v1 decision. user_id columns are present on every table from the first migration, so RLS policies can slot in later without a
schema migration. But making this genuinely multi-tenant means real auth, policies on every table, per-user bot registration and per-user scheduling.
Deploy it for yourself, not for other people. If you point it at a shared Supabase project or hand the bot to a second person, the isolation you are assuming is not there.
Deliberately not here
- No AI summaries. The bot never writes about an article for you. Haiku only picks tags from a list you control. The notes are the point, and they have to be yours.
- No full article text. Search runs over titles, descriptions and your notes. This is also what keeps a free local embedding model viable for a future v2, since its ~512-token limit is fine for short notes and useless for whole articles.
- No frontend. The Supabase Table Editor is a perfectly good admin panel for one person.
- No backfill. It starts empty on purpose. Import 800 stale links and your morning three come from 2023.
Deferred to a possible v2: note embeddings and vector search, second-brain export, inline search mode.
Status
Built, deployed and running daily. It is a personal project shared as a reference, not a maintained product. Issues and pull requests may not get a reply.
Security
Found something that shouldn't be public knowledge? Please report it privately through GitHub's private vulnerability reporting rather than opening an issue.
Worth knowing if you deploy your own: this runs with the Supabase service role key and no row-level security, so anything that can reach the functions can reach the whole database. The defences are the webhook's secret header, the chat allowlist, and the Vault-stored bearer token on the digest endpoint. See Single-user by design.
Licence
MIT. See LICENSE.