@uniquedivine/tg-sai 0.0.3 → 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.
- package/README.md +24 -0
- package/RUNBOOK.md +137 -16
- package/actionExecutor.ts +55 -55
- package/adminAudit.ts +60 -60
- package/captureProvisioningGolden.ts +98 -0
- package/cfg.ts +12 -1
- package/custodyWorker.ts +259 -0
- package/custodyWorkerCli.ts +52 -0
- package/health.ts +32 -51
- package/main.ts +460 -106
- package/mtcuteAdapter.ts +14 -6
- package/ops.ts +12 -0
- package/package.json +5 -4
- package/profiles.ts +134 -27
- package/provisioning.ts +674 -0
- package/provisioningCli.ts +140 -0
- package/provisioningCloud.ts +214 -0
- package/provisioningGoldens.ts +70 -0
- package/provisioningRuntime.ts +380 -0
- package/runtimeAssets.d.ts +4 -0
- package/runtimeHealth.ts +125 -0
- package/runtimeHealthCli.ts +46 -0
- package/session_string_generator.py +36 -24
- package/telegramRuntime.ts +25 -0
|
@@ -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
|
+
}
|
package/runtimeHealth.ts
ADDED
|
@@ -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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
+
}
|