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);