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.

feat: add Constellation reads and yesterday solver counting

Add three I/O modules: constellation.ts (backlink-index HTTP client),
identity.ts (DID→PDS resolution + per-PDS record reads), and counts.ts
(yesterdayCounts combining both). Also install @atcute/atproto and add
it to tsconfig types so XRPCQueries is populated for getRecord calls.

+172 -1
+13
package-lock.json
··· 8 8 "name": "atmot-bot", 9 9 "version": "1.0.0", 10 10 "dependencies": { 11 + "@atcute/atproto": "^4.0.2", 11 12 "@atcute/client": "^5.1.0", 12 13 "@atcute/identity-resolver": "^2.0.0" 13 14 }, ··· 16 17 "typescript": "^6.0.3", 17 18 "vitest": "^4.1.9", 18 19 "wrangler": "^4.103.0" 20 + } 21 + }, 22 + "node_modules/@atcute/atproto": { 23 + "version": "4.0.2", 24 + "resolved": "https://registry.npmjs.org/@atcute/atproto/-/atproto-4.0.2.tgz", 25 + "integrity": "sha512-hLnvjiIOStpdUm0cEN+R5YydvbV0d6ap17Iv+t7i/nhSCN3TGMya7M0ftCWtCo+xoQ1EU6HK74R8jqXWlyrM0w==", 26 + "license": "0BSD", 27 + "dependencies": { 28 + "@atcute/lexicons": "^2.0.0" 29 + }, 30 + "peerDependencies": { 31 + "@atcute/lexicons": "^2.0.0" 19 32 } 20 33 }, 21 34 "node_modules/@atcute/client": {
+1
package.json
··· 11 11 "deploy": "wrangler deploy" 12 12 }, 13 13 "dependencies": { 14 + "@atcute/atproto": "^4.0.2", 14 15 "@atcute/client": "^5.1.0", 15 16 "@atcute/identity-resolver": "^2.0.0" 16 17 },
+80
src/constellation.ts
··· 1 + /** 2 + * Constellation (microcosm) backlink-index client. Returns null/[] on any 3 + * failure so the bot degrades to no-congrats copy rather than crashing. 4 + */ 5 + import { CONSTELLATION_BASE, USER_AGENT, COLLECTION, puzzleTarget, type Lang } from './config.js'; 6 + 7 + export interface BacklinkRecord { 8 + did: string; 9 + collection: string; 10 + rkey: string; 11 + } 12 + 13 + /** Source path Constellation indexes for the result record's leaderboard link. */ 14 + const RESULT_TARGET_PATH = 'puzzleTarget'; 15 + 16 + async function get(path: string, params: Record<string, string>): Promise<any | null> { 17 + const url = new URL(path, CONSTELLATION_BASE); 18 + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); 19 + try { 20 + const res = await fetch(url, { headers: { accept: 'application/json', 'user-agent': USER_AGENT } }); 21 + if (!res.ok) return null; 22 + return await res.json(); 23 + } catch { 24 + return null; 25 + } 26 + } 27 + 28 + /** Distinct DIDs linking to `target`. Null on failure. */ 29 + async function countDistinctDids(target: string, collection: string, dottedPath: string): Promise<number | null> { 30 + const data = await get('/links/count/distinct-dids', { 31 + target, 32 + collection, 33 + path: dottedPath.startsWith('.') ? dottedPath : `.${dottedPath}`, 34 + }); 35 + return typeof data?.total === 'number' ? data.total : null; 36 + } 37 + 38 + async function getBacklinksPage( 39 + target: string, 40 + collection: string, 41 + path: string, 42 + opts: { limit: number; cursor?: string }, 43 + ): Promise<{ records: BacklinkRecord[]; cursor: string | null } | null> { 44 + const params: Record<string, string> = { 45 + subject: target, 46 + source: `${collection}:${path}`, 47 + limit: String(opts.limit), 48 + }; 49 + if (opts.cursor) params.cursor = opts.cursor; 50 + const data = await get('/xrpc/blue.microcosm.links.getBacklinks', params); 51 + if (!data || !Array.isArray(data.records)) return null; 52 + return { records: data.records as BacklinkRecord[], cursor: data.cursor ?? null }; 53 + } 54 + 55 + async function collectBacklinks(target: string, collection: string, path: string, cap: number): Promise<BacklinkRecord[]> { 56 + const out: BacklinkRecord[] = []; 57 + 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; 61 + out.push(...page.records); 62 + if (!page.cursor) break; 63 + cursor = page.cursor; 64 + } 65 + return out.slice(0, cap); 66 + } 67 + 68 + /** Distinct players who recorded a result for this puzzle (wins + losses). Null on failure. */ 69 + export function dailyPlayerCount(lang: Lang, puzzleNumber: number): Promise<number | null> { 70 + return countDistinctDids(puzzleTarget(lang, puzzleNumber), COLLECTION.result, RESULT_TARGET_PATH); 71 + } 72 + 73 + /** Up to `cap` result-record backlinks for this puzzle. */ 74 + export function dailyResultBacklinks(lang: Lang, puzzleNumber: number, cap: number): Promise<BacklinkRecord[]> { 75 + return collectBacklinks(puzzleTarget(lang, puzzleNumber), COLLECTION.result, RESULT_TARGET_PATH, cap); 76 + } 77 + 78 + export function backlinkUri(r: BacklinkRecord): string { 79 + return `at://${r.did}/${r.collection}/${r.rkey}`; 80 + }
+25
src/counts.ts
··· 1 + import { SOLVER_SAMPLE_CAP, type Lang, type ResultRecord, type YesterdayCounts } from './config.js'; 2 + import { dailyPlayerCount, dailyResultBacklinks, backlinkUri } from './constellation.js'; 3 + import { getRecordByUri } from './identity.js'; 4 + 5 + /** 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. 9 + */ 10 + export async function yesterdayCounts(lang: Lang, yesterdayN: number): Promise<YesterdayCounts> { 11 + const [players, backlinks] = await Promise.all([ 12 + dailyPlayerCount(lang, yesterdayN), 13 + dailyResultBacklinks(lang, yesterdayN, SOLVER_SAMPLE_CAP), 14 + ]); 15 + 16 + let solvers = 0; 17 + await Promise.all( 18 + backlinks.map(async (bl) => { 19 + const rec = await getRecordByUri<ResultRecord>(backlinkUri(bl)); 20 + if (rec && rec.lang === lang && rec.puzzleNumber === yesterdayN && rec.solved) solvers++; 21 + }), 22 + ); 23 + 24 + return { players, solvers, sampled: backlinks.length >= SOLVER_SAMPLE_CAP }; 25 + }
+52
src/identity.ts
··· 1 + /** 2 + * Identity resolution (handle/DID -> PDS) and per-PDS record reads. 3 + * Mirrors the app's read path; failures resolve to null and drop the entry. 4 + */ 5 + import { 6 + CompositeDidDocumentResolver, 7 + LocalActorResolver, 8 + PlcDidDocumentResolver, 9 + WebDidDocumentResolver, 10 + XrpcHandleResolver, 11 + type ResolvedActor, 12 + } from '@atcute/identity-resolver'; 13 + import { Client, simpleFetchHandler } from '@atcute/client'; 14 + import { APPVIEW_URL } from './config.js'; 15 + 16 + const actorResolver = new LocalActorResolver({ 17 + handleResolver: new XrpcHandleResolver({ serviceUrl: APPVIEW_URL }), 18 + didDocumentResolver: new CompositeDidDocumentResolver({ 19 + methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() }, 20 + }), 21 + }); 22 + 23 + /** Resolve a handle or DID to { did, handle, pds }. May reject. */ 24 + export function resolveActor(identifier: string): Promise<ResolvedActor> { 25 + return actorResolver.resolve(identifier as Parameters<typeof actorResolver.resolve>[0]); 26 + } 27 + 28 + /** Unauthenticated read client pointed at a specific PDS. */ 29 + export function pdsClient(pds: string): Client { 30 + return new Client({ handler: simpleFetchHandler({ service: pds }) }); 31 + } 32 + 33 + /** Public read of a record by at:// URI; null on any failure. */ 34 + export async function getRecordByUri<T = unknown>(uri: string): Promise<T | null> { 35 + const m = /^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(uri); 36 + if (!m) return null; 37 + try { 38 + const actor = await resolveActor(m[1]!); 39 + const rpc = pdsClient(actor.pds); 40 + const res = await rpc.get('com.atproto.repo.getRecord', { 41 + params: { 42 + repo: m[1]! as `did:${string}:${string}`, 43 + collection: m[2]! as `${string}.${string}.${string}`, 44 + rkey: m[3]!, 45 + }, 46 + }); 47 + if (!res.ok) return null; 48 + return res.data.value as unknown as T; 49 + } catch { 50 + return null; 51 + } 52 + }
+1 -1
tsconfig.json
··· 4 4 "module": "ESNext", 5 5 "moduleResolution": "Bundler", 6 6 "lib": ["ES2022"], 7 - "types": ["@cloudflare/workers-types"], 7 + "types": ["@cloudflare/workers-types", "@atcute/atproto"], 8 8 "strict": true, 9 9 "noEmit": true, 10 10 "skipLibCheck": true,