@uniquedivine/tg-sai 0.0.4 → 0.0.5

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,380 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { mkdir, open, readFile, rename, rm } from "node:fs/promises"
3
+ import { dirname, isAbsolute, join, resolve } from "node:path"
4
+ import type { ChatMember } from "@mtcute/bun"
5
+ import {
6
+ addChatMembers,
7
+ createInviteLink,
8
+ createSupergroup,
9
+ getChatMember,
10
+ getPrimaryInviteLink,
11
+ iterDialogs,
12
+ } from "@mtcute/bun/methods.js"
13
+ import { saiBot } from "./cfg"
14
+ import { createMtcuteClient, promoteTargetAdmin } from "./mtcuteAdapter"
15
+ import { configDir, strictProfileCredentials } from "./profiles"
16
+ import { PROVISIONING_BOT_ID } from "./provisioning"
17
+ import type {
18
+ JournalRecord,
19
+ Member,
20
+ ProvisionJournal,
21
+ ProvisionRequest,
22
+ ProvisioningLinks,
23
+ TelegramProvisioner,
24
+ } from "./provisioning"
25
+
26
+ interface PrimaryInvite {
27
+ link: string
28
+ isPrimary: boolean
29
+ isRevoked: boolean
30
+ isMyLink: boolean
31
+ endDate: Date | null
32
+ }
33
+ const COMPLETION_LINK_TIMEOUT_MS = 10_000
34
+
35
+ /** Fetch completion links without creating invitations or sending messages. */
36
+ export async function readProvisioningLinks(
37
+ chatId: number,
38
+ reader: {
39
+ history(
40
+ signal: AbortSignal,
41
+ ): Promise<readonly { id: number; chat: { id: number } }[]>
42
+ primaryInvite(signal: AbortSignal): Promise<PrimaryInvite>
43
+ },
44
+ timeoutMs = COMPLETION_LINK_TIMEOUT_MS,
45
+ ): Promise<ProvisioningLinks> {
46
+ // Bot API supergroup IDs encode the channel ID as -(10^12 + channel ID).
47
+ const channelId = -chatId - 1_000_000_000_000
48
+ if (!Number.isSafeInteger(chatId) || channelId <= 0) return {}
49
+ const bounded = async <T>(
50
+ read: (signal: AbortSignal) => Promise<T>,
51
+ ): Promise<T> => {
52
+ let timer: ReturnType<typeof setTimeout> | undefined
53
+ const controller = new AbortController()
54
+ try {
55
+ return await Promise.race([
56
+ Promise.resolve().then(() => read(controller.signal)),
57
+ new Promise<never>((_, reject) => {
58
+ timer = setTimeout(() => {
59
+ controller.abort()
60
+ reject(new Error("Completion link read timed out"))
61
+ }, timeoutMs)
62
+ }),
63
+ ])
64
+ } finally {
65
+ clearTimeout(timer)
66
+ }
67
+ }
68
+ const [history, invite] = await Promise.allSettled([
69
+ bounded(reader.history),
70
+ bounded(reader.primaryInvite),
71
+ ])
72
+ const result: ProvisioningLinks = {}
73
+ if (history.status === "fulfilled") {
74
+ const message = history.value.find(
75
+ (value) =>
76
+ value.chat.id === chatId &&
77
+ Number.isSafeInteger(value.id) &&
78
+ value.id > 0 &&
79
+ value.id <= 2_147_483_647,
80
+ )
81
+ if (message) result.openGroupUrl = `https://t.me/c/${channelId}/${message.id}`
82
+ }
83
+ if (invite.status === "fulfilled") {
84
+ const link = invite.value
85
+ if (
86
+ link.isPrimary &&
87
+ link.isMyLink &&
88
+ !link.isRevoked &&
89
+ (!link.endDate || link.endDate.getTime() > Date.now()) &&
90
+ /^https:\/\/t\.me\/(?:\+|joinchat\/)[A-Za-z0-9_-]{5,128}$/.test(link.link)
91
+ )
92
+ result.primaryInviteLink = link.link
93
+ }
94
+ return result
95
+ }
96
+
97
+ // Fresh custody sessions do not have access hashes for numeric user IDs yet.
98
+ // Handles discover the peer, but only the pinned numeric ID establishes identity.
99
+ const DEFAULT_MEMBER_HANDLES = new Map<number, string>([
100
+ [1912700300, "uniquedivine"],
101
+ [7930936303, "corinnebernett"],
102
+ [5965882418, "lilnaptime"],
103
+ [472528179, "erickpinos"],
104
+ ])
105
+
106
+ export async function resolveProvisioningPeer(
107
+ getUser: (peer: string | number) => Promise<{ id: number; isBot: boolean }>,
108
+ peer: string | number,
109
+ ): Promise<{ id: number; isBot: boolean }> {
110
+ const normalized = typeof peer === "string" ? peer.replace(/^@/, "") : peer
111
+ const numeric =
112
+ typeof normalized === "number"
113
+ ? normalized
114
+ : /^[0-9]+$/.test(normalized)
115
+ ? Number(normalized)
116
+ : undefined
117
+ const expectedId =
118
+ numeric ??
119
+ [...DEFAULT_MEMBER_HANDLES].find(
120
+ ([, handle]) => handle.toLowerCase() === String(normalized).toLowerCase(),
121
+ )?.[0]
122
+ const lookup =
123
+ expectedId === undefined
124
+ ? normalized
125
+ : (DEFAULT_MEMBER_HANDLES.get(expectedId) ?? expectedId)
126
+ const user = await getUser(lookup)
127
+ if (expectedId !== undefined && user.id !== expectedId)
128
+ throw new Error("Provisioning participant identity mismatch")
129
+ if (
130
+ expectedId !== undefined &&
131
+ DEFAULT_MEMBER_HANDLES.has(expectedId) &&
132
+ user.isBot
133
+ )
134
+ throw new Error("Default provisioning participant must be a user account")
135
+ return { id: user.id, isBot: user.isBot }
136
+ }
137
+
138
+ export function normalizeProvisioningMember(
139
+ value: Pick<ChatMember, "status" | "isMember" | "permissions"> | null,
140
+ ): Member {
141
+ const role =
142
+ value?.status === "creator"
143
+ ? "creator"
144
+ : value?.status === "admin"
145
+ ? "admin"
146
+ : value?.isMember
147
+ ? "member"
148
+ : "absent"
149
+ return {
150
+ role,
151
+ rights: Object.fromEntries(
152
+ Object.entries(value?.permissions ?? {}).filter(
153
+ (entry): entry is [string, boolean] => typeof entry[1] === "boolean",
154
+ ),
155
+ ),
156
+ }
157
+ }
158
+
159
+ /** Separate files and a process lock prevent account/session crossover. */
160
+ export function accountPaths(
161
+ root: string,
162
+ userId: number,
163
+ ): { storage: string; lock: string } {
164
+ if (!Number.isSafeInteger(userId) || userId <= 0)
165
+ throw new Error("Invalid custody user ID")
166
+ const base = resolve(root, "provisioning", "accounts", String(userId))
167
+ return {
168
+ storage: join(base, "mtcute.session"),
169
+ lock: join(base, "worker.lock"),
170
+ }
171
+ }
172
+ export async function withAccountLock<T>(
173
+ lockPath: string,
174
+ operation: () => Promise<T>,
175
+ ): Promise<T> {
176
+ if (!isAbsolute(lockPath))
177
+ throw new Error("Absolute account lock path required")
178
+ await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 })
179
+ // The kernel releases flock when this process dies. Persistent lock metadata
180
+ // never authorizes deleting a live executor's lock or retrying group creation.
181
+ if (!Bun.which("flock"))
182
+ throw new Error("Custody worker requires the Linux flock utility")
183
+ const guard = Bun.spawn(
184
+ [
185
+ "flock",
186
+ "--exclusive",
187
+ "--nonblock",
188
+ lockPath,
189
+ "sh",
190
+ "-c",
191
+ "printf 'locked\\n'; cat >/dev/null",
192
+ ],
193
+ {
194
+ stdin: "pipe",
195
+ stdout: "pipe",
196
+ stderr: "pipe",
197
+ },
198
+ )
199
+ const reader = guard.stdout.getReader()
200
+ const first = await reader.read()
201
+ reader.releaseLock()
202
+ if (first.done || new TextDecoder().decode(first.value).trim() !== "locked") {
203
+ guard.stdin.end()
204
+ await guard.exited
205
+ throw new Error("Custody account is locked; another executor is active")
206
+ }
207
+ try {
208
+ return await operation()
209
+ } finally {
210
+ guard.stdin.end()
211
+ await guard.exited
212
+ }
213
+ }
214
+
215
+ export class FileProvisionJournal implements ProvisionJournal {
216
+ constructor(private root = configDir()) {}
217
+ private path(requestId: string): string {
218
+ return join(
219
+ resolve(this.root),
220
+ "provisioning",
221
+ "receipts",
222
+ `${createHash("sha256").update(requestId).digest("hex")}.json`,
223
+ )
224
+ }
225
+ async read(requestId: string): Promise<JournalRecord | null> {
226
+ try {
227
+ return JSON.parse(
228
+ await readFile(this.path(requestId), "utf8"),
229
+ ) as JournalRecord
230
+ } catch (error) {
231
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null
232
+ throw new Error("Cannot read provisioning recovery receipt")
233
+ }
234
+ }
235
+ async write(requestId: string, record: JournalRecord): Promise<void> {
236
+ const path = this.path(requestId)
237
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
238
+ const tmp = `${path}.${randomUUID()}.tmp`
239
+ const file = await open(tmp, "wx", 0o600)
240
+ try {
241
+ await file.writeFile(JSON.stringify(record))
242
+ await file.sync()
243
+ } finally {
244
+ await file.close()
245
+ }
246
+ await rename(tmp, path)
247
+ const directory = await open(dirname(path), "r")
248
+ try {
249
+ await directory.sync()
250
+ } finally {
251
+ await directory.close()
252
+ }
253
+ }
254
+ }
255
+
256
+ export async function withProvisionTelegram<T>(
257
+ request: ProvisionRequest,
258
+ operation: (telegram: TelegramProvisioner) => Promise<T>,
259
+ ): Promise<T> {
260
+ const credentials = await strictProfileCredentials(
261
+ request.custodyProfile,
262
+ request.expectedUserId,
263
+ )
264
+ const paths = accountPaths(configDir(), request.expectedUserId)
265
+ return withAccountLock(paths.lock, async () => {
266
+ const client = await createMtcuteClient({
267
+ apiId: credentials.apiId,
268
+ apiHash: credentials.apiHash,
269
+ session: credentials.sessionString,
270
+ storage: paths.storage,
271
+ })
272
+ try {
273
+ if ((await client.getMe()).id !== request.expectedUserId)
274
+ throw new Error("Custody account identity mismatch")
275
+ const bot = await client.getUser(saiBot.handle)
276
+ if (bot.id !== PROVISIONING_BOT_ID || !bot.isBot)
277
+ throw new Error("Configured Sai bot identity mismatch")
278
+ const member = async (
279
+ chatId: number,
280
+ userId: number,
281
+ ): Promise<Member> => {
282
+ return normalizeProvisioningMember(
283
+ await getChatMember(client, { chatId, userId }),
284
+ )
285
+ }
286
+ const verifyMarker = async (
287
+ chatId: number,
288
+ marker: string,
289
+ ): Promise<boolean> => {
290
+ const chat = await client.getFullChat(chatId)
291
+ return (
292
+ chat.chatType === "supergroup" &&
293
+ !chat.isForum &&
294
+ chat.isCreator &&
295
+ chat.bio === marker
296
+ )
297
+ }
298
+ const linkClient = (abortSignal: AbortSignal) => client.withParams({
299
+ abortSignal,
300
+ timeout: COMPLETION_LINK_TIMEOUT_MS,
301
+ floodSleepThreshold: 0,
302
+ maxRetryCount: 0,
303
+ })
304
+ const adapter: TelegramProvisioner = {
305
+ self: async () => (await client.getMe()).id,
306
+ resolve: (peer) =>
307
+ resolveProvisioningPeer((value) => client.getUser(value), peer),
308
+ create: async (title, marker) =>
309
+ (
310
+ await createSupergroup(client, {
311
+ title,
312
+ description: marker,
313
+ forum: false,
314
+ })
315
+ ).id,
316
+ member,
317
+ add: async (chatId, userId) => {
318
+ const missing = await addChatMembers(client, chatId, userId, {
319
+ forwardCount: 0,
320
+ })
321
+ if (missing.length)
322
+ throw Object.assign(new Error("Participant privacy restriction"), {
323
+ text: "USER_PRIVACY_RESTRICTED",
324
+ })
325
+ },
326
+ inviteLink: async (chatId) => {
327
+ const expires = new Date(Date.now() + 24 * 60 * 60 * 1000)
328
+ const link = await createInviteLink(client, chatId, {
329
+ expires,
330
+ withApproval: true,
331
+ })
332
+ if (
333
+ !link.isMyLink ||
334
+ link.isRevoked ||
335
+ !link.approvalNeeded ||
336
+ !link.endDate
337
+ ) {
338
+ throw new Error(
339
+ "Controlled invitation properties were not confirmed",
340
+ )
341
+ }
342
+ return {
343
+ inviteLink: link.link,
344
+ expiresAt: link.endDate.toISOString(),
345
+ }
346
+ },
347
+ completionLinks: (chatId) =>
348
+ readProvisioningLinks(chatId, {
349
+ history: (abortSignal) =>
350
+ linkClient(abortSignal).getHistory(chatId, { limit: 1 }),
351
+ primaryInvite: (abortSignal) =>
352
+ getPrimaryInviteLink(linkClient(abortSignal), chatId),
353
+ }),
354
+ promote: async (chatId, userId, rights) =>
355
+ promoteTargetAdmin(client, { chatId, targetPeer: userId, rights }),
356
+ verifyMarker,
357
+ candidates: async (marker) => {
358
+ const candidates: number[] = []
359
+ for await (const dialog of iterDialogs(client, {
360
+ limit: 200,
361
+ archived: "keep",
362
+ })) {
363
+ const chat = dialog.peer
364
+ if (
365
+ chat.type !== "chat" ||
366
+ chat.chatType !== "supergroup" ||
367
+ !chat.isCreator
368
+ )
369
+ continue
370
+ if (await verifyMarker(chat.id, marker)) candidates.push(chat.id)
371
+ }
372
+ return candidates
373
+ },
374
+ }
375
+ return await operation(adapter)
376
+ } finally {
377
+ await client.disconnect()
378
+ }
379
+ })
380
+ }
@@ -0,0 +1,4 @@
1
+ declare module "*.wasm" {
2
+ const path: string
3
+ export default path
4
+ }
@@ -0,0 +1,125 @@
1
+ import { createHash } from "node:crypto"
2
+ import { gzipSync, inflateSync } from "node:zlib"
3
+ import { createTelegramCryptoProvider, verifyRuntimeAssets } from "./telegramRuntime"
4
+ import { createMtcuteClient, createMtcuteRuntimeClient } from "./mtcuteAdapter"
5
+ import { mkdtemp, rm } from "node:fs/promises"
6
+ import { tmpdir } from "node:os"
7
+ import { join } from "node:path"
8
+ import { configDir, strictProfileCredentials } from "./profiles"
9
+ import { accountPaths, withAccountLock } from "./provisioningRuntime"
10
+
11
+ export const HEALTH_CUSTODY_USER_ID = 8274304175
12
+ export interface RuntimeHealthCheck {
13
+ name: string
14
+ ok: boolean
15
+ reason?: string
16
+ }
17
+ export interface RuntimeHealthResult {
18
+ ok: boolean
19
+ mode: "offline" | "online"
20
+ checks: RuntimeHealthCheck[]
21
+ userId?: number
22
+ }
23
+ export interface RuntimeHealthOptions {
24
+ online?: boolean
25
+ custodyProfile?: string
26
+ timeoutMs?: number
27
+ }
28
+
29
+ async function bounded<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
30
+ let timer: ReturnType<typeof setTimeout> | undefined
31
+ try {
32
+ return await Promise.race([
33
+ promise,
34
+ new Promise<never>((_, reject) => {
35
+ timer = setTimeout(() => reject(new Error("Health operation timed out")), timeoutMs)
36
+ }),
37
+ ])
38
+ } finally {
39
+ clearTimeout(timer)
40
+ }
41
+ }
42
+
43
+ /** Initialize the same provider passed to TelegramClient, without credentials or I/O to Telegram. */
44
+ export async function checkOfflineTelegramRuntime(): Promise<RuntimeHealthResult> {
45
+ const checks: RuntimeHealthCheck[] = []
46
+ try {
47
+ await verifyRuntimeAssets()
48
+ const crypto = createTelegramCryptoProvider()
49
+ await crypto.initialize()
50
+ checks.push({ name: "telegram_runtime_assets", ok: true })
51
+ const key = new Uint8Array(32).fill(7)
52
+ const iv = new Uint8Array(32).fill(3)
53
+ const input = Uint8Array.from({ length: 32 }, (_, i) => i)
54
+ const encrypted = crypto.createAesIge(key, iv).encrypt(input)
55
+ const decrypted = crypto.createAesIge(key, iv).decrypt(encrypted)
56
+ if (!Buffer.from(decrypted).equals(Buffer.from(input)) ||
57
+ Buffer.from(encrypted).equals(Buffer.from(input)))
58
+ throw new Error("Telegram crypto roundtrip failed")
59
+ const sample = new TextEncoder().encode("tg-sai compiled runtime readiness")
60
+ if (Buffer.from(crypto.sha256(sample)).toString("hex") !==
61
+ createHash("sha256").update(sample).digest("hex"))
62
+ throw new Error("Telegram hash check failed")
63
+ checks.push({ name: "telegram_crypto", ok: true })
64
+ const compressed = crypto.gzip(sample, 4096)
65
+ if (!compressed || !Buffer.from(inflateSync(compressed)).equals(Buffer.from(sample)) ||
66
+ !Buffer.from(crypto.gunzip(gzipSync(sample))).equals(Buffer.from(sample)))
67
+ throw new Error("Telegram compression check failed")
68
+ checks.push({ name: "telegram_compression", ok: true })
69
+ const directory = await mkdtemp(join(tmpdir(), "tg-sai-runtime-health-"))
70
+ const client = createMtcuteRuntimeClient({ apiId: 1, apiHash: "0".repeat(32),
71
+ storage: join(directory, "session") })
72
+ try {
73
+ // prepare initializes the real client storage and crypto, without connect/getMe.
74
+ await client.prepare()
75
+ checks.push({ name: "telegram_client_prepare", ok: true })
76
+ } finally {
77
+ try { await client.destroy() } finally { await rm(directory, { recursive: true, force: true }) }
78
+ }
79
+ return { ok: true, mode: "offline", checks }
80
+ } catch {
81
+ // Dependency errors can contain paths and imported source. Keep readiness output bounded.
82
+ checks.push({ name: "telegram_runtime", ok: false, reason: "telegram_runtime_initialization_failed" })
83
+ return { ok: false, mode: "offline", checks }
84
+ }
85
+ }
86
+
87
+ /** Explicit online mode checks only the pinned custody identity under the worker's account lock. */
88
+ export async function checkTelegramRuntimeHealth(
89
+ options: RuntimeHealthOptions = {},
90
+ ): Promise<RuntimeHealthResult> {
91
+ const offline = await checkOfflineTelegramRuntime()
92
+ if (!offline.ok || !options.online) return offline
93
+ const result: RuntimeHealthResult = { ...offline, mode: "online" }
94
+ if (!options.custodyProfile?.trim()) {
95
+ result.ok = false
96
+ result.checks.push({ name: "custody_identity", ok: false, reason: "explicit_custody_profile_required" })
97
+ return result
98
+ }
99
+ const timeoutMs = options.timeoutMs ?? 20000
100
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 60000)
101
+ throw new Error("Health timeout must be 1000 through 60000ms")
102
+ try {
103
+ const credentials = await strictProfileCredentials(options.custodyProfile, HEALTH_CUSTODY_USER_ID)
104
+ const paths = accountPaths(configDir(), HEALTH_CUSTODY_USER_ID)
105
+ await withAccountLock(paths.lock, async () => {
106
+ const client = await createMtcuteClient({ apiId: credentials.apiId,
107
+ apiHash: credentials.apiHash, session: credentials.sessionString, storage: paths.storage })
108
+ try {
109
+ const me = await bounded(client.getMe(), timeoutMs)
110
+ if (me.id !== HEALTH_CUSTODY_USER_ID || me.isBot)
111
+ throw new Error("Custody identity mismatch")
112
+ result.userId = me.id
113
+ } finally {
114
+ await bounded(client.destroy(), 10000)
115
+ }
116
+ })
117
+ result.checks.push({ name: "custody_identity", ok: true })
118
+ return result
119
+ } catch {
120
+ result.ok = false
121
+ result.checks.push({ name: "custody_identity", ok: false,
122
+ reason: "custody_identity_check_failed_or_account_locked" })
123
+ return result
124
+ }
125
+ }
@@ -0,0 +1,46 @@
1
+ import type { Command } from "commander"
2
+ import { checkTelegramRuntimeHealth } from "./runtimeHealth"
3
+
4
+ export function addRuntimeHealthCommands(program: Command, legacyLocal?: () => Promise<void>): void {
5
+ const health = program.command("health")
6
+ .description("Check Telegram runtime readiness; defaults to offline crypto/compression checks")
7
+ .option("--offline", "Initialize the actual Telegram runtime without credentials or network access")
8
+ .option("--online", "Also verify custody getMe under the account lock; no Telegram writes")
9
+ .option("--custody-profile <name>", "Explicit saved custody profile for user 8274304175")
10
+ .option("--timeout-ms <ms>", "Online identity timeout, 1000 through 60000", "20000")
11
+ .option("--json", "Print a machine-readable readiness result")
12
+ .addHelpText("after", `
13
+ Examples:
14
+ tg-sai health
15
+ tg-sai health --offline --json
16
+ tg-sai health --online --custody-profile SaiTeam_AI --json
17
+
18
+ Offline success proves runtime initialization, not Telegram authentication.
19
+ Online mode checks the custody account only and refuses personal-profile fallback.
20
+ `)
21
+ if (legacyLocal)
22
+ health.option("--local", "Run the earlier active-profile and workstation diagnostics, including getMe")
23
+ health.action(async (options: { offline?: boolean; online?: boolean;
24
+ custodyProfile?: string; timeoutMs: string; json?: boolean; local?: boolean }) => {
25
+ if (options.offline && options.online)
26
+ throw new Error("Choose --offline or --online")
27
+ if (options.local) {
28
+ if (options.offline || options.online || options.custodyProfile || options.json)
29
+ throw new Error("--local cannot be combined with runtime health options")
30
+ await legacyLocal!()
31
+ return
32
+ }
33
+ if (options.custodyProfile && !options.online)
34
+ throw new Error("--custody-profile requires explicit --online")
35
+ const result = await checkTelegramRuntimeHealth({ online: options.online,
36
+ custodyProfile: options.custodyProfile, timeoutMs: Number(options.timeoutMs) })
37
+ if (options.json) console.log(JSON.stringify(result))
38
+ else {
39
+ console.log(`mode: ${result.mode}`)
40
+ for (const check of result.checks)
41
+ console.log(`${check.name}: ${check.ok ? "ok" : check.reason ?? "failed"}`)
42
+ console.log(`ready: ${result.ok}`)
43
+ }
44
+ process.exitCode = result.ok ? 0 : 1
45
+ })
46
+ }
@@ -69,6 +69,11 @@ def _parse_args() -> argparse.Namespace:
69
69
  return parser.parse_args()
