upbot #
tl;dr upvote from your timeline by replying with @upvote.at, or send a link/post to upbot in a Bluesky DM.
A Bluesky bot for @upvote.at. After a one-time authorization, a reply mention or direct message can create an at.upvote.signal record in your own repo.
How it works #
- upbot polls both its mention notifications (
app.bsky.notification.listNotifications) and the Bluesky chat event log (chat.bsky.convo.getLog). It does not consume the firehose/Jetstream. Mention scans exhaust every cursor page and explicitly include non-priority notifications, so bursts and the bot account's priority-notification preference cannot hide requests. The first chat poll seeds a durable cursor without replaying old private-message history; later polls process new messages in order. - A mention only matters if it's a reply. The parent post is read through HappyView (see below). Subject precedence is: external embed, external media inside
recordWithMedia, the first link facet, the first link detected in raw post text, then the parent post's ownat://URI. Thus a matching text link plus embed creates one signal, and a different text link never overrides the embed. - A one-to-one DM can contain either a shared Bluesky post or a URI/at-uri. A shared post is read through HappyView and goes through the same subject extraction as a mention. A directly sent URI goes straight to normalization. A shared-post embed wins over different accompanying text; group-chat messages are ignored.
- The extracted input is normalized by upvote.at itself through its stateless
POST /api/subjectsendpoint, the exact same normalization the submit box runs. Absky.applink becomes the post'sat://URI, verified discovery-tag pages become their records, handles resolve to DIDs, and fragments are stripped. A400is a definitive "that can't be upvoted" result; network and server failures are retried. - Before writing, the sender must have a stored OAuth session. Reply mentions additionally require the live
at.upvote.settings.upvoteThroughPostRepliesvalue to be exactlytrue; DMs do not use that reply-specific toggle because sending the DM is the explicit request. An existing signal for the normalized subject is reused rather than written again. - Each mention is claimed with a crash-safe PostgreSQL lease before its gates run. If the gates pass, upbot durably records the normalized request, ensures the
at.upvote.signalexists in the sender's repo, and records whether that write was created, already present, or reconciled after an ambiguous response. It then ensures the mention post is liked from upbot's own account—even when the signal was already present. Claim-bound completion prevents racing workers from suppressing recovery or emitting the same definitive outcome twice. DM completion uses a compare-and-set from the exact opaque chat cursor read for that event, so a delayed replica cannot regress the stream or emit a second outcome. Both repository writes use protocol-valid TIDs reserved in PostgreSQL and exact-record reconciliation; when a reserved path is absent, a live collection scan preserves records created at arbitrary keys by older or external writers.
After each successful poll, upbot emits a structured Pino heartbeat with confirmed-new-signal and skip counts, a complete skip-reason histogram, poll duration, and in-process running totals. Polls with activity log at info; empty polls log at debug to avoid noisy production output. Heartbeats deliberately contain no user handles, DIDs, DM identities, message IDs, subject URLs, upstream error messages, or stacks.
A gate saying no is a definitive outcome and is not re-evaluated. An infrastructure failure—HappyView unreachable, session refresh failure, PDS write failure, or chat outage—is retried without advancing the relevant notification/chat state. Signal creation and mention acknowledgment are idempotent across those retries and across concurrently running bot instances.
Why upbot needs its own OAuth clients #
upvote.at's own login (src/lib/auth.svelte.ts in that repo) is deliberately browser-only: tokens and DPoP keys live in browser storage, and there is no server-side session anywhere. That's fine for a site that only ever acts while you're looking at it, but a bot reacting to a mention hours later has no browser session to reuse.
So upbot exposes two separate confidential OAuth client identities through @atproto/oauth-client-node (the server-side counterpart to the @happyview/oauth-client-browser package upvote.at uses):
- The main client is discovered at
/client-metadata.json, redirects to/oauth/callback, requests onlyrepo:at.upvote.signal?action=create, and persists sessions (refresh tokens) in PostgreSQL by DID so an authorization survives restarts. - The ownership-proof client is discovered at
/deauthorization-client-metadata.json, redirects only to/oauth/deauthorization-callback, and requests only baselineatproto. Its authorization state is durable but namespaced away from main-client state so neither callback can consume the other's flow. Its sessions live only in memory between callback exchange and immediate cleanup. - Both identities authenticate through
private_key_jwtwith the same signing key generated once and persisted by this process (seesrc/oauth.ts), but their differentclient_idand redirect URI keep their sessions completely separate.
A user grants this by visiting GET /login?handle=<their handle or DID> once, which redirects to their own PDS's real OAuth consent screen. Mentioning upbot without having authorized it is silently ignored, by design (see the gates above).
The settings UI reads GET /authorization?did=<DID> through upvote.at's same-origin server proxy. The response is { "authorized": true | false }, always with Cache-Control: no-store. This read-only endpoint is intentionally unauthenticated and server-side enumerable: anyone who can reach upbot and supply a DID can learn whether upbot retains a session row for it. The upvote.at proxy avoids exposing the cross-origin response directly to arbitrary browser pages, but it is not access control. Status is deliberately just a cheap durable-row lookup—rendering settings never performs a rate-heavy restore, contacts an authorization server, or rotates a refresh token. Consequently, the boolean means "upbot currently has a stored grant"; if someone revokes the grant directly at their PDS, the row is removed when upbot next restores or uses that invalid session.
Deauthorization is not an unauthenticated delete-by-DID endpoint. GET /deauthorize?did=<DID> starts the ownership-proof client with prompt=login; the expected DID is held in its namespaced server-side OAuth state. Its callback immediately revokes the ephemeral proof token, then compares the authorization server's authenticated subject to the expected DID. A mismatch never calls the main client's callback or revoke path, so both the expected account's grant and any existing main upbot grant belonging to the actually authenticated account remain unchanged. Only a match asks the main client to revoke its durable grant. The OAuth library attempts server-side token revocation and removes the stored main session in a finally block, and upbot performs an additional idempotent local delete so an upstream revocation/discovery failure still leaves the bot unable to act for that account. A failure of that durable local delete is an infrastructure error rather than a successful or user-rejected deauthorization.
The discoverable entry point for this lives in the other repo: upvote.at's /settings page reads the status endpoint and builds the matching authorization or deauthorization URL for the signed-in viewer's stable DID, under the same "bot" section as the upvoteThroughPostReplies toggle (src/lib/server/upbot.ts and src/routes/settings/+page.svelte in that repo). Those controls only render when upvote.at's own UPBOT_URL env var is set to wherever this process is reachable. That environment variable must match this app's PUBLIC_URL (scheme and host; upvote.at trims a trailing slash the same way this app's own config does). Deploying upbot without updating upvote.at's UPBOT_URL to match just means the settings page hides the controls rather than showing broken ones. No failure but no discoverability.
A confidential client's client_id must be a real, publicly resolvable https:// URL: the loopback/IP-literal exception in the AT Protocol OAuth spec only exists for public clients (like upvote.at's own browser one). That's a real, unavoidable requirement, not a limitation of this implementation: a real authorization server has to independently fetch both metadata URLs under PUBLIC_URL over the network to learn who it's talking to. Set PUBLIC_URL to wherever this process is actually reachable before deploying.
Reads go through HappyView, never a direct PDS contact #
Every source record upbot fetches by at-uri—a mentioner's at.upvote.settings, a reply's parent post, or a post shared by DM—goes through HappyView's fetch-through at.upvote.getRecords query (src/records.ts, src/happyview.ts). Its legacy response can omit a URI for both a missing record and an upstream failure, so upbot no longer guesses: returned records are accepted, an explicit notFound classification is definitive, and an unclassified omission is retried. HappyView must emit that explicit classification before genuinely missing records can settle instead of retrying. A recovery-only existence check asks upvote.at to refresh and read its public per-account signal index when a PDS write was ambiguous and the user has since revoked OAuth. upbot never independently resolves a DID to a PDS endpoint or fetches from one directly, so upbot itself never discovers or contacts a PDS.
Register upbot as its own confidential HappyView client, not upvote.at's public/browser one. HappyView distinguishes the two the same way AT Protocol OAuth does: public clients (upvote.at's browser) prove themselves with an Origin header + PKCE and can't hold a secret; confidential clients (server-to-server, upbot's case) get a client secret alongside the key and prove themselves with it instead. Register via HappyView's dashboard (Settings → API Clients → New client) or POST /admin/api-clients, which returns an hvc_... key (HAPPYVIEW_KEY) and an hvs_... secret (HAPPYVIEW_SECRET). Keeping upbot on its own client (rather than reusing upvote.at's) also means a burst of bot traffic can't rate-limit the live website, or vice versa.
happyViewFetch sends both as X-Client-Key / X-Client-Secret headers on every request when HAPPYVIEW_SECRET is set (omitted entirely if not - HappyView currently validates the secret best-effort, logging a warning on mismatch rather than rejecting, but sending it correctly is the documented, forward-compatible way to prove a confidential client's identity, and the intent is clearly to enforce it). This is the same client-key-header-plus-429-retry request shape upvote.at's own happyViewFetch uses, copied over rather than reinvented since it's already a proven, deliberate design... just extended with the secret header for the confidential case, which upvote.at's browser client never needs.
Data model #
Originally stored in ./lexicons, we keep our lexicons centrally stored in the upvote.at/lexicons repo.
This bot specifically leverages three upvote.at lexicons:
signal.json: the lexicon for the record upbot creates.settings.json: upvote.at's settings record, includingupvoteThroughPostReplies(boolean, opt-in, absent means disabled). This field is real on the upvote.at side too: it's in that repo's ownsettings.json/putSettings.jsonlexicons, its settings store (src/lib/settings.svelte.ts, defaultedfalse), its sync layer (src/lib/settingsSync.svelte.ts), and its settings page UI. upbot only ever reads it, upvote.at is what lets someone actually flip it.getRecords.json: the HappyView fetch-through query used for settings and source posts. Its response needs an explicit missing-record classification; a bare omission is treated as retryable because the current producer also omits transient failures.
Configuration #
Copy .env.example to .env and fill in:
| Var | Meaning |
|---|---|
PUBLIC_URL |
Where this process is reachable. Must be a real https:// URL in production. |
PORT |
Port to listen on. |
BSKY_HANDLE / BSKY_APP_PASSWORD |
The bot's own Bluesky account. The app password must be created with Allow access to your direct messages enabled. |
BSKY_SERVICE |
PDS/service the bot's own account logs into (default https://bsky.social). |
POLL_INTERVAL_MS |
How often to check mentions and Bluesky chat. |
LOG_LEVEL |
Pino minimum level (info by default; also supports fatal, error, warn, debug, trace, and silent). |
DATABASE_URL |
PostgreSQL connection string, where OAuth sessions, the signing key, mention state, and chat cursor persist. Required, no default. |
HAPPYVIEW_URL |
The HappyView instance to read at.upvote.* records through (default https://happyview.upvote.at). |
HAPPYVIEW_KEY |
HappyView client key (hvc_...) - required. Register upbot as its own confidential client, don't reuse upvote.at's. |
HAPPYVIEW_SECRET |
HappyView client secret (hvs_...) - optional, but sent whenever set; issued alongside the key for a confidential client. |
UPVOTE_URL |
Where upvote.at itself is deployed (default https://upvote.at); every subject is normalized through its POST /api/subjects before a signal is written. The mirror image of upvote.at's own UPBOT_URL. |
Storage #
Everything that has to outlive the process lives in PostgreSQL (src/db.ts): the two stores @atproto/oauth-client-node requires (per-DID sessions and in-flight authorization state), the confidential client's signing key, leased mention/acknowledgment state, semantic repository-write key reservations, and the Bluesky chat cursor. Tables and additive migrations are created idempotently on every boot while a PostgreSQL advisory lock serializes concurrent schema changes.
This is deliberately not on local disk. upbot runs on Railway, where the container filesystem is ephemeral: a redeploy would otherwise drop refresh tokens, mention acknowledgment state, and the chat cursor. The signing key matters just as much: it is what private_key_jwt uses to prove the client's identity, so losing it invalidates every refresh token issued against it.
On Railway, add a PostgreSQL service to the project and point upbot at it by reference, not by pasting a URL:
DATABASE_URL=${{Postgres.DATABASE_URL}}
That resolves to the private-network URL (*.railway.internal), which stays inside the project. Connecting from outside Railway (a migration, a psql session) means the public URL instead, with ?sslmode=require appended: TLS is read from the connection string rather than guessed from the hostname, so whatever the URL says is what happens.
For local development, any Postgres will do:
docker run --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:17
There is no automatic migration of an existing data/upbot.sqlite. If a deployment has real sessions in one, everyone re-authorizes via /login (and the bot picks a new signing key on first boot). Worth knowing before cutting over, though on Railway that file was never durable in the first place.
Development #
npm install
npm run dev # watch TypeScript and pretty-print structured logs
npm start writes newline-delimited JSON logs for production ingestion. npm run dev pipes the same events through pino-pretty; the formatter is development-only and is not loaded by the production process. Logger redaction covers configured credentials, database URLs, OAuth key/token fields, cookies, and authorization headers. Application logging also avoids raw OAuth query strings and private DM content by policy.
Testing #
npm test # vitest run --coverage
npm run lint
npm run check # type-check application and tooling configs
Every module is unit tested with the network/HappyView boundary mocked (db.ts's Postgres is real - PGlite is Postgres itself compiled to WebAssembly, running in-process with no server to install, so the actual SQL is exercised rather than stubbed; see src/db.fixture.ts). happyview.spec.ts mirrors upvote.at's own test suite for the retry/backoff behavior directly (same scenarios: 429 retry, Retry-After honored, retry budget exhausted), plus upbot's own confidential-client addition (the secret header is sent when configured, omitted when not). oauth.spec.ts additionally proves createOAuthClient builds both NodeOAuthClient identities with accepted metadata and keys; it also exercises state namespace isolation, ephemeral proof-session cleanup, and the rule that a wrong-account proof cannot touch either account's durable main grant.
What isn't (and can't be) covered by an automated test: a human completing a PDS's own consent screen in a browser, and a real authorization server fetching the two metadata documents over the actual network; a confidential client's client_id must be a real HTTPS domain by spec, so there's no local/offline stand-in for that leg, the same way upvote.at's own README notes its logged-in browser flows have no e2e coverage since login is real OAuth against a live PDS.
TODO #
- Currently,
processed_notificationsis unbounded. We can useprocessed_atto prune old entries.
- TypeScript 100%
- JavaScript <1%