@uniquedivine/tg-sai 0.0.4 → 0.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,805 @@
1
+ import { createHash } from "node:crypto"
2
+
3
+ import { holdUntil, PEER_FLOOD_WAIT_S } from "./state/holds"
4
+
5
+ export const PROVISIONING_BOT_ID = 8983431101
6
+ export type Peer = number | string
7
+ export interface ProvisionRequest {
8
+ requestId: string
9
+ custodyProfile: string
10
+ expectedUserId: number
11
+ title: string
12
+ participants: Peer[]
13
+ optionalParticipants?: Peer[]
14
+ admins: { userId: Peer; rightsProfile: "team" }[]
15
+ botRightsProfile: "baseline"
16
+ dealIds: string[]
17
+ }
18
+ export type ProvisionState =
19
+ | "prepared"
20
+ | "creating"
21
+ | "creation_unknown"
22
+ | "configuring"
23
+ | "incomplete"
24
+ | "ready"
25
+ | "cancelled"
26
+ export interface Binding {
27
+ participants: number[]
28
+ admins: number[]
29
+ optionalParticipants?: number[]
30
+ }
31
+ export interface Setup extends ProvisionRequest {
32
+ state: ProvisionState
33
+ chatId?: number
34
+ binding?: Binding
35
+ claimToken?: string
36
+ receipt?: unknown
37
+ retryAt?: string | null
38
+ }
39
+ export interface Member {
40
+ role: "creator" | "admin" | "member" | "absent"
41
+ rights: Record<string, boolean>
42
+ }
43
+ export interface ProvisioningLinks {
44
+ openGroupUrl?: string
45
+ primaryInviteLink?: string
46
+ }
47
+ export interface TelegramProvisioner {
48
+ self(): Promise<number>
49
+ resolve(peer: Peer): Promise<{ id: number; isBot: boolean }>
50
+ create(title: string, marker: string): Promise<number>
51
+ member(chatId: number, userId: number): Promise<Member>
52
+ add(chatId: number, userId: number): Promise<void>
53
+ /**
54
+ * Adds several users in one call. Resolves to the IDs Telegram reports as
55
+ * not invited (privacy); throws for errors that refuse the whole call.
56
+ */
57
+ addMany?(chatId: number, userIds: number[]): Promise<number[]>
58
+ inviteLink?(
59
+ chatId: number,
60
+ userId: number,
61
+ ): Promise<{ inviteLink: string; expiresAt: string }>
62
+ /** Read existing links only. Failure must not invalidate verified setup. */
63
+ completionLinks?(chatId: number): Promise<ProvisioningLinks>
64
+ promote(
65
+ chatId: number,
66
+ userId: number,
67
+ rights: Record<string, boolean>,
68
+ ): Promise<void>
69
+ candidates(marker: string): Promise<number[]>
70
+ verifyMarker(chatId: number, marker: string): Promise<boolean>
71
+ }
72
+ export interface Progress {
73
+ state: ProvisionState
74
+ chatId?: number
75
+ binding?: Binding
76
+ receipt?: unknown
77
+ error?: string
78
+ creationRejected?: boolean
79
+ retryAt?: string | null
80
+ }
81
+ export interface ProvisionCloud {
82
+ view(requestId: string): Promise<Setup>
83
+ claim(requestId: string, workerId: string): Promise<Setup>
84
+ progress(
85
+ requestId: string,
86
+ claimToken: string,
87
+ progress: Progress,
88
+ ): Promise<Setup>
89
+ resolve(requestId: string, chatId: number, reason: string): Promise<Setup>
90
+ }
91
+ export interface JournalRecord {
92
+ fingerprint: string
93
+ state: ProvisionState
94
+ chatId?: number
95
+ binding?: Binding
96
+ workerId?: string
97
+ pendingInvitations?: PendingInvitation[]
98
+ }
99
+ export interface PendingInvitation {
100
+ userId: string
101
+ inviteLink: string
102
+ expiresAt: string
103
+ reason: string
104
+ }
105
+ export interface ProvisionJournal {
106
+ read(requestId: string): Promise<JournalRecord | null>
107
+ write(requestId: string, record: JournalRecord): Promise<void>
108
+ }
109
+
110
+ const rightsOff = {
111
+ changeInfo: false,
112
+ postMessages: false,
113
+ editMessages: false,
114
+ deleteMessages: false,
115
+ banUsers: false,
116
+ inviteUsers: false,
117
+ pinMessages: false,
118
+ addAdmins: false,
119
+ anonymous: false,
120
+ manageCall: false,
121
+ other: false,
122
+ manageTopics: false,
123
+ postStories: false,
124
+ editStories: false,
125
+ deleteStories: false,
126
+ manageDirectMessages: false,
127
+ manageRanks: false,
128
+ }
129
+ export const BOT_BASELINE_RIGHTS = {
130
+ ...rightsOff,
131
+ // Telegram enables basic chat management for every administrator.
132
+ other: true,
133
+ inviteUsers: true,
134
+ pinMessages: true,
135
+ }
136
+ export const TEAM_ADMIN_RIGHTS = {
137
+ ...rightsOff,
138
+ other: true,
139
+ changeInfo: true,
140
+ inviteUsers: true,
141
+ pinMessages: true,
142
+ }
143
+
144
+ export function validateRequest(value: ProvisionRequest): ProvisionRequest {
145
+ if (!value || !/^[A-Za-z0-9:_-]{1,128}$/.test(value.requestId))
146
+ throw new Error("Invalid requestId")
147
+ if (
148
+ typeof value.custodyProfile !== "string" ||
149
+ !value.custodyProfile.trim() ||
150
+ value.custodyProfile.length > 64
151
+ )
152
+ throw new Error("Explicit custodyProfile required")
153
+ if (
154
+ !Number.isSafeInteger(value.expectedUserId) ||
155
+ value.expectedUserId <= 0 ||
156
+ value.expectedUserId === PROVISIONING_BOT_ID
157
+ )
158
+ throw new Error("Invalid expectedUserId")
159
+ if (
160
+ typeof value.title !== "string" ||
161
+ !value.title.trim() ||
162
+ Array.from(value.title).length > 128
163
+ )
164
+ throw new Error("Nonblank title required")
165
+ for (const key of ["participants", "admins"] as const) {
166
+ if (
167
+ !Array.isArray(value[key]) ||
168
+ value[key].length > (key === "participants" ? 100 : 30) ||
169
+ value[key].some((entry) => {
170
+ const peer =
171
+ key === "admins"
172
+ ? (entry as { userId: Peer; rightsProfile: string })?.userId
173
+ : entry
174
+ if (
175
+ key === "admins" &&
176
+ (entry as { rightsProfile: string })?.rightsProfile !== "team"
177
+ )
178
+ return true
179
+ return typeof peer === "number"
180
+ ? !Number.isSafeInteger(peer) || peer <= 0
181
+ : typeof peer !== "string" ||
182
+ !/^(?:[1-9][0-9]*|@?[A-Za-z][A-Za-z0-9_]{4,31})$/.test(peer)
183
+ })
184
+ )
185
+ throw new Error(`Invalid ${key}`)
186
+ }
187
+ if (
188
+ value.optionalParticipants !== undefined &&
189
+ (!Array.isArray(value.optionalParticipants) ||
190
+ value.optionalParticipants.length > 100 ||
191
+ new Set(value.optionalParticipants.map(String)).size !==
192
+ value.optionalParticipants.length ||
193
+ value.optionalParticipants.some(
194
+ (peer) =>
195
+ !value.participants.some((p) => String(p) === String(peer)) ||
196
+ value.admins.some((a) => String(a.userId) === String(peer)),
197
+ ))
198
+ )
199
+ throw new Error(
200
+ "Optional participants must be distinct ordinary requested members",
201
+ )
202
+ if (value.botRightsProfile !== "baseline")
203
+ throw new Error("Unknown botRightsProfile; use baseline")
204
+ if (
205
+ !Array.isArray(value.dealIds) ||
206
+ value.dealIds.length > 50 ||
207
+ value.dealIds.some((id) => typeof id !== "string" || !id.trim()) ||
208
+ new Set(value.dealIds).size !== value.dealIds.length
209
+ )
210
+ throw new Error("Invalid or duplicate dealIds")
211
+ return value
212
+ }
213
+ export function requestFingerprint(request: ProvisionRequest): string {
214
+ const value = validateRequest(request)
215
+ return createHash("sha256")
216
+ .update(
217
+ JSON.stringify({
218
+ requestId: value.requestId,
219
+ custodyProfile: value.custodyProfile,
220
+ expectedUserId: value.expectedUserId,
221
+ title: value.title,
222
+ participants: value.participants,
223
+ ...(value.optionalParticipants === undefined
224
+ ? {}
225
+ : { optionalParticipants: value.optionalParticipants }),
226
+ admins: value.admins,
227
+ botRightsProfile: value.botRightsProfile,
228
+ dealIds: value.dealIds,
229
+ }),
230
+ )
231
+ .digest("hex")
232
+ }
233
+ export function creationMarker(request: ProvisionRequest): string {
234
+ return `sai-setup:${requestFingerprint(request)}`
235
+ }
236
+ function validBinding(binding: Binding, ownerId: number): boolean {
237
+ return (
238
+ Array.isArray(binding.participants) &&
239
+ Array.isArray(binding.admins) &&
240
+ [...binding.participants, ...binding.admins].every(
241
+ (id) =>
242
+ Number.isSafeInteger(id) &&
243
+ id > 0 &&
244
+ id !== PROVISIONING_BOT_ID &&
245
+ id !== ownerId,
246
+ ) &&
247
+ binding.admins.every((id) => binding.participants.includes(id)) &&
248
+ (binding.optionalParticipants === undefined ||
249
+ (Array.isArray(binding.optionalParticipants) &&
250
+ binding.optionalParticipants.every(
251
+ (id) =>
252
+ binding.participants.includes(id) && !binding.admins.includes(id),
253
+ ) &&
254
+ new Set(binding.optionalParticipants).size ===
255
+ binding.optionalParticipants.length)) &&
256
+ new Set(binding.participants).size === binding.participants.length &&
257
+ new Set(binding.admins).size === binding.admins.length
258
+ )
259
+ }
260
+ function hasRights(member: Member, expected: Record<string, boolean>): boolean {
261
+ return (
262
+ member.role === "admin" &&
263
+ Object.entries(expected).every(
264
+ ([key, value]) => Boolean(member.rights[key]) === value,
265
+ )
266
+ )
267
+ }
268
+ export async function bindRequest(
269
+ request: ProvisionRequest,
270
+ telegram: TelegramProvisioner,
271
+ ): Promise<Binding> {
272
+ const ids = new Map<Peer, number>()
273
+ for (const peer of [
274
+ ...request.participants,
275
+ ...request.admins.map((admin) => admin.userId),
276
+ ]) {
277
+ if (ids.has(peer)) continue
278
+ const resolved = await telegram.resolve(peer)
279
+ if (
280
+ resolved.isBot ||
281
+ !Number.isSafeInteger(resolved.id) ||
282
+ resolved.id <= 0
283
+ )
284
+ throw new Error(
285
+ "Participants and team admins must resolve to user accounts",
286
+ )
287
+ ids.set(peer, resolved.id)
288
+ }
289
+ const admins = request.admins.map((admin) => ids.get(admin.userId)!)
290
+ const participants = request.participants.map((peer) => ids.get(peer)!)
291
+ const binding = {
292
+ participants,
293
+ admins,
294
+ ...(request.optionalParticipants === undefined
295
+ ? {}
296
+ : {
297
+ optionalParticipants: request.optionalParticipants.map(
298
+ (peer) => ids.get(peer)!,
299
+ ),
300
+ }),
301
+ }
302
+ if (!validBinding(binding, request.expectedUserId))
303
+ throw new Error("Invalid account binding")
304
+ return binding
305
+ }
306
+
307
+ /** Only explicit server rejection proves creation did not happen. Transport failures remain unknown. */
308
+ export function definiteCreateRejection(error: unknown): boolean {
309
+ const e = error as { text?: string; code?: number }
310
+ if (typeof e?.text !== "string") return false
311
+ // The local hold guard refuses before sending, so the call never reached
312
+ // Telegram: a hold (420) or an unreadable hold store (500).
313
+ if (
314
+ (e.code === 420 && /^TG_SAI_HOLD_\d+$/.test(e.text)) ||
315
+ (e.code === 500 && e.text === HOLD_STATE_UNAVAILABLE)
316
+ )
317
+ return true
318
+ return (
319
+ [400, 401, 403, 420].includes(e.code ?? 0) &&
320
+ /^(?:FLOOD_WAIT|FLOOD_PREMIUM_WAIT|PEER_FLOOD|CHANNELS_TOO_MUCH|USER_RESTRICTED|CHAT_TITLE_EMPTY|CHAT_ABOUT_TOO_LONG|AUTH_KEY_UNREGISTERED|SESSION_REVOKED)(?:_%d|_\d+)?$/.test(
321
+ e.text,
322
+ )
323
+ )
324
+ }
325
+ export function safeProvisionError(error: unknown): string {
326
+ const e = error as { text?: string; errorMessage?: string; seconds?: number }
327
+ const code = e?.text ?? e?.errorMessage
328
+ const normalized =
329
+ typeof code === "string"
330
+ ? code.replace(
331
+ "%d",
332
+ String(Number.isSafeInteger(e.seconds) ? e.seconds : "UNKNOWN"),
333
+ )
334
+ : ""
335
+ return /^(?:FLOOD(?:_PREMIUM)?_WAIT_(?:\d{1,10}|UNKNOWN)|PEER_FLOOD|TG_SAI_HOLD_\d{1,10}|TG_SAI_HOLD_STATE_UNAVAILABLE|USER_PRIVACY_RESTRICTED|USER_NOT_MUTUAL_CONTACTS?|USER_NOT_PARTICIPANT|USER_CHANNELS_TOO_MUCH|USER_BOT|USER_ID_INVALID|PEER_ID_INVALID|CHAT_ID_INVALID|CHANNEL_INVALID|CHANNEL_PRIVATE|CHAT_ADMIN_REQUIRED|CHAT_WRITE_FORBIDDEN|USER_ADMIN_INVALID|USER_CREATOR|ADMINS_TOO_MUCH|BOTS_TOO_MUCH|BOT_GROUPS_BLOCKED|RIGHT_FORBIDDEN|USER_RESTRICTED|USER_DEACTIVATED|INPUT_USER_DEACTIVATED|CHANNELS_TOO_MUCH|USERS_TOO_MUCH|AUTH_KEY_UNREGISTERED|SESSION_REVOKED|INVITE_HASH_EXPIRED|INVITE_HASH_INVALID|INVITE_REQUEST_SENT)$/.test(
336
+ normalized,
337
+ )
338
+ ? normalized
339
+ : "provisioning_step_failed"
340
+ }
341
+
342
+ type ProvisionStep =
343
+ | "verify_marker"
344
+ | "save_configuring"
345
+ | "read_member"
346
+ | "add_member"
347
+ | "verify_added_member"
348
+ | "create_invitation"
349
+ | "save_invitation"
350
+ | "read_admin"
351
+ | "promote_admin"
352
+ | "read_bot"
353
+ | "promote_bot"
354
+ | "audit_membership"
355
+ | "save_ready"
356
+
357
+ /** Fixed operation names add context without copying Telegram response bodies. */
358
+ export function safeProvisionDiagnostic(
359
+ error: unknown,
360
+ step: ProvisionStep,
361
+ ): string {
362
+ return `${step}:${safeProvisionError(error)}`
363
+ }
364
+
365
+ /**
366
+ * Whether a refused add concerns only the user(s) being added, so the other
367
+ * users can still be tried one by one: privacy refusals, and 400 errors
368
+ * named `USER_*`, `CHAT_*`, or `BOT_GROUPS_BLOCKED` (e.g.
369
+ * `USER_CHANNELS_TOO_MUCH`). Flood waits, `PEER_FLOOD`, and local holds
370
+ * are never per-user.
371
+ */
372
+ export function isPerUserAddError(error: unknown): boolean {
373
+ if (isPrivacyRestriction(error)) return true
374
+ const e = error as { text?: unknown; errorMessage?: unknown; code?: unknown }
375
+ const text = typeof e?.text === "string" ? e.text : e?.errorMessage
376
+ return (
377
+ e?.code === 400 &&
378
+ typeof text === "string" &&
379
+ /^(?:USER_[A-Z0-9_]+|CHAT_[A-Z0-9_]+|BOT_GROUPS_BLOCKED)$/.test(text)
380
+ )
381
+ }
382
+
383
+ export function isPrivacyRestriction(error: unknown): boolean {
384
+ const e = error as { text?: string; errorMessage?: string; code?: number }
385
+ return [e?.text, e?.errorMessage].some(
386
+ (value) =>
387
+ typeof value === "string" &&
388
+ /^(USER_PRIVACY_RESTRICTED|USER_NOT_MUTUAL_CONTACT|USER_NOT_MUTUAL_CONTACTS)$/.test(
389
+ value,
390
+ ),
391
+ )
392
+ }
393
+ /** Text of `HoldStoreUnavailableError` (state/holds.ts). */
394
+ export const HOLD_STATE_UNAVAILABLE = "TG_SAI_HOLD_STATE_UNAVAILABLE"
395
+ /** Retry delay while the hold store cannot be read. */
396
+ export const HOLD_STATE_UNAVAILABLE_RETRY_MS = 5 * 60 * 1000
397
+ /** Telegram gives no duration for `PEER_FLOOD`; holds use 24 h. */
398
+ export const PEER_FLOOD_RETRY_MS = PEER_FLOOD_WAIT_S * 1000
399
+ /**
400
+ * Least lead a cloud `retryAt` gets over now, so Deal Desk never rejects
401
+ * it as not in the future after clock skew or a slow request.
402
+ */
403
+ export const MIN_CLOUD_RETRY_MS = 30_000
404
+ const MAX_RETRY_MS = 31_536_000 * 1000
405
+
406
+ /**
407
+ * Cloud `retryAt` for a local hold ending at `untilMs`: that time, or
408
+ * `MIN_CLOUD_RETRY_MS` from now when later. Never before the hold ends.
409
+ * `null` when the time is not finite or more than a year out.
410
+ */
411
+ export function cloudRetryAt(untilMs: number, now = Date.now()): string | null {
412
+ if (!Number.isFinite(untilMs)) return null
413
+ const at = Math.max(Math.ceil(untilMs), now + MIN_CLOUD_RETRY_MS)
414
+ return at - now <= MAX_RETRY_MS ? new Date(at).toISOString() : null
415
+ }
416
+
417
+ /**
418
+ * When a flood or hold error allows an unattended retry: `FLOOD_WAIT_N`
419
+ * (also mtcute's `FLOOD_WAIT_%d` with `seconds`), `FLOOD_PREMIUM_WAIT_N`,
420
+ * `PEER_FLOOD` (24 h), `TG_SAI_HOLD_N` (the hold's `untilMs` when present),
421
+ * and `TG_SAI_HOLD_STATE_UNAVAILABLE` (5 min). Telegram waits get the same
422
+ * margin as the local hold (`holdUntil`), and every time is at least
423
+ * `MIN_CLOUD_RETRY_MS` out (`cloudRetryAt`). `null` for anything else.
424
+ */
425
+ export function provisionRetryAt(
426
+ error: unknown,
427
+ now = Date.now(),
428
+ ): string | null {
429
+ const e = error as {
430
+ text?: string
431
+ errorMessage?: string
432
+ seconds?: number
433
+ untilMs?: number
434
+ }
435
+ const code = e?.text ?? e?.errorMessage ?? ""
436
+ const after = (waitS: number): string | null =>
437
+ Number.isSafeInteger(waitS) && waitS > 0
438
+ ? cloudRetryAt(holdUntil(now, waitS), now)
439
+ : null
440
+ if (code === "PEER_FLOOD") return after(PEER_FLOOD_WAIT_S)
441
+ if (code === HOLD_STATE_UNAVAILABLE)
442
+ return cloudRetryAt(now + HOLD_STATE_UNAVAILABLE_RETRY_MS, now)
443
+ const hold = /^TG_SAI_HOLD_(\d+)$/.exec(code)
444
+ if (hold) {
445
+ if (Number.isSafeInteger(e.untilMs) && e.untilMs! > now)
446
+ return cloudRetryAt(e.untilMs!, now)
447
+ const seconds = Number(hold[1])
448
+ return Number.isSafeInteger(seconds) && seconds > 0
449
+ ? cloudRetryAt(now + seconds * 1000, now)
450
+ : null
451
+ }
452
+ const match = /^FLOOD(?:_PREMIUM)?_WAIT_(\d+|%d)$/.exec(code)
453
+ const seconds = match ? Number(match[1] === "%d" ? e.seconds : match[1]) : NaN
454
+ return after(seconds)
455
+ }
456
+
457
+ /** The cloud is authoritative. The journal only recovers a result not yet acknowledged by cloud. */
458
+ export async function provisionSetup(params: {
459
+ setup: Setup
460
+ telegram: TelegramProvisioner
461
+ cloud: ProvisionCloud
462
+ journal: ProvisionJournal
463
+ workerId: string
464
+ }): Promise<Setup | { state: "creation_unknown"; candidates: number[] }> {
465
+ const { telegram, cloud, journal } = params
466
+ let setup = params.setup
467
+ const fingerprint = requestFingerprint(setup)
468
+ if (setup.state === "cancelled") throw new Error("Setup is cancelled")
469
+ if ((await telegram.self()) !== setup.expectedUserId)
470
+ throw new Error("Custody account identity mismatch")
471
+ const local = await journal.read(setup.requestId)
472
+ if (local && local.fingerprint !== fingerprint)
473
+ throw new Error("Local receipt fingerprint conflicts with cloud request")
474
+ if (setup.state === "ready") {
475
+ if (
476
+ !setup.chatId ||
477
+ !(await telegram.verifyMarker(setup.chatId, creationMarker(setup)))
478
+ )
479
+ throw new Error("Ready setup owner or marker changed")
480
+ const owner = await telegram.member(setup.chatId, setup.expectedUserId)
481
+ const bot = await telegram.member(setup.chatId, PROVISIONING_BOT_ID)
482
+ if (owner.role !== "creator" || !hasRights(bot, BOT_BASELINE_RIGHTS))
483
+ throw new Error("Ready setup rights changed")
484
+ if (setup.binding)
485
+ for (const id of setup.binding.admins)
486
+ if (
487
+ !hasRights(await telegram.member(setup.chatId, id), TEAM_ADMIN_RIGHTS)
488
+ )
489
+ throw new Error("Ready setup admin rights changed")
490
+ return setup
491
+ }
492
+ let pendingInvitations = local?.pendingInvitations ?? []
493
+ const workerId = local?.workerId ?? params.workerId
494
+ if (
495
+ !setup.chatId &&
496
+ (setup.state === "creating" ||
497
+ setup.state === "creation_unknown" ||
498
+ local?.state === "creating" ||
499
+ local?.state === "creation_unknown")
500
+ ) {
501
+ if (setup.state === "creating") {
502
+ const claimed = await cloud.claim(setup.requestId, workerId)
503
+ if (!claimed.claimToken)
504
+ throw new Error("Cloud did not return a recovery claim")
505
+ await cloud.progress(setup.requestId, claimed.claimToken, {
506
+ state: "creation_unknown",
507
+ binding: setup.binding,
508
+ })
509
+ }
510
+ // Even a unique candidate is presented for explicit adoption, never silently trusted.
511
+ return {
512
+ state: "creation_unknown",
513
+ candidates: [
514
+ ...new Set([
515
+ ...(local?.chatId ? [local.chatId] : []),
516
+ ...(await telegram.candidates(creationMarker(setup))),
517
+ ]),
518
+ ],
519
+ }
520
+ }
521
+ await journal.write(setup.requestId, {
522
+ fingerprint,
523
+ state: setup.state,
524
+ chatId: setup.chatId,
525
+ binding: setup.binding,
526
+ workerId,
527
+ pendingInvitations,
528
+ })
529
+ const claimed = await cloud.claim(setup.requestId, workerId)
530
+ if (requestFingerprint(claimed) !== fingerprint || !claimed.claimToken)
531
+ throw new Error(
532
+ "Cloud claim did not preserve request or return claim token",
533
+ )
534
+ const claimToken = claimed.claimToken
535
+ // Request receipts intentionally replay old responses. Never act on a claim snapshot.
536
+ setup = { ...(await cloud.view(setup.requestId)), claimToken }
537
+ if (requestFingerprint(setup) !== fingerprint)
538
+ throw new Error("Cloud request changed after claim")
539
+ if (setup.state === "ready") return setup
540
+ if (setup.state === "cancelled") throw new Error("Setup is cancelled")
541
+ if (
542
+ !setup.chatId &&
543
+ (setup.state === "creating" || setup.state === "creation_unknown")
544
+ ) {
545
+ if (setup.state === "creating")
546
+ await cloud.progress(setup.requestId, claimToken, {
547
+ state: "creation_unknown",
548
+ binding: setup.binding,
549
+ })
550
+ return {
551
+ state: "creation_unknown",
552
+ candidates: await telegram.candidates(creationMarker(setup)),
553
+ }
554
+ }
555
+ const save = async (progress: Progress): Promise<void> => {
556
+ await journal.write(setup.requestId, {
557
+ fingerprint,
558
+ state: progress.state,
559
+ chatId: progress.chatId ?? setup.chatId,
560
+ binding: progress.binding ?? setup.binding,
561
+ workerId,
562
+ pendingInvitations,
563
+ })
564
+ setup = {
565
+ ...(await cloud.progress(setup.requestId, claimToken, progress)),
566
+ claimToken,
567
+ }
568
+ if (requestFingerprint(setup) !== fingerprint)
569
+ throw new Error("Cloud request changed during provisioning")
570
+ }
571
+ if (!setup.binding && local?.binding && local.chatId)
572
+ setup.binding = local.binding
573
+ const binding = setup.binding ?? (await bindRequest(setup, telegram))
574
+ if (!validBinding(binding, setup.expectedUserId))
575
+ throw new Error("Invalid persisted account binding")
576
+ if (!setup.binding)
577
+ await save({
578
+ state: setup.chatId ? "configuring" : "prepared",
579
+ binding,
580
+ chatId: setup.chatId,
581
+ })
582
+ if (!setup.chatId && local?.chatId) {
583
+ if (!(await telegram.verifyMarker(local.chatId, creationMarker(setup))))
584
+ throw new Error(
585
+ "Private receipt chat does not match request marker and owner",
586
+ )
587
+ await save({ state: "configuring", chatId: local.chatId, binding })
588
+ }
589
+ if (!setup.chatId) {
590
+ await save({ state: "creating", binding })
591
+ let chatId: number
592
+ try {
593
+ chatId = await telegram.create(setup.title, creationMarker(setup))
594
+ } catch (error) {
595
+ const rejected = definiteCreateRejection(error)
596
+ await save({
597
+ state: rejected ? "incomplete" : "creation_unknown",
598
+ binding,
599
+ error: safeProvisionError(error),
600
+ ...(rejected ? { creationRejected: true } : {}),
601
+ retryAt: provisionRetryAt(error),
602
+ })
603
+ // `cause` lets the CLI map a hold refusal to exit 75.
604
+ throw new Error(
605
+ "Group creation failed; inspect authoritative setup state before resuming",
606
+ { cause: error },
607
+ )
608
+ }
609
+ // Persist immediately, before another network call or cloud acknowledgement.
610
+ await journal.write(setup.requestId, {
611
+ fingerprint,
612
+ state: "configuring",
613
+ chatId,
614
+ binding,
615
+ workerId,
616
+ pendingInvitations,
617
+ })
618
+ await save({ state: "configuring", chatId, binding })
619
+ }
620
+ const chatId = setup.chatId!
621
+ let step: ProvisionStep = "verify_marker"
622
+ try {
623
+ if (!(await telegram.verifyMarker(chatId, creationMarker(setup))))
624
+ throw new Error("Chat marker or custody owner verification failed")
625
+ step = "save_configuring"
626
+ await save({ state: "configuring", chatId, binding })
627
+ // One membership read per user, then one batched add for everyone absent.
628
+ const absent: number[] = []
629
+ for (const userId of [...binding.participants, PROVISIONING_BOT_ID]) {
630
+ step = "read_member"
631
+ const member = await telegram.member(chatId, userId)
632
+ if (member.role !== "absent") {
633
+ pendingInvitations = pendingInvitations.filter(
634
+ (invite) => invite.userId !== String(userId),
635
+ )
636
+ continue
637
+ }
638
+ absent.push(userId)
639
+ }
640
+ // Per-user refusals (privacy, USER_*/CHAT_* 400s) never stop the other
641
+ // users: a refused batch falls back to one add per user. Flood and hold
642
+ // errors refuse the whole batch and propagate with their retry time.
643
+ const failures = new Map<number, { error: unknown; step: ProvisionStep }>()
644
+ const addEach = async (userIds: number[]): Promise<void> => {
645
+ for (const userId of userIds) {
646
+ try {
647
+ step = "add_member"
648
+ await telegram.add(chatId, userId)
649
+ } catch (error) {
650
+ if (!isPerUserAddError(error)) throw error
651
+ failures.set(userId, { error, step: "add_member" })
652
+ }
653
+ }
654
+ }
655
+ if (absent.length && telegram.addMany) {
656
+ step = "add_member"
657
+ try {
658
+ const missing = new Set(await telegram.addMany(chatId, absent))
659
+ for (const userId of absent)
660
+ if (missing.has(userId))
661
+ failures.set(userId, {
662
+ error: { text: "USER_PRIVACY_RESTRICTED" },
663
+ step: "add_member",
664
+ })
665
+ } catch (error) {
666
+ if (!isPerUserAddError(error)) throw error
667
+ await addEach(absent)
668
+ }
669
+ } else await addEach(absent)
670
+ for (const userId of absent) {
671
+ if (failures.has(userId)) continue
672
+ step = "verify_added_member"
673
+ if ((await telegram.member(chatId, userId)).role === "absent")
674
+ failures.set(userId, {
675
+ error: new Error("Participant invitation incomplete"),
676
+ step: "verify_added_member",
677
+ })
678
+ }
679
+ let fatal: { error: unknown; step: ProvisionStep } | undefined
680
+ for (const userId of absent) {
681
+ const failure = failures.get(userId)
682
+ if (!failure) continue
683
+ if (
684
+ !binding.optionalParticipants?.includes(userId) ||
685
+ !isPrivacyRestriction(failure.error) ||
686
+ !telegram.inviteLink
687
+ ) {
688
+ fatal ??= failure
689
+ continue
690
+ }
691
+ let invite = pendingInvitations.find(
692
+ (i) =>
693
+ i.userId === String(userId) && Date.parse(i.expiresAt) > Date.now(),
694
+ )
695
+ if (!invite) {
696
+ step = "create_invitation"
697
+ const link = await telegram.inviteLink(chatId, userId)
698
+ if (
699
+ !/^https:\/\/t\.me\/(?:\+|joinchat\/)[A-Za-z0-9_-]+$/.test(
700
+ link.inviteLink,
701
+ ) ||
702
+ !Number.isFinite(Date.parse(link.expiresAt)) ||
703
+ Date.parse(link.expiresAt) <= Date.now()
704
+ )
705
+ throw new Error("Invalid controlled invitation link")
706
+ invite = {
707
+ userId: String(userId),
708
+ ...link,
709
+ reason: "privacy_restricted",
710
+ }
711
+ pendingInvitations = [
712
+ ...pendingInvitations.filter((i) => i.userId !== invite!.userId),
713
+ invite,
714
+ ]
715
+ step = "save_invitation"
716
+ await save({ state: "configuring", chatId, binding })
717
+ }
718
+ }
719
+ if (fatal) {
720
+ step = fatal.step
721
+ throw fatal.error
722
+ }
723
+ for (const userId of binding.admins) {
724
+ step = "read_admin"
725
+ if (
726
+ !hasRights(await telegram.member(chatId, userId), TEAM_ADMIN_RIGHTS)
727
+ ) {
728
+ step = "promote_admin"
729
+ await telegram.promote(chatId, userId, TEAM_ADMIN_RIGHTS)
730
+ }
731
+ }
732
+ step = "read_bot"
733
+ if (
734
+ !hasRights(
735
+ await telegram.member(chatId, PROVISIONING_BOT_ID),
736
+ BOT_BASELINE_RIGHTS,
737
+ )
738
+ ) {
739
+ step = "promote_bot"
740
+ await telegram.promote(chatId, PROVISIONING_BOT_ID, BOT_BASELINE_RIGHTS)
741
+ }
742
+ step = "audit_membership"
743
+ const owner = await telegram.member(chatId, setup.expectedUserId)
744
+ const participants = await Promise.all(
745
+ binding.participants.map(async (userId) => ({
746
+ userId,
747
+ ...(await telegram.member(chatId, userId)),
748
+ })),
749
+ )
750
+ const bot = await telegram.member(chatId, PROVISIONING_BOT_ID)
751
+ if (
752
+ owner.role !== "creator" ||
753
+ participants.some((member) =>
754
+ binding.admins.includes(member.userId)
755
+ ? !hasRights(member, TEAM_ADMIN_RIGHTS)
756
+ : member.role !== "member" &&
757
+ !(
758
+ binding.optionalParticipants?.includes(member.userId) &&
759
+ member.role === "absent" &&
760
+ pendingInvitations.some(
761
+ (invite) =>
762
+ invite.userId === String(member.userId) &&
763
+ Date.parse(invite.expiresAt) > Date.now(),
764
+ )
765
+ ),
766
+ ) ||
767
+ !hasRights(bot, BOT_BASELINE_RIGHTS)
768
+ )
769
+ throw new Error("Final membership or rights audit failed")
770
+ const verifiedAt = new Date().toISOString()
771
+ // Link retrieval is presentation metadata, not a membership requirement.
772
+ // Preserve readiness even when Telegram temporarily refuses these reads.
773
+ const links = await telegram.completionLinks?.(chatId).catch(() => ({}))
774
+ const receipt = {
775
+ chatId: String(chatId),
776
+ ownerId: String(setup.expectedUserId),
777
+ participants: participants.map((member) => ({
778
+ ...member,
779
+ userId: String(member.userId),
780
+ })),
781
+ bot: { ...bot, userId: String(PROVISIONING_BOT_ID) },
782
+ verifiedAt,
783
+ ...(pendingInvitations.length ? { pendingInvitations } : {}),
784
+ ...links,
785
+ }
786
+ step = "save_ready"
787
+ await save({ state: "ready", chatId, binding, receipt, retryAt: null })
788
+ return setup
789
+ } catch (error) {
790
+ // Never discard known chat identity after partial setup or an ambiguous cloud commit.
791
+ const authoritative = await cloud.view(setup.requestId)
792
+ if (authoritative.state === "ready") return authoritative
793
+ await save({
794
+ state: "incomplete",
795
+ chatId,
796
+ binding,
797
+ error: safeProvisionDiagnostic(error, step),
798
+ retryAt: provisionRetryAt(error),
799
+ })
800
+ throw new Error(
801
+ "Setup incomplete; inspect status and resume the same request",
802
+ { cause: error },
803
+ )
804
+ }
805
+ }