70
70
 
71
71
 
72
+ def _is_wrapper_mode(args: argparse.Namespace) -> bool:
73
+ """Return True when tg-sai is calling this helper internally."""
74
+ return bool(args.session_output and args.no_env_write)
75
+
76
+
72
77
  def _render_qr(qr) -> None:
73
78
  import qrcode
74
79
 
@@ -149,6 +154,7 @@ def _phone_login(client: TelegramClient) -> None:
149
154
 
150
155
  def main() -> None:
151
156
  args = _parse_args()
157
+ wrapper_mode = _is_wrapper_mode(args)
152
158
 
153
159
  api_id = os.getenv("TELEGRAM_API_ID")
154
160
  api_hash = os.getenv("TELEGRAM_API_HASH")
@@ -164,16 +170,20 @@ def main() -> None:
164
170
  print("Error: TELEGRAM_API_ID must be an integer")
165
171
  sys.exit(1)
166
172
 
167
- print("\n----- Telegram Session String Generator -----\n")
168
- print("This script will generate a session string for your Telegram account.")
169
- print("The generated session string can be added to your tg-sai profile.")
170
- print("\nYour credentials are only used for local Telegram authentication.\n")
173
+ if not wrapper_mode:
174
+ print("\n----- Telegram Session String Generator -----\n")
175
+ print("This script will generate a session string for your Telegram account.")
176
+ print("The generated session string can be added to your tg-sai profile.")
177
+ print("\nYour credentials are only used for local Telegram authentication.\n")
171
178
 
