trending.upvote.at #
tl;dr when a link starts collecting upvotes fast, @trending.upvote.at posts it.
A Bluesky bot for the upvote.at ecosystem. It watches the network's at.upvote.signal records and, when enough distinct accounts upvote the same subject inside a short window, announces that subject with an app.bsky.feed.post — on its own, as the upvotes come in.
How it works #
- On every poll (
POLL_INTERVAL_MS), the bot reads the signal index newest-first through HappyView's publishedat.upvote.getSignalsquery — the exact same read surface upvote.at's own server hydrates its feed from — walking pages until it crosses the trending window's boundary (src/signals.ts). - It excludes
at.upvote.commentsubjects (comment upvotes belong in the site's comment feeds, never the link-announcement bot), then counts distinct accounts per remaining subject inside the window. One account, one vote: the same account signalling a subject five times counts once, so the threshold can't be self-serviced by writing extra records into your own repo (the same earliest-wins reasoning upvote.at'smergeSignalapplies). - A subject is trending when at least
TRENDING_THRESHOLDdistinct accounts signalled it within the lastTRENDING_WINDOW_MINUTES. Both knobs are env config, expected to be retuned as the network grows; garbage values stop the boot rather than silently falling back to a default nobody chose (src/config.ts). - Before anything posts, the gates run cheapest-first (
src/bot.ts):- Not already posted: one post per subject, forever, recorded in PostgreSQL. This mirrors upvote.at's "every URL has one submission" model — a subject that trends again a year later has already had its announcement. Already-posted subjects are skipped silently (every posted subject keeps re-qualifying for as long as it trends).
- Resolvable: the subject is resolved into everything it is (see filters below) and the URL + title a post needs (
src/subject.ts). All record reads go through HappyView'sat.upvote.getRecords, batched (src/records.ts); this bot never resolves a DID or contacts a PDS, same rule as upbot and upvote.at's server. - Not blocked / allowed: the filter lists (below).
- If all gates pass, the bot claims the subject in Postgres, composes the post, and creates it on its own account (
src/post.ts): text is▲ {points} · {title}, and the link travels as anapp.bsky.embed.externalcard. Web-page subjects get their title from the page itself (OpenGraphog:title, thentwitter:title, then<title>— the same chain and the same SSRF guards as upvote.at's display path, adapted insrc/page.ts), fetched only after the filters said yes, so no outside page is ever touched for a subject that would be refused. A page that won't yield a title still posts, titled by its own URL.
After each successful poll, a structured JSON heartbeat reports aggregate posted and skipped counts, skip-reason counts, and since-start totals. Polls with definitive outcomes log at info; quiet successful polls log at debug and therefore stay hidden at the default info level. Subjects, titles, account identifiers, URIs, URLs, and filter values never enter the logs; infrastructure failures are separate events with a bounded error type and factual retry metadata (src/heartbeat.ts).
What a post looks like, per subject kind #
The signal lexicon allows two subject shapes (an http(s) URL, or an at-uri), and the at-uris fan out by collection:
| Subject | Posted link | Title |
|---|---|---|
| Web URL | the URL, verbatim | fetched from the page (OG tags), best-effort |
app.bsky.feed.post |
its https://bsky.app/profile/<did>/post/<rkey> URL |
the post's text |
site.standard.document |
the publication's canonical origin joined with the document's path (the same join upvote.at's documentMeta does) |
the document's own title/description |
| any other record | the record's own url field |
title/name/displayName/text, first present |
A record that states no web home, a dangling at-uri, or a non-http/at subject is unpostable — there is nothing clickable to post. That verdict is not persisted: it re-evaluates each poll (a record can heal) and the candidate simply ages out when its window passes. External-embed cards rather than quote posts for Bluesky subjects is deliberate: at.upvote.getRecords answers record values without CIDs, and a quote embed (app.bsky.embed.record) requires the CID; the card needs only the URL.
The spam gates: allowlist and blocklist #
Both lists live in env (ALLOWLIST / BLOCKLIST, comma- or newline-separated) and accept three kinds of entry, typed by shape — nothing to annotate:
| Entry looks like | Kind | Matches |
|---|---|---|
did:plc:... |
account | the subject's author (an at-uri's authority); for a standard.site document, also the publication's owner |
at://.../site.standard.publication/... |
standard.site publisher | the publication a document names in its site field |
example.com (also tolerates a pasted URL or stray dots) |
domain | the subject's own host for web URLs, the publication's canonical host for documents, a record's own url host otherwise — and, for Bluesky posts, the host of the post's external embed, so a spam link can't hide inside a post wrapper. Suffix-matched on label boundaries: example.com covers blog.example.com, never notexample.com |
Every identity a subject carries is checked against both lists (src/filters.ts):
- Blocklist wins. A match is never posted, full stop — even if the allowlist also matches.
- Allowlist empty (the default): everything not blocked is eligible. Allowlist non-empty: only matching subjects are eligible.
- Verdicts are live, not recorded: lists are re-consulted every poll, so removing a blocklist entry takes effect while the subject still trends, and blocked subjects age out naturally with their window.
- An entry no kind can parse stops the boot with the offending entry named. A filter list that silently drops an entry is a spam gate with an invisible hole in it.
Adding a new filter kind later (a labeler, a keyword, whatever's next) means extending the discriminated Filter union in src/types/filters.ts and adding its keyed definition to FILTER_KINDS in src/filters.ts — how to recognize an entry, normalize it, and match it. If it matches on something new, SubjectIdentity and the resolver grow that facet too. The registry types then require every kind to participate in parsing and matching; heartbeat telemetry deliberately continues to expose only aggregate blocked counts, never the matched kind or value.
Definitive outcomes vs. infrastructure failures #
The same discipline as upbot: a gate saying no (blocked, not on the allowlist, explicitly not found, or otherwise unpostable) is an outcome — logged in aggregate and re-evaluated fresh next poll. HappyView transport/upstream/schema failures, database failures, and PDS write failures are infrastructure failures, not outcomes. Page-title fetching is deliberately best-effort instead: its own failure is logged and the URL becomes the title, so it does not block a post.
The subject is claimed in Postgres before the post is created, closing the normal overlap race. Failures release that claim when possible so the next poll can retry; a failed release means automatic retry is not guaranteed, and a retained claim needs startup recovery. The PDS write and SQL confirmation cannot be atomic, though: if the PDS accepted a post but its response was lost, confirmation failed, or the process stopped between those operations, releasing the claim can produce a duplicate on retry. Keeping an ambiguous claim would instead risk missing the announcement. Boot sweeps interrupted unconfirmed claims and logs that duplicate window (releaseUnconfirmed in src/db.ts). The exhaustive getRecords response distinguishes explicit notFound (a definitive unpostable result, including a missing document publication) from transport, resolver, malformed, contradictory, or incomplete responses, which throw for retry.
Reads go through HappyView, never a direct PDS contact #
Every record this bot reads — the signal index (at.upvote.getSignals) and subject/publication records (at.upvote.getRecords) — goes through HappyView, the same AT Protocol boundary upvote.at's server and upbot read through, with the same client-key header and 429 retry/backoff (src/happyview.ts, copied not reinvented). The bot never discovers or contacts a PDS. The one non-HappyView fetch is the OpenGraph title read for plain web-page subjects, which by definition isn't atmosphere data; it runs behind the SSRF guards described above.
Register this bot as its own confidential HappyView client (Settings → API Clients → New client, or POST /admin/api-clients), not upvote.at's or upbot's: a burst of bot traffic must not rate-limit the live website or vice versa. That yields the hvc_... key (HAPPYVIEW_KEY) and hvs_... secret (HAPPYVIEW_SECRET) pair.
The bot's own writes (the trending posts) use its own Bluesky account via an app password — CredentialSession from @atproto/api, the same arrangement upbot uses for its notification-reading account. No OAuth flows, no sessions to store, and therefore no HTTP server at all: this is a pure worker process.
Data model / lexicons #
lexicons/ carries this bot's own copies of the schemas it depends on (every AT Protocol implementation carries the schemas it needs; upvote.at and upbot's lexicons/ folders exist for the same reason):
signal.json: the record the bot aggregates. It never writes one.getSignals.json: the HappyView query the trending window is scanned through.getRecords.json: the batched record reads subjects and publications resolve through.
npm run lexicons:diff checks these against upvote.at's copies (npm run lexicons:diff:copy re-syncs).
Configuration #
Copy .env.example to .env and fill in:
| Var | Meaning |
|---|---|
BSKY_HANDLE / BSKY_APP_PASSWORD |
The bot's own Bluesky account (an app password, not the account password). |
BSKY_SERVICE |
PDS/service the bot's account logs into (default https://bsky.social). |
POLL_INTERVAL_MS |
How often to re-evaluate the window (default 60s). |
LOG_LEVEL |
Structured log threshold (fatal, error, warn, info, debug, trace, or silent; default info). |
TRENDING_THRESHOLD |
Distinct accounts required (default 5). Invalid values stop the boot. |
TRENDING_WINDOW_MINUTES |
The window those accounts must fall inside (default 60). Invalid values stop the boot. |
ALLOWLIST / BLOCKLIST |
Filter entries, comma- or newline-separated, typed by shape (see above). Unparseable entries stop the boot. |
DATABASE_URL |
PostgreSQL connection string; the posted-subjects log lives here. Required, no default. |
HAPPYVIEW_URL |
The HappyView instance to read through (default https://happyview.upvote.at). |
HAPPYVIEW_KEY |
HappyView client key (hvc_...) — required. Register this bot as its own confidential client. |
HAPPYVIEW_SECRET |
HappyView client secret (hvs_...) — optional, but sent whenever set. |
Storage #
One table (posted_subjects, created idempotently on boot — src/db.ts): which subjects have been announced, when, with which post. PostgreSQL rather than local disk for the same reason as upbot: on Railway the container filesystem is ephemeral, and a bot that forgot its history on redeploy would re-announce the entire currently-trending window. On Railway, reference the Postgres service rather than pasting a URL:
DATABASE_URL=${{Postgres.DATABASE_URL}}
For local development, any Postgres will do:
docker run --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:17
Development #
npm install
npm run dev # tsx watch src/index.ts
Testing #
npm test # vitest run --coverage
npm run lint
npm run check # tsc --noEmit
Every module is unit tested with the network boundary mocked (db.ts's Postgres is real — PGlite is Postgres itself compiled to WebAssembly, running in-process, so the actual SQL is exercised rather than stubbed; see src/db.fixture.ts). happyview.spec.ts mirrors upbot's suite for the retry/backoff behavior directly. The SSRF boundary in page.spec.ts covers private IPv4/IPv6 literals (including bracketed ones — WHATWG URL keeps the brackets, which almost let literals dodge the check), private DNS answers, and redirect hops. bot.spec.ts proves the gate ordering, the blocklist-beats-allowlist rule, the claim/release retry path, and that one broken candidate never stops the rest.
What isn't covered by an automated test: the composition root (src/index.ts: real login, real timers, process signals — everything it wires together is tested on its own) and a real post landing on a real PDS.
TODO #
- Web-URL subjects that are atmosphere records (a raw
bsky.apppost link written by another client's signal) are filtered by their domain but not converged onto their record identity; upvote.at's discovery-tag verification is the model that ever matters here. posted_subjectsis append-only forever. Fine at current scale; prune byposted_atif it ever isn't.