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 = '{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(/&/gi, '&')
286 .replace(/"/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};