forked from
j4ck.xyz/tweets2bsky
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 { 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}