@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.
- 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 +31 -50
- package/main.ts +442 -100
- 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
package/provisioning.ts
ADDED
|
@@ -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
|
+
}
|