Import Instagram archive to a Bluesky account
9
fork

Configure Feed

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

Migrate remaining process.env config into the AppConfig class so instagram-to-bluesky does not have any process.env references.

+126 -7
+79 -1
src/config.test.ts
··· 13 13 delete process.env.SIMULATE; 14 14 delete process.env.MIN_DATE; 15 15 delete process.env.MAX_DATE; 16 + delete process.env.BLUESKY_USERNAME; 17 + delete process.env.BLUESKY_PASSWORD; 18 + delete process.env.ARCHIVE_FOLDER; 16 19 }); 17 20 18 21 afterEach(() => { ··· 34 37 const config = AppConfig.fromEnv(); 35 38 expect(config.getMinDate()).toBeUndefined(); 36 39 expect(config.getMaxDate()).toBeUndefined(); 40 + }); 41 + 42 + test('should create config with empty Bluesky credentials by default', () => { 43 + const config = AppConfig.fromEnv(); 44 + expect(config.getBlueskyUsername()).toBe(''); 45 + expect(config.getBlueskyPassword()).toBe(''); 46 + }); 47 + 48 + test('should create config with empty archive folder by default', () => { 49 + const config = AppConfig.fromEnv(); 50 + expect(config.getArchiveFolder()).toBe(''); 37 51 }); 38 52 39 53 test('should enable test video mode when TEST_VIDEO_MODE=1', () => { ··· 79 93 expect(config.getMinDate()).toEqual(new Date('2023-01-01')); 80 94 expect(config.getMaxDate()).toEqual(new Date('2024-12-31')); 81 95 }); 96 + 97 + test('should set Bluesky credentials when provided', () => { 98 + process.env.BLUESKY_USERNAME = 'test_user'; 99 + process.env.BLUESKY_PASSWORD = 'test_pass'; 100 + const config = AppConfig.fromEnv(); 101 + expect(config.getBlueskyUsername()).toBe('test_user'); 102 + expect(config.getBlueskyPassword()).toBe('test_pass'); 103 + }); 104 + 105 + test('should set archive folder when provided', () => { 106 + process.env.ARCHIVE_FOLDER = '/custom/archive/path'; 107 + const config = AppConfig.fromEnv(); 108 + expect(config.getArchiveFolder()).toBe('/custom/archive/path'); 109 + }); 82 110 }); 83 111 84 112 describe('validate', () => { 85 - test('should not throw when no test modes are enabled', () => { 113 + test('should not throw when no test modes are enabled and in simulate mode', () => { 114 + process.env.SIMULATE = '1'; 115 + process.env.ARCHIVE_FOLDER = '/custom/archive/path'; // Add archive folder to avoid validation error 86 116 const config = AppConfig.fromEnv(); 87 117 expect(() => config.validate()).not.toThrow(); 88 118 }); 89 119 90 120 test('should not throw when only one test mode is enabled', () => { 91 121 process.env.TEST_VIDEO_MODE = '1'; 122 + // In test mode, we need to set SIMULATE or provide Bluesky credentials 123 + process.env.SIMULATE = '1'; 92 124 const config = AppConfig.fromEnv(); 93 125 expect(() => config.validate()).not.toThrow(); 94 126 }); ··· 96 128 test('should throw when multiple test modes are enabled', () => { 97 129 process.env.TEST_VIDEO_MODE = '1'; 98 130 process.env.TEST_IMAGE_MODE = '1'; 131 + process.env.SIMULATE = '1'; // Add simulate mode to avoid other validation errors 99 132 const config = AppConfig.fromEnv(); 100 133 expect(() => config.validate()).toThrow('Cannot enable multiple test modes simultaneously'); 101 134 }); 135 + 136 + test('should throw when not in simulate mode and Bluesky username is missing', () => { 137 + process.env.BLUESKY_PASSWORD = 'test_pass'; 138 + process.env.ARCHIVE_FOLDER = '/custom/archive/path'; // Add archive folder to avoid that validation error 139 + const config = AppConfig.fromEnv(); 140 + expect(() => config.validate()).toThrow('BLUESKY_USERNAME is required when not in simulate mode'); 141 + }); 142 + 143 + test('should throw when not in simulate mode and Bluesky password is missing', () => { 144 + process.env.BLUESKY_USERNAME = 'test_user'; 145 + process.env.ARCHIVE_FOLDER = '/custom/archive/path'; // Add archive folder to avoid that validation error 146 + const config = AppConfig.fromEnv(); 147 + expect(() => config.validate()).toThrow('BLUESKY_PASSWORD is required when not in simulate mode'); 148 + }); 149 + 150 + test('should throw when not in test mode and archive folder is missing', () => { 151 + process.env.BLUESKY_USERNAME = 'test_user'; 152 + process.env.BLUESKY_PASSWORD = 'test_pass'; 153 + const config = AppConfig.fromEnv(); 154 + expect(() => config.validate()).toThrow('ARCHIVE_FOLDER is required when not in test mode'); 155 + }); 156 + 157 + test('should not throw when all required fields are provided', () => { 158 + process.env.BLUESKY_USERNAME = 'test_user'; 159 + process.env.BLUESKY_PASSWORD = 'test_pass'; 160 + process.env.ARCHIVE_FOLDER = '/custom/archive/path'; 161 + const config = AppConfig.fromEnv(); 162 + expect(() => config.validate()).not.toThrow(); 163 + }); 102 164 }); 103 165 104 166 describe('getArchiveFolder', () => { ··· 188 250 const config = AppConfig.fromEnv(); 189 251 expect(config.getMinDate()?.toString()).toBe('Invalid Date'); 190 252 expect(config.getMaxDate()?.toString()).toBe('Invalid Date'); 253 + }); 254 + }); 255 + 256 + describe('getBlueskyUsername and getBlueskyPassword', () => { 257 + test('should return empty strings when credentials are not set', () => { 258 + const config = AppConfig.fromEnv(); 259 + expect(config.getBlueskyUsername()).toBe(''); 260 + expect(config.getBlueskyPassword()).toBe(''); 261 + }); 262 + 263 + test('should return correct values when credentials are set', () => { 264 + process.env.BLUESKY_USERNAME = 'test_user'; 265 + process.env.BLUESKY_PASSWORD = 'test_pass'; 266 + const config = AppConfig.fromEnv(); 267 + expect(config.getBlueskyUsername()).toBe('test_user'); 268 + expect(config.getBlueskyPassword()).toBe('test_pass'); 191 269 }); 192 270 }); 193 271 });
+44 -2
src/config.ts
··· 5 5 6 6 /** 7 7 * Configuration for the application 8 + * Includes all environment configuration except the log level to keep it simple. 8 9 */ 9 10 export class AppConfig { 10 11 private readonly testVideoMode: boolean; ··· 13 14 private readonly simulate: boolean; 14 15 private readonly minDate: Date | undefined; 15 16 private readonly maxDate: Date | undefined; 17 + private readonly blueskyUsername: string; 18 + private readonly blueskyPassword: string; 19 + private readonly archiveFolder: string; 16 20 17 21 constructor(config: { 18 22 testVideoMode: boolean; ··· 21 25 simulate: boolean; 22 26 minDate?: Date; 23 27 maxDate?: Date; 28 + blueskyUsername: string; 29 + blueskyPassword: string; 30 + archiveFolder: string; 24 31 }) { 25 32 this.testVideoMode = config.testVideoMode; 26 33 this.testImageMode = config.testImageMode; ··· 28 35 this.simulate = config.simulate; 29 36 this.minDate = config.minDate; 30 37 this.maxDate = config.maxDate; 38 + this.blueskyUsername = config.blueskyUsername; 39 + this.blueskyPassword = config.blueskyPassword; 40 + this.archiveFolder = config.archiveFolder; 31 41 } 32 42 33 43 /** ··· 40 50 testImagesMode: process.env.TEST_IMAGES_MODE === '1', 41 51 simulate: process.env.SIMULATE === '1', 42 52 minDate: process.env.MIN_DATE ? new Date(process.env.MIN_DATE) : undefined, 43 - maxDate: process.env.MAX_DATE ? new Date(process.env.MAX_DATE) : undefined 53 + maxDate: process.env.MAX_DATE ? new Date(process.env.MAX_DATE) : undefined, 54 + blueskyUsername: process.env.BLUESKY_USERNAME || '', 55 + blueskyPassword: process.env.BLUESKY_PASSWORD || '', 56 + archiveFolder: process.env.ARCHIVE_FOLDER || '' 44 57 }); 45 58 } 46 59 ··· 73 86 } 74 87 75 88 /** 89 + * Gets the Bluesky username 90 + */ 91 + getBlueskyUsername(): string { 92 + return this.blueskyUsername; 93 + } 94 + 95 + /** 96 + * Gets the Bluesky password 97 + */ 98 + getBlueskyPassword(): string { 99 + return this.blueskyPassword; 100 + } 101 + 102 + /** 76 103 * Gets the archive folder path based on test configuration 77 104 */ 78 105 getArchiveFolder(): string { ··· 81 108 if (this.testVideoMode) return path.join(rootDir, 'transfer/test_video'); 82 109 if (this.testImageMode) return path.join(rootDir, 'transfer/test_image'); 83 110 if (this.testImagesMode) return path.join(rootDir, 'transfer/test_images'); 84 - return process.env.ARCHIVE_FOLDER!; 111 + return this.archiveFolder; 85 112 } 86 113 87 114 /** ··· 101 128 throw new Error( 102 129 `Cannot enable multiple test modes simultaneously: ${enabledModes.join(', ')}` 103 130 ); 131 + } 132 + 133 + // Validate required fields when not in simulate mode 134 + if (!this.simulate) { 135 + if (!this.blueskyUsername) { 136 + throw new Error('BLUESKY_USERNAME is required when not in simulate mode'); 137 + } 138 + if (!this.blueskyPassword) { 139 + throw new Error('BLUESKY_PASSWORD is required when not in simulate mode'); 140 + } 141 + } 142 + 143 + // Validate archive folder 144 + if (!this.isTestModeEnabled() && !this.archiveFolder) { 145 + throw new Error('ARCHIVE_FOLDER is required when not in test mode'); 104 146 } 105 147 } 106 148 }
+3 -4
src/instagram-to-bluesky.ts
··· 1 1 import FS from 'fs'; 2 2 import path from 'path'; 3 - import * as process from 'process'; 4 3 5 4 import { BlobRef } from '@atproto/api'; 6 5 ··· 121 120 logger.info(`Import started at ${importStart.toISOString()}`); 122 121 logger.info({ 123 122 SourceFolder: archivalFolder, 124 - username: process.env.BLUESKY_USERNAME, 123 + username: config.getBlueskyUsername(), 125 124 MIN_DATE: config.getMinDate(), 126 125 MAX_DATE: config.getMaxDate(), 127 126 SIMULATE: config.isSimulateEnabled(), ··· 133 132 if (!config.isSimulateEnabled()) { 134 133 logger.info("--- SIMULATE mode is disabled, posts will be imported ---"); 135 134 bluesky = new BlueskyClient( 136 - process.env.BLUESKY_USERNAME!, 137 - process.env.BLUESKY_PASSWORD! 135 + config.getBlueskyUsername(), 136 + config.getBlueskyPassword() 138 137 ); 139 138 await bluesky.login(); 140 139 } else {