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.
at master 461 lines 13 kB view raw
1import { randomUUID } from 'node:crypto'; 2import fs from 'node:fs'; 3import path from 'node:path'; 4import { fileURLToPath } from 'node:url'; 5 6const __filename = fileURLToPath(import.meta.url); 7const __dirname = path.dirname(__filename); 8 9const CONFIG_FILE = path.join(__dirname, '..', 'config.json'); 10 11export interface TwitterConfig { 12 authToken: string; 13 ct0: string; 14 backupAuthToken?: string; 15 backupCt0?: string; 16} 17 18export interface UserPermissions { 19 viewAllMappings: boolean; 20 manageOwnMappings: boolean; 21 manageAllMappings: boolean; 22 manageGroups: boolean; 23 queueBackfills: boolean; 24 runNow: boolean; 25} 26 27export type UserRole = 'admin' | 'user'; 28 29export interface WebUser { 30 id: string; 31 username?: string; 32 email?: string; 33 passwordHash: string; 34 role: UserRole; 35 permissions: UserPermissions; 36 createdAt: string; 37 updatedAt: string; 38} 39 40export interface AIConfig { 41 provider: 'gemini' | 'openai' | 'anthropic' | 'custom'; 42 apiKey?: string; 43 model?: string; 44 baseUrl?: string; 45} 46 47export interface AccountMapping { 48 id: string; 49 twitterUsernames: string[]; 50 bskyIdentifier: string; 51 bskyPassword: string; 52 bskyServiceUrl?: string; 53 enabled: boolean; 54 owner?: string; 55 groupName?: string; 56 groupEmoji?: string; 57 createdByUserId?: string; 58} 59 60export interface AccountGroup { 61 name: string; 62 emoji?: string; 63} 64 65export interface AppConfig { 66 twitter: TwitterConfig; 67 mappings: AccountMapping[]; 68 groups: AccountGroup[]; 69 users: WebUser[]; 70 checkIntervalMinutes: number; 71 geminiApiKey?: string; 72 ai?: AIConfig; 73} 74 75const DEFAULT_TWITTER_CONFIG: TwitterConfig = { 76 authToken: '', 77 ct0: '', 78}; 79 80export const DEFAULT_USER_PERMISSIONS: UserPermissions = { 81 viewAllMappings: false, 82 manageOwnMappings: true, 83 manageAllMappings: false, 84 manageGroups: false, 85 queueBackfills: true, 86 runNow: true, 87}; 88 89export const ADMIN_USER_PERMISSIONS: UserPermissions = { 90 viewAllMappings: true, 91 manageOwnMappings: true, 92 manageAllMappings: true, 93 manageGroups: true, 94 queueBackfills: true, 95 runNow: true, 96}; 97 98const DEFAULT_CONFIG: AppConfig = { 99 twitter: DEFAULT_TWITTER_CONFIG, 100 mappings: [], 101 groups: [], 102 users: [], 103 checkIntervalMinutes: 5, 104}; 105 106const normalizeString = (value: unknown): string | undefined => { 107 if (typeof value !== 'string') { 108 return undefined; 109 } 110 const trimmed = value.trim(); 111 return trimmed.length > 0 ? trimmed : undefined; 112}; 113 114const normalizeEmail = (value: unknown): string | undefined => { 115 const normalized = normalizeString(value); 116 return normalized ? normalized.toLowerCase() : undefined; 117}; 118 119const normalizeUsername = (value: unknown): string | undefined => { 120 const normalized = normalizeString(value); 121 if (!normalized) { 122 return undefined; 123 } 124 return normalized.replace(/^@/, '').toLowerCase(); 125}; 126 127const normalizeRole = (value: unknown): UserRole | undefined => { 128 if (value === 'admin' || value === 'user') { 129 return value; 130 } 131 return undefined; 132}; 133 134const normalizeBoolean = (value: unknown, fallback: boolean): boolean => { 135 if (typeof value === 'boolean') { 136 return value; 137 } 138 return fallback; 139}; 140 141const normalizeUserPermissions = (value: unknown, role: UserRole): UserPermissions => { 142 if (role === 'admin') { 143 return { ...ADMIN_USER_PERMISSIONS }; 144 } 145 146 const defaults = { ...DEFAULT_USER_PERMISSIONS }; 147 if (!value || typeof value !== 'object') { 148 return defaults; 149 } 150 151 const record = value as Record<string, unknown>; 152 return { 153 viewAllMappings: normalizeBoolean(record.viewAllMappings, defaults.viewAllMappings), 154 manageOwnMappings: normalizeBoolean(record.manageOwnMappings, defaults.manageOwnMappings), 155 manageAllMappings: normalizeBoolean(record.manageAllMappings, defaults.manageAllMappings), 156 manageGroups: normalizeBoolean(record.manageGroups, defaults.manageGroups), 157 queueBackfills: normalizeBoolean(record.queueBackfills, defaults.queueBackfills), 158 runNow: normalizeBoolean(record.runNow, defaults.runNow), 159 }; 160}; 161 162export function getDefaultUserPermissions(role: UserRole): UserPermissions { 163 return role === 'admin' ? { ...ADMIN_USER_PERMISSIONS } : { ...DEFAULT_USER_PERMISSIONS }; 164} 165 166const normalizeUser = (rawUser: unknown, index: number, fallbackNowIso: string): WebUser | null => { 167 if (!rawUser || typeof rawUser !== 'object') { 168 return null; 169 } 170 171 const record = rawUser as Record<string, unknown>; 172 const passwordHash = normalizeString(record.passwordHash); 173 if (!passwordHash) { 174 return null; 175 } 176 177 const role = normalizeRole(record.role) ?? (index === 0 ? 'admin' : 'user'); 178 const createdAt = normalizeString(record.createdAt) ?? fallbackNowIso; 179 const updatedAt = normalizeString(record.updatedAt) ?? createdAt; 180 181 return { 182 id: normalizeString(record.id) ?? randomUUID(), 183 username: normalizeUsername(record.username), 184 email: normalizeEmail(record.email), 185 passwordHash, 186 role, 187 permissions: normalizeUserPermissions(record.permissions, role), 188 createdAt, 189 updatedAt, 190 }; 191}; 192 193const normalizeTwitterUsernames = (value: unknown, legacyValue: unknown): string[] => { 194 const seen = new Set<string>(); 195 const usernames: string[] = []; 196 197 const addUsername = (candidate: unknown) => { 198 const normalized = normalizeUsername(candidate); 199 if (!normalized || seen.has(normalized)) { 200 return; 201 } 202 seen.add(normalized); 203 usernames.push(normalized); 204 }; 205 206 if (Array.isArray(value)) { 207 for (const item of value) { 208 addUsername(item); 209 } 210 } else if (typeof value === 'string') { 211 for (const item of value.split(',')) { 212 addUsername(item); 213 } 214 } 215 216 if (usernames.length === 0) { 217 addUsername(legacyValue); 218 } 219 220 return usernames; 221}; 222 223const normalizeGroup = (group: unknown): AccountGroup | null => { 224 if (!group || typeof group !== 'object') { 225 return null; 226 } 227 const record = group as Record<string, unknown>; 228 const name = normalizeString(record.name); 229 if (!name) { 230 return null; 231 } 232 const emoji = normalizeString(record.emoji); 233 return { 234 name, 235 ...(emoji ? { emoji } : {}), 236 }; 237}; 238 239const findAdminUserId = (users: WebUser[]): string | undefined => users.find((user) => user.role === 'admin')?.id; 240 241const matchOwnerToUserId = (owner: string | undefined, users: WebUser[]): string | undefined => { 242 if (!owner) { 243 return undefined; 244 } 245 246 const normalizedOwner = owner.trim().toLowerCase(); 247 if (!normalizedOwner) { 248 return undefined; 249 } 250 251 return users.find((user) => { 252 const username = user.username?.toLowerCase(); 253 const email = user.email?.toLowerCase(); 254 const emailLocalPart = email?.split('@')[0]; 255 return normalizedOwner === username || normalizedOwner === email || normalizedOwner === emailLocalPart; 256 })?.id; 257}; 258 259const normalizeMapping = (rawMapping: unknown, users: WebUser[], adminUserId?: string): AccountMapping | null => { 260 if (!rawMapping || typeof rawMapping !== 'object') { 261 return null; 262 } 263 264 const record = rawMapping as Record<string, unknown>; 265 const bskyIdentifier = normalizeString(record.bskyIdentifier); 266 if (!bskyIdentifier) { 267 return null; 268 } 269 270 const owner = normalizeString(record.owner); 271 const usernames = normalizeTwitterUsernames(record.twitterUsernames, record.twitterUsername); 272 const explicitCreator = normalizeString(record.createdByUserId) ?? normalizeString(record.ownerUserId); 273 const explicitCreatorExists = explicitCreator && users.some((user) => user.id === explicitCreator); 274 275 return { 276 id: normalizeString(record.id) ?? randomUUID(), 277 twitterUsernames: usernames, 278 bskyIdentifier: bskyIdentifier.toLowerCase(), 279 bskyPassword: normalizeString(record.bskyPassword) ?? '', 280 bskyServiceUrl: normalizeString(record.bskyServiceUrl) ?? 'https://bsky.social', 281 enabled: normalizeBoolean(record.enabled, true), 282 owner, 283 groupName: normalizeString(record.groupName), 284 groupEmoji: normalizeString(record.groupEmoji), 285 createdByUserId: 286 (explicitCreatorExists ? explicitCreator : undefined) ?? matchOwnerToUserId(owner, users) ?? adminUserId, 287 }; 288}; 289 290const normalizeUsers = (rawUsers: unknown): WebUser[] => { 291 if (!Array.isArray(rawUsers)) { 292 return []; 293 } 294 295 const fallbackNowIso = new Date().toISOString(); 296 const normalized = rawUsers 297 .map((user, index) => normalizeUser(user, index, fallbackNowIso)) 298 .filter((user): user is WebUser => user !== null); 299 300 const usedIds = new Set<string>(); 301 for (const user of normalized) { 302 if (usedIds.has(user.id)) { 303 user.id = randomUUID(); 304 } 305 usedIds.add(user.id); 306 } 307 308 const firstUser = normalized[0]; 309 if (firstUser && !normalized.some((user) => user.role === 'admin')) { 310 firstUser.role = 'admin'; 311 firstUser.permissions = { ...ADMIN_USER_PERMISSIONS }; 312 firstUser.updatedAt = new Date().toISOString(); 313 } 314 315 for (const user of normalized) { 316 if (user.role === 'admin') { 317 user.permissions = { ...ADMIN_USER_PERMISSIONS }; 318 } 319 } 320 321 return normalized; 322}; 323 324const normalizeAiConfig = (rawAi: unknown): AIConfig | undefined => { 325 if (!rawAi || typeof rawAi !== 'object') { 326 return undefined; 327 } 328 const record = rawAi as Record<string, unknown>; 329 const provider = record.provider; 330 if (provider !== 'gemini' && provider !== 'openai' && provider !== 'anthropic' && provider !== 'custom') { 331 return undefined; 332 } 333 334 const apiKey = normalizeString(record.apiKey); 335 const model = normalizeString(record.model); 336 const baseUrl = normalizeString(record.baseUrl); 337 return { 338 provider, 339 ...(apiKey ? { apiKey } : {}), 340 ...(model ? { model } : {}), 341 ...(baseUrl ? { baseUrl } : {}), 342 }; 343}; 344 345const normalizeConfigShape = (rawConfig: unknown): AppConfig => { 346 if (!rawConfig || typeof rawConfig !== 'object') { 347 return { ...DEFAULT_CONFIG }; 348 } 349 350 const record = rawConfig as Record<string, unknown>; 351 const rawTwitter = 352 record.twitter && typeof record.twitter === 'object' ? (record.twitter as Record<string, unknown>) : {}; 353 const users = normalizeUsers(record.users); 354 const adminUserId = findAdminUserId(users); 355 356 const mappings = Array.isArray(record.mappings) 357 ? record.mappings 358 .map((mapping) => normalizeMapping(mapping, users, adminUserId)) 359 .filter((mapping): mapping is AccountMapping => mapping !== null) 360 : []; 361 362 const groups = Array.isArray(record.groups) 363 ? record.groups.map(normalizeGroup).filter((group): group is AccountGroup => group !== null) 364 : []; 365 366 const seenGroups = new Set<string>(); 367 const dedupedGroups = groups.filter((group) => { 368 const key = group.name.toLowerCase(); 369 if (seenGroups.has(key)) { 370 return false; 371 } 372 seenGroups.add(key); 373 return true; 374 }); 375 376 const checkIntervalCandidate = Number(record.checkIntervalMinutes); 377 const checkIntervalMinutes = 378 Number.isFinite(checkIntervalCandidate) && checkIntervalCandidate >= 1 ? Math.round(checkIntervalCandidate) : 5; 379 380 const geminiApiKey = normalizeString(record.geminiApiKey); 381 const ai = normalizeAiConfig(record.ai); 382 383 return { 384 twitter: { 385 authToken: normalizeString(rawTwitter.authToken) ?? '', 386 ct0: normalizeString(rawTwitter.ct0) ?? '', 387 backupAuthToken: normalizeString(rawTwitter.backupAuthToken), 388 backupCt0: normalizeString(rawTwitter.backupCt0), 389 }, 390 mappings, 391 groups: dedupedGroups, 392 users, 393 checkIntervalMinutes, 394 ...(geminiApiKey ? { geminiApiKey } : {}), 395 ...(ai ? { ai } : {}), 396 }; 397}; 398 399const writeConfigFile = (config: AppConfig) => { 400 fs.writeFileSync(CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`); 401}; 402 403export function getConfig(): AppConfig { 404 if (!fs.existsSync(CONFIG_FILE)) { 405 return { ...DEFAULT_CONFIG }; 406 } 407 408 try { 409 const rawText = fs.readFileSync(CONFIG_FILE, 'utf8'); 410 const rawConfig = JSON.parse(rawText); 411 const normalizedConfig = normalizeConfigShape(rawConfig); 412 413 if (JSON.stringify(rawConfig) !== JSON.stringify(normalizedConfig)) { 414 writeConfigFile(normalizedConfig); 415 } 416 417 return normalizedConfig; 418 } catch (err) { 419 console.error('Error reading config:', err); 420 return { ...DEFAULT_CONFIG }; 421 } 422} 423 424export function saveConfig(config: AppConfig): void { 425 const normalizedConfig = normalizeConfigShape(config); 426 writeConfigFile(normalizedConfig); 427} 428 429export function addMapping(mapping: Omit<AccountMapping, 'id' | 'enabled'>): void { 430 const config = getConfig(); 431 const newMapping: AccountMapping = { 432 ...mapping, 433 id: randomUUID(), 434 enabled: true, 435 }; 436 config.mappings.push(newMapping); 437 saveConfig(config); 438} 439 440export function updateMapping(id: string, updates: Partial<Omit<AccountMapping, 'id'>>): void { 441 const config = getConfig(); 442 const index = config.mappings.findIndex((m) => m.id === id); 443 const existing = config.mappings[index]; 444 445 if (index !== -1 && existing) { 446 config.mappings[index] = { ...existing, ...updates }; 447 saveConfig(config); 448 } 449} 450 451export function removeMapping(id: string): void { 452 const config = getConfig(); 453 config.mappings = config.mappings.filter((m) => m.id !== id); 454 saveConfig(config); 455} 456 457export function updateTwitterConfig(twitter: TwitterConfig): void { 458 const config = getConfig(); 459 config.twitter = twitter; 460 saveConfig(config); 461}