// Bluesky channel: queries the public AppView's getAuthorFeed (no auth) // and scans post text for KEZ proofs. import type { Identity } from "@kez/core"; import { ChannelError, type Channel, type ChannelHit, parseAndVerifyFor } from "./index.js"; const DEFAULT_APPVIEW = "https://public.api.bsky.app"; const USER_AGENT = "kez-channels-node/0.1 (+https://example.invalid/kez)"; export interface BlueskyChannelOptions { appviewBase?: string; fetch?: typeof fetch; } export class BlueskyChannel implements Channel { readonly system = "bluesky"; private readonly base: string; private readonly fetch: typeof fetch; constructor(opts: BlueskyChannelOptions = {}) { this.base = opts.appviewBase ?? DEFAULT_APPVIEW; this.fetch = opts.fetch ?? globalThis.fetch; } async fetchAndVerify(identity: Identity): Promise { const actor = identity.id; if (!actor) throw ChannelError.other("bluesky identity has empty handle"); const url = authorFeedUrl(this.base, actor); let resp: Response; try { resp = await this.fetch(url, { headers: { "User-Agent": USER_AGENT } }); } catch (e) { throw ChannelError.unreachable(`GET ${url}: ${(e as Error).message}`, e); } if (!resp.ok) throw ChannelError.unreachable(`GET ${url}: ${resp.status}`); const body = (await resp.json()) as unknown; const candidates = extractPostTexts(body); let lastError: ChannelError | undefined; for (const text of candidates) { try { return parseAndVerifyFor(text, identity); } catch (e) { lastError = e instanceof ChannelError ? e : ChannelError.invalid((e as Error).message, e); } } throw lastError ?? ChannelError.notFound(identity); } } export function authorFeedUrl(base: string, actor: string): string { return `${base}/xrpc/app.bsky.feed.getAuthorFeed?actor=${encodeURIComponent(actor)}&limit=100`; } export function extractPostTexts(body: unknown): string[] { if (typeof body !== "object" || body === null) return []; const feed = (body as Record).feed; if (!Array.isArray(feed)) return []; const out: string[] = []; for (const item of feed) { const text = (((item as Record)?.post as Record) ?.record as Record)?.text; if (typeof text === "string" && text.trim().length > 0) out.push(text); } return out; }