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.
11
fork

Configure Feed

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

at c2e10731a55ee1293bfb98a325fd2e4ee1296cff 700 lines 22 kB view raw
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};