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 app-password session, idempotency, and publishing

+68
+68
src/post.ts
··· 1 + /** 2 + * Posting side: create an app-password session against the bot's PDS, check we 3 + * haven't already posted today (by scanning our own recent posts — stateless), 4 + * and publish the post. 5 + */ 6 + import { Client, simpleFetchHandler, type FetchHandler } from '@atcute/client'; 7 + import { resolveActor } from './identity.js'; 8 + import type { ComposedPost } from './compose.js'; 9 + 10 + export interface BotSession { 11 + client: Client; 12 + did: string; 13 + } 14 + 15 + /** Log in with an app password; returns a client whose requests carry the bearer token. */ 16 + export async function createBotSession(identifier: string, appPassword: string): Promise<BotSession> { 17 + const actor = await resolveActor(identifier); 18 + const pds = actor.pds; 19 + 20 + const loginClient = new Client({ handler: simpleFetchHandler({ service: pds }) }); 21 + const res = await loginClient.post('com.atproto.server.createSession', { 22 + input: { identifier, password: appPassword }, 23 + }); 24 + if (!res.ok) throw new Error(`createSession failed: ${JSON.stringify(res.data)}`); 25 + 26 + const accessJwt = res.data.accessJwt; 27 + const base = simpleFetchHandler({ service: pds }); 28 + const authed: FetchHandler = (pathname, init) => { 29 + const headers = new Headers(init.headers ?? {}); 30 + headers.set('authorization', `Bearer ${accessJwt}`); 31 + return base(pathname, { ...init, headers }); 32 + }; 33 + 34 + return { client: new Client({ handler: authed }), did: res.data.did }; 35 + } 36 + 37 + /** True if any of the bot's recent posts already contains `marker` (today's puzzle token). */ 38 + export async function alreadyPosted(session: BotSession, marker: string): Promise<boolean> { 39 + const res = await session.client.get('com.atproto.repo.listRecords', { 40 + params: { 41 + repo: session.did as `did:${string}:${string}`, 42 + collection: 'app.bsky.feed.post' as `${string}.${string}.${string}`, 43 + limit: 20, 44 + }, 45 + }); 46 + if (!res.ok) return false; // can't confirm — allow the post rather than skip forever 47 + const records = res.data.records as Array<{ value?: { text?: string } }>; 48 + return records.some((r) => typeof r.value?.text === 'string' && r.value.text.includes(marker)); 49 + } 50 + 51 + /** Create the post; returns its at:// uri. */ 52 + export async function publishPost(session: BotSession, post: ComposedPost): Promise<string> { 53 + const res = await session.client.post('com.atproto.repo.createRecord', { 54 + input: { 55 + repo: session.did as `did:${string}:${string}`, 56 + collection: 'app.bsky.feed.post' as `${string}.${string}.${string}`, 57 + record: { 58 + $type: 'app.bsky.feed.post', 59 + text: post.text, 60 + langs: post.langs, 61 + facets: post.facets, 62 + createdAt: new Date().toISOString(), 63 + } as unknown as Record<string, unknown>, 64 + }, 65 + }); 66 + if (!res.ok) throw new Error(`createRecord failed: ${JSON.stringify(res.data)}`); 67 + return res.data.uri; 68 + }