storebase-cli 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,393 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ copyFile,
5
+ mkdir,
6
+ readFile,
7
+ rename,
8
+ rm,
9
+ stat,
10
+ writeFile,
11
+ } = require('node:fs/promises');
12
+ const { createReadStream } = require('node:fs');
13
+ const { createHash, randomBytes } = require('node:crypto');
14
+ const path = require('node:path');
15
+ const { spawnSync } = require('node:child_process');
16
+ const { createInterface } = require('node:readline/promises');
17
+ const { stdin, stdout } = require('node:process');
18
+ const { tmpdir } = require('node:os');
19
+
20
+ const CATEGORIES = Object.freeze([
21
+ 'Education',
22
+ 'Social',
23
+ 'Productivity',
24
+ 'Communication',
25
+ 'Entertainment',
26
+ 'Games',
27
+ 'Business',
28
+ 'Shopping',
29
+ 'Finance',
30
+ 'Utilities',
31
+ 'Health & Fitness',
32
+ 'Travel & Navigation',
33
+ 'AI & Machine Learning',
34
+ ]);
35
+
36
+ function fail(message) {
37
+ throw new Error(message);
38
+ }
39
+
40
+ function normalizeFingerprint(value) {
41
+ return String(value || '').replace(/[^a-f\d]/gi, '').toUpperCase();
42
+ }
43
+
44
+ function slugify(value) {
45
+ const slug = String(value)
46
+ .normalize('NFKD')
47
+ .replace(/[\u0300-\u036f]/g, '')
48
+ .toLowerCase()
49
+ .replace(/[^a-z0-9]+/g, '-')
50
+ .replace(/^-+|-+$/g, '');
51
+ return slug || `app-${createHash('sha256').update(String(value)).digest('hex').slice(0, 10)}`;
52
+ }
53
+
54
+ async function requireFile(value, label, cwd) {
55
+ if (typeof value !== 'string' || !value.trim()) fail(`${label} is required.`);
56
+ const resolved = path.resolve(cwd, value.trim());
57
+ let details;
58
+ try {
59
+ details = await stat(resolved);
60
+ } catch {
61
+ fail(`${label} does not exist: ${resolved}`);
62
+ }
63
+ if (!details.isFile() || details.size === 0) fail(`${label} must be a non-empty file: ${resolved}`);
64
+ return resolved;
65
+ }
66
+
67
+ async function detectImageExtension(filePath) {
68
+ const handle = await require('node:fs/promises').open(filePath, 'r');
69
+ try {
70
+ const buffer = Buffer.alloc(12);
71
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
72
+ const head = buffer.subarray(0, bytesRead);
73
+ if (head.length >= 8 && head.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return '.png';
74
+ if (head.length >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return '.jpg';
75
+ if (head.length >= 12 && head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP') return '.webp';
76
+ fail('Avatar must be a PNG, JPEG, or WebP image.');
77
+ } finally {
78
+ await handle.close();
79
+ }
80
+ }
81
+
82
+ async function validateInput(raw, options = {}) {
83
+ const cwd = options.cwd || process.cwd();
84
+ const name = typeof raw.name === 'string' ? raw.name.trim() : '';
85
+ if (!name) fail('App name is required.');
86
+ if ([...name].length > 100) fail('App name must be 100 characters or fewer.');
87
+
88
+ const version = typeof raw.version === 'string' ? raw.version.trim() : '';
89
+ if (!/^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$/.test(version)) {
90
+ fail('Version is required and must be 1–64 safe characters (letters, numbers, dot, plus, underscore, or hyphen).');
91
+ }
92
+ const id = slugify(name);
93
+ const androidVersionCode = raw.androidVersionCode === undefined || raw.androidVersionCode === ''
94
+ ? undefined : Number(raw.androidVersionCode);
95
+ const minimumAndroidVersionCode = raw.minimumAndroidVersionCode === undefined || raw.minimumAndroidVersionCode === ''
96
+ ? 0 : Number(raw.minimumAndroidVersionCode);
97
+ if (id === 'storebase' && !Number.isSafeInteger(androidVersionCode)) {
98
+ fail('StoreBase Telegram releases require --android-version-code.');
99
+ }
100
+ if (id !== 'storebase' && androidVersionCode !== undefined) {
101
+ fail('Android updater version metadata is reserved for the StoreBase app ID.');
102
+ }
103
+ if ((androidVersionCode !== undefined && (!Number.isSafeInteger(androidVersionCode) || androidVersionCode <= 0 || androidVersionCode > 2147483647))
104
+ || !Number.isSafeInteger(minimumAndroidVersionCode) || minimumAndroidVersionCode < 0
105
+ || (androidVersionCode === undefined && minimumAndroidVersionCode !== 0)
106
+ || (androidVersionCode !== undefined && minimumAndroidVersionCode > androidVersionCode)) {
107
+ fail('Android version codes must be valid positive integers and the minimum cannot exceed the release code.');
108
+ }
109
+
110
+ const description = typeof raw.description === 'string' ? raw.description.trim() : '';
111
+ if (!description) fail('Description is required.');
112
+ if (description.length > 1000) fail('Description must be 1000 characters or fewer.');
113
+
114
+ const categoryText = typeof raw.category === 'string' ? raw.category.trim() : '';
115
+ const category = CATEGORIES.find((item) => item.toLocaleLowerCase('en') === categoryText.toLocaleLowerCase('en'));
116
+ if (!category) fail(`Category must match one of: ${CATEGORIES.join(', ')}.`);
117
+
118
+ const avatarPath = await requireFile(raw.avatar, 'Avatar file', cwd);
119
+ const avatarExtension = await detectImageExtension(avatarPath);
120
+ const apkPath = await requireFile(raw.apk, 'APK file', cwd);
121
+ if (path.extname(apkPath).toLowerCase() !== '.apk') fail('APK file must have the .apk extension.');
122
+ const apkHandle = await require('node:fs/promises').open(apkPath, 'r');
123
+ try {
124
+ const signature = Buffer.alloc(4);
125
+ const { bytesRead } = await apkHandle.read(signature, 0, 4, 0);
126
+ if (bytesRead !== 4 || signature.toString('hex') !== '504b0304') fail('APK file does not have a valid ZIP/APK header.');
127
+ } finally {
128
+ await apkHandle.close();
129
+ }
130
+ let deltaPath;
131
+ let deltaBaseVersionCode;
132
+ if (raw.delta) {
133
+ if (id !== 'storebase') fail('Telegram delta artifacts are currently supported only for the StoreBase Android app.');
134
+ if (!Number.isSafeInteger(androidVersionCode)) fail('A StoreBase delta requires --android-version-code.');
135
+ deltaBaseVersionCode = Number(raw.deltaBaseVersionCode);
136
+ if (!Number.isSafeInteger(deltaBaseVersionCode) || deltaBaseVersionCode <= 0 || deltaBaseVersionCode >= androidVersionCode) {
137
+ fail('--delta-base-version-code must be a positive version lower than --android-version-code.');
138
+ }
139
+ deltaPath = await requireFile(raw.delta, 'Delta patch file', cwd);
140
+ if (path.extname(deltaPath).toLowerCase() !== '.sbd') fail('Delta patch file must have the .sbd extension.');
141
+ const patchFile = await require('node:fs/promises').open(deltaPath, 'r');
142
+ try {
143
+ const patchHeader = Buffer.alloc(2);
144
+ const { bytesRead } = await patchFile.read(patchHeader, 0, 2, 0);
145
+ if (bytesRead !== 2 || patchHeader[0] !== 0x1f || patchHeader[1] !== 0x8b) {
146
+ fail('Delta patch must be a gzip-compressed SBD file.');
147
+ }
148
+ } finally {
149
+ await patchFile.close();
150
+ }
151
+ } else if (raw.deltaBaseVersionCode !== undefined && raw.deltaBaseVersionCode !== '') {
152
+ fail('--delta-base-version-code requires --delta.');
153
+ }
154
+ const keystorePath = await requireFile(raw.keystore, 'Signed keystore file', cwd);
155
+
156
+ return {
157
+ id,
158
+ name,
159
+ avatarPath,
160
+ avatarExtension,
161
+ version,
162
+ apkPath,
163
+ category,
164
+ description,
165
+ keystorePath,
166
+ alias: typeof raw.alias === 'string' && raw.alias.trim() ? raw.alias.trim() : undefined,
167
+ androidVersionCode,
168
+ minimumAndroidVersionCode,
169
+ deltaPath,
170
+ deltaBaseVersionCode,
171
+ };
172
+ }
173
+
174
+ function runTool(command, args, options = {}) {
175
+ const result = spawnSync(command, args, {
176
+ encoding: 'utf8',
177
+ maxBuffer: 16 * 1024 * 1024,
178
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
179
+ ...options,
180
+ });
181
+ if (result.error) fail(`Could not run ${command}: ${result.error.message}`);
182
+ if (result.status !== 0) {
183
+ const details = [result.stderr, result.stdout].filter(Boolean).join('\n').trim();
184
+ fail(`${command} failed${details ? `:\n${details}` : '.'}`);
185
+ }
186
+ return `${result.stdout || ''}\n${result.stderr || ''}`;
187
+ }
188
+
189
+ function parseKeystoreEntries(output) {
190
+ const blocks = output.split(/(?=Alias name:\s*)/i).filter((block) => /Alias name:/i.test(block));
191
+ return blocks.flatMap((block) => {
192
+ const alias = block.match(/Alias name:\s*([^\r\n]+)/i)?.[1]?.trim();
193
+ const entryType = block.match(/Entry type:\s*([^\r\n]+)/i)?.[1]?.trim();
194
+ const fingerprints = [...block.matchAll(/SHA256:\s*([A-F\d:]+)/gi)].map((match) => normalizeFingerprint(match[1]));
195
+ if (!alias || !/PrivateKeyEntry/i.test(entryType || '') || fingerprints.length === 0) return [];
196
+ return [{ alias, fingerprint: fingerprints[0] }];
197
+ });
198
+ }
199
+
200
+ function parseApkSignerFingerprints(output) {
201
+ return [...output.matchAll(/Signer\s+#\d+\s+certificate\s+SHA-256\s+digest:\s*([a-f\d:]+)/gi)]
202
+ .map((match) => normalizeFingerprint(match[1]))
203
+ .filter((fingerprint) => fingerprint.length === 64);
204
+ }
205
+
206
+ async function inspectKeystore(keystorePath, password) {
207
+ if (typeof password !== 'string') fail('A keystore password is required to verify the signing certificate.');
208
+ if (password.includes('\n') || password.includes('\r')) fail('Keystore passwords cannot contain line breaks.');
209
+ const fs = require('node:fs');
210
+ const folder = await require('node:fs/promises').mkdtemp(path.join(tmpdir(), 'storebase-keystore-'));
211
+ const passwordFile = path.join(folder, 'password');
212
+ try {
213
+ await writeFile(passwordFile, `${password}\n`, { mode: 0o600, flag: 'wx' });
214
+ const output = runTool('keytool', [
215
+ '-J-Duser.language=en',
216
+ '-J-Duser.country=US',
217
+ '-list',
218
+ '-v',
219
+ '-keystore',
220
+ keystorePath,
221
+ '-storepass:file',
222
+ passwordFile,
223
+ ]);
224
+ const entries = parseKeystoreEntries(output);
225
+ if (!entries.length) fail('The keystore has no readable private-key entry with a SHA-256 certificate.');
226
+ return entries;
227
+ } finally {
228
+ // Best-effort overwrite before removal; the temporary directory is private (0700).
229
+ try {
230
+ const fd = fs.openSync(passwordFile, 'r+');
231
+ const size = fs.fstatSync(fd).size;
232
+ fs.writeSync(fd, Buffer.alloc(size));
233
+ fs.closeSync(fd);
234
+ } catch {}
235
+ await rm(folder, { recursive: true, force: true });
236
+ }
237
+ }
238
+
239
+ function findApksigner() {
240
+ const fs = require('node:fs');
241
+ const pathCandidates = [];
242
+ if (process.env.PATH) {
243
+ for (const folder of process.env.PATH.split(path.delimiter)) pathCandidates.push(path.join(folder, 'apksigner'));
244
+ }
245
+ const sdkRoots = [
246
+ process.env.ANDROID_SDK_ROOT,
247
+ process.env.ANDROID_HOME,
248
+ path.join(require('node:os').homedir(), 'Android', 'Sdk'),
249
+ '/usr/lib/android-sdk',
250
+ '/opt/android-sdk',
251
+ ].filter(Boolean);
252
+ for (const root of sdkRoots) {
253
+ const buildTools = path.join(root, 'build-tools');
254
+ try {
255
+ const versions = fs.readdirSync(buildTools).sort((a, b) => {
256
+ const aa = a.split('.').map((part) => Number.parseInt(part, 10) || 0);
257
+ const bb = b.split('.').map((part) => Number.parseInt(part, 10) || 0);
258
+ for (let i = 0; i < Math.max(aa.length, bb.length); i += 1) {
259
+ const difference = (bb[i] || 0) - (aa[i] || 0);
260
+ if (difference) return difference;
261
+ }
262
+ return 0;
263
+ });
264
+ for (const version of versions) pathCandidates.push(path.join(buildTools, version, 'apksigner'));
265
+ } catch {}
266
+ }
267
+ const found = pathCandidates.find((candidate) => {
268
+ try { return fs.statSync(candidate).isFile(); } catch { return false; }
269
+ });
270
+ if (!found) fail('Android apksigner was not found. Install Android SDK Build Tools or add apksigner to PATH.');
271
+ return found;
272
+ }
273
+
274
+ function verifyApkSignature(apkPath, keystoreEntry) {
275
+ const apksigner = findApksigner();
276
+ const output = runTool(apksigner, ['verify', '--verbose', '--print-certs', apkPath]);
277
+ const fingerprints = parseApkSignerFingerprints(output);
278
+ if (!fingerprints.length) fail('apksigner did not report a SHA-256 signer certificate for this APK.');
279
+ const matchingFingerprint = fingerprints.find((fingerprint) => fingerprint === normalizeFingerprint(keystoreEntry.fingerprint));
280
+ if (!matchingFingerprint) {
281
+ fail(`APK signer does not match keystore alias "${keystoreEntry.alias}". The APK was not added to the app list.`);
282
+ }
283
+ return matchingFingerprint;
284
+ }
285
+
286
+ async function sha256File(filePath) {
287
+ const hash = createHash('sha256');
288
+ for await (const chunk of createReadStream(filePath)) hash.update(chunk);
289
+ return hash.digest('hex');
290
+ }
291
+
292
+ function toCatalogPath(fromDirectory, absolutePath) {
293
+ return path.relative(fromDirectory, absolutePath).split(path.sep).join('/');
294
+ }
295
+
296
+ async function copyIfMissing(source, destination) {
297
+ await mkdir(path.dirname(destination), { recursive: true });
298
+ try {
299
+ await stat(destination);
300
+ return;
301
+ } catch {}
302
+ const temporary = `${destination}.tmp-${process.pid}-${randomBytes(5).toString('hex')}`;
303
+ try {
304
+ await copyFile(source, temporary);
305
+ try {
306
+ await rename(temporary, destination);
307
+ } catch (error) {
308
+ // A concurrent publish of the identical content-addressed asset is harmless.
309
+ try { await stat(destination); } catch { throw error; }
310
+ await rm(temporary, { force: true });
311
+ }
312
+ } finally {
313
+ await rm(temporary, { force: true });
314
+ }
315
+ }
316
+
317
+ async function writeJsonAtomically(filePath, value) {
318
+ await mkdir(path.dirname(filePath), { recursive: true });
319
+ const temporary = `${filePath}.tmp-${process.pid}-${randomBytes(5).toString('hex')}`;
320
+ try {
321
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: 0o644 });
322
+ await rename(temporary, filePath);
323
+ } finally {
324
+ await rm(temporary, { force: true });
325
+ }
326
+ }
327
+
328
+ async function releaseApp(input, options = {}) {
329
+ const catalogPath = path.resolve(options.catalogPath || path.join(process.cwd(), 'storebase-app-list.json'));
330
+ const catalogDirectory = path.dirname(catalogPath);
331
+ const assetsDirectory = path.resolve(options.assetsDir || path.join(catalogDirectory, 'storebase-app-assets'));
332
+ const certificate = normalizeFingerprint(options.signingCertificateSha256);
333
+ if (certificate.length !== 64) fail('A verified 64-character SHA-256 signing certificate is required before release.');
334
+
335
+ let current = { schemaVersion: 1, apps: [] };
336
+ try {
337
+ current = JSON.parse(await readFile(catalogPath, 'utf8'));
338
+ } catch (error) {
339
+ if (error.code !== 'ENOENT') fail(`Cannot read app-list catalog ${catalogPath}: ${error.message}`);
340
+ }
341
+ if (!current || current.schemaVersion !== 1 || !Array.isArray(current.apps)) {
342
+ fail(`Catalog ${catalogPath} must be JSON with schemaVersion: 1 and an apps array.`);
343
+ }
344
+ const conflicting = current.apps.find((app) => app.id === input.id && app.name !== input.name);
345
+ if (conflicting) fail(`App id "${input.id}" is already used by "${conflicting.name}".`);
346
+
347
+ const apkHash = await sha256File(input.apkPath);
348
+ const avatarHash = await sha256File(input.avatarPath);
349
+ const versionDirectory = path.join(assetsDirectory, input.id, input.version);
350
+ const apkDestination = path.join(versionDirectory, `app-${apkHash}.apk`);
351
+ const avatarDestination = path.join(versionDirectory, `avatar-${avatarHash}${input.avatarExtension}`);
352
+
353
+ // Content-addressed paths ensure that a failed catalog write never corrupts the previous listing.
354
+ await copyIfMissing(input.apkPath, apkDestination);
355
+ await copyIfMissing(input.avatarPath, avatarDestination);
356
+
357
+ const appRecord = {
358
+ id: input.id,
359
+ name: input.name,
360
+ avatar: toCatalogPath(catalogDirectory, avatarDestination),
361
+ version: input.version,
362
+ apk: toCatalogPath(catalogDirectory, apkDestination),
363
+ category: input.category,
364
+ description: input.description,
365
+ signingCertificateSha256: certificate,
366
+ publishedAt: options.publishedAt || new Date().toISOString(),
367
+ };
368
+ const apps = current.apps.filter((app) => app.id !== input.id);
369
+ apps.push(appRecord);
370
+ apps.sort((a, b) => a.name.localeCompare(b.name));
371
+ const nextCatalog = { ...current, schemaVersion: 1, updatedAt: appRecord.publishedAt, apps };
372
+ await writeJsonAtomically(catalogPath, nextCatalog);
373
+
374
+ return {
375
+ app: appRecord,
376
+ catalogPath,
377
+ assetsDirectory,
378
+ catalog: nextCatalog,
379
+ };
380
+ }
381
+
382
+ module.exports = {
383
+ CATEGORIES,
384
+ findApksigner,
385
+ normalizeFingerprint,
386
+ parseApkSignerFingerprints,
387
+ parseKeystoreEntries,
388
+ releaseApp,
389
+ slugify,
390
+ validateInput,
391
+ verifyApkSignature,
392
+ inspectKeystore,
393
+ };
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ function fail(message) {
4
+ throw new Error(message);
5
+ }
6
+
7
+ function readTelegramConfig(env = process.env, options = {}) {
8
+ const apiIdText = env.STOREBASE_TELEGRAM_API_ID;
9
+ const apiHash = env.STOREBASE_TELEGRAM_API_HASH;
10
+ const botToken = env.STOREBASE_TELEGRAM_BOT_TOKEN;
11
+ const session = env.STOREBASE_TELEGRAM_SESSION || '';
12
+ const readSession = env.STOREBASE_TELEGRAM_READ_SESSION || '';
13
+ const channelValue = env.STOREBASE_TELEGRAM_CHANNEL;
14
+ const apiId = Number(apiIdText);
15
+
16
+ if (!apiIdText || !Number.isSafeInteger(apiId) || apiId <= 0) {
17
+ fail('Set STOREBASE_TELEGRAM_API_ID to the Telegram application API ID.');
18
+ }
19
+ if (typeof apiHash !== 'string' || !apiHash.trim()) {
20
+ fail('Set STOREBASE_TELEGRAM_API_HASH to the Telegram application API hash.');
21
+ }
22
+ if (options.requireChannel !== false && (!channelValue || !String(channelValue).trim())) {
23
+ fail('Set STOREBASE_TELEGRAM_CHANNEL to the target channel username (for example @StoreBaseApps) or channel ID.');
24
+ }
25
+ if (!session && !botToken) {
26
+ fail('Set STOREBASE_TELEGRAM_BOT_TOKEN for initial MTProto bot authorization, or provide an authorized STOREBASE_TELEGRAM_SESSION.');
27
+ }
28
+ if (options.requireReader !== false && !readSession) {
29
+ fail('Set STOREBASE_TELEGRAM_READ_SESSION to an authorized personal MTProto StringSession with read access to the channel. Telegram bots cannot read channel history.');
30
+ }
31
+
32
+ return {
33
+ apiId,
34
+ apiHash: apiHash.trim(),
35
+ botToken: botToken?.trim(),
36
+ session,
37
+ readSession,
38
+ channel: channelValue ? String(channelValue).trim() : undefined,
39
+ };
40
+ }
41
+
42
+ function createClient(StringSession, TelegramClient, session, config) {
43
+ return new TelegramClient(new StringSession(session), config.apiId, config.apiHash, {
44
+ connectionRetries: 5,
45
+ autoReconnect: true,
46
+ });
47
+ }
48
+
49
+ function requireBroadcastChannel(entity, label) {
50
+ if (!entity || entity.className !== 'Channel' || entity.broadcast !== true) {
51
+ fail(`${label} must resolve to a Telegram broadcast channel.`);
52
+ }
53
+ }
54
+
55
+ async function disconnectTelegram(connection) {
56
+ if (!connection) return;
57
+ const clients = [...new Set([connection.client, connection.readerClient].filter(Boolean))];
58
+ await Promise.allSettled(clients.map((client) => client.disconnect()));
59
+ }
60
+
61
+ async function disconnectTelegramReader(connection) {
62
+ if (!connection?.client) return;
63
+ await Promise.allSettled([connection.client.disconnect()]);
64
+ }
65
+
66
+ function readTelegramReaderConfig(env = process.env) {
67
+ const apiIdText = env.STOREBASE_TELEGRAM_API_ID;
68
+ const apiHash = env.STOREBASE_TELEGRAM_API_HASH;
69
+ const readSession = env.STOREBASE_TELEGRAM_READ_SESSION;
70
+ const channelValue = env.STOREBASE_TELEGRAM_CHANNEL;
71
+ const apiId = Number(apiIdText);
72
+ if (!apiIdText || !Number.isSafeInteger(apiId) || apiId <= 0) {
73
+ fail('Set STOREBASE_TELEGRAM_API_ID to the Telegram application API ID.');
74
+ }
75
+ if (typeof apiHash !== 'string' || !apiHash.trim()) {
76
+ fail('Set STOREBASE_TELEGRAM_API_HASH to the Telegram application API hash.');
77
+ }
78
+ if (typeof readSession !== 'string' || !readSession.trim()) {
79
+ fail('Set STOREBASE_TELEGRAM_READ_SESSION to an authorized personal MTProto StringSession with read access to the channel.');
80
+ }
81
+ if (!channelValue || !String(channelValue).trim()) {
82
+ fail('Set STOREBASE_TELEGRAM_CHANNEL to the target channel username or channel ID.');
83
+ }
84
+ return { apiId, apiHash: apiHash.trim(), readSession: readSession.trim(), channel: String(channelValue).trim() };
85
+ }
86
+
87
+ async function connectTelegramReader(env = process.env) {
88
+ const config = readTelegramReaderConfig(env);
89
+ const { TelegramClient } = require('teleproto');
90
+ const { StringSession } = require('teleproto/sessions');
91
+ const client = createClient(StringSession, TelegramClient, config.readSession, config);
92
+ try {
93
+ await client.connect();
94
+ if (!await client.checkAuthorization()) {
95
+ fail('STOREBASE_TELEGRAM_READ_SESSION is not authorized. Supply a valid user StringSession with access to the StoreBase channel.');
96
+ }
97
+ if (typeof client.isBot === 'function' && await client.isBot()) {
98
+ fail('STOREBASE_TELEGRAM_READ_SESSION must be a personal user session. Bot sessions cannot read channel history.');
99
+ }
100
+ const channelEntity = await client.getEntity(config.channel);
101
+ requireBroadcastChannel(channelEntity, 'STOREBASE_TELEGRAM_CHANNEL as seen by the reader session');
102
+ await client.getMessages(channelEntity, { limit: 1 });
103
+ return { client, channelEntity, channel: config.channel };
104
+ } catch (error) {
105
+ await disconnectTelegramReader({ client });
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ async function connectTelegram(env = process.env, options = {}) {
111
+ const config = readTelegramConfig(env, options);
112
+ const { TelegramClient } = require('teleproto');
113
+ const { StringSession } = require('teleproto/sessions');
114
+ const client = createClient(StringSession, TelegramClient, config.session, config);
115
+ let readerClient;
116
+
117
+ try {
118
+ if (config.session) {
119
+ await client.connect();
120
+ const authorized = await client.checkAuthorization();
121
+ if (!authorized) {
122
+ if (!config.botToken) fail('The saved Telegram bot session is not authorized. Set STOREBASE_TELEGRAM_BOT_TOKEN to authenticate it again.');
123
+ await client.start({ botAuthToken: config.botToken });
124
+ }
125
+ } else {
126
+ await client.start({ botAuthToken: config.botToken });
127
+ }
128
+
129
+ if (typeof client.isBot === 'function' && !await client.isBot()) {
130
+ fail('STOREBASE_TELEGRAM_SESSION must belong to the StoreBase admin bot; use STOREBASE_TELEGRAM_READ_SESSION for a personal user session.');
131
+ }
132
+ const channelEntity = config.channel ? await client.getEntity(config.channel) : undefined;
133
+ if (config.channel) requireBroadcastChannel(channelEntity, 'STOREBASE_TELEGRAM_CHANNEL');
134
+
135
+ let readerChannel;
136
+ if (options.requireReader !== false) {
137
+ readerClient = createClient(StringSession, TelegramClient, config.readSession, config);
138
+ await readerClient.connect();
139
+ if (!await readerClient.checkAuthorization()) {
140
+ fail('STOREBASE_TELEGRAM_READ_SESSION is not authorized. Supply a valid user StringSession with access to the StoreBase channel.');
141
+ }
142
+ if (typeof readerClient.isBot === 'function' && await readerClient.isBot()) {
143
+ fail('STOREBASE_TELEGRAM_READ_SESSION must be a personal user session. Bot sessions cannot read channel history.');
144
+ }
145
+ readerChannel = await readerClient.getEntity(config.channel);
146
+ requireBroadcastChannel(readerChannel, 'STOREBASE_TELEGRAM_CHANNEL as seen by the reader session');
147
+ if (String(readerChannel.id) !== String(channelEntity.id)) {
148
+ fail('The bot and reader sessions resolved STOREBASE_TELEGRAM_CHANNEL to different channels.');
149
+ }
150
+ // Bot accounts are barred from reading channel history; a personal MTProto session
151
+ // performs this read-only check and handles catalog listing and artifact retrieval.
152
+ await readerClient.getMessages(readerChannel, { limit: 1 });
153
+ }
154
+
155
+ return {
156
+ client,
157
+ readerClient,
158
+ channelEntity,
159
+ readerChannel,
160
+ channel: config.channel,
161
+ session: client.session.save(),
162
+ readSession: config.readSession,
163
+ hasStoredSession: Boolean(config.session),
164
+ };
165
+ } catch (error) {
166
+ await disconnectTelegram({ client, readerClient });
167
+ throw error;
168
+ }
169
+ }
170
+
171
+ module.exports = {
172
+ connectTelegram,
173
+ connectTelegramReader,
174
+ disconnectTelegram,
175
+ disconnectTelegramReader,
176
+ readTelegramConfig,
177
+ readTelegramReaderConfig,
178
+ };