172
- label = (
173
- input("Account label (optional, e.g. 'work', 'personal'; leave empty for default): ")
174
- .strip()
175
- .lower()
176
- )
179
+ if wrapper_mode:
180
+ label = ""
181
+ else:
182
+ label = (
183
+ input("Account label (optional, e.g. 'work', 'personal'; leave empty for default): ")
184
+ .strip()
185
+ .lower()
186
+ )
177
187
 
178
188
  if args.qr:
179
189
  method = "1"
@@ -207,25 +217,27 @@ def main() -> None:
207
217
  else:
208
218
  env_var = "TELEGRAM_SESSION_STRING"
209
219
 
210
- print("\nAuthentication successful!")
211
- print("\n----- Your Session String -----")
212
- print(f"\n{session_string}\n")
213
- print("Save this in tg-sai with:")
214
- print(
215
- "just tg-sai profile add "
216
- "--name <name> "
217
- f"--api-id {api_id_int} "
218
- f"--api-hash {api_hash} "
219
- "--session-string '<session string>'"
220
- )
221
- print("\nOr add this to an .env file as:")
222
- print(f"{env_var}={session_string}")
223
- print("\nIMPORTANT: Keep this string private and never share it with anyone!")
224
-
225
220
  if args.session_output:
226
221
  with open(args.session_output, "w", encoding="utf-8") as file:
