Skip to content

Posting the daily puzzle

The server can post each day's puzzle to X and Instagram on its own: the position as a picture, a short caption, and a link back to that day's page. It never includes the answer. src/server/social.js talks to both over plain fetch — no SDK.

It is off until you give it credentials, and each network is independent: set X's, and only X is posted to.

What goes out

Every day, at SOCIAL_HOUR UTC (default 9), once the day's puzzle exists:

  • X: the wide card (1200×630) and a caption with the puzzle number, the roll, the match score and the link to /puzzle/<date>.
  • Instagram: the square card (1080×1080 JPEG) and a caption with hashtags. Links in Instagram captions are not clickable, so it says "link in bio" — point your bio at https://<your domain>/puzzle, which always redirects to today's puzzle.

To see exactly what would be posted, without posting:

bash
docker compose exec server node bin/social.js preview

That writes both images and both captions to social-preview/ and prints the captions.

X

  1. Create a developer account at developer.x.com and a project with an app. The free tier allows posting.
  2. In the app's User authentication settings, set App permissions to Read and write. Do this before the next step: access tokens keep the permissions they were created with.
  3. Under Keys and tokens, copy the API Key and API Key Secret, then generate an Access Token and Secret for the account that should post.
  4. Add all four to deploy/.env:
bash
X_API_KEY=
X_API_SECRET=
X_ACCESS_TOKEN=
X_ACCESS_SECRET=

Instagram

Instagram only allows posting through the Graph API, from a professional (Business or Creator) account linked to a Facebook Page.

  1. Switch the Instagram account to a professional account and link it to a Facebook Page (Instagram → Settings → Account type and tools).
  2. At developers.facebook.com, create an app of type Business and add the Instagram product (Instagram API with Facebook Login).
  3. Generate a token with instagram_basic, instagram_content_publish and pages_read_engagement. Use a system user token from Business Settings if you can: it does not expire. A user token must be exchanged for a long-lived one, and even that lasts only 60 days.
  4. Find the Instagram account's numeric id: GET /me/accounts, then GET /<page-id>?fields=instagram_business_account in the Graph API Explorer.
  5. Add both to deploy/.env:
bash
IG_USER_ID=17841400000000000
IG_ACCESS_TOKEN=

Instagram fetches the image itself from https://<your domain>/puzzle/<date>.jpg. If Cloudflare's bot protection challenges Meta's crawler, the post fails with the container in ERROR; allow facebookexternalhit in your WAF rules if that happens.

Turning it on

After editing deploy/.env, redeploy so the server picks up the variables:

bash
SSH_KEY=~/.ssh/hetzner ./deploy/push.sh gammon@<your server>

/healthz then shows which networks are active and today's status for each:

json
"social": { "networks": ["x", "instagram"], "dryRun": false, "hour": 9,
            "today": { "x": "posted", "instagram": null } }

To rehearse first, add SOCIAL_DRY_RUN=1: the scheduler logs what it would post and calls nothing. To post today's puzzle immediately rather than waiting for the hour:

bash
docker compose exec server node bin/social.js post              # every configured network
docker compose exec server node bin/social.js post --network x  # one network

Guarantees

  • Once a day, per network. Each post is recorded in the social_posts table, and a restart, a second tick or a manual run finds the record and does not post again. --force is the only way to post the same day twice.
  • Failures are retried, slowly. A failed post is recorded with its error and retried at most once an hour for the rest of that day. Each failure goes to the error reporter, so a revoked token shows up in your alerts rather than as silence.
  • The answer is never posted. Captions describe the roll and the score only, and the images are the same cards the public puzzle page uses, which never show the best play for a day that has not ended.

Variables

variabledefaultpurpose
X_API_KEY, X_API_SECRETunsetthe X app's key and secret
X_ACCESS_TOKEN, X_ACCESS_SECRETunsetthe posting account's token, with Read and write permission
IG_USER_IDunsetthe Instagram professional account's numeric id
IG_ACCESS_TOKENunseta token with instagram_content_publish
IG_GRAPH_VERSIONv23.0Graph API version
SOCIAL_HOUR9UTC hour after which the day's puzzle is posted
SOCIAL_DRY_RUNunset1 logs the posts instead of sending them

Rules engine, fairness protocol, verifier, analysis and worker: MIT. Server and client: AGPL-3.0-or-later.