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

Configure Feed

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

Fix fediverse bridge detection and add bridge-all progress

j4ckxyz 1ef90d7c 2f6120a0

+243 -48
+189 -42
src/server.ts
··· 26 26 applyProfileMirrorSyncState, 27 27 bridgeBlueskyAccountToFediverse, 28 28 fetchTwitterMirrorProfile, 29 - getFediverseBridgeStatus, 30 29 syncBlueskyProfileFromTwitter, 31 30 validateBlueskyCredentials, 32 31 } from './profile-mirror.js'; ··· 60 59 const APPVIEW_MAX_ATTEMPTS = 2; 61 60 const APPVIEW_RETRY_DELAY_MS = 700; 62 61 const FEDIVERSE_BRIDGE_STATUS_CHUNK_SIZE = 2; 62 + const FEDIVERSE_BRIDGE_STATUS_CACHE_TTL_MS = 10 * 60_000; 63 + const FEDIVERSE_BRIDGE_HANDLES = ['ap.brid.gy', 'bsky.brid.gy']; 63 64 64 65 function loadPersistedJwtSecret(): string | undefined { 65 66 if (!fs.existsSync(JWT_SECRET_FILE_PATH)) { ··· 217 218 218 219 const postViewCache = new Map<string, CacheEntry<any>>(); 219 220 const profileCache = new Map<string, CacheEntry<BskyProfileView>>(); 221 + const fediverseBridgeStatusCache = new Map<string, CacheEntry<FediverseBridgeStatusView>>(); 222 + let fediverseBridgeActorIdsCache: CacheEntry<Set<string>> | null = null; 220 223 221 224 function chunkArray<T>(items: T[], size: number): T[][] { 222 225 if (size <= 0) return [items]; ··· 637 640 const cached = profileCache.get(actor); 638 641 if (cached && cached.expiresAt > nowMs()) { 639 642 result[actor] = cached.value; 643 + } 644 + } 645 + 646 + return result; 647 + } 648 + 649 + async function getFediverseBridgeActorIds(): Promise<Set<string>> { 650 + const cached = fediverseBridgeActorIdsCache; 651 + if (cached && cached.expiresAt > nowMs()) { 652 + return new Set(cached.value); 653 + } 654 + 655 + const ids = new Set<string>(FEDIVERSE_BRIDGE_HANDLES.map((handle) => normalizeActor(handle))); 656 + const profiles = await fetchProfilesByActor(FEDIVERSE_BRIDGE_HANDLES); 657 + for (const profile of Object.values(profiles)) { 658 + if (typeof profile?.handle === 'string' && profile.handle.length > 0) { 659 + ids.add(normalizeActor(profile.handle)); 660 + } 661 + if (typeof profile?.did === 'string' && profile.did.length > 0) { 662 + ids.add(normalizeActor(profile.did)); 663 + } 664 + } 665 + 666 + fediverseBridgeActorIdsCache = { 667 + value: ids, 668 + expiresAt: nowMs() + PROFILE_CACHE_TTL_MS, 669 + }; 670 + 671 + return new Set(ids); 672 + } 673 + 674 + async function isActorFollowingFediverseBridge(actor: string): Promise<FediverseBridgeStatusView> { 675 + const normalizedActor = normalizeActor(actor); 676 + const checkedAt = new Date().toISOString(); 677 + if (!normalizedActor) { 678 + return { 679 + bridged: false, 680 + checkedAt, 681 + error: 'Missing actor identifier.', 682 + }; 683 + } 684 + 685 + const bridgeActorIds = await getFediverseBridgeActorIds(); 686 + let cursor: string | undefined; 687 + let pageCount = 0; 688 + 689 + while (pageCount < 200) { 690 + pageCount += 1; 691 + const params = new URLSearchParams(); 692 + params.set('actor', normalizedActor); 693 + params.set('limit', '100'); 694 + if (cursor) { 695 + params.set('cursor', cursor); 696 + } 697 + 698 + const responseData = await fetchAppview( 699 + '/xrpc/app.bsky.graph.getFollows', 700 + params, 701 + `getFollows actor=${normalizedActor}`, 702 + ); 703 + if (!responseData) { 704 + return { 705 + bridged: false, 706 + checkedAt, 707 + error: 'Failed to read follows from Bluesky AppView.', 708 + }; 709 + } 710 + 711 + const follows = Array.isArray(responseData.follows) ? responseData.follows : []; 712 + for (const follow of follows) { 713 + const followedHandle = typeof follow?.handle === 'string' ? normalizeActor(follow.handle) : ''; 714 + const followedDid = typeof follow?.did === 'string' ? normalizeActor(follow.did) : ''; 715 + if ((followedHandle && bridgeActorIds.has(followedHandle)) || (followedDid && bridgeActorIds.has(followedDid))) { 716 + return { 717 + bridged: true, 718 + checkedAt, 719 + }; 720 + } 721 + } 722 + 723 + cursor = 724 + typeof responseData.cursor === 'string' && responseData.cursor.length > 0 ? responseData.cursor : undefined; 725 + if (!cursor) { 726 + break; 727 + } 728 + } 729 + 730 + return { 731 + bridged: false, 732 + checkedAt, 733 + }; 734 + } 735 + 736 + async function fetchFediverseBridgeStatusesByActor( 737 + actors: string[], 738 + ): Promise<Record<string, FediverseBridgeStatusView>> { 739 + const uniqueActors = [...new Set(actors.map(normalizeActor).filter((actor) => actor.length > 0))]; 740 + const result: Record<string, FediverseBridgeStatusView> = {}; 741 + const pendingActors: string[] = []; 742 + 743 + for (const actor of uniqueActors) { 744 + const cached = fediverseBridgeStatusCache.get(actor); 745 + if (cached && cached.expiresAt > nowMs()) { 746 + result[actor] = cached.value; 747 + continue; 748 + } 749 + pendingActors.push(actor); 750 + } 751 + 752 + for (const chunk of chunkArray(pendingActors, FEDIVERSE_BRIDGE_STATUS_CHUNK_SIZE)) { 753 + if (chunk.length === 0) { 754 + continue; 755 + } 756 + 757 + const chunkResults = await Promise.all( 758 + chunk.map(async (actor) => { 759 + try { 760 + const status = await isActorFollowingFediverseBridge(actor); 761 + return { actor, status }; 762 + } catch (error) { 763 + return { 764 + actor, 765 + status: { 766 + bridged: false, 767 + checkedAt: new Date().toISOString(), 768 + error: getErrorMessage(error, 'Failed to check fediverse bridge status.'), 769 + } satisfies FediverseBridgeStatusView, 770 + }; 771 + } 772 + }), 773 + ); 774 + 775 + for (const item of chunkResults) { 776 + fediverseBridgeStatusCache.set(item.actor, { 777 + value: item.status, 778 + expiresAt: nowMs() + FEDIVERSE_BRIDGE_STATUS_CACHE_TTL_MS, 779 + }); 780 + result[item.actor] = item.status; 640 781 } 641 782 } 642 783 ··· 2214 2355 bskyPassword: mapping.bskyPassword, 2215 2356 bskyServiceUrl: mapping.bskyServiceUrl, 2216 2357 }); 2358 + 2359 + fediverseBridgeStatusCache.set(normalizeActor(mapping.bskyIdentifier), { 2360 + value: { 2361 + bridged: true, 2362 + checkedAt: new Date().toISOString(), 2363 + }, 2364 + expiresAt: nowMs() + FEDIVERSE_BRIDGE_STATUS_CACHE_TTL_MS, 2365 + }); 2366 + 2217 2367 res.json({ success: true, ...result }); 2218 2368 } catch (error) { 2219 2369 res.status(400).json({ error: getErrorMessage(error, 'Failed to bridge account to the fediverse.') }); ··· 2235 2385 return; 2236 2386 } 2237 2387 2388 + const actorByMappingId = new Map<string, string>(); 2389 + const actorsToCheck: string[] = []; 2238 2390 const statuses: Record<string, FediverseBridgeStatusView> = {}; 2239 - for (const chunk of chunkArray(idsToCheck, FEDIVERSE_BRIDGE_STATUS_CHUNK_SIZE)) { 2240 - const chunkResults = await Promise.all( 2241 - chunk.map(async (id) => { 2242 - const mapping = visibleMappingsById.get(id); 2243 - if (!mapping) { 2244 - return { 2245 - id, 2246 - status: { 2247 - bridged: false, 2248 - checkedAt: new Date().toISOString(), 2249 - error: 'Mapping not visible to current user.', 2250 - } satisfies FediverseBridgeStatusView, 2251 - }; 2252 - } 2253 2391 2254 - try { 2255 - const result = await getFediverseBridgeStatus({ 2256 - bskyIdentifier: mapping.bskyIdentifier, 2257 - bskyPassword: mapping.bskyPassword, 2258 - bskyServiceUrl: mapping.bskyServiceUrl, 2259 - }); 2392 + for (const id of idsToCheck) { 2393 + const mapping = visibleMappingsById.get(id); 2394 + if (!mapping) { 2395 + statuses[id] = { 2396 + bridged: false, 2397 + checkedAt: new Date().toISOString(), 2398 + error: 'Mapping not visible to current user.', 2399 + }; 2400 + continue; 2401 + } 2260 2402 2261 - return { 2262 - id, 2263 - status: { 2264 - bridged: result.bridged, 2265 - checkedAt: new Date().toISOString(), 2266 - } satisfies FediverseBridgeStatusView, 2267 - }; 2268 - } catch (error) { 2269 - return { 2270 - id, 2271 - status: { 2272 - bridged: false, 2273 - checkedAt: new Date().toISOString(), 2274 - error: getErrorMessage(error, 'Failed to check fediverse bridge status.'), 2275 - } satisfies FediverseBridgeStatusView, 2276 - }; 2277 - } 2278 - }), 2279 - ); 2403 + const actor = normalizeActor(mapping.bskyIdentifier); 2404 + if (!actor) { 2405 + statuses[id] = { 2406 + bridged: false, 2407 + checkedAt: new Date().toISOString(), 2408 + error: 'Missing Bluesky identifier for mapping.', 2409 + }; 2410 + continue; 2411 + } 2280 2412 2281 - for (const result of chunkResults) { 2282 - statuses[result.id] = result.status; 2413 + actorByMappingId.set(id, actor); 2414 + actorsToCheck.push(actor); 2415 + } 2416 + 2417 + const actorStatuses = await fetchFediverseBridgeStatusesByActor(actorsToCheck); 2418 + 2419 + for (const [mappingId, actor] of actorByMappingId.entries()) { 2420 + const actorStatus = actorStatuses[actor]; 2421 + if (actorStatus) { 2422 + statuses[mappingId] = actorStatus; 2423 + continue; 2283 2424 } 2425 + 2426 + statuses[mappingId] = { 2427 + bridged: false, 2428 + checkedAt: new Date().toISOString(), 2429 + error: 'Bridge status could not be determined for this account.', 2430 + }; 2284 2431 } 2285 2432 2286 2433 res.json(statuses);
+54 -6
web/src/App.tsx
··· 782 782 const [isSyncAllProfilesBusy, setIsSyncAllProfilesBusy] = useState(false); 783 783 const [bridgingMappingId, setBridgingMappingId] = useState<string | null>(null); 784 784 const [isBridgeAllBusy, setIsBridgeAllBusy] = useState(false); 785 + const [bridgeAllProgress, setBridgeAllProgress] = useState<{ 786 + currentHandle: string; 787 + completed: number; 788 + total: number; 789 + } | null>(null); 785 790 const [fediverseBridgeStatusByMappingId, setFediverseBridgeStatusByMappingId] = useState< 786 791 Record<string, FediverseBridgeStatusView> 787 792 >({}); ··· 934 939 setIsSyncAllProfilesBusy(false); 935 940 setBridgingMappingId(null); 936 941 setIsBridgeAllBusy(false); 942 + setBridgeAllProgress(null); 937 943 setFediverseBridgeStatusByMappingId({}); 938 944 setEditTwitterUsers([]); 939 945 setNewGroupName(''); ··· 1600 1606 (canManageOwnMappings && (!mapping.createdByUserId || mapping.createdByUserId === me?.id)), 1601 1607 ) 1602 1608 .filter((mapping) => { 1603 - if (fediverseBridgeStatusByMappingId[mapping.id]?.bridged === true) { 1609 + const bridgeStatus = fediverseBridgeStatusByMappingId[mapping.id]; 1610 + if (!bridgeStatus || bridgeStatus.error) { 1611 + return false; 1612 + } 1613 + if (bridgeStatus.bridged === true) { 1604 1614 return false; 1605 1615 } 1606 1616 const profile = profilesByActor[normalizeTwitterUsername(mapping.bskyIdentifier)]; ··· 2557 2567 }; 2558 2568 2559 2569 const alreadyBridged = manageable.filter((mapping) => statusLookup[mapping.id]?.bridged === true); 2570 + const unknownStatusMappings = manageable.filter((mapping) => { 2571 + const status = statusLookup[mapping.id]; 2572 + return !status || Boolean(status.error); 2573 + }); 2560 2574 const eligible = manageable.filter((mapping) => { 2561 - if (statusLookup[mapping.id]?.bridged === true) { 2575 + const status = statusLookup[mapping.id]; 2576 + if (!status || status.error) { 2577 + return false; 2578 + } 2579 + if (status.bridged === true) { 2562 2580 return false; 2563 2581 } 2564 2582 const profile = profileLookup[normalizeTwitterUsername(mapping.bskyIdentifier)]; ··· 2570 2588 'info', 2571 2589 alreadyBridged.length > 0 2572 2590 ? 'No new accounts to bridge. Eligible accounts are already bridged.' 2573 - : 'No eligible unbridged accounts found (accounts must be at least 7 days old).', 2591 + : unknownStatusMappings.length > 0 2592 + ? `Could not verify bridge status for ${unknownStatusMappings.length} account(s). Try again in a few seconds.` 2593 + : 'No eligible unbridged accounts found (accounts must be at least 7 days old).', 2574 2594 ); 2575 2595 return; 2576 2596 } ··· 2583 2603 } 2584 2604 2585 2605 setIsBridgeAllBusy(true); 2606 + setBridgeAllProgress({ 2607 + currentHandle: '', 2608 + completed: 0, 2609 + total: eligible.length, 2610 + }); 2586 2611 const newlyBridgedLabels: string[] = []; 2587 2612 let failedCount = 0; 2588 2613 2589 2614 try { 2590 - for (const mapping of eligible) { 2615 + for (let index = 0; index < eligible.length; index += 1) { 2616 + const mapping = eligible[index]; 2617 + setBridgeAllProgress({ 2618 + currentHandle: `@${mapping.bskyIdentifier}`, 2619 + completed: index, 2620 + total: eligible.length, 2621 + }); 2622 + 2591 2623 const outcome = await bridgeMappingToFediverse(mapping, { 2592 2624 confirm: false, 2593 2625 showNoticeOnResult: false, ··· 2597 2629 } else { 2598 2630 failedCount += 1; 2599 2631 } 2632 + 2633 + setBridgeAllProgress({ 2634 + currentHandle: `@${mapping.bskyIdentifier}`, 2635 + completed: index + 1, 2636 + total: eligible.length, 2637 + }); 2600 2638 } 2601 2639 2602 2640 if (newlyBridgedLabels.length > 0) { ··· 2612 2650 2613 2651 showNotice( 2614 2652 failedCount > 0 ? 'info' : 'success', 2615 - `${summary} Already bridged: ${alreadyBridged.length}. Failed: ${failedCount}.`, 2653 + `${summary} Already bridged: ${alreadyBridged.length}. Unknown status: ${unknownStatusMappings.length}. Failed: ${failedCount}.`, 2616 2654 ); 2617 2655 } finally { 2618 2656 setIsBridgeAllBusy(false); 2657 + setBridgeAllProgress(null); 2619 2658 } 2620 2659 }; 2621 2660 ··· 3874 3913 ) : ( 3875 3914 <Link2 className="mr-2 h-4 w-4" /> 3876 3915 )} 3877 - Bridge all 3916 + {isBridgeAllBusy && bridgeAllProgress 3917 + ? `Bridging ${bridgeAllProgress.completed}/${bridgeAllProgress.total}` 3918 + : 'Bridge all'} 3878 3919 </Button> 3920 + {isBridgeAllBusy && bridgeAllProgress ? ( 3921 + <Badge variant="outline" className="max-w-[280px] truncate"> 3922 + {bridgeAllProgress.currentHandle 3923 + ? `Now bridging ${bridgeAllProgress.currentHandle}` 3924 + : 'Preparing bridge-all...'} 3925 + </Badge> 3926 + ) : null} 3879 3927 <Badge variant="outline">{accountMappingsForView.length} configured</Badge> 3880 3928 </div> 3881 3929 </div>