227
222
  file.write(f"{session_string}\n")
228
223
 
224
+ if wrapper_mode:
225
+ print("\nAuthentication successful. Session captured by tg-sai.")
226
+ else:
227
+ print("\nAuthentication successful!")
228
+ print("\n----- Your Session String -----")
229
+ print(f"\n{session_string}\n")
230
+ print("Save this in tg-sai with:")
231
+ print(
232
+ "tg-sai profile add "
233
+ f"--api-id {api_id_int} "
234
+ f"--api-hash {api_hash} "
235
+ "--session-string '<session string>'"
236
+ )
237
+ print("\nOr add this to an .env file as:")
238
+ print(f"{env_var}={session_string}")
239
+ print("\nIMPORTANT: Keep this string private and never share it with anyone!")
240
+
229
241
  if args.no_env_write:
230
242
  choice = "n"
231
243
  else:
@@ -0,0 +1,25 @@
1
+ import { BunCryptoProvider } from "@mtcute/bun"
2
+ import { initSync, SIMD_AVAILABLE } from "@mtcute/wasm"
3
+ import wasmPath from "@mtcute/wasm/mtcute.wasm" with { type: "file" }
4
+ import simdWasmPath from "@mtcute/wasm/mtcute-simd.wasm" with { type: "file" }
5
+
6
+ /** Static file imports make Bun embed both lazily loaded dependency assets. */
7
+ export class EmbeddedTelegramCryptoProvider extends BunCryptoProvider {
8
+ override async initialize(): Promise<void> {
9
+ const bytes = await Bun.file(SIMD_AVAILABLE ? simdWasmPath : wasmPath).arrayBuffer()
10
+ initSync(bytes)
11
+ }
12
+ }
13
+
14
+ /** The client and readiness check must use this same concrete provider. */
15
+ export function createTelegramCryptoProvider(): EmbeddedTelegramCryptoProvider {
16
+ return new EmbeddedTelegramCryptoProvider()
17
+ }
18
+
19
+ export async function verifyRuntimeAssets(): Promise<void> {
20
+ // Read both files so missing fallback assets fail even on a SIMD-capable host.
21
+ for (const path of [wasmPath, simdWasmPath]) {
22
+ const bytes = await Bun.file(path).arrayBuffer()
23
+ if (!bytes.byteLength) throw new Error("Empty Telegram runtime asset")
24
+ }
25
+ }