@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.
- package/README.md +41 -0
- package/RUNBOOK.md +204 -24
- package/actionExecutor.ts +183 -131
- package/adminAudit.ts +60 -60
- package/captureProvisioningGolden.ts +98 -0
- package/cfg.ts +12 -1
- package/custodyWorker.ts +501 -0
- package/custodyWorkerCli.ts +58 -0
- package/health.ts +31 -50
- package/main.ts +860 -214
- package/mtcuteAdapter.ts +266 -10
- package/ops.ts +12 -0
- package/package.json +14 -5
- package/profiles.ts +157 -32
- package/provisioning.ts +805 -0
- package/provisioningCli.ts +147 -0
- package/provisioningCloud.ts +214 -0
- package/provisioningGoldens.ts +70 -0
- package/provisioningRuntime.ts +417 -0
- package/runtimeAssets.d.ts +4 -0
- package/runtimeHealth.ts +132 -0
- package/runtimeHealthCli.ts +48 -0
- package/session_string_generator.py +36 -24
- package/state/cli.ts +641 -0
- package/state/db.ts +72 -0
- package/state/guard.ts +270 -0
- package/state/holds.ts +381 -0
- package/state/migrations/0001-flood-hold.sql +16 -0
- package/telegramRuntime.ts +25 -0
- package/telemetry/cli.ts +698 -0
- package/telemetry/db.ts +471 -0
- package/telemetry/middleware.ts +475 -0
- package/telemetry/migrations/0001-event.sql +33 -0
- package/telemetry/migrations/0002-event-job-index.sql +2 -0
- package/telemetry/migrations/0003-event-log.sql +10 -0
- package/telemetry/queries.ts +475 -0
- package/telemetry/run.ts +491 -0
- package/telemetry/sqlAssets.d.ts +4 -0
- package/telemetry/summarize.ts +219 -0
package/provisioning.ts
ADDED
|
@@ -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
|
+
}
|