ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
1import { AuthenticatedHandler } from "./core/types";
2import { SessionService } from "./services/SessionService";
3import { FollowService } from "./services/FollowService";
4import { MatchRepository } from "./repositories";
5import { successResponse, validateArrayInput, ValidationSchemas } from "./utils";
6import { withAuthErrorHandling } from "./core/middleware";
7import {
8 createRateLimiter,
9 applyRateLimit,
10} from "./core/middleware/rateLimit.middleware";
11
12// Rate limit: 8 requests per hour
13// Each request can follow up to 100 users (300 points)
14// Leaves ~50% of 5,000 points/hr for other operations
15const checkRateLimit = createRateLimiter({
16 maxRequests: 8,
17 windowMs: 60 * 60 * 1000, // 1 hour
18});
19
20const batchFollowHandler: AuthenticatedHandler = async (context) => {
21 applyRateLimit(checkRateLimit, context.event, "minutes");
22 const body = JSON.parse(context.event.body || "{}");
23 const dids = validateArrayInput<string>(
24 context.event.body,
25 "dids",
26 ValidationSchemas.didsArray,
27 );
28 const followLexicon: string = body.followLexicon || "app.bsky.graph.follow";
29
30 const { agent } = await SessionService.getAgentForSession(
31 context.sessionId,
32 context.event,
33 );
34
35 const alreadyFollowing = await FollowService.getAlreadyFollowing(
36 agent,
37 context.did,
38 dids,
39 followLexicon,
40 );
41
42 const matchRepo = new MatchRepository();
43 const CONCURRENCY = 5; // Process 5 follows in parallel
44
45 // Helper function to follow a single user
46 const followUser = async (did: string) => {
47 if (alreadyFollowing.has(did)) {
48 try {
49 await matchRepo.updateFollowStatus(did, followLexicon, true);
50 } catch (dbError) {
51 console.error("Failed to update follow status in DB:", dbError);
52 }
53
54 return {
55 did,
56 success: true,
57 alreadyFollowing: true,
58 error: null,
59 };
60 }
61
62 try {
63 await agent.api.com.atproto.repo.createRecord({
64 repo: context.did,
65 collection: followLexicon,
66 record: {
67 $type: followLexicon,
68 subject: did,
69 createdAt: new Date().toISOString(),
70 },
71 });
72
73 try {
74 await matchRepo.updateFollowStatus(did, followLexicon, true);
75 } catch (dbError) {
76 console.error("Failed to update follow status in DB:", dbError);
77 }
78
79 return {
80 did,
81 success: true,
82 alreadyFollowing: false,
83 error: null,
84 };
85 } catch (error) {
86 // Rate limit handling with backoff
87 if (
88 error instanceof Error &&
89 (error.message.includes("rate limit") || error.message.includes("429"))
90 ) {
91 const backoffDelay = 1000; // 1 second backoff for rate limits
92 console.log(`Rate limit hit for ${did}. Backing off for ${backoffDelay}ms...`);
93 await new Promise((resolve) => setTimeout(resolve, backoffDelay));
94 }
95
96 return {
97 did,
98 success: false,
99 alreadyFollowing: false,
100 error: error instanceof Error ? error.message : "Follow failed",
101 };
102 }
103 };
104
105 // Process follows in chunks with controlled concurrency
106 const results = [];
107 for (let i = 0; i < dids.length; i += CONCURRENCY) {
108 const chunk = dids.slice(i, i + CONCURRENCY);
109 const chunkResults = await Promise.allSettled(
110 chunk.map(did => followUser(did))
111 );
112
113 // Extract results from Promise.allSettled
114 for (const result of chunkResults) {
115 if (result.status === 'fulfilled') {
116 results.push(result.value);
117 } else {
118 // This shouldn't happen as we handle errors in followUser
119 console.error('Unexpected promise rejection:', result.reason);
120 results.push({
121 did: 'unknown',
122 success: false,
123 alreadyFollowing: false,
124 error: 'Unexpected error',
125 });
126 }
127 }
128 }
129
130 const successCount = results.filter((r) => r.success).length;
131 const failCount = results.filter((r) => !r.success).length;
132 const alreadyFollowingCount = results.filter(
133 (r) => r.alreadyFollowing,
134 ).length;
135
136 return successResponse({
137 success: true,
138 total: dids.length,
139 succeeded: successCount,
140 failed: failCount,
141 alreadyFollowing: alreadyFollowingCount,
142 results,
143 });
144};
145
146export const handler = withAuthErrorHandling(batchFollowHandler);