Daily Bluesky bot for AT Mot. Invites players and congratulates yesterday's solvers.
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'fix/adversarial-review-findings': adversarial review fixes

Distinct solver counting with honest sampling, explicit cron→lang
mapping, unknown-typed untrusted JSON, expanded test coverage (23→42),
and corrected SOLVER_SAMPLE_CAP docs.

+362 -27
+8 -1
README.md
··· 52 52 The free Workers plan is sufficient: each language runs in its own scheduled 53 53 invocation, so each gets the full 50-subrequest budget. Solver counting samples up 54 54 to `SOLVER_SAMPLE_CAP` (20) records per language; beyond that the count is hedged 55 - (e.g. "20+"). On the paid plan, raise `SOLVER_SAMPLE_CAP` in `src/config.ts` to ~200. 55 + (e.g. "20+"). 56 + 57 + Each sampled record costs ~2 subrequests (DID-document resolution + the record 58 + read), so the default cap of 20 already uses most of the per-invocation budget on 59 + the free plan — keep headroom for the count, session, idempotency check, and 60 + publish (~6 more). On the paid plan you can raise `SOLVER_SAMPLE_CAP` in 61 + `src/config.ts`, but size it against the subrequest cost (~2 per record), not just 62 + the desired sample size.
+2
src/compose.ts
··· 32 32 if (players == null || players === 0) return null; 33 33 34 34 if (solvers === 0) { 35 + // An incomplete sample can't prove nobody won — only claim it when we're sure. 36 + if (sampled) return null; 35 37 return lang === 'fr' 36 38 ? `Personne n'a trouvé le mot d'hier. Nouveau départ aujourd'hui !` 37 39 : `Nobody cracked yesterday's word. Fresh start today!`;
+8 -3
src/config.ts
··· 38 38 export const APPVIEW_URL = 'https://public.api.bsky.app'; 39 39 export const USER_AGENT = `atmot-bot/1.0 (+${ORIGIN}; daily word game bot)`; 40 40 41 - /** Free Workers plan: keep ~2 subrequests/record under the 50/invocation cap. */ 41 + /** 42 + * Solver sample size. Each record costs ~2 subrequests (DID-doc resolution + 43 + * record read), so 20 records ≈ 40, leaving ~10 of the free plan's 50/invocation 44 + * for the count, session, idempotency check, and publish. Raise with care, and 45 + * size it against the ~2-subrequests-per-record cost — not just the sample size. 46 + */ 42 47 export const SOLVER_SAMPLE_CAP = 20; 43 48 44 49 /** The result record shape we read from each player's PDS (colours only; subset we use). */ ··· 56 61 export interface YesterdayCounts { 57 62 /** Distinct players who recorded a result (wins + losses); null if Constellation was unreachable. */ 58 63 players: number | null; 59 - /** Players who solved, counted from the sampled records (≤ SOLVER_SAMPLE_CAP). */ 64 + /** Distinct DIDs that solved, from the sample (≤ SOLVER_SAMPLE_CAP); clamped to `players`. */ 60 65 solvers: number; 61 - /** True if the sample hit the cap (more results may exist than were counted). */ 66 + /** True when the sample is incomplete (truncated page or unresolved players); compose then hedges. */ 62 67 sampled: boolean; 63 68 }
+40 -13
src/constellation.ts
··· 13 13 /** Source path Constellation indexes for the result record's leaderboard link. */ 14 14 const RESULT_TARGET_PATH = 'puzzleTarget'; 15 15 16 - async function get(path: string, params: Record<string, string>): Promise<any | null> { 16 + async function get(path: string, params: Record<string, string>): Promise<unknown> { 17 17 const url = new URL(path, CONSTELLATION_BASE); 18 18 for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); 19 19 try { ··· 27 27 28 28 /** Distinct DIDs linking to `target`. Null on failure. */ 29 29 async function countDistinctDids(target: string, collection: string, dottedPath: string): Promise<number | null> { 30 - const data = await get('/links/count/distinct-dids', { 30 + const data = (await get('/links/count/distinct-dids', { 31 31 target, 32 32 collection, 33 33 path: dottedPath.startsWith('.') ? dottedPath : `.${dottedPath}`, 34 - }); 34 + })) as { total?: unknown } | null; 35 35 return typeof data?.total === 'number' ? data.total : null; 36 36 } 37 37 ··· 47 47 limit: String(opts.limit), 48 48 }; 49 49 if (opts.cursor) params.cursor = opts.cursor; 50 - const data = await get('/xrpc/blue.microcosm.links.getBacklinks', params); 50 + const data = (await get('/xrpc/blue.microcosm.links.getBacklinks', params)) as 51 + | { records?: unknown; cursor?: unknown } 52 + | null; 51 53 if (!data || !Array.isArray(data.records)) return null; 52 - return { records: data.records as BacklinkRecord[], cursor: data.cursor ?? null }; 54 + const cursor = typeof data.cursor === 'string' ? data.cursor : null; 55 + return { records: data.records as BacklinkRecord[], cursor }; 53 56 } 54 57 55 - async function collectBacklinks(target: string, collection: string, path: string, cap: number): Promise<BacklinkRecord[]> { 58 + /** 59 + * Up to `cap` backlink records. `truncated` is true when the sample is known to 60 + * be incomplete — more than `cap` records exist, a page fetch failed mid-walk, 61 + * or pages remained when the page budget ran out — so callers can hedge. 62 + */ 63 + async function collectBacklinks( 64 + target: string, 65 + collection: string, 66 + path: string, 67 + cap: number, 68 + ): Promise<{ records: BacklinkRecord[]; truncated: boolean }> { 56 69 const out: BacklinkRecord[] = []; 57 70 let cursor: string | undefined; 58 - for (let i = 0; i < 10 && out.length < cap; i++) { 59 - const page = await getBacklinksPage(target, collection, path, { limit: Math.min(100, cap), cursor }); 60 - if (!page) break; 71 + let incomplete = false; 72 + for (let i = 0; i < 10; i++) { 73 + if (out.length > cap) break; // already have more than we need; the rest is truncated 74 + // Fetch one extra so we can distinguish "exactly cap" from "more than cap". 75 + const page = await getBacklinksPage(target, collection, path, { limit: Math.min(100, cap + 1), cursor }); 76 + if (!page) { 77 + incomplete = true; // a page fetch failed; we can't be sure we have everything 78 + break; 79 + } 61 80 out.push(...page.records); 62 - if (!page.cursor) break; 81 + if (!page.cursor) { 82 + cursor = undefined; 83 + break; 84 + } 63 85 cursor = page.cursor; 64 86 } 65 - return out.slice(0, cap); 87 + const truncated = incomplete || out.length > cap || cursor !== undefined; 88 + return { records: out.slice(0, cap), truncated }; 66 89 } 67 90 68 91 /** Distinct players who recorded a result for this puzzle (wins + losses). Null on failure. */ ··· 70 93 return countDistinctDids(puzzleTarget(lang, puzzleNumber), COLLECTION.result, RESULT_TARGET_PATH); 71 94 } 72 95 73 - /** Up to `cap` result-record backlinks for this puzzle. */ 74 - export function dailyResultBacklinks(lang: Lang, puzzleNumber: number, cap: number): Promise<BacklinkRecord[]> { 96 + /** Up to `cap` result-record backlinks for this puzzle, plus a `truncated` flag. */ 97 + export function dailyResultBacklinks( 98 + lang: Lang, 99 + puzzleNumber: number, 100 + cap: number, 101 + ): Promise<{ records: BacklinkRecord[]; truncated: boolean }> { 75 102 return collectBacklinks(puzzleTarget(lang, puzzleNumber), COLLECTION.result, RESULT_TARGET_PATH, cap); 76 103 } 77 104
+22 -7
src/counts.ts
··· 4 4 5 5 /** 6 6 * Yesterday's { players, solvers, sampled } for one language. `players` is the 7 - * cheap distinct-DID count; `solvers` is counted from the sampled records 8 - * (≤ SOLVER_SAMPLE_CAP) to stay under the free-plan subrequest budget. 7 + * cheap distinct-DID count; `solvers` is the number of *distinct* DIDs whose 8 + * sampled record (≤ SOLVER_SAMPLE_CAP) shows a win. 9 + * 10 + * Both numbers are deduped by DID so they are commensurable, and `solvers` is 11 + * clamped to `players` so the published copy can never say more people solved 12 + * than played. `sampled` is true whenever the sample is incomplete — the page 13 + * was truncated, or we resolved fewer distinct players than Constellation 14 + * counted (a dropped read or index skew) — so compose hedges instead of 15 + * publishing an untrustworthy exact "X solved, Y didn't". 9 16 */ 10 17 export async function yesterdayCounts(lang: Lang, yesterdayN: number): Promise<YesterdayCounts> { 11 - const [players, backlinks] = await Promise.all([ 18 + const [players, { records, truncated }] = await Promise.all([ 12 19 dailyPlayerCount(lang, yesterdayN), 13 20 dailyResultBacklinks(lang, yesterdayN, SOLVER_SAMPLE_CAP), 14 21 ]); 15 22 16 - let solvers = 0; 23 + const seenDids = new Set<string>(); 24 + const solverDids = new Set<string>(); 17 25 await Promise.all( 18 - backlinks.map(async (bl) => { 26 + records.map(async (bl) => { 19 27 const rec = await getRecordByUri<ResultRecord>(backlinkUri(bl)); 20 - if (rec && rec.lang === lang && rec.puzzleNumber === yesterdayN && rec.solved) solvers++; 28 + if (!rec || rec.lang !== lang || rec.puzzleNumber !== yesterdayN) return; 29 + seenDids.add(bl.did); 30 + if (rec.solved) solverDids.add(bl.did); 21 31 }), 22 32 ); 23 33 24 - return { players, solvers, sampled: backlinks.length >= SOLVER_SAMPLE_CAP }; 34 + let solvers = solverDids.size; 35 + if (players != null) solvers = Math.min(solvers, players); 36 + 37 + const sampled = truncated || players == null || (players > 0 && seenDids.size < players); 38 + 39 + return { players, solvers, sampled }; 25 40 }
+14 -3
src/index.ts
··· 10 10 DRY_RUN?: string; 11 11 } 12 12 13 - /** Map the firing cron expression to a language (FR runs one minute after EN). */ 14 - function langForCron(cron: string): Lang { 15 - return cron.startsWith('11 ') ? 'fr' : 'en'; 13 + /** 14 + * Map the firing cron expression to a language. Keyed on the exact expressions 15 + * in wrangler.toml (EN 00:10 UTC, FR 00:11 UTC); an unrecognized cron throws so 16 + * a trigger misconfiguration fails loudly instead of silently posting English. 17 + */ 18 + const CRON_LANG: Record<string, Lang> = { 19 + '10 0 * * *': 'en', 20 + '11 0 * * *': 'fr', 21 + }; 22 + 23 + export function langForCron(cron: string): Lang { 24 + const lang = CRON_LANG[cron]; 25 + if (!lang) throw new Error(`[atmot-bot] unmapped cron expression: ${JSON.stringify(cron)}`); 26 + return lang; 16 27 } 17 28 18 29 export default {
+79
test/constellation.test.ts
··· 1 + import { describe, it, expect, vi, afterEach } from 'vitest'; 2 + import { dailyResultBacklinks, dailyPlayerCount } from '../src/constellation.js'; 3 + 4 + interface Page { 5 + records: Array<{ did: string; collection: string; rkey: string }>; 6 + cursor?: string | null; 7 + } 8 + 9 + const rec = (did: string) => ({ did, collection: 'bzh.herve.atmot.result', rkey: 'r' }); 10 + 11 + /** Stub fetch to serve a sequence of getBacklinks pages keyed by cursor, plus a fixed count. */ 12 + function stubFetch(pages: Page[], opts: { total?: number; failOnCursor?: string } = {}) { 13 + vi.stubGlobal('fetch', async (url: URL) => { 14 + const u = new URL(url); 15 + if (u.pathname.includes('distinct-dids')) { 16 + return new Response(JSON.stringify({ total: opts.total ?? 0 }), { status: 200 }); 17 + } 18 + const cursor = u.searchParams.get('cursor'); 19 + if (opts.failOnCursor !== undefined && cursor === opts.failOnCursor) { 20 + return new Response('boom', { status: 500 }); 21 + } 22 + const idx = cursor ? Number(cursor) : 0; 23 + const page = pages[idx] ?? { records: [], cursor: null }; 24 + return new Response(JSON.stringify(page), { status: 200 }); 25 + }); 26 + } 27 + 28 + afterEach(() => vi.unstubAllGlobals()); 29 + 30 + describe('dailyResultBacklinks pagination', () => { 31 + it('returns a single complete page as not truncated', async () => { 32 + stubFetch([{ records: [rec('did:a'), rec('did:b')], cursor: null }]); 33 + const { records, truncated } = await dailyResultBacklinks('en', 5, 20); 34 + expect(records.map((r) => r.did)).toEqual(['did:a', 'did:b']); 35 + expect(truncated).toBe(false); 36 + }); 37 + 38 + it('follows the cursor across pages until it runs out', async () => { 39 + stubFetch([ 40 + { records: [rec('did:a')], cursor: '1' }, 41 + { records: [rec('did:b')], cursor: null }, 42 + ]); 43 + const { records, truncated } = await dailyResultBacklinks('en', 5, 20); 44 + expect(records.map((r) => r.did)).toEqual(['did:a', 'did:b']); 45 + expect(truncated).toBe(false); 46 + }); 47 + 48 + it('slices to cap and reports truncated when more than cap records exist', async () => { 49 + stubFetch([{ records: [rec('did:a'), rec('did:b'), rec('did:c')], cursor: null }]); 50 + const { records, truncated } = await dailyResultBacklinks('en', 5, 2); 51 + expect(records).toHaveLength(2); 52 + expect(truncated).toBe(true); 53 + }); 54 + 55 + it('reports truncated when a page fetch fails mid-walk', async () => { 56 + stubFetch( 57 + [ 58 + { records: [rec('did:a')], cursor: '1' }, 59 + { records: [rec('did:b')], cursor: '2' }, 60 + ], 61 + { failOnCursor: '1' }, 62 + ); 63 + const { records, truncated } = await dailyResultBacklinks('en', 5, 20); 64 + expect(records.map((r) => r.did)).toEqual(['did:a']); 65 + expect(truncated).toBe(true); 66 + }); 67 + }); 68 + 69 + describe('dailyPlayerCount', () => { 70 + it('reads the distinct-DID total', async () => { 71 + stubFetch([], { total: 42 }); 72 + expect(await dailyPlayerCount('en', 5)).toBe(42); 73 + }); 74 + 75 + it('returns null on a server error', async () => { 76 + vi.stubGlobal('fetch', async () => new Response('nope', { status: 503 })); 77 + expect(await dailyPlayerCount('en', 5)).toBeNull(); 78 + }); 79 + });
+132
test/counts.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 + 3 + // Mock the network-facing modules so we can drive yesterdayCounts deterministically. 4 + vi.mock('../src/constellation.js', () => ({ 5 + dailyPlayerCount: vi.fn(), 6 + dailyResultBacklinks: vi.fn(), 7 + backlinkUri: (r: { did: string; rkey: string }) => `at://${r.did}/c/${r.rkey}`, 8 + })); 9 + vi.mock('../src/identity.js', () => ({ getRecordByUri: vi.fn() })); 10 + 11 + import { yesterdayCounts } from '../src/counts.js'; 12 + import { dailyPlayerCount, dailyResultBacklinks } from '../src/constellation.js'; 13 + import { getRecordByUri } from '../src/identity.js'; 14 + 15 + interface Rec { 16 + did: string; 17 + rkey: string; 18 + lang?: string; 19 + puzzleNumber?: number; 20 + solved?: boolean; 21 + /** When set, getRecordByUri resolves to null for this backlink (a dropped read). */ 22 + drop?: boolean; 23 + } 24 + 25 + /** Wire the mocks for one scenario. Records default to (lang='en', puzzle=N, solved). */ 26 + function setup(opts: { players: number | null; records: Rec[]; truncated?: boolean }, lang = 'en', n = 5) { 27 + vi.mocked(dailyPlayerCount).mockResolvedValue(opts.players); 28 + vi.mocked(dailyResultBacklinks).mockResolvedValue({ 29 + records: opts.records.map((r) => ({ did: r.did, collection: 'c', rkey: r.rkey })), 30 + truncated: opts.truncated ?? false, 31 + }); 32 + const byUri = new Map<string, unknown>(); 33 + for (const r of opts.records) { 34 + if (r.drop) continue; 35 + byUri.set(`at://${r.did}/c/${r.rkey}`, { 36 + lang: r.lang ?? lang, 37 + puzzleNumber: r.puzzleNumber ?? n, 38 + solved: r.solved ?? true, 39 + }); 40 + } 41 + vi.mocked(getRecordByUri).mockImplementation(async (uri: string) => (byUri.get(uri) ?? null) as never); 42 + } 43 + 44 + beforeEach(() => vi.clearAllMocks()); 45 + 46 + describe('yesterdayCounts', () => { 47 + it('dedupes solvers by DID — one player with two winning records counts once', async () => { 48 + setup({ 49 + players: 3, 50 + records: [ 51 + { did: 'did:a', rkey: '1' }, 52 + { did: 'did:a', rkey: '2' }, // same player, second record 53 + { did: 'did:b', rkey: '1' }, 54 + ], 55 + }); 56 + const { solvers } = await yesterdayCounts('en', 5); 57 + expect(solvers).toBe(2); 58 + }); 59 + 60 + it('clamps solvers to players when the two endpoints disagree', async () => { 61 + setup({ 62 + players: 1, // distinct-DID count says 1... 63 + records: [ 64 + { did: 'did:a', rkey: '1' }, 65 + { did: 'did:b', rkey: '1' }, // ...but the backlinks index returned two DIDs 66 + ], 67 + }); 68 + const { players, solvers } = await yesterdayCounts('en', 5); 69 + expect(players).toBe(1); 70 + expect(solvers).toBe(1); // never report more solvers than players 71 + }); 72 + 73 + it('ignores records for the wrong lang or puzzle, and losses', async () => { 74 + setup({ 75 + players: 4, 76 + records: [ 77 + { did: 'did:a', rkey: '1', solved: true }, 78 + { did: 'did:b', rkey: '1', solved: false }, // a loss 79 + { did: 'did:c', rkey: '1', lang: 'fr' }, // wrong language 80 + { did: 'did:d', rkey: '1', puzzleNumber: 99 }, // wrong puzzle 81 + ], 82 + }); 83 + const { solvers, sampled } = await yesterdayCounts('en', 5); 84 + expect(solvers).toBe(1); // only did:a 85 + // Only did:a and did:b are valid players for this puzzle; 2 < players(4) => hedge. 86 + expect(sampled).toBe(true); 87 + }); 88 + 89 + it('is not sampled when every player was resolved and the page was complete', async () => { 90 + setup({ 91 + players: 2, 92 + records: [ 93 + { did: 'did:a', rkey: '1', solved: true }, 94 + { did: 'did:b', rkey: '1', solved: false }, 95 + ], 96 + }); 97 + const { players, solvers, sampled } = await yesterdayCounts('en', 5); 98 + expect(players).toBe(2); 99 + expect(solvers).toBe(1); 100 + expect(sampled).toBe(false); 101 + }); 102 + 103 + it('is sampled when the backlink page was truncated', async () => { 104 + setup({ 105 + players: 10, 106 + records: [{ did: 'did:a', rkey: '1' }], 107 + truncated: true, 108 + }); 109 + const { sampled } = await yesterdayCounts('en', 5); 110 + expect(sampled).toBe(true); 111 + }); 112 + 113 + it('is sampled when a record read is dropped (fewer resolved than players)', async () => { 114 + setup({ 115 + players: 2, 116 + records: [ 117 + { did: 'did:a', rkey: '1', solved: true }, 118 + { did: 'did:b', rkey: '1', drop: true }, // transient read failure 119 + ], 120 + }); 121 + const { solvers, sampled } = await yesterdayCounts('en', 5); 122 + expect(solvers).toBe(1); 123 + expect(sampled).toBe(true); 124 + }); 125 + 126 + it('propagates a null player count (Constellation unreachable) as sampled', async () => { 127 + setup({ players: null, records: [{ did: 'did:a', rkey: '1' }] }); 128 + const { players, sampled } = await yesterdayCounts('en', 5); 129 + expect(players).toBeNull(); 130 + expect(sampled).toBe(true); 131 + }); 132 + });
+15
test/index.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { langForCron } from '../src/index.js'; 3 + 4 + describe('langForCron', () => { 5 + it('maps the configured EN and FR crons', () => { 6 + expect(langForCron('10 0 * * *')).toBe('en'); 7 + expect(langForCron('11 0 * * *')).toBe('fr'); 8 + }); 9 + 10 + it('throws on an unrecognized cron rather than defaulting to EN', () => { 11 + expect(() => langForCron('11 1 * * *')).toThrow(/unmapped cron/); 12 + expect(() => langForCron('')).toThrow(/unmapped cron/); 13 + expect(() => langForCron('* * * * *')).toThrow(/unmapped cron/); 14 + }); 15 + });
+42
test/post.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { alreadyPosted, type BotSession } from '../src/post.js'; 3 + import { postMarker } from '../src/compose.js'; 4 + 5 + /** A fake session whose listRecords returns `texts` (or fails when `ok` is false). */ 6 + function fakeSession(texts: string[], ok = true): BotSession { 7 + return { 8 + did: 'did:plc:bot', 9 + client: { 10 + get: async () => ({ 11 + ok, 12 + data: { records: texts.map((text) => ({ value: { text } })) }, 13 + }), 14 + }, 15 + } as unknown as BotSession; 16 + } 17 + 18 + describe('alreadyPosted', () => { 19 + it('detects today’s marker in a recent post', async () => { 20 + const marker = postMarker('en', 5); // "AT Mot #5" 21 + const session = fakeSession([`🟩 ${marker} is live! ...`]); 22 + expect(await alreadyPosted(session, marker)).toBe(true); 23 + }); 24 + 25 + it('returns false when no recent post carries the marker', async () => { 26 + const session = fakeSession(['🟩 AT Mot #4 is live! ...', 'unrelated post']); 27 + expect(await alreadyPosted(session, postMarker('en', 5))).toBe(false); 28 + }); 29 + 30 + it('does not cross-match EN and FR markers for the same puzzle', async () => { 31 + const enSession = fakeSession([`🟩 ${postMarker('fr', 5)} est en ligne ! ...`]); 32 + expect(await alreadyPosted(enSession, postMarker('en', 5))).toBe(false); 33 + 34 + const frSession = fakeSession([`🟩 ${postMarker('en', 5)} is live! ...`]); 35 + expect(await alreadyPosted(frSession, postMarker('fr', 5))).toBe(false); 36 + }); 37 + 38 + it('allows the post (returns false) when the listRecords read fails', async () => { 39 + const session = fakeSession([`🟩 ${postMarker('en', 5)} is live! ...`], false); 40 + expect(await alreadyPosted(session, postMarker('en', 5))).toBe(false); 41 + }); 42 + });