import { Agent } from "@atproto/api";
import { BrowserOAuthClient, type OAuthSession } from "@atproto/oauth-client-browser";
import type {
SessionInfo,
FeedRequest,
FeedSignal,
FeedAuthor,
FeedImportPayload,
FeedItem,
PublishFeedRequest,
BlueskyList,
ListMember,
FollowsAnalysisResult,
OAuthConfig,
} from "../types";
let sessionInfo: SessionInfo = {
isAuthenticated: false,
did: null,
handle: null,
pdsUrl: null,
};
let clientPromise: Promise<BrowserOAuthClient> | null = null;
let oauthConfigPromise: Promise<OAuthConfig> | null = null;
async function resolvePdsUrl(did: string): Promise<string | null> {
try {
// Resolve DID to get PDS endpoint
const didDocUrl = did.startsWith("did:plc:")
? `https://plc.directory/${did}`
: `https://resolver.identity.foundation/1.0/identifiers/${did}`;
const response = await fetch(didDocUrl, {
headers: { Accept: "application/json" },
});
if (!response.ok) return null;
const doc = await response.json();
// Look for atproto PDS service
const services = doc.service || [];
const pdsService = services.find(
(s: any) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer"
);
return pdsService?.serviceEndpoint || null;
} catch (err) {
console.error("[bluesky] Failed to resolve PDS:", err);
return null;
}
}
async function toSessionInfo(session: OAuthSession): Promise<SessionInfo> {
const did = session.sub;
const handle = session.handle ?? did;
// Try to get PDS from session audience or resolve from DID
let pdsUrl = session.aud ?? null;
if (!pdsUrl || pdsUrl.startsWith("did:")) {
pdsUrl = await resolvePdsUrl(did);
}
return {
isAuthenticated: true,
did,
handle,
pdsUrl,
};
}
async function getAgent() {
const client = await getClient();
if (!sessionInfo.did) {
throw new Error("No active Bluesky session.");
}
const session = await client.restore(sessionInfo.did);
if (!session) {
throw new Error("Unable to restore Bluesky session.");
}
sessionInfo = await toSessionInfo(session);
return new Agent(session);
}
function isUnauthorizedError(error: unknown) {
if (!error || typeof error !== "object") {
return false;
}
const maybeStatus = (error as { status?: number }).status;
if (maybeStatus === 401) {
return true;
}
const maybeMessage = (error as { message?: string }).message;
return typeof maybeMessage === "string" && maybeMessage.includes("401");
}
async function callWithAuthRetry<T>(operation: (agent: Agent) => Promise<T>) {
let lastError: unknown;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const agent = await getAgent();
return await operation(agent);
} catch (error) {
lastError = error;
if (!isUnauthorizedError(error) || attempt === 1) {
break;
}
await new Promise((resolve) => window.setTimeout(resolve, 300));
}
}
throw lastError;
}
async function mapInBatches<TInput, TOutput>(
items: TInput[],
batchSize: number,
mapper: (item: TInput) => Promise<TOutput>
) {
const output: TOutput[] = [];
for (let index = 0; index < items.length; index += batchSize) {
const batch = items.slice(index, index + batchSize);
const settled = await Promise.allSettled(batch.map(mapper));
for (const result of settled) {
if (result.status === "fulfilled") {
output.push(result.value);
}
}
}
return output;
}
async function mapInBatchesDetailed<TInput, TOutput>(
items: TInput[],
batchSize: number,
mapper: (item: TInput) => Promise<TOutput>
) {
const fulfilled: TOutput[] = [];
const rejected: unknown[] = [];
for (let index = 0; index < items.length; index += batchSize) {
const batch = items.slice(index, index + batchSize);
const settled = await Promise.allSettled(batch.map(mapper));
for (const result of settled) {
if (result.status === "fulfilled") {
fulfilled.push(result.value);
} else {
rejected.push(result.reason);
}
}
}
return { fulfilled, rejected };
}
async function fetchActorLikes(actor: string, maxItems: number) {
const likedEntries: any[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
while (likedEntries.length < maxItems) {
const response = await callWithAuthRetry((agent) =>
agent.app.bsky.feed.getActorLikes({
actor,
limit: Math.min(100, maxItems - likedEntries.length),
cursor,
})
);
likedEntries.push(...response.data.feed);
cursor = response.data.cursor;
if (!cursor || seenCursors.has(cursor)) {
break;
}
seenCursors.add(cursor);
}
return likedEntries;
}
async function fetchPostLikers(uri: string, maxItems: number) {
const likes: any[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
while (likes.length < maxItems) {
const response = await callWithAuthRetry((agent) =>
agent.app.bsky.feed.getLikes({
uri,
limit: Math.min(100, maxItems - likes.length),
cursor,
})
);
likes.push(...response.data.likes);
cursor = response.data.cursor;
if (!cursor || seenCursors.has(cursor)) {
break;
}
seenCursors.add(cursor);
}
return likes;
}
function isLoopbackOrigin(origin: string) {
try {
const url = new URL(origin);
const hostname = url.hostname;
// Standard loopback
if (hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1" || hostname === "localhost") {
return true;
}
// Any 127.x.x.x address
if (hostname.startsWith("127.")) {
return true;
}
// 0.0.0.0 (bind all)
if (hostname === "0.0.0.0") {
return true;
}
// Private IP ranges (common for local dev)
if (hostname.startsWith("10.") || hostname.startsWith("192.168.")) {
return true;
}
// 172.16-31.x.x
if (hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)) {
return true;
}
return false;
} catch {
return false;
}
}
async function getOAuthConfig(): Promise<OAuthConfig> {
if (!oauthConfigPromise) {
oauthConfigPromise = (async () => {
const response = await fetch("/api/oauth/config", {
credentials: "same-origin",
});
if (!response.ok) {
throw new Error("Unable to load Bluesky OAuth configuration.");
}
return (await response.json()) as OAuthConfig;
})();
}
return oauthConfigPromise;
}
async function getClient(): Promise<BrowserOAuthClient> {
if (!clientPromise) {
clientPromise = (async () => {
const config = await getOAuthConfig();
console.log("[oauth] config:", config);
const loopback = config.allowLocalDev && isLoopbackOrigin(config.currentOrigin);
console.log("[oauth] loopback check:", config.currentOrigin, "=>", loopback);
const isHttps = config.publicOrigin?.startsWith("https://");
const isIpAddress = config.publicOrigin?.match(/:\/\/\d+\.\d+\.\d+\.\d+/) || config.publicOrigin?.includes("://localhost");
const clientMetadata =
isHttps && !isIpAddress
? {
// Production HTTPS web client with valid hostname
client_id: `${config.publicOrigin}/oauth/client-metadata.json`,
client_name: "Bluesky Feed Dashboard",
client_uri: config.publicOrigin,
redirect_uris: [`${config.publicOrigin}/oauth/callback`],
application_type: "web" as const,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none" as const,
scope: "atproto transition:generic",
dpop_bound_access_tokens: true,
}
: loopback
? {
// Loopback native client (HTTP or IP addresses)
client_id: `http://localhost?redirect_uri=${encodeURIComponent(`${config.currentOrigin}/oauth/callback`)}&scope=${encodeURIComponent("atproto transition:generic")}`,
client_name: "Bluesky Feed Dashboard (Dev)",
application_type: "native" as const,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
redirect_uris: [`${config.currentOrigin}/oauth/callback`],
token_endpoint_auth_method: "none" as const,
scope: "atproto transition:generic",
dpop_bound_access_tokens: true,
}
: null;
console.log("[oauth] clientMetadata:", clientMetadata);
if (!clientMetadata) {
throw new Error(
"Bluesky OAuth requires either a public HTTPS hostname or loopback development on 127.0.0.1/localhost."
);
}
const client = new BrowserOAuthClient({
clientMetadata,
handleResolver: "https://bsky.social",
});
const result = await client.init();
console.log("[oauth] client.init() result:", result);
if (result?.session) {
sessionInfo = await toSessionInfo(result.session);
console.log("[oauth] Session restored:", sessionInfo);
} else {
console.log("[oauth] No session to restore");
}
return client;
})();
}
return clientPromise;
}
function normalizePost(entry: any): FeedItem {
const post = entry.post ?? entry;
const author = post.author ?? {};
const record = post.record ?? {};
return {
subjectUri: post.uri,
authorDid: author.did,
authorHandle: author.handle,
authorDisplayName: author.displayName ?? author.handle ?? author.did,
text: record.text ?? "",
origin: "app.bsky.feed.getTimeline",
labels: (post.labels ?? []).map((label: any) => label.val),
createdAt: record.createdAt ?? post.indexedAt ?? null,
};
}
function normalizeLikedPost(entry: any): FeedItem {
const post = entry.post ?? entry;
const author = post.author ?? {};
const record = post.record ?? {};
return {
subjectUri: post.uri,
authorDid: author.did,
authorHandle: author.handle ?? author.did,
authorDisplayName: author.displayName ?? author.handle ?? author.did,
text: record.text ?? "",
origin: "liked-seed",
labels: (post.labels ?? []).map((label: any) => label.val),
createdAt: record.createdAt ?? post.indexedAt ?? null,
};
}
async function fetchAuthorPosts(author: FeedAuthor & { source: "first-order" | "second-order" }, limit: number) {
const authorFeed = await callWithAuthRetry((agent) =>
agent.app.bsky.feed.getAuthorFeed({
actor: author.did,
filter: "posts_no_replies",
limit,
})
);
return authorFeed.data.feed.map((entry: any) => ({
...normalizePost(entry),
origin:
author.source === "first-order"
? `liked-author:${author.handle}`
: `second-order:${author.handle}`,
}));
}
function isAtUri(value: string | undefined) {
return !!value && value.startsWith("at://");
}
export const blueskyService = {
async getSession(): Promise<SessionInfo> {
await getClient();
console.log("[blueskyService] getSession returning:", sessionInfo);
return sessionInfo;
},
async beginLogin(identifier: string): Promise<void> {
if (!identifier) {
return;
}
const client = await getClient();
void client.signIn(identifier, { scope: "atproto transition:generic" });
},
async fetchFeed(request: FeedRequest): Promise<FeedItem[]> {
const source = request.source?.trim();
if (!source || source === "home") {
const timeline = await callWithAuthRetry((agent) =>
agent.app.bsky.feed.getTimeline({ limit: request.limit })
);
return timeline.data.feed.map(normalizePost);
}
if (isAtUri(source)) {
const feed = await callWithAuthRetry((agent) =>
agent.app.bsky.feed.getFeed({ feed: source, limit: request.limit })
);
return feed.data.feed.map(normalizePost);
}
const authorFeed = await callWithAuthRetry((agent) =>
agent.app.bsky.feed.getAuthorFeed({ actor: source, limit: request.limit })
);
return authorFeed.data.feed.map(normalizePost);
},
async fetchLikedNetwork(): Promise<FeedImportPayload> {
const actor = sessionInfo.did;
if (!actor) {
throw new Error("No active Bluesky session.");
}
console.log("[import] Starting liked-network crawl for", actor);
const t0 = performance.now();
console.log("[import] Fetching actor likes (max 300)…");
const likedEntries = await fetchActorLikes(actor, 300);
console.log("[import] Got", likedEntries.length, "liked entries in", Math.round(performance.now() - t0), "ms");
const likedPosts = likedEntries.map(normalizeLikedPost);
const seedItems = likedPosts.map((liked) => ({
subjectUri: liked.subjectUri,
authorDid: liked.authorDid,
authorHandle: liked.authorHandle,
authorDisplayName: liked.authorDisplayName,
text: liked.text,
origin: "liked-seed",
labels: liked.labels,
createdAt: liked.createdAt,
}));
const authorMap = new Map<string, FeedAuthor>();
for (const liked of likedPosts) {
const existing = authorMap.get(liked.authorDid);
authorMap.set(liked.authorDid, {
did: liked.authorDid,
handle: liked.authorHandle,
displayName: liked.authorDisplayName,
likeCount: (existing?.likeCount ?? 0) + 1,
source: "first-order",
});
}
const topAuthors = [...authorMap.values()]
.sort((left, right) => right.likeCount - left.likeCount)
.slice(0, 25);
const firstOrderAuthors: FeedAuthor[] = topAuthors.map((author) => ({ ...author, source: "first-order" }));
console.log("[import] Top first-order authors:", firstOrderAuthors.length);
const secondOrderSeedPosts = seedItems.slice(0, 40);
console.log("[import] Crawling second-order likers for", secondOrderSeedPosts.length, "seed posts…");
const secondOrderResults = await mapInBatchesDetailed(secondOrderSeedPosts, 4, async (seedItem) => {
const likes = await fetchPostLikers(seedItem.subjectUri, 40);
return likes.map((like: any) => ({
seedPostUri: seedItem.subjectUri,
seedWeight: 1,
actor: like.actor,
}));
});
const secondOrderLikes = secondOrderResults.fulfilled;
console.log("[import] Second-order: fulfilled =", secondOrderLikes.length, "rejected =", secondOrderResults.rejected.length, "in", Math.round(performance.now() - t0), "ms");
if (secondOrderResults.rejected.length > 0) {
const errorSummary = secondOrderResults.rejected
.slice(0, 3)
.map((error) => (error instanceof Error ? error.message : String(error)))
.join(" | ");
throw new Error(
`Second-order co-liker crawl failed for ${secondOrderResults.rejected.length} seed post(s). ` +
`Seed count=${secondOrderSeedPosts.length}. Errors: ${errorSummary}`
);
}
const rawSecondOrderRows = secondOrderLikes.flat();
if (secondOrderSeedPosts.length > 0 && rawSecondOrderRows.length === 0) {
throw new Error(
`Second-order co-liker crawl returned zero rows. ` +
`Seed posts=${secondOrderSeedPosts.length}`
);
}
const secondOrderAuthorMap = new Map<string, FeedAuthor>();
let droppedAsSelf = 0;
let droppedAsAlreadyFirstOrder = 0;
for (const discovered of rawSecondOrderRows) {
const likedActor = discovered.actor ?? {};
const likedActorDid = likedActor.did;
if (!likedActorDid) {
continue;
}
if (likedActorDid === actor) {
droppedAsSelf += 1;
continue;
}
if (authorMap.has(likedActorDid)) {
droppedAsAlreadyFirstOrder += 1;
continue;
}
const existing = secondOrderAuthorMap.get(likedActorDid);
secondOrderAuthorMap.set(likedActorDid, {
did: likedActorDid,
handle: likedActor.handle ?? likedActorDid,
displayName: likedActor.displayName ?? likedActor.handle ?? likedActorDid,
likeCount: (existing?.likeCount ?? 0) + discovered.seedWeight,
source: "second-order",
});
}
const secondOrderAuthors = [...secondOrderAuthorMap.values()]
.sort((left, right) => right.likeCount - left.likeCount)
.slice(0, 30);
console.log("[import] Second-order authors:", secondOrderAuthors.length);
if (rawSecondOrderRows.length > 0 && secondOrderAuthors.length === 0) {
throw new Error(
`Second-order crawl collapsed to zero authors after filtering. ` +
`Raw rows=${rawSecondOrderRows.length}, dropped as self=${droppedAsSelf}, dropped as already-first-order=${droppedAsAlreadyFirstOrder}.`
);
}
console.log("[import] Fetching author feeds (first-order:", firstOrderAuthors.length, "second-order:", secondOrderAuthors.length, ")…");
const fetchedAuthorFeeds = await mapInBatches(firstOrderAuthors, 4, async (author) =>
fetchAuthorPosts(author, 6)
);
const fetchedSecondOrderFeeds = await mapInBatches(secondOrderAuthors, 4, async (author) =>
fetchAuthorPosts(author, 4)
);
const feedItems = [...fetchedAuthorFeeds.flat(), ...fetchedSecondOrderFeeds.flat()];
const deduped = Array.from(new Map(feedItems.map((item) => [item.subjectUri, item])).values());
const combinedAuthors = [...firstOrderAuthors, ...secondOrderAuthors].sort((left, right) => right.likeCount - left.likeCount);
const result = {
account: {
did: sessionInfo.did!,
handle: sessionInfo.handle ?? sessionInfo.did!,
pdsUrl: sessionInfo.pdsUrl,
},
authors: combinedAuthors,
items: deduped,
seedItems,
} satisfies FeedImportPayload;
const jsonSize = new Blob([JSON.stringify(result)]).size;
console.log("[import] Crawl complete:", deduped.length, "posts,", combinedAuthors.length, "authors in", Math.round(performance.now() - t0), "ms — payload ~" + Math.round(jsonSize / 1024) + " KB");
return result;
},
async writeSignal(signal: FeedSignal): Promise<void> {
await callWithAuthRetry((agent) =>
agent.com.atproto.repo.putRecord({
repo: agent.assertDid,
collection: signal.collection,
rkey: crypto.randomUUID(),
record: {
$type: signal.collection,
subjectUri: signal.subjectUri,
signal: signal.signal,
weight: signal.weight,
generatorId: signal.generatorId,
metadata: signal.metadata,
createdAt: signal.createdAt,
},
})
);
},
async publishFeedGenerator(request: PublishFeedRequest): Promise<void> {
await callWithAuthRetry((agent) =>
agent.com.atproto.repo.putRecord({
repo: request.ownerDid,
collection: "app.bsky.feed.generator",
rkey: request.feedKey,
record: {
$type: "app.bsky.feed.generator",
did: request.serviceDid,
displayName: request.displayName,
description: request.description,
createdAt: new Date().toISOString(),
},
})
);
},
async fetchAuthorFollows(actor: string, maxFollows: number) {
const follows: any[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
while (follows.length < maxFollows) {
const response = await callWithAuthRetry((agent) =>
agent.app.bsky.graph.getFollows({
actor,
limit: Math.min(100, maxFollows - follows.length),
cursor,
})
);
follows.push(...(response.data.follows || []));
cursor = response.data.cursor;
if (!cursor || seenCursors.has(cursor)) {
break;
}
seenCursors.add(cursor);
}
return follows;
},
async fetchUserLists(): Promise<BlueskyList[]> {
const actor = sessionInfo.did;
if (!actor) {
throw new Error("No active Bluesky session.");
}
console.log("[lists] Fetching lists for", actor);
const lists: BlueskyList[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
while (true) {
const response = await callWithAuthRetry((agent) =>
agent.app.bsky.graph.getLists({
actor,
limit: 100,
cursor,
})
);
const listsData = response.data.lists || [];
for (const list of listsData) {
lists.push({
uri: list.uri,
name: list.name,
description: list.description,
creator: {
did: list.creator.did,
handle: list.creator.handle,
displayName: list.creator.displayName,
},
indexedAt: list.indexedAt,
});
}
cursor = response.data.cursor;
if (!cursor || seenCursors.has(cursor)) {
break;
}
seenCursors.add(cursor);
}
console.log("[lists] Found", lists.length, "lists");
return lists;
},
async fetchListMembers(listUri: string, maxItems: number): Promise<ListMember[]> {
console.log("[lists] Fetching members for", listUri);
const members: ListMember[] = [];
const seenCursors = new Set<string>();
let cursor: string | undefined;
while (members.length < maxItems) {
const response = await callWithAuthRetry((agent) =>
agent.app.bsky.graph.getList({
list: listUri,
limit: Math.min(100, maxItems - members.length),
cursor,
})
);
const items = response.data.items || [];
for (const item of items) {
if (item.subject) {
members.push({
did: item.subject.did,
handle: item.subject.handle,
displayName: item.subject.displayName || item.subject.handle,
});
}
}
cursor = response.data.cursor;
if (!cursor || seenCursors.has(cursor)) {
break;
}
seenCursors.add(cursor);
}
console.log("[lists] Found", members.length, "members");
return members;
},
async analyzeListFollows(request: { listUri?: string; maxFollowsPerUser: number }): Promise<FollowsAnalysisResult> {
const actor = sessionInfo.did;
if (!actor) {
throw new Error("No active Bluesky session.");
}
console.log("[list-analysis] Starting follows analysis for", actor, request.listUri ? `list: ${request.listUri}` : "liked network");
const t0 = performance.now();
let sourceAuthors: { did: string; handle: string; displayName: string }[];
if (request.listUri) {
// Fetch members from a specific list
console.log("[list-analysis] Fetching list members...");
const members = await this.fetchListMembers(request.listUri, 200);
sourceAuthors = members.map(m => ({
did: m.did,
handle: m.handle,
displayName: m.displayName,
}));
console.log("[list-analysis] Found", sourceAuthors.length, "list members");
} else {
// Fall back to imported liked network - need to fetch it first
console.log("[list-analysis] Fetching liked network for analysis...");
const likedNetwork = await this.fetchLikedNetwork();
sourceAuthors = likedNetwork.authors
.filter(a => a.source === "first-order")
.map(a => ({
did: a.did,
handle: a.handle,
displayName: a.displayName,
}));
console.log("[list-analysis] Found", sourceAuthors.length, "source authors from liked network");
}
if (sourceAuthors.length === 0) {
return {
sourceCount: 0,
analyzedCount: 0,
topFollowed: [],
};
}
// Fetch follows for each source author
console.log("[list-analysis] Fetching follows for each source author...");
const followsResults = await mapInBatchesDetailed(
sourceAuthors,
3,
async (author) => {
const follows = await this.fetchAuthorFollows(author.did, request.maxFollowsPerUser);
return {
sourceDid: author.did,
sourceHandle: author.handle,
follows: follows.map((f: any) => ({
did: f.did,
handle: f.handle,
displayName: f.displayName || f.handle,
})),
};
}
);
console.log(
"[list-analysis] Fetched follows:",
followsResults.fulfilled.length,
"succeeded,",
followsResults.rejected.length,
"failed in",
Math.round(performance.now() - t0),
"ms"
);
// Aggregate follows and count mutuals
const followedMap = new Map<string, { did: string; handle: string; displayName: string; followerCount: number; followedBy: string[] }>();
for (const result of followsResults.fulfilled) {
for (const followed of result.follows) {
const existing = followedMap.get(followed.did);
if (existing) {
existing.followerCount++;
if (!existing.followedBy.includes(result.sourceHandle)) {
existing.followedBy.push(result.sourceHandle);
}
} else {
followedMap.set(followed.did, {
did: followed.did,
handle: followed.handle,
displayName: followed.displayName,
followerCount: 1,
followedBy: [result.sourceHandle],
});
}
}
}
// Sort by follower count and take top 50
const topFollowed = [...followedMap.values()]
.sort((a, b) => b.followerCount - a.followerCount)
.slice(0, 50);
console.log(
"[list-analysis] Analysis complete:",
followedMap.size,
"unique follows found,",
topFollowed.length,
"top followed in",
Math.round(performance.now() - t0),
"ms"
);
return {
sourceCount: sourceAuthors.length,
analyzedCount: followsResults.fulfilled.length,
topFollowed,
};
},
};