···11+# atmot-bot
22+33+Daily Bluesky bot for [AT Mot](https://atmot.herve.bzh). Posts once per language
44+each day just after the UTC puzzle rollover: invites players to today's puzzle and
55+congratulates yesterday's solvers (by count only).
66+77+Runs as a stateless Cloudflare Worker with two Cron Triggers (EN 00:10 UTC, FR
88+00:11 UTC). Counts are read from the public Constellation backlink index; there is
99+no database. The bot duplicates a small set of the app's frozen constants
1010+(`src/config.ts`) rather than importing the app.
1111+1212+## Develop
1313+1414+```sh
1515+npm install
1616+npm test # vitest — composer + facets + puzzle math
1717+npm run typecheck
1818+npm run dev # wrangler dev --test-scheduled (see Dry run below)
1919+```
2020+2121+## Dry run (no real post)
2222+2323+Create a gitignored `.dev.vars`:
2424+2525+```
2626+DRY_RUN = "1"
2727+ATMOT_BOT_IDENTIFIER = "atmot.herve.bzh"
2828+ATMOT_BOT_APP_PASSWORD = "dry-run-unused"
2929+```
3030+3131+Then `npm run dev` and, in another shell:
3232+3333+```sh
3434+curl "http://localhost:8787/__scheduled?cron=10+0+*+*+*" # EN
3535+curl "http://localhost:8787/__scheduled?cron=11+0+*+*+*" # FR
3636+```
3737+3838+The composed post is logged instead of published.
3939+4040+## Deploy
4141+4242+The bot posts as **@atmot.herve.bzh** using a Bluesky **app password** (Settings →
4343+Privacy and security → App passwords — not the account password).
4444+4545+```sh
4646+npx wrangler login
4747+npx wrangler secret put ATMOT_BOT_IDENTIFIER # e.g. atmot.herve.bzh
4848+npx wrangler secret put ATMOT_BOT_APP_PASSWORD # the app password
4949+npm run deploy
5050+```
5151+5252+The free Workers plan is sufficient: each language runs in its own scheduled
5353+invocation, so each gets the full 50-subrequest budget. Solver counting samples up
5454+to `SOLVER_SAMPLE_CAP` (20) records per language; beyond that the count is hedged
5555+(e.g. "20+"). On the paid plan, raise `SOLVER_SAMPLE_CAP` in `src/config.ts` to ~200.
+45
src/index.ts
···11+import { puzzleNumberFor, type Lang } from './config.js';
22+import { yesterdayCounts } from './counts.js';
33+import { composePost, postMarker } from './compose.js';
44+import { createBotSession, alreadyPosted, publishPost } from './post.js';
55+66+export interface Env {
77+ ATMOT_BOT_IDENTIFIER: string;
88+ ATMOT_BOT_APP_PASSWORD: string;
99+ /** When "1", compose and log the post but do not authenticate or publish. */
1010+ DRY_RUN?: string;
1111+}
1212+1313+/** Map the firing cron expression to a language (FR runs one minute after EN). */
1414+function langForCron(cron: string): Lang {
1515+ return cron.startsWith('11 ') ? 'fr' : 'en';
1616+}
1717+1818+export default {
1919+ async scheduled(controller: ScheduledController, env: Env, _ctx: ExecutionContext): Promise<void> {
2020+ const lang = langForCron(controller.cron);
2121+ const todayN = puzzleNumberFor();
2222+ const yesterdayN = todayN - 1;
2323+2424+ try {
2525+ const yesterday = yesterdayN >= 1 ? await yesterdayCounts(lang, yesterdayN) : null;
2626+ const post = composePost({ lang, todayN, yesterday });
2727+2828+ if (env.DRY_RUN === '1') {
2929+ console.log(`[atmot-bot] DRY_RUN ${lang} #${todayN}:\n${post.text}`);
3030+ return;
3131+ }
3232+3333+ const session = await createBotSession(env.ATMOT_BOT_IDENTIFIER, env.ATMOT_BOT_APP_PASSWORD);
3434+ if (await alreadyPosted(session, postMarker(lang, todayN))) {
3535+ console.log(`[atmot-bot] ${lang} #${todayN} already posted; skipping`);
3636+ return;
3737+ }
3838+ const uri = await publishPost(session, post);
3939+ console.log(`[atmot-bot] posted ${lang} #${todayN}: ${uri}`);
4040+ } catch (err) {
4141+ console.error(`[atmot-bot] ${lang} #${todayN} failed:`, err);
4242+ throw err; // surface the failure to Cloudflare's cron logs
4343+ }
4444+ },
4545+};