@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.
@@ -0,0 +1,674 @@
1
+ import { createHash } from "node:crypto"
2
+
3
+ export const PROVISIONING_BOT_ID = 8983431101
4
+ export type Peer = number | string
5
+ export interface ProvisionRequest {
6
+ requestId: string
7
+ custodyProfile: string
8
+ expectedUserId: number
9
+ title: string
10
+ participants: Peer[]
11
+ optionalParticipants?: Peer[]
12
+ admins: { userId: Peer; rightsProfile: "team" }[]
13
+ botRightsProfile: "baseline"
14
+ dealIds: string[]
15
+ }
16
+ export type ProvisionState =
17
+ | "prepared"
18
+ | "creating"
19
+ | "creation_unknown"
20
+ | "configuring"
21
+ | "incomplete"
22
+ | "ready"
23
+ | "cancelled"
24
+ export interface Binding {
25
+ participants: number[]
26
+ admins: number[]
27
+ optionalParticipants?: number[]
28
+ }
29
+ export interface Setup extends ProvisionRequest {
30
+ state: ProvisionState
31
+ chatId?: number
32
+ binding?: Binding
33
+ claimToken?: string
34
+ receipt?: unknown
35
+ retryAt?: string | null
36
+ }
37
+ export interface Member {
38
+ role: "creator" | "admin" | "member" | "absent"
39
+ rights: Record<string, boolean>
40
+ }
41
+ export interface ProvisioningLinks {
42
+ openGroupUrl?: string
43
+ primaryInviteLink?: string
44
+ }
45
+ export interface TelegramProvisioner {
46
+ self(): Promise<number>
47
+ resolve(peer: Peer): Promise<{ id: number; isBot: boolean }>
48
+ create(title: string, marker: string): Promise<number>
49
+ member(chatId: number, userId: number): Promise<Member>
50
+ add(chatId: number, userId: number): Promise<void>
51
+ inviteLink?(
52
+ chatId: number,
53
+ userId: number,
54
+ ): Promise<{ inviteLink: string; expiresAt: string }>
55
+ /** Read existing links only. Failure must not invalidate verified setup. */
56
+ completionLinks?(chatId: number): Promise<ProvisioningLinks>
57
+ promote(
58
+ chatId: number,
59
+ userId: number,
60
+ rights: Record<string, boolean>,
61
+ ): Promise<void>
62
+ candidates(marker: string): Promise<number[]>
63
+ verifyMarker(chatId: number, marker: string): Promise<boolean>
64
+ }
65
+ export interface Progress {
66
+ state: ProvisionState
67
+ chatId?: number
68
+ binding?: Binding
69
+ receipt?: unknown
70
+ error?: string
71
+ creationRejected?: boolean
72
+ retryAt?: string | null
73
+ }
74
+ export interface ProvisionCloud {
75
+ view(requestId: string): Promise<Setup>
76
+ claim(requestId: string, workerId: string): Promise<Setup>
77
+ progress(
78
+ requestId: string,
79
+ claimToken: string,
80
+ progress: Progress,
81
+ ): Promise<Setup>
82
+ resolve(requestId: string, chatId: number, reason: string): Promise<Setup>
83
+ }
84
+ export interface JournalRecord {
85
+ fingerprint: string
86
+ state: ProvisionState
87
+ chatId?: number
88
+ binding?: Binding
89
+ workerId?: string
90
+ pendingInvitations?: PendingInvitation[]
91
+ }
92
+ export interface PendingInvitation {
93
+ userId: string
94
+ inviteLink: string
95
+ expiresAt: string
96
+ reason: string
97
+ }
98
+ export interface ProvisionJournal {
99
+ read(requestId: string): Promise<JournalRecord | null>
100
+ write(requestId: string, record: JournalRecord): Promise<void>
101
+ }
102
+
103
+ const rightsOff = {
104
+ changeInfo: false,
105
+ postMessages: false,
106
+ editMessages: false,
107
+ deleteMessages: false,
108
+ banUsers: false,
109
+ inviteUsers: false,
110
+ pinMessages: false,
111
+ addAdmins: false,
112
+ anonymous: false,
113
+ manageCall: false,
114
+ other: false,
115
+ manageTopics: false,
116
+ postStories: false,
117
+ editStories: false,
118
+ deleteStories: false,
119
+ manageDirectMessages: false,
120
+ manageRanks: false,
121
+ }
122
+ export const BOT_BASELINE_RIGHTS = {
123
+ ...rightsOff,
124
+ // Telegram enables basic chat management for every administrator.
125
+ other: true,
126
+ inviteUsers: true,
127
+ pinMessages: true,
128
+ }
129
+ export const TEAM_ADMIN_RIGHTS = {
130
+ ...rightsOff,
131
+ other: true,
132
+ changeInfo: true,
133
+ inviteUsers: true,
134
+ pinMessages: true,
135
+ }
136
+
137
+ export function validateRequest(value: ProvisionRequest): ProvisionRequest {
138
+ if (!value || !/^[A-Za-z0-9:_-]{1,128}$/.test(value.requestId))
139
+ throw new Error("Invalid requestId")
140
+ if (
141
+ typeof value.custodyProfile !== "string" ||
142
+ !value.custodyProfile.trim() ||
143
+ value.custodyProfile.length > 64
144
+ )
145
+ throw new Error("Explicit custodyProfile required")
146
+ if (
147
+ !Number.isSafeInteger(value.expectedUserId) ||
148
+ value.expectedUserId <= 0 ||
149
+ value.expectedUserId === PROVISIONING_BOT_ID
150
+ )
151
+ throw new Error("Invalid expectedUserId")
152
+ if (
153
+ typeof value.title !== "string" ||
154
+ !value.title.trim() ||
155
+ Array.from(value.title).length > 128
156
+ )
157
+ throw new Error("Nonblank title required")
158
+ for (const key of ["participants", "admins"] as const) {
159
+ if (
160
+ !Array.isArray(value[key]) ||
161
+ value[key].length > (key === "participants" ? 100 : 30) ||
162
+ value[key].some((entry) => {
163
+ const peer =
164
+ key === "admins"
165
+ ? (entry as { userId: Peer; rightsProfile: string })?.userId
166
+ : entry
167
+ if (
168
+ key === "admins" &&
169
+ (entry as { rightsProfile: string })?.rightsProfile !== "team"
170
+ )
171
+ return true
172
+ return typeof peer === "number"
173
+ ? !Number.isSafeInteger(peer) || peer <= 0
174
+ : typeof peer !== "string" ||
175
+ !/^(?:[1-9][0-9]*|@?[A-Za-z][A-Za-z0-9_]{4,31})$/.test(peer)
176
+ })
177
+ )
178
+ throw new Error(`Invalid ${key}`)
179
+ }
180
+ if (
181
+ value.optionalParticipants !== undefined &&
182
+ (!Array.isArray(value.optionalParticipants) ||
183
+ value.optionalParticipants.length > 100 ||
184
+ new Set(value.optionalParticipants.map(String)).size !==
185
+ value.optionalParticipants.length ||
186
+ value.optionalParticipants.some(
187
+ (peer) =>
188
+ !value.participants.some((p) => String(p) === String(peer)) ||
189
+ value.admins.some((a) => String(a.userId) === String(peer)),
190
+ ))
191
+ )
192
+ throw new Error(
193
+ "Optional participants must be distinct ordinary requested members",
194
+ )
195
+ if (value.botRightsProfile !== "baseline")
196
+ throw new Error("Unknown botRightsProfile; use baseline")
197
+ if (
198
+ !Array.isArray(value.dealIds) ||
199
+ value.dealIds.length > 50 ||
200
+ value.dealIds.some((id) => typeof id !== "string" || !id.trim()) ||
201
+ new Set(value.dealIds).size !== value.dealIds.length
202
+ )
203
+ throw new Error("Invalid or duplicate dealIds")
204
+ return value
205
+ }
206
+ export function requestFingerprint(request: ProvisionRequest): string {
207
+ const value = validateRequest(request)
208
+ return createHash("sha256")
209
+ .update(
210
+ JSON.stringify({
211
+ requestId: value.requestId,
212
+ custodyProfile: value.custodyProfile,
213
+ expectedUserId: value.expectedUserId,
214
+ title: value.title,
215
+ participants: value.participants,
216
+ ...(value.optionalParticipants === undefined
217
+ ? {}
218
+ : { optionalParticipants: value.optionalParticipants }),
219
+ admins: value.admins,
220
+ botRightsProfile: value.botRightsProfile,
221
+ dealIds: value.dealIds,
222
+ }),
223
+ )
224
+ .digest("hex")
225
+ }
226
+ export function creationMarker(request: ProvisionRequest): string {
227
+ return `sai-setup:${requestFingerprint(request)}`
228
+ }
229
+ function validBinding(binding: Binding, ownerId: number): boolean {
230
+ return (
231
+ Array.isArray(binding.participants) &&
232
+ Array.isArray(binding.admins) &&
233
+ [...binding.participants, ...binding.admins].every(
234
+ (id) =>
235
+ Number.isSafeInteger(id) &&
236
+ id > 0 &&
237
+ id !== PROVISIONING_BOT_ID &&
238
+ id !== ownerId,
239
+ ) &&
240
+ binding.admins.every((id) => binding.participants.includes(id)) &&
241
+ (binding.optionalParticipants === undefined ||
242
+ (Array.isArray(binding.optionalParticipants) &&
243
+ binding.optionalParticipants.every(
244
+ (id) =>
245
+ binding.participants.includes(id) && !binding.admins.includes(id),
246
+ ) &&
247
+ new Set(binding.optionalParticipants).size ===
248
+ binding.optionalParticipants.length)) &&
249
+ new Set(binding.participants).size === binding.participants.length &&
250
+ new Set(binding.admins).size === binding.admins.length
251
+ )
252
+ }
253
+ function hasRights(member: Member, expected: Record<string, boolean>): boolean {
254
+ return (
255
+ member.role === "admin" &&
256
+ Object.entries(expected).every(
257
+ ([key, value]) => Boolean(member.rights[key]) === value,
258
+ )
259
+ )
260
+ }
261
+ export async function bindRequest(
262
+ request: ProvisionRequest,
263
+ telegram: TelegramProvisioner,
264
+ ): Promise<Binding> {
265
+ const ids = new Map<Peer, number>()
266
+ for (const peer of [
267
+ ...request.participants,
268
+ ...request.admins.map((admin) => admin.userId),
269
+ ]) {
270
+ if (ids.has(peer)) continue
271
+ const resolved = await telegram.resolve(peer)
272
+ if (
273
+ resolved.isBot ||
274
+ !Number.isSafeInteger(resolved.id) ||
275
+ resolved.id <= 0
276
+ )
277
+ throw new Error(
278
+ "Participants and team admins must resolve to user accounts",
279
+ )
280
+ ids.set(peer, resolved.id)
281
+ }
282
+ const admins = request.admins.map((admin) => ids.get(admin.userId)!)
283
+ const participants = request.participants.map((peer) => ids.get(peer)!)
284
+ const binding = {
285
+ participants,
286
+ admins,
287
+ ...(request.optionalParticipants === undefined
288
+ ? {}
289
+ : {
290
+ optionalParticipants: request.optionalParticipants.map(
291
+ (peer) => ids.get(peer)!,
292
+ ),
293
+ }),
294
+ }
295
+ if (!validBinding(binding, request.expectedUserId))
296
+ throw new Error("Invalid account binding")
297
+ return binding
298
+ }
299
+
300
+ /** Only explicit server rejection proves creation did not happen. Transport failures remain unknown. */
301
+ export function definiteCreateRejection(error: unknown): boolean {
302
+ const e = error as { text?: string; code?: number }
303
+ return (
304
+ [400, 401, 403, 420].includes(e?.code ?? 0) &&
305
+ typeof e.text === "string" &&
306
+ /^(?:FLOOD_WAIT|CHANNELS_TOO_MUCH|USER_RESTRICTED|CHAT_TITLE_EMPTY|CHAT_ABOUT_TOO_LONG|AUTH_KEY_UNREGISTERED|SESSION_REVOKED)(?:_%d|_\d+)?$/.test(
307
+ e.text,
308
+ )
309
+ )
310
+ }
311
+ export function safeProvisionError(error: unknown): string {
312
+ const e = error as { text?: string; errorMessage?: string; seconds?: number }
313
+ const code = e?.text ?? e?.errorMessage
314
+ const normalized =
315
+ typeof code === "string"
316
+ ? code.replace(
317
+ "%d",
318
+ String(Number.isSafeInteger(e.seconds) ? e.seconds : "UNKNOWN"),
319
+ )
320
+ : ""
321
+ return /^(?:FLOOD(?:_PREMIUM)?_WAIT_(?:\d{1,10}|UNKNOWN)|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(
322
+ normalized,
323
+ )
324
+ ? normalized
325
+ : "provisioning_step_failed"
326
+ }
327
+
328
+ type ProvisionStep =
329
+ | "verify_marker"
330
+ | "save_configuring"
331
+ | "read_member"
332
+ | "add_member"
333
+ | "verify_added_member"
334
+ | "create_invitation"
335
+ | "save_invitation"
336
+ | "read_admin"
337
+ | "promote_admin"
338
+ | "read_bot"
339
+ | "promote_bot"
340
+ | "audit_membership"
341
+ | "save_ready"
342
+
343
+ /** Fixed operation names add context without copying Telegram response bodies. */
344
+ export function safeProvisionDiagnostic(
345
+ error: unknown,
346
+ step: ProvisionStep,
347
+ ): string {
348
+ return `${step}:${safeProvisionError(error)}`
349
+ }
350
+
351
+ export function isPrivacyRestriction(error: unknown): boolean {
352
+ const e = error as { text?: string; errorMessage?: string; code?: number }
353
+ return [e?.text, e?.errorMessage].some(
354
+ (value) =>
355
+ typeof value === "string" &&
356
+ /^(USER_PRIVACY_RESTRICTED|USER_NOT_MUTUAL_CONTACT|USER_NOT_MUTUAL_CONTACTS)$/.test(
357
+ value,
358
+ ),
359
+ )
360
+ }
361
+ export function provisionRetryAt(
362
+ error: unknown,
363
+ now = Date.now(),
364
+ ): string | null {
365
+ const e = error as { text?: string; errorMessage?: string; seconds?: number }
366
+ const code = e?.text ?? e?.errorMessage ?? ""
367
+ const match = /^FLOOD_WAIT_(\d+|%d)$/.exec(code)
368
+ const seconds = match ? Number(match[1] === "%d" ? e.seconds : match[1]) : NaN
369
+ return Number.isSafeInteger(seconds) && seconds > 0 && seconds <= 31_536_000
370
+ ? new Date(now + seconds * 1000).toISOString()
371
+ : null
372
+ }
373
+
374
+ /** The cloud is authoritative. The journal only recovers a result not yet acknowledged by cloud. */
375
+ export async function provisionSetup(params: {
376
+ setup: Setup
377
+ telegram: TelegramProvisioner
378
+ cloud: ProvisionCloud
379
+ journal: ProvisionJournal
380
+ workerId: string
381
+ }): Promise<Setup | { state: "creation_unknown"; candidates: number[] }> {
382
+ const { telegram, cloud, journal } = params
383
+ let setup = params.setup
384
+ const fingerprint = requestFingerprint(setup)
385
+ if (setup.state === "cancelled") throw new Error("Setup is cancelled")
386
+ if ((await telegram.self()) !== setup.expectedUserId)
387
+ throw new Error("Custody account identity mismatch")
388
+ const local = await journal.read(setup.requestId)
389
+ if (local && local.fingerprint !== fingerprint)
390
+ throw new Error("Local receipt fingerprint conflicts with cloud request")
391
+ if (setup.state === "ready") {
392
+ if (
393
+ !setup.chatId ||
394
+ !(await telegram.verifyMarker(setup.chatId, creationMarker(setup)))
395
+ )
396
+ throw new Error("Ready setup owner or marker changed")
397
+ const owner = await telegram.member(setup.chatId, setup.expectedUserId)
398
+ const bot = await telegram.member(setup.chatId, PROVISIONING_BOT_ID)
399
+ if (owner.role !== "creator" || !hasRights(bot, BOT_BASELINE_RIGHTS))
400
+ throw new Error("Ready setup rights changed")
401
+ if (setup.binding)
402
+ for (const id of setup.binding.admins)
403
+ if (
404
+ !hasRights(await telegram.member(setup.chatId, id), TEAM_ADMIN_RIGHTS)
405
+ )
406
+ throw new Error("Ready setup admin rights changed")
407
+ return setup
408
+ }
409
+ let pendingInvitations = local?.pendingInvitations ?? []
410
+ const workerId = local?.workerId ?? params.workerId
411
+ if (
412
+ !setup.chatId &&
413
+ (setup.state === "creating" ||
414
+ setup.state === "creation_unknown" ||
415
+ local?.state === "creating" ||
416
+ local?.state === "creation_unknown")
417
+ ) {
418
+ if (setup.state === "creating") {
419
+ const claimed = await cloud.claim(setup.requestId, workerId)
420
+ if (!claimed.claimToken)
421
+ throw new Error("Cloud did not return a recovery claim")
422
+ await cloud.progress(setup.requestId, claimed.claimToken, {
423
+ state: "creation_unknown",
424
+ binding: setup.binding,
425
+ })
426
+ }
427
+ // Even a unique candidate is presented for explicit adoption, never silently trusted.
428
+ return {
429
+ state: "creation_unknown",
430
+ candidates: [
431
+ ...new Set([
432
+ ...(local?.chatId ? [local.chatId] : []),
433
+ ...(await telegram.candidates(creationMarker(setup))),
434
+ ]),
435
+ ],
436
+ }
437
+ }
438
+ await journal.write(setup.requestId, {
439
+ fingerprint,
440
+ state: setup.state,
441
+ chatId: setup.chatId,
442
+ binding: setup.binding,
443
+ workerId,
444
+ pendingInvitations,
445
+ })
446
+ const claimed = await cloud.claim(setup.requestId, workerId)
447
+ if (requestFingerprint(claimed) !== fingerprint || !claimed.claimToken)
448
+ throw new Error(
449
+ "Cloud claim did not preserve request or return claim token",
450
+ )
451
+ const claimToken = claimed.claimToken
452
+ // Request receipts intentionally replay old responses. Never act on a claim snapshot.
453
+ setup = { ...(await cloud.view(setup.requestId)), claimToken }
454
+ if (requestFingerprint(setup) !== fingerprint)
455
+ throw new Error("Cloud request changed after claim")
456
+ if (setup.state === "ready") return setup
457
+ if (setup.state === "cancelled") throw new Error("Setup is cancelled")
458
+ if (
459
+ !setup.chatId &&
460
+ (setup.state === "creating" || setup.state === "creation_unknown")
461
+ ) {
462
+ if (setup.state === "creating")
463
+ await cloud.progress(setup.requestId, claimToken, {
464
+ state: "creation_unknown",
465
+ binding: setup.binding,
466
+ })
467
+ return {
468
+ state: "creation_unknown",
469
+ candidates: await telegram.candidates(creationMarker(setup)),
470
+ }
471
+ }
472
+ const save = async (progress: Progress): Promise<void> => {
473
+ await journal.write(setup.requestId, {
474
+ fingerprint,
475
+ state: progress.state,
476
+ chatId: progress.chatId ?? setup.chatId,
477
+ binding: progress.binding ?? setup.binding,
478
+ workerId,
479
+ pendingInvitations,
480
+ })
481
+ setup = {
482
+ ...(await cloud.progress(setup.requestId, claimToken, progress)),
483
+ claimToken,
484
+ }
485
+ if (requestFingerprint(setup) !== fingerprint)
486
+ throw new Error("Cloud request changed during provisioning")
487
+ }
488
+ if (!setup.binding && local?.binding && local.chatId)
489
+ setup.binding = local.binding
490
+ const binding = setup.binding ?? (await bindRequest(setup, telegram))
491
+ if (!validBinding(binding, setup.expectedUserId))
492
+ throw new Error("Invalid persisted account binding")
493
+ if (!setup.binding)
494
+ await save({
495
+ state: setup.chatId ? "configuring" : "prepared",
496
+ binding,
497
+ chatId: setup.chatId,
498
+ })
499
+ if (!setup.chatId && local?.chatId) {
500
+ if (!(await telegram.verifyMarker(local.chatId, creationMarker(setup))))
501
+ throw new Error(
502
+ "Private receipt chat does not match request marker and owner",
503
+ )
504
+ await save({ state: "configuring", chatId: local.chatId, binding })
505
+ }
506
+ if (!setup.chatId) {
507
+ await save({ state: "creating", binding })
508
+ let chatId: number
509
+ try {
510
+ chatId = await telegram.create(setup.title, creationMarker(setup))
511
+ } catch (error) {
512
+ const rejected = definiteCreateRejection(error)
513
+ await save({
514
+ state: rejected ? "incomplete" : "creation_unknown",
515
+ binding,
516
+ error: safeProvisionError(error),
517
+ ...(rejected ? { creationRejected: true } : {}),
518
+ retryAt: provisionRetryAt(error),
519
+ })
520
+ throw new Error(
521
+ "Group creation failed; inspect authoritative setup state before resuming",
522
+ )
523
+ }
524
+ // Persist immediately, before another network call or cloud acknowledgement.
525
+ await journal.write(setup.requestId, {
526
+ fingerprint,
527
+ state: "configuring",
528
+ chatId,
529
+ binding,
530
+ workerId,
531
+ pendingInvitations,
532
+ })
533
+ await save({ state: "configuring", chatId, binding })
534
+ }
535
+ const chatId = setup.chatId!
536
+ let step: ProvisionStep = "verify_marker"
537
+ try {
538
+ if (!(await telegram.verifyMarker(chatId, creationMarker(setup))))
539
+ throw new Error("Chat marker or custody owner verification failed")
540
+ step = "save_configuring"
541
+ await save({ state: "configuring", chatId, binding })
542
+ for (const userId of [...binding.participants, PROVISIONING_BOT_ID]) {
543
+ step = "read_member"
544
+ const member = await telegram.member(chatId, userId)
545
+ if (member.role !== "absent") {
546
+ pendingInvitations = pendingInvitations.filter(
547
+ (invite) => invite.userId !== String(userId),
548
+ )
549
+ continue
550
+ }
551
+ try {
552
+ step = "add_member"
553
+ await telegram.add(chatId, userId)
554
+ step = "verify_added_member"
555
+ if ((await telegram.member(chatId, userId)).role === "absent")
556
+ throw new Error("Participant invitation incomplete")
557
+ } catch (error) {
558
+ if (
559
+ !binding.optionalParticipants?.includes(userId) ||
560
+ !isPrivacyRestriction(error) ||
561
+ !telegram.inviteLink
562
+ )
563
+ throw error
564
+ let invite = pendingInvitations.find(
565
+ (i) =>
566
+ i.userId === String(userId) && Date.parse(i.expiresAt) > Date.now(),
567
+ )
568
+ if (!invite) {
569
+ step = "create_invitation"
570
+ const link = await telegram.inviteLink(chatId, userId)
571
+ if (
572
+ !/^https:\/\/t\.me\/(?:\+|joinchat\/)[A-Za-z0-9_-]+$/.test(
573
+ link.inviteLink,
574
+ ) ||
575
+ !Number.isFinite(Date.parse(link.expiresAt)) ||
576
+ Date.parse(link.expiresAt) <= Date.now()
577
+ )
578
+ throw new Error("Invalid controlled invitation link")
579
+ invite = {
580
+ userId: String(userId),
581
+ ...link,
582
+ reason: "privacy_restricted",
583
+ }
584
+ pendingInvitations = [
585
+ ...pendingInvitations.filter((i) => i.userId !== invite!.userId),
586
+ invite,
587
+ ]
588
+ step = "save_invitation"
589
+ await save({ state: "configuring", chatId, binding })
590
+ }
591
+ }
592
+ }
593
+ for (const userId of binding.admins) {
594
+ step = "read_admin"
595
+ if (
596
+ !hasRights(await telegram.member(chatId, userId), TEAM_ADMIN_RIGHTS)
597
+ ) {
598
+ step = "promote_admin"
599
+ await telegram.promote(chatId, userId, TEAM_ADMIN_RIGHTS)
600
+ }
601
+ }
602
+ step = "read_bot"
603
+ if (
604
+ !hasRights(
605
+ await telegram.member(chatId, PROVISIONING_BOT_ID),
606
+ BOT_BASELINE_RIGHTS,
607
+ )
608
+ ) {
609
+ step = "promote_bot"
610
+ await telegram.promote(chatId, PROVISIONING_BOT_ID, BOT_BASELINE_RIGHTS)
611
+ }
612
+ step = "audit_membership"
613
+ const owner = await telegram.member(chatId, setup.expectedUserId)
614
+ const participants = await Promise.all(
615
+ binding.participants.map(async (userId) => ({
616
+ userId,
617
+ ...(await telegram.member(chatId, userId)),
618
+ })),
619
+ )
620
+ const bot = await telegram.member(chatId, PROVISIONING_BOT_ID)
621
+ if (
622
+ owner.role !== "creator" ||
623
+ participants.some((member) =>
624
+ binding.admins.includes(member.userId)
625
+ ? !hasRights(member, TEAM_ADMIN_RIGHTS)
626
+ : member.role !== "member" &&
627
+ !(
628
+ binding.optionalParticipants?.includes(member.userId) &&
629
+ member.role === "absent" &&
630
+ pendingInvitations.some(
631
+ (invite) =>
632
+ invite.userId === String(member.userId) &&
633
+ Date.parse(invite.expiresAt) > Date.now(),
634
+ )
635
+ ),
636
+ ) ||
637
+ !hasRights(bot, BOT_BASELINE_RIGHTS)
638
+ )
639
+ throw new Error("Final membership or rights audit failed")
640
+ const verifiedAt = new Date().toISOString()
641
+ // Link retrieval is presentation metadata, not a membership requirement.
642
+ // Preserve readiness even when Telegram temporarily refuses these reads.
643
+ const links = await telegram.completionLinks?.(chatId).catch(() => ({}))
644
+ const receipt = {
645
+ chatId: String(chatId),
646
+ ownerId: String(setup.expectedUserId),
647
+ participants: participants.map((member) => ({
648
+ ...member,
649
+ userId: String(member.userId),
650
+ })),
651
+ bot: { ...bot, userId: String(PROVISIONING_BOT_ID) },
652
+ verifiedAt,
653
+ ...(pendingInvitations.length ? { pendingInvitations } : {}),
654
+ ...links,
655
+ }
656
+ step = "save_ready"
657
+ await save({ state: "ready", chatId, binding, receipt, retryAt: null })
658
+ return setup
659
+ } catch (error) {
660
+ // Never discard known chat identity after partial setup or an ambiguous cloud commit.
661
+ const authoritative = await cloud.view(setup.requestId)
662
+ if (authoritative.state === "ready") return authoritative
663
+ await save({
664
+ state: "incomplete",
665
+ chatId,
666
+ binding,
667
+ error: safeProvisionDiagnostic(error, step),
668
+ retryAt: provisionRetryAt(error),
669
+ })
670
+ throw new Error(
671
+ "Setup incomplete; inspect status and resume the same request",
672
+ )
673
+ }
674
+ }