Daily Bluesky bot for AT Mot. Invites players and congratulates yesterday's solvers.
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 */
6import { Client, simpleFetchHandler, type FetchHandler } from '@atcute/client';
7import { resolveActor } from './identity.js';
8import type { ComposedPost } from './compose.js';
9
10export 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. */
16export 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). */
38export 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. */
52export 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}