A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.
1import { BskyAgent, RichText } from '@atproto/api';
2import type { BlobRef } from '@atproto/api';
3import { Scraper, type Profile as TwitterProfile } from '@the-convocation/twitter-scraper';
4import axios from 'axios';
5import sharp from 'sharp';
6import { getConfig } from './config-manager.js';
7
8const PROFILE_IMAGE_MAX_BYTES = 1_000_000;
9const PROFILE_IMAGE_TARGET_BYTES = 950 * 1024;
10const DEFAULT_BSKY_SERVICE_URL = 'https://bsky.social';
11const BSKY_SETTINGS_URL = 'https://bsky.app/settings/account';
12const BSKY_PUBLIC_APPVIEW_URL = (process.env.BSKY_PUBLIC_APPVIEW_URL || 'https://public.api.bsky.app').replace(
13 /\/$/,
14 '',
15);
16const MIRROR_SUFFIX = '{UNOFFICIAL}';
17const FEDIVERSE_BRIDGE_HANDLE = 'ap.brid.gy';
18const MIN_BRIDGE_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000;
19
20type ProfileImageKind = 'avatar' | 'banner';
21
22interface TwitterCookieSet {
23 label: string;
24 authToken: string;
25 ct0: string;
26}
27
28interface ProcessedProfileImage {
29 buffer: Buffer;
30 mimeType: 'image/jpeg' | 'image/png';
31}
32
33export interface TwitterMirrorProfile {
34 username: string;
35 profileUrl: string;
36 name?: string;
37 biography?: string;
38 avatarUrl?: string;
39 bannerUrl?: string;
40 mirroredDisplayName: string;
41 mirroredDescription: string;
42}
43
44export interface BlueskyCredentialValidation {
45 did: string;
46 handle: string;
47 email?: string;
48 emailConfirmed: boolean;
49 serviceUrl: string;
50 settingsUrl: string;
51}
52
53export interface MirrorProfileSyncResult {
54 twitterProfile: TwitterMirrorProfile;
55 bsky: BlueskyCredentialValidation;
56 avatarSynced: boolean;
57 bannerSynced: boolean;
58 skipped: boolean;
59 changed: {
60 displayName: boolean;
61 description: boolean;
62 avatar: boolean;
63 banner: boolean;
64 };
65 warnings: string[];
66}
67
68export interface ProfileMirrorSyncState {
69 sourceUsername?: string;
70 mirroredDisplayName?: string;
71 mirroredDescription?: string;
72 avatarUrl?: string;
73 bannerUrl?: string;
74}
75
76export interface MappingProfileSyncState {
77 profileSyncSourceUsername?: string;
78 lastProfileSyncAt?: string;
79 lastMirroredDisplayName?: string;
80 lastMirroredDescription?: string;
81 lastMirroredAvatarUrl?: string;
82 lastMirroredBannerUrl?: string;
83}
84
85export interface FediverseBridgeResult {
86 bsky: BlueskyCredentialValidation;
87 bridgedAccountHandle: string;
88 fediverseAddress: string;
89 accountCreatedAt: string;
90 ageDays: number;
91 followedBridgeAccount: boolean;
92 announcementUri: string;
93 announcementCid: string;
94}
95
96export interface FediverseBridgeStatusResult {
97 bsky: BlueskyCredentialValidation;
98 bridgeAccountHandle: string;
99 bridged: boolean;
100}
101
102const normalizeTwitterUsername = (value: string) => value.trim().replace(/^@/, '').toLowerCase();
103
104const normalizeOptionalString = (value: unknown): string | undefined => {
105 if (typeof value !== 'string') return undefined;
106 const trimmed = value.trim();
107 return trimmed.length > 0 ? trimmed : undefined;
108};
109
110const normalizeMirrorStateUrl = (value?: string): string | undefined => normalizeOptionalString(value);
111
112const toNormalizedMirrorState = (state?: ProfileMirrorSyncState) => ({
113 sourceUsername: normalizeTwitterUsername(state?.sourceUsername || ''),
114 mirroredDisplayName: normalizeOptionalString(state?.mirroredDisplayName),
115 mirroredDescription: normalizeOptionalString(state?.mirroredDescription),
116 avatarUrl: normalizeMirrorStateUrl(state?.avatarUrl),
117 bannerUrl: normalizeMirrorStateUrl(state?.bannerUrl),
118});
119
120const buildMirrorStateFromTwitterProfile = (twitterProfile: TwitterMirrorProfile): ProfileMirrorSyncState => ({
121 sourceUsername: normalizeTwitterUsername(twitterProfile.username),
122 mirroredDisplayName: twitterProfile.mirroredDisplayName,
123 mirroredDescription: twitterProfile.mirroredDescription,
124 avatarUrl: normalizeMirrorStateUrl(twitterProfile.avatarUrl),
125 bannerUrl: normalizeMirrorStateUrl(twitterProfile.bannerUrl),
126});
127
128const hasMirrorStateChanges = (previous: ProfileMirrorSyncState | undefined, next: ProfileMirrorSyncState) => {
129 const normalizedPrevious = toNormalizedMirrorState(previous);
130 const normalizedNext = toNormalizedMirrorState(next);
131
132 return {
133 displayName: normalizedPrevious.mirroredDisplayName !== normalizedNext.mirroredDisplayName,
134 description: normalizedPrevious.mirroredDescription !== normalizedNext.mirroredDescription,
135 avatar: normalizedPrevious.avatarUrl !== normalizedNext.avatarUrl,
136 banner: normalizedPrevious.bannerUrl !== normalizedNext.bannerUrl,
137 };
138};
139
140const normalizeBskyServiceUrl = (value?: string): string => {
141 const raw = normalizeOptionalString(value) || DEFAULT_BSKY_SERVICE_URL;
142 const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
143 const url = new URL(withProtocol);
144 return url.toString().replace(/\/$/, '');
145};
146
147const getGraphemeSegments = (value: string): string[] => {
148 const SegmenterCtor = (globalThis.Intl as any).Segmenter as
149 | (new (
150 locale: string,
151 options: { granularity: 'grapheme' },
152 ) => {
153 segment: (input: string) => Iterable<{ segment: string }>;
154 })
155 | undefined;
156 if (SegmenterCtor) {
157 const segmenter = new SegmenterCtor('en', { granularity: 'grapheme' });
158 return [...segmenter.segment(value)].map((segment) => segment.segment);
159 }
160 return Array.from(value);
161};
162
163const truncateGraphemes = (value: string, limit: number): string => {
164 if (limit <= 0) return '';
165 const segments = getGraphemeSegments(value);
166 if (segments.length <= limit) {
167 return value;
168 }
169 return segments.slice(0, limit).join('');
170};
171
172const normalizeWhitespace = (value: string): string => value.replace(/\s+/g, ' ').trim();
173
174const normalizeTwitterAvatarUrl = (url?: string): string | undefined => {
175 if (!url) return undefined;
176 return url.replace('_normal.', '_400x400.');
177};
178
179const normalizeTwitterBannerUrl = (url?: string): string | undefined => {
180 if (!url) return undefined;
181 if (/\/\d+x\d+(?:$|\?)/.test(url)) {
182 return url;
183 }
184 return `${url}/1500x500`;
185};
186
187const inferImageMimeTypeFromUrl = (url: string): 'image/jpeg' | 'image/png' => {
188 const lower = url.toLowerCase();
189 if (lower.includes('.png')) return 'image/png';
190 return 'image/jpeg';
191};
192
193const detectImageMimeType = (contentType: unknown, url: string): 'image/jpeg' | 'image/png' => {
194 if (typeof contentType === 'string') {
195 const normalized = contentType.split(';')[0]?.trim().toLowerCase();
196 if (normalized === 'image/png') return 'image/png';
197 if (normalized === 'image/jpeg' || normalized === 'image/jpg') return 'image/jpeg';
198 }
199 return inferImageMimeTypeFromUrl(url);
200};
201
202const getProfileImagePreset = (kind: ProfileImageKind) => {
203 if (kind === 'avatar') {
204 return {
205 width: 640,
206 height: 640,
207 };
208 }
209 return {
210 width: 1500,
211 height: 500,
212 };
213};
214
215const compressProfileImage = async (
216 sourceBuffer: Buffer,
217 sourceMimeType: 'image/jpeg' | 'image/png',
218 kind: ProfileImageKind,
219): Promise<ProcessedProfileImage> => {
220 const preset = getProfileImagePreset(kind);
221 const metadata = await sharp(sourceBuffer, { failOn: 'none' }).metadata();
222 const hasAlpha = Boolean(metadata.hasAlpha);
223 const scales = [1, 0.92, 0.85, 0.78, 0.7, 0.62, 0.54, 0.46];
224 const jpegQualities = [92, 88, 84, 80, 76, 72, 68, 64];
225 const basePng = sourceMimeType === 'image/png' && hasAlpha;
226
227 let best: ProcessedProfileImage | null = null;
228
229 for (let i = 0; i < scales.length; i += 1) {
230 const scale = scales[i] || 1;
231 const jpegQuality = jpegQualities[i] || 70;
232 const width = Math.max(kind === 'avatar' ? 256 : 800, Math.round(preset.width * scale));
233 const height = Math.max(kind === 'avatar' ? 256 : 260, Math.round(preset.height * scale));
234
235 const resized = sharp(sourceBuffer, { failOn: 'none' }).rotate().resize(width, height, {
236 fit: 'cover',
237 position: 'centre',
238 withoutEnlargement: false,
239 });
240
241 const pngBuffer = basePng
242 ? await resized
243 .clone()
244 .png({
245 compressionLevel: 9,
246 adaptiveFiltering: true,
247 palette: true,
248 quality: 90,
249 })
250 .toBuffer()
251 : null;
252
253 if (pngBuffer) {
254 if (pngBuffer.length <= PROFILE_IMAGE_TARGET_BYTES) {
255 return {
256 buffer: pngBuffer,
257 mimeType: 'image/png',
258 };
259 }
260 if (pngBuffer.length <= PROFILE_IMAGE_MAX_BYTES) {
261 if (!best || pngBuffer.length < best.buffer.length) {
262 best = {
263 buffer: pngBuffer,
264 mimeType: 'image/png',
265 };
266 }
267 }
268 }
269
270 const jpegBuffer = await resized
271 .clone()
272 .flatten({ background: '#ffffff' })
273 .jpeg({ quality: jpegQuality, mozjpeg: true })
274 .toBuffer();
275
276 if (jpegBuffer.length <= PROFILE_IMAGE_TARGET_BYTES) {
277 return {
278 buffer: jpegBuffer,
279 mimeType: 'image/jpeg',
280 };
281 }
282
283 if (jpegBuffer.length <= PROFILE_IMAGE_MAX_BYTES) {
284 if (!best || jpegBuffer.length < best.buffer.length) {
285 best = {
286 buffer: jpegBuffer,
287 mimeType: 'image/jpeg',
288 };
289 }
290 }
291 }
292
293 if (best) {
294 return best;
295 }
296
297 throw new Error('Could not compress image under Bluesky profile limit (1MB).');
298};
299
300const buildTwitterCookieSets = (): TwitterCookieSet[] => {
301 const config = getConfig();
302 const sets: TwitterCookieSet[] = [];
303
304 if (config.twitter.authToken && config.twitter.ct0) {
305 sets.push({
306 label: 'primary',
307 authToken: config.twitter.authToken,
308 ct0: config.twitter.ct0,
309 });
310 }
311
312 if (config.twitter.backupAuthToken && config.twitter.backupCt0) {
313 sets.push({
314 label: 'backup',
315 authToken: config.twitter.backupAuthToken,
316 ct0: config.twitter.backupCt0,
317 });
318 }
319
320 return sets;
321};
322
323const fetchTwitterProfileWithCookies = async (
324 username: string,
325 cookieSet: TwitterCookieSet,
326): Promise<TwitterProfile> => {
327 const scraper = new Scraper();
328 await scraper.setCookies([`auth_token=${cookieSet.authToken}`, `ct0=${cookieSet.ct0}`]);
329 return scraper.getProfile(username);
330};
331
332export const buildMirroredDisplayName = (name: string | undefined, username: string): string => {
333 const baseName = normalizeWhitespace(name || '') || `@${normalizeTwitterUsername(username)}`;
334 const lowerSuffix = MIRROR_SUFFIX.toLowerCase();
335 const merged = baseName.toLowerCase().endsWith(lowerSuffix) ? baseName : `${baseName} ${MIRROR_SUFFIX}`;
336 return truncateGraphemes(merged, 64);
337};
338
339export const buildMirroredDescription = (biography: string | undefined, username: string): string => {
340 const normalizedUsername = normalizeTwitterUsername(username);
341 const intro = `Unofficial mirror account of https://x.com/${normalizedUsername} from Twitter`;
342 const bio = normalizeWhitespace(biography || '');
343 if (!bio) {
344 return truncateGraphemes(intro, 256);
345 }
346
347 const full = `${intro}\n\n"${bio}"`;
348 if (getGraphemeSegments(full).length <= 256) {
349 return full;
350 }
351
352 const reserved = getGraphemeSegments(`${intro}\n\n""`).length;
353 const maxBioLength = Math.max(0, 256 - reserved);
354 const truncatedBio = truncateGraphemes(bio, maxBioLength);
355 return `${intro}\n\n"${truncatedBio}"`;
356};
357
358export const fetchTwitterMirrorProfile = async (inputUsername: string): Promise<TwitterMirrorProfile> => {
359 const username = normalizeTwitterUsername(inputUsername);
360 if (!username) {
361 throw new Error('Twitter username is required.');
362 }
363
364 const cookieSets = buildTwitterCookieSets();
365 if (cookieSets.length === 0) {
366 throw new Error('Twitter cookies are not configured. Save auth_token and ct0 in settings first.');
367 }
368
369 let lastError: unknown;
370 for (const cookieSet of cookieSets) {
371 try {
372 const profile = await fetchTwitterProfileWithCookies(username, cookieSet);
373 const resolvedUsername = normalizeTwitterUsername(profile.username || username);
374 const cleanedName = normalizeOptionalString(profile.name);
375 const cleanedBio = normalizeOptionalString(profile.biography);
376
377 return {
378 username: resolvedUsername,
379 profileUrl: `https://x.com/${resolvedUsername}`,
380 name: cleanedName,
381 biography: cleanedBio,
382 avatarUrl: normalizeTwitterAvatarUrl(profile.avatar),
383 bannerUrl: normalizeTwitterBannerUrl(profile.banner),
384 mirroredDisplayName: buildMirroredDisplayName(cleanedName, resolvedUsername),
385 mirroredDescription: buildMirroredDescription(cleanedBio, resolvedUsername),
386 };
387 } catch (error) {
388 lastError = error;
389 }
390 }
391
392 if (lastError instanceof Error && lastError.message) {
393 throw new Error(`Failed to fetch Twitter profile: ${lastError.message}`);
394 }
395 throw new Error('Failed to fetch Twitter profile.');
396};
397
398const loginBlueskyAgent = async (args: {
399 bskyIdentifier: string;
400 bskyPassword: string;
401 bskyServiceUrl?: string;
402}): Promise<{ agent: BskyAgent; credentials: BlueskyCredentialValidation }> => {
403 const identifier = normalizeOptionalString(args.bskyIdentifier);
404 const password = normalizeOptionalString(args.bskyPassword);
405 if (!identifier || !password) {
406 throw new Error('Bluesky identifier and app password are required.');
407 }
408
409 const serviceUrl = normalizeBskyServiceUrl(args.bskyServiceUrl);
410 const agent = new BskyAgent({ service: serviceUrl });
411 await agent.login({ identifier, password });
412
413 const sessionResponse = await agent.com.atproto.server.getSession();
414 const session = sessionResponse.data;
415
416 return {
417 agent,
418 credentials: {
419 did: session.did,
420 handle: session.handle,
421 email: session.email,
422 emailConfirmed: Boolean(session.emailConfirmed),
423 serviceUrl,
424 settingsUrl: BSKY_SETTINGS_URL,
425 },
426 };
427};
428
429export const validateBlueskyCredentials = async (args: {
430 bskyIdentifier: string;
431 bskyPassword: string;
432 bskyServiceUrl?: string;
433}): Promise<BlueskyCredentialValidation> => {
434 const { credentials } = await loginBlueskyAgent(args);
435 return credentials;
436};
437
438export const applyProfileMirrorSyncState = <T extends MappingProfileSyncState>(
439 mapping: T,
440 sourceTwitterUsername: string,
441 result: MirrorProfileSyncResult,
442): T => {
443 const normalizedSource = normalizeTwitterUsername(sourceTwitterUsername);
444 const next: T = {
445 ...mapping,
446 profileSyncSourceUsername: normalizedSource || mapping.profileSyncSourceUsername,
447 lastProfileSyncAt: new Date().toISOString(),
448 lastMirroredDisplayName: result.twitterProfile.mirroredDisplayName,
449 lastMirroredDescription: result.twitterProfile.mirroredDescription,
450 };
451
452 if (result.changed.avatar && result.avatarSynced) {
453 next.lastMirroredAvatarUrl = normalizeMirrorStateUrl(result.twitterProfile.avatarUrl);
454 }
455
456 if (result.changed.banner && result.bannerSynced) {
457 next.lastMirroredBannerUrl = normalizeMirrorStateUrl(result.twitterProfile.bannerUrl);
458 }
459
460 return next;
461};
462
463const fetchPublicProfile = async (actor: string): Promise<{ did: string; handle: string; createdAt?: string }> => {
464 const normalizedActor = normalizeOptionalString(actor);
465 if (!normalizedActor) {
466 throw new Error('Actor is required.');
467 }
468
469 const response = await axios.get(`${BSKY_PUBLIC_APPVIEW_URL}/xrpc/app.bsky.actor.getProfile`, {
470 params: {
471 actor: normalizedActor,
472 },
473 timeout: 15_000,
474 });
475
476 const did = normalizeOptionalString(response.data?.did);
477 const handle = normalizeOptionalString(response.data?.handle);
478 if (!did || !handle) {
479 throw new Error(`Could not resolve Bluesky profile for ${normalizedActor}.`);
480 }
481
482 return {
483 did,
484 handle,
485 createdAt: normalizeOptionalString(response.data?.createdAt),
486 };
487};
488
489const hasFollowRecordForDid = async (agent: BskyAgent, subjectDid: string): Promise<boolean> => {
490 const repo = agent.session?.did;
491 if (!repo) {
492 throw new Error('Missing Bluesky session DID.');
493 }
494
495 let cursor: string | undefined;
496 let pageCount = 0;
497
498 while (pageCount < 200) {
499 pageCount += 1;
500 const response = await agent.com.atproto.repo.listRecords({
501 repo,
502 collection: 'app.bsky.graph.follow',
503 limit: 100,
504 cursor,
505 });
506
507 const records = Array.isArray(response.data.records) ? response.data.records : [];
508 for (const record of records) {
509 const value = record.value as { subject?: string };
510 if (typeof value?.subject === 'string' && value.subject === subjectDid) {
511 return true;
512 }
513 }
514
515 cursor = response.data.cursor;
516 if (!cursor) {
517 break;
518 }
519 }
520
521 return false;
522};
523
524export const getFediverseBridgeStatus = async (args: {
525 bskyIdentifier: string;
526 bskyPassword: string;
527 bskyServiceUrl?: string;
528}): Promise<FediverseBridgeStatusResult> => {
529 const { agent, credentials } = await loginBlueskyAgent(args);
530
531 const bridgeProfile = await fetchPublicProfile(FEDIVERSE_BRIDGE_HANDLE);
532 const bridged = await hasFollowRecordForDid(agent, bridgeProfile.did);
533
534 return {
535 bsky: credentials,
536 bridgeAccountHandle: bridgeProfile.handle,
537 bridged,
538 };
539};
540
541const uploadProfileImage = async (agent: BskyAgent, url: string, kind: ProfileImageKind): Promise<BlobRef> => {
542 const response = await axios.get<ArrayBuffer>(url, {
543 responseType: 'arraybuffer',
544 timeout: 20_000,
545 maxContentLength: 10 * 1024 * 1024,
546 });
547
548 const mimeType = detectImageMimeType(response.headers?.['content-type'], url);
549 const sourceBuffer = Buffer.from(response.data);
550 const processed = await compressProfileImage(sourceBuffer, mimeType, kind);
551 const { data } = await agent.uploadBlob(processed.buffer, {
552 encoding: processed.mimeType,
553 });
554 return data.blob;
555};
556
557export const syncBlueskyProfileFromTwitter = async (args: {
558 twitterUsername: string;
559 bskyIdentifier: string;
560 bskyPassword: string;
561 bskyServiceUrl?: string;
562 previousSync?: ProfileMirrorSyncState;
563}): Promise<MirrorProfileSyncResult> => {
564 const twitterProfile = await fetchTwitterMirrorProfile(args.twitterUsername);
565 const nextMirrorState = buildMirrorStateFromTwitterProfile(twitterProfile);
566 const changed = hasMirrorStateChanges(args.previousSync, nextMirrorState);
567 const bsky = await validateBlueskyCredentials({
568 bskyIdentifier: args.bskyIdentifier,
569 bskyPassword: args.bskyPassword,
570 bskyServiceUrl: args.bskyServiceUrl,
571 });
572
573 if (!changed.displayName && !changed.description && !changed.avatar && !changed.banner) {
574 return {
575 twitterProfile,
576 bsky,
577 avatarSynced: false,
578 bannerSynced: false,
579 skipped: true,
580 changed,
581 warnings: [],
582 };
583 }
584
585 const agent = new BskyAgent({ service: bsky.serviceUrl });
586 await agent.login({
587 identifier: args.bskyIdentifier,
588 password: args.bskyPassword,
589 });
590
591 const warnings: string[] = [];
592 let avatarBlob: BlobRef | undefined;
593 let bannerBlob: BlobRef | undefined;
594
595 if (changed.avatar && twitterProfile.avatarUrl) {
596 try {
597 avatarBlob = await uploadProfileImage(agent, twitterProfile.avatarUrl, 'avatar');
598 } catch (error) {
599 warnings.push(`Avatar sync failed: ${error instanceof Error ? error.message : String(error)}`);
600 }
601 } else if (changed.avatar) {
602 warnings.push('No Twitter avatar found for this profile.');
603 }
604
605 if (changed.banner && twitterProfile.bannerUrl) {
606 try {
607 bannerBlob = await uploadProfileImage(agent, twitterProfile.bannerUrl, 'banner');
608 } catch (error) {
609 warnings.push(`Banner sync failed: ${error instanceof Error ? error.message : String(error)}`);
610 }
611 } else if (changed.banner) {
612 warnings.push('No Twitter banner found for this profile.');
613 }
614
615 const shouldUpdateProfile = changed.displayName || changed.description || Boolean(avatarBlob) || Boolean(bannerBlob);
616
617 if (shouldUpdateProfile) {
618 await agent.upsertProfile((existing) => ({
619 ...(existing || {}),
620 ...(changed.displayName ? { displayName: twitterProfile.mirroredDisplayName } : {}),
621 ...(changed.description ? { description: twitterProfile.mirroredDescription } : {}),
622 ...(avatarBlob ? { avatar: avatarBlob } : {}),
623 ...(bannerBlob ? { banner: bannerBlob } : {}),
624 }));
625 }
626
627 return {
628 twitterProfile,
629 bsky,
630 avatarSynced: Boolean(avatarBlob),
631 bannerSynced: Boolean(bannerBlob),
632 skipped: false,
633 changed,
634 warnings,
635 };
636};
637
638export const bridgeBlueskyAccountToFediverse = async (args: {
639 bskyIdentifier: string;
640 bskyPassword: string;
641 bskyServiceUrl?: string;
642}): Promise<FediverseBridgeResult> => {
643 const { agent, credentials: bsky } = await loginBlueskyAgent(args);
644 const accountProfile = await fetchPublicProfile(bsky.did || bsky.handle);
645 const createdAtRaw = normalizeOptionalString(accountProfile.createdAt);
646 if (!createdAtRaw) {
647 throw new Error('Could not determine when this Bluesky account was created.');
648 }
649
650 const createdAtMs = Date.parse(createdAtRaw);
651 if (!Number.isFinite(createdAtMs)) {
652 throw new Error('Invalid Bluesky account creation timestamp.');
653 }
654
655 const ageMs = Date.now() - createdAtMs;
656 if (ageMs < MIN_BRIDGE_ACCOUNT_AGE_MS) {
657 const ageDays = Math.floor(ageMs / (24 * 60 * 60 * 1000));
658 throw new Error(`Account must be at least 7 days old before bridging (currently ${ageDays} day(s)).`);
659 }
660
661 const bridgeProfile = await fetchPublicProfile(FEDIVERSE_BRIDGE_HANDLE);
662 const alreadyFollowing = await hasFollowRecordForDid(agent, bridgeProfile.did);
663 if (!alreadyFollowing) {
664 const repo = agent.session?.did;
665 if (!repo) {
666 throw new Error('Missing Bluesky session DID.');
667 }
668
669 await agent.com.atproto.repo.createRecord({
670 repo,
671 collection: 'app.bsky.graph.follow',
672 record: {
673 subject: bridgeProfile.did,
674 createdAt: new Date().toISOString(),
675 },
676 });
677 }
678
679 const fediverseAddress = `@${bsky.handle}@bsky.brid.gy`;
680 const text = `This account can now be found on the fediverse at ${fediverseAddress}`;
681 const richText = new RichText({ text });
682 await richText.detectFacets(agent);
683
684 const post = await agent.post({
685 text: richText.text,
686 facets: richText.facets,
687 createdAt: new Date().toISOString(),
688 });
689
690 return {
691 bsky,
692 bridgedAccountHandle: bsky.handle,
693 fediverseAddress,
694 accountCreatedAt: new Date(createdAtMs).toISOString(),
695 ageDays: Math.floor(ageMs / (24 * 60 * 60 * 1000)),
696 followedBridgeAccount: !alreadyFollowing,
697 announcementUri: post.uri,
698 announcementCid: post.cid,
699 };
700};