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 main 1059 lines 33 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 = '{bot}'; 17const LEGACY_MIRROR_SUFFIX = '{unofficial}'; 18const FEDIVERSE_BRIDGE_HANDLE = 'ap.brid.gy'; 19const MIN_BRIDGE_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000; 20const BOT_SELF_LABEL_VALUE = 'bot'; 21const TCO_LINK_REGEX = /https:\/\/t\.co\/[a-zA-Z0-9]+/gi; 22const TRACKING_QUERY_PARAM_PREFIXES = ['utm_']; 23const TRACKING_QUERY_PARAM_NAMES = new Set([ 24 'fbclid', 25 'gclid', 26 'dclid', 27 'yclid', 28 'mc_cid', 29 'mc_eid', 30 'mkt_tok', 31 'igshid', 32 'ref', 33 'ref_src', 34 'ref_url', 35 'source', 36 's', 37 'si', 38]); 39const URL_EXPANSION_HEADERS = { 40 'user-agent': 41 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', 42 accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 43}; 44 45type ProfileImageKind = 'avatar' | 'banner'; 46 47interface TwitterCookieSet { 48 label: string; 49 authToken: string; 50 ct0: string; 51} 52 53interface ProcessedProfileImage { 54 buffer: Buffer; 55 mimeType: 'image/jpeg' | 'image/png'; 56} 57 58export interface TwitterMirrorProfile { 59 username: string; 60 profileUrl: string; 61 name?: string; 62 biography?: string; 63 avatarUrl?: string; 64 bannerUrl?: string; 65 mirroredDisplayName: string; 66 mirroredDescription: string; 67} 68 69export interface BlueskyCredentialValidation { 70 did: string; 71 handle: string; 72 email?: string; 73 emailConfirmed: boolean; 74 serviceUrl: string; 75 settingsUrl: string; 76} 77 78export interface MirrorProfileSyncResult { 79 twitterProfile: TwitterMirrorProfile; 80 bsky: BlueskyCredentialValidation; 81 avatarSynced: boolean; 82 bannerSynced: boolean; 83 skipped: boolean; 84 changed: { 85 displayName: boolean; 86 description: boolean; 87 avatar: boolean; 88 banner: boolean; 89 }; 90 warnings: string[]; 91} 92 93export interface ProfileMirrorSyncState { 94 sourceUsername?: string; 95 mirroredDisplayName?: string; 96 mirroredDescription?: string; 97 avatarUrl?: string; 98 bannerUrl?: string; 99} 100 101export interface MappingProfileSyncState { 102 profileSyncSourceUsername?: string; 103 lastProfileSyncAt?: string; 104 lastMirroredDisplayName?: string; 105 lastMirroredDescription?: string; 106 lastMirroredAvatarUrl?: string; 107 lastMirroredBannerUrl?: string; 108} 109 110export interface FediverseBridgeResult { 111 bsky: BlueskyCredentialValidation; 112 bridgedAccountHandle: string; 113 fediverseAddress: string; 114 accountCreatedAt: string; 115 ageDays: number; 116 followedBridgeAccount: boolean; 117 announcementUri: string; 118 announcementCid: string; 119} 120 121export interface FediverseBridgeStatusResult { 122 bsky: BlueskyCredentialValidation; 123 bridgeAccountHandle: string; 124 bridged: boolean; 125} 126 127export interface EnsureBotSelfLabelResult { 128 bsky: BlueskyCredentialValidation; 129 updated: boolean; 130 hasBotLabel: true; 131} 132 133export interface EnsureDisplayNameBotSuffixResult { 134 bsky: BlueskyCredentialValidation; 135 updated: boolean; 136 displayName: string; 137} 138 139const normalizeTwitterUsername = (value: string) => value.trim().replace(/^@/, '').toLowerCase(); 140 141const normalizeOptionalString = (value: unknown): string | undefined => { 142 if (typeof value !== 'string') return undefined; 143 const trimmed = value.trim(); 144 return trimmed.length > 0 ? trimmed : undefined; 145}; 146 147const normalizeMirrorStateUrl = (value?: string): string | undefined => normalizeOptionalString(value); 148 149const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null; 150 151const normalizeSelfLabelValues = (value: unknown): Array<Record<string, unknown>> => { 152 if (!Array.isArray(value)) { 153 return []; 154 } 155 156 const entries: Array<Record<string, unknown>> = []; 157 for (const item of value) { 158 if (!isRecord(item)) { 159 continue; 160 } 161 const normalizedVal = normalizeOptionalString(item.val); 162 if (!normalizedVal) { 163 continue; 164 } 165 entries.push({ 166 ...item, 167 val: normalizedVal, 168 }); 169 } 170 171 return entries; 172}; 173 174const toNormalizedMirrorState = (state?: ProfileMirrorSyncState) => ({ 175 sourceUsername: normalizeTwitterUsername(state?.sourceUsername || ''), 176 mirroredDisplayName: normalizeOptionalString(state?.mirroredDisplayName), 177 mirroredDescription: normalizeOptionalString(state?.mirroredDescription), 178 avatarUrl: normalizeMirrorStateUrl(state?.avatarUrl), 179 bannerUrl: normalizeMirrorStateUrl(state?.bannerUrl), 180}); 181 182const buildMirrorStateFromTwitterProfile = (twitterProfile: TwitterMirrorProfile): ProfileMirrorSyncState => ({ 183 sourceUsername: normalizeTwitterUsername(twitterProfile.username), 184 mirroredDisplayName: twitterProfile.mirroredDisplayName, 185 mirroredDescription: twitterProfile.mirroredDescription, 186 avatarUrl: normalizeMirrorStateUrl(twitterProfile.avatarUrl), 187 bannerUrl: normalizeMirrorStateUrl(twitterProfile.bannerUrl), 188}); 189 190const hasMirrorStateChanges = (previous: ProfileMirrorSyncState | undefined, next: ProfileMirrorSyncState) => { 191 const normalizedPrevious = toNormalizedMirrorState(previous); 192 const normalizedNext = toNormalizedMirrorState(next); 193 194 return { 195 displayName: normalizedPrevious.mirroredDisplayName !== normalizedNext.mirroredDisplayName, 196 description: normalizedPrevious.mirroredDescription !== normalizedNext.mirroredDescription, 197 avatar: normalizedPrevious.avatarUrl !== normalizedNext.avatarUrl, 198 banner: normalizedPrevious.bannerUrl !== normalizedNext.bannerUrl, 199 }; 200}; 201 202const normalizeBskyServiceUrl = (value?: string): string => { 203 const raw = normalizeOptionalString(value) || DEFAULT_BSKY_SERVICE_URL; 204 const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; 205 const url = new URL(withProtocol); 206 return url.toString().replace(/\/$/, ''); 207}; 208 209const getGraphemeSegments = (value: string): string[] => { 210 const SegmenterCtor = (globalThis.Intl as any).Segmenter as 211 | (new ( 212 locale: string, 213 options: { granularity: 'grapheme' }, 214 ) => { 215 segment: (input: string) => Iterable<{ segment: string }>; 216 }) 217 | undefined; 218 if (SegmenterCtor) { 219 const segmenter = new SegmenterCtor('en', { granularity: 'grapheme' }); 220 return [...segmenter.segment(value)].map((segment) => segment.segment); 221 } 222 return Array.from(value); 223}; 224 225const truncateGraphemes = (value: string, limit: number): string => { 226 if (limit <= 0) return ''; 227 const segments = getGraphemeSegments(value); 228 if (segments.length <= limit) { 229 return value; 230 } 231 return segments.slice(0, limit).join(''); 232}; 233 234const normalizeWhitespace = (value: string): string => value.replace(/\s+/g, ' ').trim(); 235 236const stripMirrorDisplaySuffixes = (value: string): string => { 237 if (!value) { 238 return value; 239 } 240 241 const suffixPattern = /\s*\{(?:bot|unofficial)\}\s*/gi; 242 return normalizeWhitespace(value.replace(suffixPattern, ' ')); 243}; 244 245const shouldStripTrackingParam = (rawName: string): boolean => { 246 const name = rawName.trim().toLowerCase(); 247 if (!name) { 248 return false; 249 } 250 251 if (TRACKING_QUERY_PARAM_NAMES.has(name)) { 252 return true; 253 } 254 255 return TRACKING_QUERY_PARAM_PREFIXES.some((prefix) => name.startsWith(prefix)); 256}; 257 258const stripTrackingParamsFromUrl = (rawUrl: string): string => { 259 try { 260 const parsed = new URL(rawUrl); 261 const names = [...parsed.searchParams.keys()]; 262 for (const name of names) { 263 if (shouldStripTrackingParam(name)) { 264 parsed.searchParams.delete(name); 265 } 266 } 267 return parsed.toString(); 268 } catch { 269 return rawUrl; 270 } 271}; 272 273const resolveRedirectUrl = (response: unknown): string | undefined => { 274 if (!isRecord(response)) { 275 return undefined; 276 } 277 const request = isRecord(response.request) ? response.request : undefined; 278 const res = request && isRecord(request.res) ? request.res : undefined; 279 return normalizeOptionalString(res?.responseUrl); 280}; 281 282const decodeEscapedUrlValue = (value: string): string => { 283 return value 284 .replace(/\\\//g, '/') 285 .replace(/&amp;/gi, '&') 286 .replace(/&quot;/gi, '"') 287 .trim(); 288}; 289 290const extractRedirectUrlFromHtml = (html: string): string | undefined => { 291 const metaRefresh = 292 html.match(/http-equiv=["']refresh["'][^>]*content=["'][^"']*url=([^"'>\s]+)/i)?.[1] || 293 html.match(/<meta[^>]+content=["'][^"']*url=([^"'>\s]+)/i)?.[1]; 294 if (metaRefresh) { 295 return decodeEscapedUrlValue(metaRefresh); 296 } 297 298 const locationReplace = 299 html.match(/location\.replace\(\s*(["'])(.+?)\1\s*\)/i)?.[2] || 300 html.match(/window\.location(?:\.href)?\s*=\s*(["'])(.+?)\1/i)?.[2]; 301 if (locationReplace) { 302 return decodeEscapedUrlValue(locationReplace); 303 } 304 305 const titleUrl = html.match(/<title>\s*(https?:\/\/[^<\s]+)\s*<\/title>/i)?.[1]; 306 if (titleUrl) { 307 return decodeEscapedUrlValue(titleUrl); 308 } 309 310 return undefined; 311}; 312 313const expandShortUrl = async (shortUrl: string): Promise<string> => { 314 try { 315 const head = await axios.head(shortUrl, { 316 maxRedirects: 8, 317 timeout: 8_000, 318 headers: URL_EXPANSION_HEADERS, 319 validateStatus: (status) => status >= 200 && status < 400, 320 }); 321 const resolvedByHead = resolveRedirectUrl(head); 322 if (resolvedByHead && resolvedByHead !== shortUrl) { 323 return resolvedByHead; 324 } 325 } catch { 326 // Fall through to GET-based resolver. 327 } 328 329 try { 330 const get = await axios.get<string>(shortUrl, { 331 maxRedirects: 8, 332 timeout: 8_000, 333 headers: URL_EXPANSION_HEADERS, 334 maxContentLength: 512 * 1024, 335 validateStatus: (status) => status >= 200 && status < 400, 336 }); 337 338 const resolvedByGet = resolveRedirectUrl(get); 339 if (resolvedByGet && resolvedByGet !== shortUrl) { 340 return resolvedByGet; 341 } 342 343 const html = typeof get.data === 'string' ? get.data : ''; 344 const resolvedFromHtml = extractRedirectUrlFromHtml(html); 345 if (resolvedFromHtml) { 346 return resolvedFromHtml; 347 } 348 } catch { 349 return shortUrl; 350 } 351 352 return shortUrl; 353}; 354 355const expandAndNormalizeTwitterBioLinks = async (biography?: string): Promise<string | undefined> => { 356 const bio = normalizeOptionalString(biography); 357 if (!bio) { 358 return undefined; 359 } 360 361 let expandedBio = bio; 362 const matches = expandedBio.match(TCO_LINK_REGEX) || []; 363 const uniqueMatches = [...new Set(matches)]; 364 for (const tcoUrl of uniqueMatches) { 365 const resolvedUrl = await expandShortUrl(tcoUrl); 366 const normalizedUrl = stripTrackingParamsFromUrl(resolvedUrl); 367 if (!normalizedUrl || normalizedUrl === tcoUrl) { 368 continue; 369 } 370 expandedBio = expandedBio.split(tcoUrl).join(normalizedUrl); 371 } 372 373 return normalizeOptionalString(expandedBio); 374}; 375 376const normalizeTwitterAvatarUrl = (url?: string): string | undefined => { 377 if (!url) return undefined; 378 return url.replace('_normal.', '_400x400.'); 379}; 380 381const normalizeTwitterBannerUrl = (url?: string): string | undefined => { 382 if (!url) return undefined; 383 if (/\/\d+x\d+(?:$|\?)/.test(url)) { 384 return url; 385 } 386 return `${url}/1500x500`; 387}; 388 389const inferImageMimeTypeFromUrl = (url: string): 'image/jpeg' | 'image/png' => { 390 const lower = url.toLowerCase(); 391 if (lower.includes('.png')) return 'image/png'; 392 return 'image/jpeg'; 393}; 394 395const detectImageMimeType = (contentType: unknown, url: string): 'image/jpeg' | 'image/png' => { 396 if (typeof contentType === 'string') { 397 const normalized = contentType.split(';')[0]?.trim().toLowerCase(); 398 if (normalized === 'image/png') return 'image/png'; 399 if (normalized === 'image/jpeg' || normalized === 'image/jpg') return 'image/jpeg'; 400 } 401 return inferImageMimeTypeFromUrl(url); 402}; 403 404const getProfileImagePreset = (kind: ProfileImageKind) => { 405 if (kind === 'avatar') { 406 return { 407 width: 640, 408 height: 640, 409 }; 410 } 411 return { 412 width: 1500, 413 height: 500, 414 }; 415}; 416 417const compressProfileImage = async ( 418 sourceBuffer: Buffer, 419 sourceMimeType: 'image/jpeg' | 'image/png', 420 kind: ProfileImageKind, 421): Promise<ProcessedProfileImage> => { 422 const preset = getProfileImagePreset(kind); 423 const metadata = await sharp(sourceBuffer, { failOn: 'none' }).metadata(); 424 const hasAlpha = Boolean(metadata.hasAlpha); 425 const scales = [1, 0.92, 0.85, 0.78, 0.7, 0.62, 0.54, 0.46]; 426 const jpegQualities = [92, 88, 84, 80, 76, 72, 68, 64]; 427 const basePng = sourceMimeType === 'image/png' && hasAlpha; 428 429 let best: ProcessedProfileImage | null = null; 430 431 for (let i = 0; i < scales.length; i += 1) { 432 const scale = scales[i] || 1; 433 const jpegQuality = jpegQualities[i] || 70; 434 const width = Math.max(kind === 'avatar' ? 256 : 800, Math.round(preset.width * scale)); 435 const height = Math.max(kind === 'avatar' ? 256 : 260, Math.round(preset.height * scale)); 436 437 const resized = sharp(sourceBuffer, { failOn: 'none' }).rotate().resize(width, height, { 438 fit: 'cover', 439 position: 'centre', 440 withoutEnlargement: false, 441 }); 442 443 const pngBuffer = basePng 444 ? await resized 445 .clone() 446 .png({ 447 compressionLevel: 9, 448 adaptiveFiltering: true, 449 palette: true, 450 quality: 90, 451 }) 452 .toBuffer() 453 : null; 454 455 if (pngBuffer) { 456 if (pngBuffer.length <= PROFILE_IMAGE_TARGET_BYTES) { 457 return { 458 buffer: pngBuffer, 459 mimeType: 'image/png', 460 }; 461 } 462 if (pngBuffer.length <= PROFILE_IMAGE_MAX_BYTES) { 463 if (!best || pngBuffer.length < best.buffer.length) { 464 best = { 465 buffer: pngBuffer, 466 mimeType: 'image/png', 467 }; 468 } 469 } 470 } 471 472 const jpegBuffer = await resized 473 .clone() 474 .flatten({ background: '#ffffff' }) 475 .jpeg({ quality: jpegQuality, mozjpeg: true }) 476 .toBuffer(); 477 478 if (jpegBuffer.length <= PROFILE_IMAGE_TARGET_BYTES) { 479 return { 480 buffer: jpegBuffer, 481 mimeType: 'image/jpeg', 482 }; 483 } 484 485 if (jpegBuffer.length <= PROFILE_IMAGE_MAX_BYTES) { 486 if (!best || jpegBuffer.length < best.buffer.length) { 487 best = { 488 buffer: jpegBuffer, 489 mimeType: 'image/jpeg', 490 }; 491 } 492 } 493 } 494 495 if (best) { 496 return best; 497 } 498 499 throw new Error('Could not compress image under Bluesky profile limit (1MB).'); 500}; 501 502const buildTwitterCookieSets = (): TwitterCookieSet[] => { 503 const config = getConfig(); 504 const sets: TwitterCookieSet[] = []; 505 506 if (config.twitter.authToken && config.twitter.ct0) { 507 sets.push({ 508 label: 'primary', 509 authToken: config.twitter.authToken, 510 ct0: config.twitter.ct0, 511 }); 512 } 513 514 if (config.twitter.backupAuthToken && config.twitter.backupCt0) { 515 sets.push({ 516 label: 'backup', 517 authToken: config.twitter.backupAuthToken, 518 ct0: config.twitter.backupCt0, 519 }); 520 } 521 522 return sets; 523}; 524 525const fetchTwitterProfileWithCookies = async ( 526 username: string, 527 cookieSet: TwitterCookieSet, 528): Promise<TwitterProfile> => { 529 const scraper = new Scraper(); 530 await scraper.setCookies([`auth_token=${cookieSet.authToken}`, `ct0=${cookieSet.ct0}`]); 531 return scraper.getProfile(username); 532}; 533 534export const buildMirroredDisplayName = (name: string | undefined, username: string): string => { 535 const cleanedName = stripMirrorDisplaySuffixes(normalizeWhitespace(name || '')); 536 const baseName = cleanedName || `@${normalizeTwitterUsername(username)}`; 537 const lowerSuffix = MIRROR_SUFFIX.toLowerCase(); 538 const merged = baseName.toLowerCase().endsWith(lowerSuffix) ? baseName : `${baseName} ${MIRROR_SUFFIX}`; 539 return truncateGraphemes(merged, 64); 540}; 541 542export const buildMirroredDescription = (biography: string | undefined, username: string): string => { 543 const normalizedUsername = normalizeTwitterUsername(username); 544 const intro = `Unofficial mirror account of https://x.com/${normalizedUsername} from Twitter`; 545 const bio = normalizeWhitespace(biography || ''); 546 if (!bio) { 547 return truncateGraphemes(intro, 256); 548 } 549 550 const full = `${intro}\n\n${bio}`; 551 if (getGraphemeSegments(full).length <= 256) { 552 return full; 553 } 554 555 const reserved = getGraphemeSegments(`${intro}\n\n`).length; 556 const maxBioLength = Math.max(0, 256 - reserved); 557 const truncatedBio = truncateGraphemes(bio, maxBioLength); 558 return `${intro}\n\n${truncatedBio}`; 559}; 560 561export const fetchTwitterMirrorProfile = async (inputUsername: string): Promise<TwitterMirrorProfile> => { 562 const username = normalizeTwitterUsername(inputUsername); 563 if (!username) { 564 throw new Error('Twitter username is required.'); 565 } 566 567 const cookieSets = buildTwitterCookieSets(); 568 if (cookieSets.length === 0) { 569 throw new Error('Twitter cookies are not configured. Save auth_token and ct0 in settings first.'); 570 } 571 572 let lastError: unknown; 573 for (const cookieSet of cookieSets) { 574 try { 575 const profile = await fetchTwitterProfileWithCookies(username, cookieSet); 576 const resolvedUsername = normalizeTwitterUsername(profile.username || username); 577 const cleanedName = normalizeOptionalString(profile.name); 578 const cleanedBio = await expandAndNormalizeTwitterBioLinks(profile.biography); 579 580 return { 581 username: resolvedUsername, 582 profileUrl: `https://x.com/${resolvedUsername}`, 583 name: cleanedName, 584 biography: cleanedBio, 585 avatarUrl: normalizeTwitterAvatarUrl(profile.avatar), 586 bannerUrl: normalizeTwitterBannerUrl(profile.banner), 587 mirroredDisplayName: buildMirroredDisplayName(cleanedName, resolvedUsername), 588 mirroredDescription: buildMirroredDescription(cleanedBio, resolvedUsername), 589 }; 590 } catch (error) { 591 lastError = error; 592 } 593 } 594 595 if (lastError instanceof Error && lastError.message) { 596 throw new Error(`Failed to fetch Twitter profile: ${lastError.message}`); 597 } 598 throw new Error('Failed to fetch Twitter profile.'); 599}; 600 601const loginBlueskyAgent = async (args: { 602 bskyIdentifier: string; 603 bskyPassword: string; 604 bskyServiceUrl?: string; 605}): Promise<{ agent: BskyAgent; credentials: BlueskyCredentialValidation }> => { 606 const identifier = normalizeOptionalString(args.bskyIdentifier); 607 const password = normalizeOptionalString(args.bskyPassword); 608 if (!identifier || !password) { 609 throw new Error('Bluesky identifier and app password are required.'); 610 } 611 612 const serviceUrl = normalizeBskyServiceUrl(args.bskyServiceUrl); 613 const agent = new BskyAgent({ service: serviceUrl }); 614 await agent.login({ identifier, password }); 615 616 const sessionResponse = await agent.com.atproto.server.getSession(); 617 const session = sessionResponse.data; 618 619 return { 620 agent, 621 credentials: { 622 did: session.did, 623 handle: session.handle, 624 email: session.email, 625 emailConfirmed: Boolean(session.emailConfirmed), 626 serviceUrl, 627 settingsUrl: BSKY_SETTINGS_URL, 628 }, 629 }; 630}; 631 632export const validateBlueskyCredentials = async (args: { 633 bskyIdentifier: string; 634 bskyPassword: string; 635 bskyServiceUrl?: string; 636}): Promise<BlueskyCredentialValidation> => { 637 const { credentials } = await loginBlueskyAgent(args); 638 return credentials; 639}; 640 641export const ensureBlueskyBotSelfLabel = async (args: { 642 bskyIdentifier: string; 643 bskyPassword: string; 644 bskyServiceUrl?: string; 645}): Promise<EnsureBotSelfLabelResult> => { 646 const { agent, credentials } = await loginBlueskyAgent(args); 647 const repo = agent.session?.did || credentials.did; 648 if (!repo) { 649 throw new Error('Missing Bluesky session DID.'); 650 } 651 652 let existingProfileRecord: Record<string, unknown> = { 653 $type: 'app.bsky.actor.profile', 654 }; 655 656 try { 657 const response = await agent.com.atproto.repo.getRecord({ 658 repo, 659 collection: 'app.bsky.actor.profile', 660 rkey: 'self', 661 }); 662 if (isRecord(response.data?.value)) { 663 existingProfileRecord = { ...response.data.value }; 664 } 665 } catch (error) { 666 const message = error instanceof Error ? error.message : String(error); 667 const looksLikeMissingRecord = /not found|record.*not.*found|could not locate/i.test(message); 668 if (!looksLikeMissingRecord) { 669 throw error; 670 } 671 } 672 673 const existingLabels = isRecord(existingProfileRecord.labels) ? existingProfileRecord.labels : undefined; 674 const existingValues = normalizeSelfLabelValues(existingLabels?.values); 675 const alreadyHasBotLabel = existingValues.some( 676 (entry) => normalizeOptionalString(entry.val)?.toLowerCase() === BOT_SELF_LABEL_VALUE, 677 ); 678 if (alreadyHasBotLabel) { 679 return { 680 bsky: credentials, 681 updated: false, 682 hasBotLabel: true, 683 }; 684 } 685 686 const nextValues = [...existingValues, { val: BOT_SELF_LABEL_VALUE }]; 687 const nextProfileRecord: Record<string, unknown> = { 688 ...existingProfileRecord, 689 $type: 'app.bsky.actor.profile', 690 labels: { 691 $type: 'com.atproto.label.defs#selfLabels', 692 values: nextValues, 693 }, 694 }; 695 696 await agent.com.atproto.repo.putRecord({ 697 repo, 698 collection: 'app.bsky.actor.profile', 699 rkey: 'self', 700 record: nextProfileRecord, 701 }); 702 703 return { 704 bsky: credentials, 705 updated: true, 706 hasBotLabel: true, 707 }; 708}; 709 710export const ensureBlueskyDisplayNameBotSuffix = async (args: { 711 bskyIdentifier: string; 712 bskyPassword: string; 713 bskyServiceUrl?: string; 714 twitterUsername?: string; 715}): Promise<EnsureDisplayNameBotSuffixResult> => { 716 const { agent, credentials } = await loginBlueskyAgent(args); 717 const repo = agent.session?.did || credentials.did; 718 if (!repo) { 719 throw new Error('Missing Bluesky session DID.'); 720 } 721 722 let existingProfileRecord: Record<string, unknown> = { 723 $type: 'app.bsky.actor.profile', 724 }; 725 726 try { 727 const response = await agent.com.atproto.repo.getRecord({ 728 repo, 729 collection: 'app.bsky.actor.profile', 730 rkey: 'self', 731 }); 732 if (isRecord(response.data?.value)) { 733 existingProfileRecord = { ...response.data.value }; 734 } 735 } catch (error) { 736 const message = error instanceof Error ? error.message : String(error); 737 const looksLikeMissingRecord = /not found|record.*not.*found|could not locate/i.test(message); 738 if (!looksLikeMissingRecord) { 739 throw error; 740 } 741 } 742 743 const currentDisplayName = normalizeOptionalString(existingProfileRecord.displayName); 744 const normalizedTwitterUsername = normalizeTwitterUsername(args.twitterUsername || ''); 745 let sourceDisplayName = currentDisplayName; 746 let sourceUsername = credentials.handle; 747 748 if (normalizedTwitterUsername) { 749 const twitterProfile = await fetchTwitterMirrorProfile(normalizedTwitterUsername); 750 sourceDisplayName = normalizeOptionalString(twitterProfile.name); 751 sourceUsername = twitterProfile.username; 752 } 753 754 const nextDisplayName = buildMirroredDisplayName(sourceDisplayName, sourceUsername); 755 const currentNormalized = normalizeWhitespace(currentDisplayName || ''); 756 const legacySuffixPresent = currentNormalized.toLowerCase().includes(LEGACY_MIRROR_SUFFIX); 757 const updated = currentNormalized !== nextDisplayName || legacySuffixPresent; 758 759 if (updated) { 760 const nextProfileRecord: Record<string, unknown> = { 761 ...existingProfileRecord, 762 $type: 'app.bsky.actor.profile', 763 displayName: nextDisplayName, 764 }; 765 766 await agent.com.atproto.repo.putRecord({ 767 repo, 768 collection: 'app.bsky.actor.profile', 769 rkey: 'self', 770 record: nextProfileRecord, 771 }); 772 } 773 774 return { 775 bsky: credentials, 776 updated, 777 displayName: nextDisplayName, 778 }; 779}; 780 781export const applyProfileMirrorSyncState = <T extends MappingProfileSyncState>( 782 mapping: T, 783 sourceTwitterUsername: string, 784 result: MirrorProfileSyncResult, 785): T => { 786 const normalizedSource = normalizeTwitterUsername(sourceTwitterUsername); 787 const next: T = { 788 ...mapping, 789 profileSyncSourceUsername: normalizedSource || mapping.profileSyncSourceUsername, 790 lastProfileSyncAt: new Date().toISOString(), 791 }; 792 793 if (result.changed.displayName) { 794 next.lastMirroredDisplayName = result.twitterProfile.mirroredDisplayName; 795 } 796 797 if (result.changed.description) { 798 next.lastMirroredDescription = result.twitterProfile.mirroredDescription; 799 } 800 801 if (result.changed.avatar && result.avatarSynced) { 802 next.lastMirroredAvatarUrl = normalizeMirrorStateUrl(result.twitterProfile.avatarUrl); 803 } 804 805 if (result.changed.banner && result.bannerSynced) { 806 next.lastMirroredBannerUrl = normalizeMirrorStateUrl(result.twitterProfile.bannerUrl); 807 } 808 809 return next; 810}; 811 812const fetchPublicProfile = async (actor: string): Promise<{ did: string; handle: string; createdAt?: string }> => { 813 const normalizedActor = normalizeOptionalString(actor); 814 if (!normalizedActor) { 815 throw new Error('Actor is required.'); 816 } 817 818 const response = await axios.get(`${BSKY_PUBLIC_APPVIEW_URL}/xrpc/app.bsky.actor.getProfile`, { 819 params: { 820 actor: normalizedActor, 821 }, 822 timeout: 15_000, 823 }); 824 825 const did = normalizeOptionalString(response.data?.did); 826 const handle = normalizeOptionalString(response.data?.handle); 827 if (!did || !handle) { 828 throw new Error(`Could not resolve Bluesky profile for ${normalizedActor}.`); 829 } 830 831 return { 832 did, 833 handle, 834 createdAt: normalizeOptionalString(response.data?.createdAt), 835 }; 836}; 837 838const hasFollowRecordForDid = async (agent: BskyAgent, subjectDid: string): Promise<boolean> => { 839 const repo = agent.session?.did; 840 if (!repo) { 841 throw new Error('Missing Bluesky session DID.'); 842 } 843 844 let cursor: string | undefined; 845 let pageCount = 0; 846 847 while (pageCount < 200) { 848 pageCount += 1; 849 const response = await agent.com.atproto.repo.listRecords({ 850 repo, 851 collection: 'app.bsky.graph.follow', 852 limit: 100, 853 cursor, 854 }); 855 856 const records = Array.isArray(response.data.records) ? response.data.records : []; 857 for (const record of records) { 858 const value = record.value as { subject?: string }; 859 if (typeof value?.subject === 'string' && value.subject === subjectDid) { 860 return true; 861 } 862 } 863 864 cursor = response.data.cursor; 865 if (!cursor) { 866 break; 867 } 868 } 869 870 return false; 871}; 872 873export const getFediverseBridgeStatus = async (args: { 874 bskyIdentifier: string; 875 bskyPassword: string; 876 bskyServiceUrl?: string; 877}): Promise<FediverseBridgeStatusResult> => { 878 const { agent, credentials } = await loginBlueskyAgent(args); 879 880 const bridgeProfile = await fetchPublicProfile(FEDIVERSE_BRIDGE_HANDLE); 881 const bridged = await hasFollowRecordForDid(agent, bridgeProfile.did); 882 883 return { 884 bsky: credentials, 885 bridgeAccountHandle: bridgeProfile.handle, 886 bridged, 887 }; 888}; 889 890const uploadProfileImage = async (agent: BskyAgent, url: string, kind: ProfileImageKind): Promise<BlobRef> => { 891 const response = await axios.get<ArrayBuffer>(url, { 892 responseType: 'arraybuffer', 893 timeout: 20_000, 894 maxContentLength: 10 * 1024 * 1024, 895 }); 896 897 const mimeType = detectImageMimeType(response.headers?.['content-type'], url); 898 const sourceBuffer = Buffer.from(response.data); 899 const processed = await compressProfileImage(sourceBuffer, mimeType, kind); 900 const { data } = await agent.uploadBlob(processed.buffer, { 901 encoding: processed.mimeType, 902 }); 903 return data.blob; 904}; 905 906export const syncBlueskyProfileFromTwitter = async (args: { 907 twitterUsername: string; 908 bskyIdentifier: string; 909 bskyPassword: string; 910 bskyServiceUrl?: string; 911 previousSync?: ProfileMirrorSyncState; 912 syncDisplayName?: boolean; 913 syncDescription?: boolean; 914 syncAvatar?: boolean; 915 syncBanner?: boolean; 916}): Promise<MirrorProfileSyncResult> => { 917 const twitterProfile = await fetchTwitterMirrorProfile(args.twitterUsername); 918 const nextMirrorState = buildMirrorStateFromTwitterProfile(twitterProfile); 919 const rawChanged = hasMirrorStateChanges(args.previousSync, nextMirrorState); 920 const changed = { 921 displayName: (args.syncDisplayName ?? true) ? rawChanged.displayName : false, 922 description: (args.syncDescription ?? true) ? rawChanged.description : false, 923 avatar: (args.syncAvatar ?? true) ? rawChanged.avatar : false, 924 banner: (args.syncBanner ?? true) ? rawChanged.banner : false, 925 }; 926 const bsky = await validateBlueskyCredentials({ 927 bskyIdentifier: args.bskyIdentifier, 928 bskyPassword: args.bskyPassword, 929 bskyServiceUrl: args.bskyServiceUrl, 930 }); 931 932 if (!changed.displayName && !changed.description && !changed.avatar && !changed.banner) { 933 return { 934 twitterProfile, 935 bsky, 936 avatarSynced: false, 937 bannerSynced: false, 938 skipped: true, 939 changed, 940 warnings: [], 941 }; 942 } 943 944 const agent = new BskyAgent({ service: bsky.serviceUrl }); 945 await agent.login({ 946 identifier: args.bskyIdentifier, 947 password: args.bskyPassword, 948 }); 949 950 const warnings: string[] = []; 951 let avatarBlob: BlobRef | undefined; 952 let bannerBlob: BlobRef | undefined; 953 954 if (changed.avatar && twitterProfile.avatarUrl) { 955 try { 956 avatarBlob = await uploadProfileImage(agent, twitterProfile.avatarUrl, 'avatar'); 957 } catch (error) { 958 warnings.push(`Avatar sync failed: ${error instanceof Error ? error.message : String(error)}`); 959 } 960 } else if (changed.avatar) { 961 warnings.push('No Twitter avatar found for this profile.'); 962 } 963 964 if (changed.banner && twitterProfile.bannerUrl) { 965 try { 966 bannerBlob = await uploadProfileImage(agent, twitterProfile.bannerUrl, 'banner'); 967 } catch (error) { 968 warnings.push(`Banner sync failed: ${error instanceof Error ? error.message : String(error)}`); 969 } 970 } else if (changed.banner) { 971 warnings.push('No Twitter banner found for this profile.'); 972 } 973 974 const shouldUpdateProfile = changed.displayName || changed.description || Boolean(avatarBlob) || Boolean(bannerBlob); 975 976 if (shouldUpdateProfile) { 977 await agent.upsertProfile((existing) => ({ 978 ...(existing || {}), 979 ...(changed.displayName ? { displayName: twitterProfile.mirroredDisplayName } : {}), 980 ...(changed.description ? { description: twitterProfile.mirroredDescription } : {}), 981 ...(avatarBlob ? { avatar: avatarBlob } : {}), 982 ...(bannerBlob ? { banner: bannerBlob } : {}), 983 })); 984 } 985 986 return { 987 twitterProfile, 988 bsky, 989 avatarSynced: Boolean(avatarBlob), 990 bannerSynced: Boolean(bannerBlob), 991 skipped: false, 992 changed, 993 warnings, 994 }; 995}; 996 997export const bridgeBlueskyAccountToFediverse = async (args: { 998 bskyIdentifier: string; 999 bskyPassword: string; 1000 bskyServiceUrl?: string; 1001}): Promise<FediverseBridgeResult> => { 1002 const { agent, credentials: bsky } = await loginBlueskyAgent(args); 1003 const accountProfile = await fetchPublicProfile(bsky.did || bsky.handle); 1004 const createdAtRaw = normalizeOptionalString(accountProfile.createdAt); 1005 if (!createdAtRaw) { 1006 throw new Error('Could not determine when this Bluesky account was created.'); 1007 } 1008 1009 const createdAtMs = Date.parse(createdAtRaw); 1010 if (!Number.isFinite(createdAtMs)) { 1011 throw new Error('Invalid Bluesky account creation timestamp.'); 1012 } 1013 1014 const ageMs = Date.now() - createdAtMs; 1015 if (ageMs < MIN_BRIDGE_ACCOUNT_AGE_MS) { 1016 const ageDays = Math.floor(ageMs / (24 * 60 * 60 * 1000)); 1017 throw new Error(`Account must be at least 7 days old before bridging (currently ${ageDays} day(s)).`); 1018 } 1019 1020 const bridgeProfile = await fetchPublicProfile(FEDIVERSE_BRIDGE_HANDLE); 1021 const alreadyFollowing = await hasFollowRecordForDid(agent, bridgeProfile.did); 1022 if (!alreadyFollowing) { 1023 const repo = agent.session?.did; 1024 if (!repo) { 1025 throw new Error('Missing Bluesky session DID.'); 1026 } 1027 1028 await agent.com.atproto.repo.createRecord({ 1029 repo, 1030 collection: 'app.bsky.graph.follow', 1031 record: { 1032 subject: bridgeProfile.did, 1033 createdAt: new Date().toISOString(), 1034 }, 1035 }); 1036 } 1037 1038 const fediverseAddress = `@${bsky.handle}@bsky.brid.gy`; 1039 const text = `This account can now be found on the fediverse at ${fediverseAddress}`; 1040 const richText = new RichText({ text }); 1041 await richText.detectFacets(agent); 1042 1043 const post = await agent.post({ 1044 text: richText.text, 1045 facets: richText.facets, 1046 createdAt: new Date().toISOString(), 1047 }); 1048 1049 return { 1050 bsky, 1051 bridgedAccountHandle: bsky.handle, 1052 fediverseAddress, 1053 accountCreatedAt: new Date(createdAtMs).toISOString(), 1054 ageDays: Math.floor(ageMs / (24 * 60 * 60 * 1000)), 1055 followedBridgeAccount: !alreadyFollowing, 1056 announcementUri: post.uri, 1057 announcementCid: post.cid, 1058 }; 1059};