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.

Add Bluesky bot self-label flow and harden profile sync behavior

jack 3d92a6bb 6b495985

+391 -12
+29
src/cli.ts
··· 17 17 import { dbService } from './db.js'; 18 18 import { 19 19 applyProfileMirrorSyncState, 20 + ensureBlueskyBotSelfLabel, 20 21 fetchTwitterMirrorProfile, 21 22 syncBlueskyProfileFromTwitter, 22 23 validateBlueskyCredentials, ··· 425 426 if (!createdMapping) { 426 427 console.log('Mapping added, but could not locate it for automatic profile sync.'); 427 428 return; 429 + } 430 + 431 + try { 432 + const botLabelResult = await ensureBlueskyBotSelfLabel({ 433 + bskyIdentifier: createdMapping.bskyIdentifier, 434 + bskyPassword: createdMapping.bskyPassword, 435 + bskyServiceUrl: createdMapping.bskyServiceUrl, 436 + }); 437 + const labeledConfig = getConfig(); 438 + const labeledIndex = labeledConfig.mappings.findIndex((entry) => entry.id === createdMapping.id); 439 + if (labeledIndex !== -1) { 440 + const current = labeledConfig.mappings[labeledIndex]; 441 + if (current && !current.hasBotLabel) { 442 + current.hasBotLabel = true; 443 + saveConfig(labeledConfig); 444 + } 445 + } 446 + if (botLabelResult.updated) { 447 + console.log('Applied Bluesky bot self-label.'); 448 + } else { 449 + console.log('Bluesky bot self-label already present.'); 450 + } 451 + } catch (error) { 452 + console.log( 453 + `Warning: failed to apply Bluesky bot self-label automatically: ${ 454 + error instanceof Error ? error.message : String(error) 455 + }`, 456 + ); 428 457 } 429 458 430 459 try {
+2
src/config-manager.ts
··· 61 61 lastMirroredDescription?: string; 62 62 lastMirroredAvatarUrl?: string; 63 63 lastMirroredBannerUrl?: string; 64 + hasBotLabel?: boolean; 64 65 } 65 66 66 67 export interface AccountGroup { ··· 313 314 lastMirroredDescription: normalizeString(record.lastMirroredDescription), 314 315 lastMirroredAvatarUrl: normalizeString(record.lastMirroredAvatarUrl), 315 316 lastMirroredBannerUrl: normalizeString(record.lastMirroredBannerUrl), 317 + hasBotLabel: normalizeBoolean(record.hasBotLabel, false), 316 318 createdByUserId: 317 319 (explicitCreatorExists ? explicitCreator : undefined) ?? matchOwnerToUserId(owner, users) ?? adminUserId, 318 320 };
+1
src/index.ts
··· 2136 2136 bskyIdentifier: mapping.bskyIdentifier, 2137 2137 bskyPassword: mapping.bskyPassword, 2138 2138 bskyServiceUrl: mapping.bskyServiceUrl, 2139 + syncDescription: false, 2139 2140 previousSync: { 2140 2141 sourceUsername: mapping.profileSyncSourceUsername, 2141 2142 mirroredDisplayName: mapping.lastMirroredDisplayName,
+118 -4
src/profile-mirror.ts
··· 13 13 /\/$/, 14 14 '', 15 15 ); 16 - const MIRROR_SUFFIX = '{UNOFFICIAL}'; 16 + const MIRROR_SUFFIX = '{bot}'; 17 17 const FEDIVERSE_BRIDGE_HANDLE = 'ap.brid.gy'; 18 18 const MIN_BRIDGE_ACCOUNT_AGE_MS = 7 * 24 * 60 * 60 * 1000; 19 + const BOT_SELF_LABEL_VALUE = 'bot'; 19 20 20 21 type ProfileImageKind = 'avatar' | 'banner'; 21 22 ··· 99 100 bridged: boolean; 100 101 } 101 102 103 + export interface EnsureBotSelfLabelResult { 104 + bsky: BlueskyCredentialValidation; 105 + updated: boolean; 106 + hasBotLabel: true; 107 + } 108 + 102 109 const normalizeTwitterUsername = (value: string) => value.trim().replace(/^@/, '').toLowerCase(); 103 110 104 111 const normalizeOptionalString = (value: unknown): string | undefined => { ··· 108 115 }; 109 116 110 117 const normalizeMirrorStateUrl = (value?: string): string | undefined => normalizeOptionalString(value); 118 + 119 + const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null; 120 + 121 + const normalizeSelfLabelValues = (value: unknown): Array<Record<string, unknown>> => { 122 + if (!Array.isArray(value)) { 123 + return []; 124 + } 125 + 126 + const entries: Array<Record<string, unknown>> = []; 127 + for (const item of value) { 128 + if (!isRecord(item)) { 129 + continue; 130 + } 131 + const normalizedVal = normalizeOptionalString(item.val); 132 + if (!normalizedVal) { 133 + continue; 134 + } 135 + entries.push({ 136 + ...item, 137 + val: normalizedVal, 138 + }); 139 + } 140 + 141 + return entries; 142 + }; 111 143 112 144 const toNormalizedMirrorState = (state?: ProfileMirrorSyncState) => ({ 113 145 sourceUsername: normalizeTwitterUsername(state?.sourceUsername || ''), ··· 435 467 return credentials; 436 468 }; 437 469 470 + export const ensureBlueskyBotSelfLabel = async (args: { 471 + bskyIdentifier: string; 472 + bskyPassword: string; 473 + bskyServiceUrl?: string; 474 + }): Promise<EnsureBotSelfLabelResult> => { 475 + const { agent, credentials } = await loginBlueskyAgent(args); 476 + const repo = agent.session?.did || credentials.did; 477 + if (!repo) { 478 + throw new Error('Missing Bluesky session DID.'); 479 + } 480 + 481 + let existingProfileRecord: Record<string, unknown> = { 482 + $type: 'app.bsky.actor.profile', 483 + }; 484 + 485 + try { 486 + const response = await agent.com.atproto.repo.getRecord({ 487 + repo, 488 + collection: 'app.bsky.actor.profile', 489 + rkey: 'self', 490 + }); 491 + if (isRecord(response.data?.value)) { 492 + existingProfileRecord = { ...response.data.value }; 493 + } 494 + } catch (error) { 495 + const message = error instanceof Error ? error.message : String(error); 496 + const looksLikeMissingRecord = /not found|record.*not.*found|could not locate/i.test(message); 497 + if (!looksLikeMissingRecord) { 498 + throw error; 499 + } 500 + } 501 + 502 + const existingLabels = isRecord(existingProfileRecord.labels) ? existingProfileRecord.labels : undefined; 503 + const existingValues = normalizeSelfLabelValues(existingLabels?.values); 504 + const alreadyHasBotLabel = existingValues.some( 505 + (entry) => normalizeOptionalString(entry.val)?.toLowerCase() === BOT_SELF_LABEL_VALUE, 506 + ); 507 + if (alreadyHasBotLabel) { 508 + return { 509 + bsky: credentials, 510 + updated: false, 511 + hasBotLabel: true, 512 + }; 513 + } 514 + 515 + const nextValues = [...existingValues, { val: BOT_SELF_LABEL_VALUE }]; 516 + const nextProfileRecord: Record<string, unknown> = { 517 + ...existingProfileRecord, 518 + $type: 'app.bsky.actor.profile', 519 + labels: { 520 + $type: 'com.atproto.label.defs#selfLabels', 521 + values: nextValues, 522 + }, 523 + }; 524 + 525 + await agent.com.atproto.repo.putRecord({ 526 + repo, 527 + collection: 'app.bsky.actor.profile', 528 + rkey: 'self', 529 + record: nextProfileRecord, 530 + }); 531 + 532 + return { 533 + bsky: credentials, 534 + updated: true, 535 + hasBotLabel: true, 536 + }; 537 + }; 538 + 438 539 export const applyProfileMirrorSyncState = <T extends MappingProfileSyncState>( 439 540 mapping: T, 440 541 sourceTwitterUsername: string, ··· 445 546 ...mapping, 446 547 profileSyncSourceUsername: normalizedSource || mapping.profileSyncSourceUsername, 447 548 lastProfileSyncAt: new Date().toISOString(), 448 - lastMirroredDisplayName: result.twitterProfile.mirroredDisplayName, 449 - lastMirroredDescription: result.twitterProfile.mirroredDescription, 450 549 }; 550 + 551 + if (result.changed.displayName) { 552 + next.lastMirroredDisplayName = result.twitterProfile.mirroredDisplayName; 553 + } 554 + 555 + if (result.changed.description) { 556 + next.lastMirroredDescription = result.twitterProfile.mirroredDescription; 557 + } 451 558 452 559 if (result.changed.avatar && result.avatarSynced) { 453 560 next.lastMirroredAvatarUrl = normalizeMirrorStateUrl(result.twitterProfile.avatarUrl); ··· 560 667 bskyPassword: string; 561 668 bskyServiceUrl?: string; 562 669 previousSync?: ProfileMirrorSyncState; 670 + syncDescription?: boolean; 563 671 }): Promise<MirrorProfileSyncResult> => { 564 672 const twitterProfile = await fetchTwitterMirrorProfile(args.twitterUsername); 565 673 const nextMirrorState = buildMirrorStateFromTwitterProfile(twitterProfile); 566 - const changed = hasMirrorStateChanges(args.previousSync, nextMirrorState); 674 + const rawChanged = hasMirrorStateChanges(args.previousSync, nextMirrorState); 675 + const changed = { 676 + displayName: rawChanged.displayName, 677 + description: (args.syncDescription ?? true) ? rawChanged.description : false, 678 + avatar: rawChanged.avatar, 679 + banner: rawChanged.banner, 680 + }; 567 681 const bsky = await validateBlueskyCredentials({ 568 682 bskyIdentifier: args.bskyIdentifier, 569 683 bskyPassword: args.bskyPassword,
+110 -1
src/server.ts
··· 25 25 import { 26 26 applyProfileMirrorSyncState, 27 27 bridgeBlueskyAccountToFediverse, 28 + ensureBlueskyBotSelfLabel, 28 29 fetchTwitterMirrorProfile, 29 30 syncBlueskyProfileFromTwitter, 30 31 validateBlueskyCredentials, ··· 2082 2083 } 2083 2084 }); 2084 2085 2085 - app.post('/api/mappings', authenticateToken, (req: any, res) => { 2086 + app.post('/api/mappings', authenticateToken, async (req: any, res) => { 2086 2087 if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 2087 2088 res.status(403).json({ error: 'You do not have permission to create mappings.' }); 2088 2089 return; ··· 2140 2141 groupEmoji: normalizedGroupEmoji || undefined, 2141 2142 createdByUserId, 2142 2143 profileSyncSourceUsername, 2144 + hasBotLabel: false, 2143 2145 }; 2144 2146 2145 2147 ensureGroupExists(config, normalizedGroupName, normalizedGroupEmoji); 2146 2148 config.mappings.push(newMapping); 2147 2149 saveConfig(config); 2150 + 2151 + try { 2152 + const labelResult = await ensureBlueskyBotSelfLabel({ 2153 + bskyIdentifier: newMapping.bskyIdentifier, 2154 + bskyPassword: newMapping.bskyPassword, 2155 + bskyServiceUrl: newMapping.bskyServiceUrl, 2156 + }); 2157 + 2158 + if (labelResult.hasBotLabel && !newMapping.hasBotLabel) { 2159 + newMapping.hasBotLabel = true; 2160 + saveConfig(config); 2161 + } 2162 + 2163 + for (const key of [ 2164 + normalizeActor(newMapping.bskyIdentifier), 2165 + normalizeActor(labelResult.bsky.handle), 2166 + normalizeActor(labelResult.bsky.did), 2167 + ]) { 2168 + if (key) { 2169 + profileCache.delete(key); 2170 + } 2171 + } 2172 + } catch (error) { 2173 + console.warn( 2174 + `[mapping:${newMapping.id}] Failed to apply Bluesky bot self-label during mapping creation: ${getErrorMessage(error)}`, 2175 + ); 2176 + } 2177 + 2148 2178 res.json(sanitizeMapping(newMapping, createUserLookupById(config), req.user)); 2149 2179 }); 2150 2180 ··· 2301 2331 } catch (error) { 2302 2332 res.status(400).json({ error: getErrorMessage(error, 'Failed to sync Bluesky profile from Twitter.') }); 2303 2333 } 2334 + }); 2335 + 2336 + app.post('/api/mappings/bot-label-all', authenticateToken, async (req: any, res) => { 2337 + if (!canManageOwnMappings(req.user) && !canManageAllMappings(req.user)) { 2338 + res.status(403).json({ error: 'You do not have permission to update mappings.' }); 2339 + return; 2340 + } 2341 + 2342 + const config = getConfig(); 2343 + const usersById = createUserLookupById(config); 2344 + const manageableMappings = getVisibleMappings(config, req.user).filter((mapping) => canManageMapping(req.user, mapping)); 2345 + const requestedIds = parseMappingIds(req.body?.mappingIds); 2346 + const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null; 2347 + const targets = requestedIdSet 2348 + ? manageableMappings.filter((mapping) => requestedIdSet.has(mapping.id)) 2349 + : manageableMappings; 2350 + 2351 + if (targets.length === 0) { 2352 + res.status(400).json({ error: 'No manageable mappings available for bot label update.' }); 2353 + return; 2354 + } 2355 + 2356 + let labeled = 0; 2357 + let alreadyLabeled = 0; 2358 + let failed = 0; 2359 + let changed = false; 2360 + const failedMappings: Array<{ id: string; bskyIdentifier: string; error: string }> = []; 2361 + 2362 + for (const mapping of targets) { 2363 + try { 2364 + const result = await ensureBlueskyBotSelfLabel({ 2365 + bskyIdentifier: mapping.bskyIdentifier, 2366 + bskyPassword: mapping.bskyPassword, 2367 + bskyServiceUrl: mapping.bskyServiceUrl, 2368 + }); 2369 + 2370 + if (result.updated) { 2371 + labeled += 1; 2372 + } else { 2373 + alreadyLabeled += 1; 2374 + } 2375 + 2376 + if (!mapping.hasBotLabel) { 2377 + mapping.hasBotLabel = true; 2378 + changed = true; 2379 + } 2380 + 2381 + for (const key of [ 2382 + normalizeActor(mapping.bskyIdentifier), 2383 + normalizeActor(result.bsky.handle), 2384 + normalizeActor(result.bsky.did), 2385 + ]) { 2386 + if (key) { 2387 + profileCache.delete(key); 2388 + } 2389 + } 2390 + } catch (error) { 2391 + failed += 1; 2392 + failedMappings.push({ 2393 + id: mapping.id, 2394 + bskyIdentifier: mapping.bskyIdentifier, 2395 + error: getErrorMessage(error, 'Failed to update bot label.'), 2396 + }); 2397 + } 2398 + } 2399 + 2400 + if (changed) { 2401 + saveConfig(config); 2402 + } 2403 + 2404 + res.json({ 2405 + success: true, 2406 + total: targets.length, 2407 + labeled, 2408 + alreadyLabeled, 2409 + failed, 2410 + failedMappings, 2411 + mappings: targets.map((mapping) => sanitizeMapping(mapping, usersById, req.user)), 2412 + }); 2304 2413 }); 2305 2414 2306 2415 app.post('/api/mappings/:id/bridge-to-fediverse', authenticateToken, async (req: any, res) => {
+131 -7
web/src/App.tsx
··· 65 65 lastMirroredDescription?: string; 66 66 lastMirroredAvatarUrl?: string; 67 67 lastMirroredBannerUrl?: string; 68 + hasBotLabel?: boolean; 68 69 createdByUser?: { 69 70 id: string; 70 71 username?: string; ··· 289 290 warnings: string[]; 290 291 sourceTwitterUsername?: string; 291 292 mapping?: AccountMapping; 293 + } 294 + 295 + interface BulkBotLabelAllResult { 296 + success: boolean; 297 + total: number; 298 + labeled: number; 299 + alreadyLabeled: number; 300 + failed: number; 301 + failedMappings?: Array<{ 302 + id: string; 303 + bskyIdentifier: string; 304 + error: string; 305 + }>; 306 + mappings?: AccountMapping[]; 292 307 } 293 308 294 309 interface BootstrapStatus { ··· 780 795 const [isCredentialValidationBusy, setIsCredentialValidationBusy] = useState(false); 781 796 const [syncingProfileMappingId, setSyncingProfileMappingId] = useState<string | null>(null); 782 797 const [isSyncAllProfilesBusy, setIsSyncAllProfilesBusy] = useState(false); 798 + const [isBotLabelAllBusy, setIsBotLabelAllBusy] = useState(false); 783 799 const [bridgingMappingId, setBridgingMappingId] = useState<string | null>(null); 784 800 const [isBridgeAllBusy, setIsBridgeAllBusy] = useState(false); 785 801 const [bridgeAllProgress, setBridgeAllProgress] = useState<{ ··· 1590 1606 } 1591 1607 return mappings.filter((mapping) => mapping.createdByUserId === accountsCreatorFilter); 1592 1608 }, [accountsCreatorFilter, isAdmin, mappings]); 1609 + const botLabeledMappingsForView = useMemo( 1610 + () => accountMappingsForView.filter((mapping) => mapping.hasBotLabel === true), 1611 + [accountMappingsForView], 1612 + ); 1593 1613 const bridgedMappingsForView = useMemo( 1594 1614 () => 1595 1615 [...accountMappingsForView] ··· 2386 2406 } 2387 2407 }; 2388 2408 2409 + const handleAddBotLabelToAllAccounts = async () => { 2410 + if (!authHeaders) { 2411 + return; 2412 + } 2413 + if (isBotLabelAllBusy || isBridgeAllBusy || isSyncAllProfilesBusy || Boolean(syncingProfileMappingId)) { 2414 + showNotice('info', 'Profile or bridge actions are running. Please wait.'); 2415 + return; 2416 + } 2417 + 2418 + const candidates = accountMappingsForView.filter((mapping) => canManageMapping(mapping)); 2419 + if (candidates.length === 0) { 2420 + showNotice('info', 'No accounts available for bot label update.'); 2421 + return; 2422 + } 2423 + 2424 + const confirmed = window.confirm( 2425 + `Add Bluesky automated-account bot label to ${candidates.length} account(s)? This appends the bot self-label to each app.bsky.actor.profile record.`, 2426 + ); 2427 + if (!confirmed) { 2428 + return; 2429 + } 2430 + 2431 + setIsBotLabelAllBusy(true); 2432 + try { 2433 + const response = await axios.post<BulkBotLabelAllResult>( 2434 + '/api/mappings/bot-label-all', 2435 + { mappingIds: candidates.map((mapping) => mapping.id) }, 2436 + { headers: authHeaders }, 2437 + ); 2438 + const result = response.data; 2439 + await fetchData(); 2440 + const firstFailure = result.failedMappings?.[0]; 2441 + showNotice( 2442 + result.failed > 0 ? 'info' : 'success', 2443 + `Bot label update complete: ${result.labeled} added, ${result.alreadyLabeled} already labeled, ${result.failed} failed.${ 2444 + firstFailure ? ` First failure: ${firstFailure.bskyIdentifier} (${firstFailure.error})` : '' 2445 + }`, 2446 + ); 2447 + } catch (error) { 2448 + handleAuthFailure(error, 'Failed to add bot labels to accounts.'); 2449 + } finally { 2450 + setIsBotLabelAllBusy(false); 2451 + } 2452 + }; 2453 + 2389 2454 const handleUpdateProfileSyncSource = async (mapping: AccountMapping, selectedSource: string) => { 2390 2455 if (!authHeaders) { 2391 2456 return; ··· 3024 3089 ); 3025 3090 3026 3091 const warnings = syncResponse.data.warnings || []; 3092 + const botLabelApplied = createResponse.data.hasBotLabel === true; 3027 3093 3028 3094 setNewMapping(defaultMappingForm()); 3029 3095 setNewTwitterUsers([]); ··· 3034 3100 setIsAddAccountSheetOpen(false); 3035 3101 setAddAccountStep(1); 3036 3102 if (warnings.length > 0) { 3037 - showNotice('info', `Account mapping added. Profile mirrored with ${warnings.length} warning(s).`); 3103 + showNotice( 3104 + 'info', 3105 + `Account mapping added. Profile mirrored with ${warnings.length} warning(s).${ 3106 + botLabelApplied ? '' : ' Bot label was not applied automatically; use the bulk bot-label button in Accounts.' 3107 + }`, 3108 + ); 3038 3109 } else { 3039 - showNotice('success', 'Account mapping added and Bluesky profile mirrored from Twitter.'); 3110 + showNotice( 3111 + botLabelApplied ? 'success' : 'info', 3112 + `Account mapping added and Bluesky profile mirrored from Twitter.${ 3113 + botLabelApplied ? '' : ' Bot label was not applied automatically; use the bulk bot-label button in Accounts.' 3114 + }`, 3115 + ); 3040 3116 } 3041 3117 await fetchData(); 3042 3118 } catch (error) { ··· 3748 3824 3749 3825 {activeTab === 'overview' ? ( 3750 3826 <section className="space-y-6 animate-fade-in"> 3751 - <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5"> 3827 + <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-6"> 3752 3828 <Card className="animate-slide-up"> 3753 3829 <CardContent className="p-4"> 3754 3830 <p className="text-xs uppercase tracking-wide text-muted-foreground">Mapped Accounts</p> 3755 3831 <p className="mt-2 text-2xl font-semibold">{mappings.length}</p> 3832 + </CardContent> 3833 + </Card> 3834 + <Card className="animate-slide-up"> 3835 + <CardContent className="p-4"> 3836 + <p className="text-xs uppercase tracking-wide text-muted-foreground">Bot-Labeled</p> 3837 + <p className="mt-2 text-2xl font-semibold"> 3838 + {mappings.filter((mapping) => mapping.hasBotLabel === true).length} 3839 + </p> 3756 3840 </CardContent> 3757 3841 </Card> 3758 3842 <Card className="animate-slide-up"> ··· 3851 3935 <Button 3852 3936 size="sm" 3853 3937 variant="outline" 3854 - disabled={isSyncAllProfilesBusy || isBridgeAllBusy || Boolean(syncingProfileMappingId)} 3938 + disabled={ 3939 + isSyncAllProfilesBusy || isBridgeAllBusy || isBotLabelAllBusy || Boolean(syncingProfileMappingId) 3940 + } 3855 3941 onClick={() => { 3856 3942 void handleSyncAllProfilesFromTwitter(); 3857 3943 }} ··· 3866 3952 <Button 3867 3953 size="sm" 3868 3954 variant="outline" 3869 - disabled={isBridgeAllBusy || isSyncAllProfilesBusy || Boolean(syncingProfileMappingId)} 3955 + disabled={ 3956 + isBridgeAllBusy || isSyncAllProfilesBusy || isBotLabelAllBusy || Boolean(syncingProfileMappingId) 3957 + } 3870 3958 onClick={() => { 3871 3959 void handleBridgeAllEligible(); 3872 3960 }} ··· 3880 3968 ? `Bridging ${bridgeAllProgress.completed}/${bridgeAllProgress.total}` 3881 3969 : 'Bridge all'} 3882 3970 </Button> 3971 + <Button 3972 + size="sm" 3973 + variant="outline" 3974 + disabled={ 3975 + isBotLabelAllBusy || isBridgeAllBusy || isSyncAllProfilesBusy || Boolean(syncingProfileMappingId) 3976 + } 3977 + onClick={() => { 3978 + void handleAddBotLabelToAllAccounts(); 3979 + }} 3980 + > 3981 + {isBotLabelAllBusy ? ( 3982 + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> 3983 + ) : ( 3984 + <Bot className="mr-2 h-4 w-4" /> 3985 + )} 3986 + Add automated account label to all accounts 3987 + </Button> 3883 3988 {isBridgeAllBusy && bridgeAllProgress ? ( 3884 3989 <Badge variant="outline" className="max-w-[280px] truncate"> 3885 3990 {bridgeAllProgress.currentHandle ··· 3888 3993 </Badge> 3889 3994 ) : null} 3890 3995 <Badge variant="outline">{accountMappingsForView.length} configured</Badge> 3996 + <Badge variant="outline">{botLabeledMappingsForView.length} bot-labeled</Badge> 3891 3997 </div> 3892 3998 </div> 3893 3999 </CardHeader> ··· 4198 4304 Bridged 4199 4305 </Badge> 4200 4306 ) : null} 4307 + {mapping.hasBotLabel ? ( 4308 + <Badge variant="outline"> 4309 + <Bot className="mr-1 h-3 w-3" /> 4310 + Bot 4311 + </Badge> 4312 + ) : null} 4201 4313 </div> 4202 4314 </td> 4203 4315 <td className="px-2 py-3 align-top"> ··· 4209 4321 !canManageThisMapping || 4210 4322 !canManageGroupsPermission || 4211 4323 isBridgeAllBusy || 4324 + isBotLabelAllBusy || 4212 4325 isSyncAllProfilesBusy || 4213 4326 Boolean(syncingProfileMappingId) 4214 4327 } ··· 4238 4351 value={mapping.profileSyncSourceUsername || ''} 4239 4352 disabled={ 4240 4353 isBridgeAllBusy || 4354 + isBotLabelAllBusy || 4241 4355 isSyncAllProfilesBusy || 4242 4356 Boolean(syncingProfileMappingId) || 4243 4357 Boolean(bridgingMappingId) ··· 4260 4374 <Button 4261 4375 variant="outline" 4262 4376 size="sm" 4263 - disabled={isBridgeAllBusy || Boolean(syncingProfileMappingId)} 4377 + disabled={ 4378 + isBridgeAllBusy || isBotLabelAllBusy || Boolean(syncingProfileMappingId) 4379 + } 4264 4380 onClick={() => startEditMapping(mapping)} 4265 4381 > 4266 4382 Edit ··· 4270 4386 size="sm" 4271 4387 disabled={ 4272 4388 isBridgeAllBusy || 4389 + isBotLabelAllBusy || 4273 4390 Boolean(syncingProfileMappingId) || 4274 4391 isSyncAllProfilesBusy || 4275 4392 Boolean(bridgingMappingId) ··· 4291 4408 size="sm" 4292 4409 disabled={ 4293 4410 isBridgeAllBusy || 4411 + isBotLabelAllBusy || 4294 4412 Boolean(bridgingMappingId) || 4295 4413 Boolean(syncingProfileMappingId) || 4296 4414 isSyncAllProfilesBusy ··· 4314 4432 size="sm" 4315 4433 disabled={ 4316 4434 isBridgeAllBusy || 4435 + isBotLabelAllBusy || 4317 4436 Boolean(syncingProfileMappingId) || 4318 4437 isSyncAllProfilesBusy 4319 4438 } ··· 4329 4448 size="sm" 4330 4449 disabled={ 4331 4450 isBridgeAllBusy || 4451 + isBotLabelAllBusy || 4332 4452 Boolean(syncingProfileMappingId) || 4333 4453 isSyncAllProfilesBusy 4334 4454 } ··· 4345 4465 size="sm" 4346 4466 disabled={ 4347 4467 isBridgeAllBusy || 4468 + isBotLabelAllBusy || 4348 4469 Boolean(syncingProfileMappingId) || 4349 4470 isSyncAllProfilesBusy 4350 4471 } ··· 4363 4484 size="sm" 4364 4485 disabled={ 4365 4486 isBridgeAllBusy || 4487 + isBotLabelAllBusy || 4366 4488 Boolean(syncingProfileMappingId) || 4367 4489 isSyncAllProfilesBusy 4368 4490 } ··· 4381 4503 size="sm" 4382 4504 disabled={ 4383 4505 isBridgeAllBusy || 4506 + isBotLabelAllBusy || 4384 4507 Boolean(syncingProfileMappingId) || 4385 4508 isSyncAllProfilesBusy 4386 4509 } ··· 5986 6109 <div className="space-y-1"> 5987 6110 <p className="text-sm font-semibold">Verify email and create mapping</p> 5988 6111 <p className="text-xs text-muted-foreground"> 5989 - Verify email in Bluesky, then create mapping to auto-sync name, bio, avatar, and banner. 6112 + Verify email in Bluesky, then create mapping to auto-sync name, bio, avatar, and banner, and 6113 + apply the Bluesky bot self-label. 5990 6114 </p> 5991 6115 </div> 5992 6116 <div className="space-y-2 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm">