ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 0e44908bfb09f54559dbc307dbdec87b46c48bcf 160 lines 5.0 kB view raw
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);