ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
1import {
2 AuthenticatedHandler,
3 ATProtoActor,
4 ATProtoProfile,
5 RankedActor,
6 EnrichedActor,
7} from "./core/types";
8import { SessionService } from "./services/SessionService";
9import { successResponse, validateArrayInput, ValidationSchemas } from "./utils";
10import { withAuthErrorHandling } from "./core/middleware";
11import {
12 createRateLimiter,
13 applyRateLimit,
14} from "./core/middleware/rateLimit.middleware";
15import { normalize } from "./utils/string.utils";
16import { FollowService } from "./services/FollowService";
17
18// Rate limit: 5 requests per minute
19// Leaves ~50% buffer for other AT Protocol operations
20const checkRateLimit = createRateLimiter({
21 maxRequests: 5,
22 windowMs: 60 * 1000, // 1 minute
23});
24
25const batchSearchHandler: AuthenticatedHandler = async (context) => {
26 applyRateLimit(checkRateLimit, context.event, "seconds");
27 const body = JSON.parse(context.event.body || "{}");
28 const usernames = validateArrayInput<string>(
29 context.event.body,
30 "usernames",
31 ValidationSchemas.usernamesArray,
32 );
33
34 const { agent } = await SessionService.getAgentForSession(
35 context.sessionId,
36 context.event,
37 );
38
39 const searchPromises = usernames.map(async (username) => {
40 try {
41 const response = await agent.app.bsky.actor.searchActors({
42 q: username,
43 limit: 20,
44 });
45
46 const normalizedUsername = normalize(username);
47
48 const rankedActors = response.data.actors
49 .map((actor: ATProtoActor): RankedActor => {
50 const handlePart = actor.handle.split(".")[0];
51 const normalizedHandle = normalize(handlePart);
52 const normalizedFullHandle = normalize(actor.handle);
53 const normalizedDisplayName = normalize(actor.displayName || "");
54
55 let score = 0;
56 if (normalizedHandle === normalizedUsername) score = 100;
57 else if (normalizedFullHandle === normalizedUsername) score = 90;
58 else if (normalizedDisplayName === normalizedUsername) score = 80;
59 else if (normalizedHandle.includes(normalizedUsername)) score = 60;
60 else if (normalizedFullHandle.includes(normalizedUsername))
61 score = 50;
62 else if (normalizedDisplayName.includes(normalizedUsername))
63 score = 40;
64 else if (normalizedUsername.includes(normalizedHandle)) score = 30;
65
66 return {
67 ...actor,
68 matchScore: score,
69 did: actor.did,
70 };
71 })
72 .filter((actor: RankedActor) => actor.matchScore > 0)
73 .sort((a: RankedActor, b: RankedActor) => b.matchScore - a.matchScore)
74 .slice(0, 5);
75
76 return {
77 username,
78 actors: rankedActors,
79 error: null,
80 };
81 } catch (error) {
82 return {
83 username,
84 actors: [],
85 error: error instanceof Error ? error.message : "Search failed",
86 };
87 }
88 });
89
90 const results = await Promise.all(searchPromises);
91
92 const allDids = results
93 .flatMap((r) => r.actors.map((a: RankedActor) => a.did))
94 .filter((did): did is string => !!did);
95
96 if (allDids.length > 0) {
97 const profileDataMap = new Map<
98 string,
99 { postCount: number; followerCount: number }
100 >();
101
102 const PROFILE_BATCH_SIZE = 25;
103 for (let i = 0; i < allDids.length; i += PROFILE_BATCH_SIZE) {
104 const batch = allDids.slice(i, i + PROFILE_BATCH_SIZE);
105 try {
106 const profilesResponse = await agent.app.bsky.actor.getProfiles({
107 actors: batch,
108 });
109
110 profilesResponse.data.profiles.forEach((profile: ATProtoProfile) => {
111 profileDataMap.set(profile.did, {
112 postCount: profile.postsCount || 0,
113 followerCount: profile.followersCount || 0,
114 });
115 });
116 } catch (error) {
117 console.error("Failed to fetch profile batch:", error);
118 }
119 }
120
121 results.forEach((result) => {
122 result.actors = result.actors.map((actor: RankedActor): EnrichedActor => {
123 const enrichedData = profileDataMap.get(actor.did);
124 return {
125 ...actor,
126 postCount: enrichedData?.postCount || 0,
127 followerCount: enrichedData?.followerCount || 0,
128 };
129 });
130 });
131 }
132
133 const followLexicon = body.followLexicon || "app.bsky.graph.follow";
134
135 if (allDids.length > 0) {
136 try {
137 const followStatus = await FollowService.checkFollowStatus(
138 agent,
139 context.did,
140 allDids,
141 followLexicon,
142 );
143
144 results.forEach((result) => {
145 result.actors = result.actors.map((actor: EnrichedActor): EnrichedActor => ({
146 ...actor,
147 followStatus: {
148 [followLexicon]: followStatus[actor.did] || false,
149 },
150 }));
151 });
152 } catch (error) {
153 console.error("Failed to check follow status during search:", error);
154 }
155 }
156
157 return successResponse({ results });
158};
159
160export const handler = withAuthErrorHandling(batchSearchHandler);