@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/mtcuteAdapter.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { TelegramClient } from "@mtcute/bun"
|
|
1
|
+
import { networkMiddlewares, TelegramClient } from "@mtcute/bun"
|
|
2
|
+
import type {
|
|
3
|
+
FloodWaiterOptions,
|
|
4
|
+
RpcCallMiddleware,
|
|
5
|
+
RpcCallOptions,
|
|
6
|
+
} from "@mtcute/bun"
|
|
7
|
+
import { createTelegramCryptoProvider } from "./telegramRuntime"
|
|
2
8
|
import {
|
|
3
9
|
addChatMembers,
|
|
4
10
|
editAdminRights,
|
|
@@ -7,10 +13,20 @@ import {
|
|
|
7
13
|
transferChatOwnership,
|
|
8
14
|
} from "@mtcute/bun/methods.js"
|
|
9
15
|
import { convertFromTelethonSession } from "@mtcute/convert"
|
|
10
|
-
|
|
11
16
|
import type { ChatAuditInput, ChatMemberData, UserData } from "./adminAudit"
|
|
12
17
|
import type { SaiChatRecord } from "./ops"
|
|
13
18
|
import { isSaiChatTitle } from "./ops"
|
|
19
|
+
import type { ProfileStoreEnv } from "./profiles"
|
|
20
|
+
import { stateDbPath } from "./state/db"
|
|
21
|
+
import { createFloodGuard } from "./state/guard"
|
|
22
|
+
import type {
|
|
23
|
+
FloodGuard,
|
|
24
|
+
HoldAccountProvider,
|
|
25
|
+
HoldStoreSource,
|
|
26
|
+
} from "./state/guard"
|
|
27
|
+
import { HoldStore, ReopeningHoldStore } from "./state/holds"
|
|
28
|
+
import { createTelemetryMiddlewares } from "./telemetry/middleware"
|
|
29
|
+
import type { ClientTelemetry } from "./telemetry/middleware"
|
|
14
30
|
|
|
15
31
|
/**
|
|
16
32
|
* Connection settings used to create a mtcute Telegram client.
|
|
@@ -24,6 +40,17 @@ export interface MtcuteClientConfig {
|
|
|
24
40
|
session: string
|
|
25
41
|
/** Optional local mtcute storage path. */
|
|
26
42
|
storage?: string
|
|
43
|
+
/**
|
|
44
|
+
* Flood-wait settings for mtcute's flood waiter, the only flood retry
|
|
45
|
+
* layer. Ignored when `telemetry.floodWaiter` is set.
|
|
46
|
+
*/
|
|
47
|
+
floodWaiter?: FloodWaiterOptions
|
|
48
|
+
/**
|
|
49
|
+
* Verified Telegram user ID the session acts as (checked against
|
|
50
|
+
* `getMe` by the caller); keys the client's saved flood holds. Unset: the
|
|
51
|
+
* guard keys holds by the account the session's own `getMe` reports.
|
|
52
|
+
*/
|
|
53
|
+
accountId?: number
|
|
27
54
|
}
|
|
28
55
|
|
|
29
56
|
/**
|
|
@@ -90,17 +117,228 @@ function isPeerNotFound(error: unknown): boolean {
|
|
|
90
117
|
)
|
|
91
118
|
}
|
|
92
119
|
|
|
120
|
+
/** mtcute's `floodWaiter` default `maxWait`. */
|
|
121
|
+
export const MTCUTE_DEFAULT_FLOOD_MAX_WAIT_MS = 10_000
|
|
122
|
+
|
|
123
|
+
const holdStores = new Map<string, ReopeningHoldStore>()
|
|
124
|
+
|
|
93
125
|
/**
|
|
94
|
-
*
|
|
126
|
+
* The process's `HoldStore` for `state.db` under `env`'s config dir, shared
|
|
127
|
+
* by every client. Never throws: a store that cannot open reports
|
|
128
|
+
* `unavailable`, so the guard fails closed, and a later call tries the
|
|
129
|
+
* open again at most every `HOLD_STORE_REOPEN_MS`.
|
|
95
130
|
*/
|
|
96
|
-
export
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
131
|
+
export function processHoldStore(
|
|
132
|
+
env: ProfileStoreEnv = process.env,
|
|
133
|
+
nowMs: number = Date.now(),
|
|
134
|
+
): HoldStore {
|
|
135
|
+
let path: string
|
|
136
|
+
try {
|
|
137
|
+
path = stateDbPath(env)
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return HoldStore.unavailable(
|
|
140
|
+
error instanceof Error ? error.message : String(error),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
let handle = holdStores.get(path)
|
|
144
|
+
if (!handle) {
|
|
145
|
+
handle = new ReopeningHoldStore(path)
|
|
146
|
+
holdStores.set(path, handle)
|
|
147
|
+
}
|
|
148
|
+
return handle.get(nowMs)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Closes every store `processHoldStore` opened; the next use reopens. */
|
|
152
|
+
export function closeProcessHoldStores(): void {
|
|
153
|
+
for (const handle of holdStores.values()) handle.close()
|
|
154
|
+
holdStores.clear()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Telegram user a client's session acts as, learned from the session's own
|
|
159
|
+
* `getMe` (a `users.getUsers` call for `inputUserSelf`). mtcute's stored
|
|
160
|
+
* self is not used: the shared storage file can carry another session's.
|
|
161
|
+
*/
|
|
162
|
+
export interface SessionAccount {
|
|
163
|
+
id: number | null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Options for `clientMiddlewares` beyond the flood waiter and telemetry. */
|
|
167
|
+
export interface ClientMiddlewareOptions {
|
|
168
|
+
/**
|
|
169
|
+
* Verified account for the flood guard (e.g. provisioning's
|
|
170
|
+
* `expectedUserId`, checked against `getMe`). Unset: the guard keys by
|
|
171
|
+
* `sessionAccount`.
|
|
172
|
+
*/
|
|
173
|
+
accountId?: number
|
|
174
|
+
/** Filled from the session's `getMe` results; default a fresh cell. */
|
|
175
|
+
sessionAccount?: SessionAccount
|
|
176
|
+
/** Runs `getMe` so `sessionAccount` is known before invite/create. */
|
|
177
|
+
resolveSessionAccount?: () => Promise<unknown>
|
|
178
|
+
/** Hold store; default `processHoldStore()`, opened on first use. */
|
|
179
|
+
holdStore?: HoldStoreSource
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface SelfRequest {
|
|
183
|
+
_?: unknown
|
|
184
|
+
id?: unknown
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Whether `request` is `getMe`'s `users.getUsers([inputUserSelf])`. */
|
|
188
|
+
export function isGetMeRequest(request: unknown): boolean {
|
|
189
|
+
const value = request as SelfRequest | null
|
|
190
|
+
return (
|
|
191
|
+
value?._ === "users.getUsers" &&
|
|
192
|
+
Array.isArray(value.id) &&
|
|
193
|
+
value.id.length === 1 &&
|
|
194
|
+
(value.id[0] as { _?: unknown } | null)?._ === "inputUserSelf"
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Innermost: saves the user ID from each `getMe` result in `account`. */
|
|
199
|
+
function captureSessionAccount(account: SessionAccount): RpcCallMiddleware {
|
|
200
|
+
return async (ctx, next) => {
|
|
201
|
+
const result = await next(ctx)
|
|
202
|
+
try {
|
|
203
|
+
if (isGetMeRequest(ctx.request) && Array.isArray(result)) {
|
|
204
|
+
const user = result[0] as { _?: unknown; id?: unknown } | undefined
|
|
205
|
+
if (
|
|
206
|
+
user?._ === "user" &&
|
|
207
|
+
typeof user.id === "number" &&
|
|
208
|
+
Number.isSafeInteger(user.id) &&
|
|
209
|
+
user.id > 0
|
|
210
|
+
) {
|
|
211
|
+
account.id = user.id
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// Capturing must never change the call's result.
|
|
216
|
+
}
|
|
217
|
+
return result
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `outer` runs first and hands `inner` the rest of the chain. */
|
|
222
|
+
function chainMiddleware(
|
|
223
|
+
outer: RpcCallMiddleware,
|
|
224
|
+
inner: RpcCallMiddleware,
|
|
225
|
+
): RpcCallMiddleware {
|
|
226
|
+
return (ctx, next) => outer(ctx, (c) => inner(c, next))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Network middlewares for a client. Passing middlewares replaces mtcute's
|
|
231
|
+
* defaults, so both branches include `networkMiddlewares.basic()` (flood
|
|
232
|
+
* waiter and 5xx retry) with the same options, wrapped by the flood guard:
|
|
233
|
+
*
|
|
234
|
+
* - without telemetry: `[check, ...basic({ floodWaiter }), record]`
|
|
235
|
+
* - with telemetry: `[outer, check, ...basic(), inner, record]`
|
|
236
|
+
*
|
|
237
|
+
* The guard saves waits longer than the flood waiter's `maxWait` (and all
|
|
238
|
+
* invite/create waits and `PEER_FLOOD`) and refuses held calls in every
|
|
239
|
+
* process. It keys holds by `accountId` when given, else by the account
|
|
240
|
+
* the session's own `getMe` reports. Until that is known, invite/create
|
|
241
|
+
* calls first run `resolveSessionAccount` and are refused with
|
|
242
|
+
* `HoldAccountUnknownError` if it fails; other calls pass unguarded.
|
|
243
|
+
*/
|
|
244
|
+
export function clientMiddlewares(
|
|
245
|
+
floodWaiter: FloodWaiterOptions | undefined,
|
|
246
|
+
telemetry: ClientTelemetry | undefined,
|
|
247
|
+
options: ClientMiddlewareOptions = {},
|
|
248
|
+
): RpcCallMiddleware[] {
|
|
249
|
+
const waiter = telemetry?.floodWaiter ?? floodWaiter
|
|
250
|
+
const session = options.sessionAccount ?? { id: null }
|
|
251
|
+
const verified = options.accountId
|
|
252
|
+
const accountProvider: HoldAccountProvider = () => verified ?? session.id
|
|
253
|
+
const guardOptions = {
|
|
254
|
+
sleepThresholdMs: waiter?.maxWait ?? MTCUTE_DEFAULT_FLOOD_MAX_WAIT_MS,
|
|
255
|
+
...(verified === undefined
|
|
256
|
+
? {
|
|
257
|
+
requireAccount: true,
|
|
258
|
+
...(options.resolveSessionAccount
|
|
259
|
+
? { resolveAccount: options.resolveSessionAccount }
|
|
260
|
+
: {}),
|
|
261
|
+
}
|
|
262
|
+
: {}),
|
|
263
|
+
}
|
|
264
|
+
const store: HoldStoreSource = options.holdStore ?? (() => processHoldStore())
|
|
265
|
+
const capture = captureSessionAccount(session)
|
|
266
|
+
const withCapture = (guard: FloodGuard): FloodGuard => ({
|
|
267
|
+
check: guard.check,
|
|
268
|
+
record: chainMiddleware(guard.record, capture),
|
|
269
|
+
})
|
|
270
|
+
if (telemetry) {
|
|
271
|
+
return createTelemetryMiddlewares(
|
|
272
|
+
telemetry.sink,
|
|
273
|
+
telemetry.context,
|
|
274
|
+
waiter,
|
|
275
|
+
(requestIdOf) =>
|
|
276
|
+
withCapture(
|
|
277
|
+
createFloodGuard(store, accountProvider, {
|
|
278
|
+
...guardOptions,
|
|
279
|
+
requestIdOf,
|
|
280
|
+
}),
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
const guard = withCapture(
|
|
285
|
+
createFloodGuard(store, accountProvider, guardOptions),
|
|
286
|
+
)
|
|
287
|
+
return [
|
|
288
|
+
guard.check,
|
|
289
|
+
...networkMiddlewares.basic(waiter ? { floodWaiter: waiter } : undefined),
|
|
290
|
+
guard.record,
|
|
291
|
+
]
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const sessionAccounts = new WeakMap<TelegramClient, SessionAccount>()
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The account a client's session acts as, once its `getMe` has run; `null`
|
|
298
|
+
* before that, and for clients not made by `createMtcuteRuntimeClient`.
|
|
299
|
+
*/
|
|
300
|
+
export function sessionAccountId(client: TelegramClient): number | null {
|
|
301
|
+
return sessionAccounts.get(client)?.id ?? null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Shared constructor used by offline readiness and authenticated operations.
|
|
306
|
+
* Every client gets the flood guard (see `clientMiddlewares`). With
|
|
307
|
+
* `telemetry`, every RPC call, wire attempt, and flood wait is recorded.
|
|
308
|
+
*/
|
|
309
|
+
export function createMtcuteRuntimeClient(
|
|
310
|
+
config: Omit<MtcuteClientConfig, "session">,
|
|
311
|
+
telemetry?: ClientTelemetry,
|
|
312
|
+
): TelegramClient {
|
|
313
|
+
const sessionAccount: SessionAccount = { id: null }
|
|
314
|
+
let client: TelegramClient | null = null
|
|
315
|
+
const middlewares = clientMiddlewares(config.floodWaiter, telemetry, {
|
|
316
|
+
accountId: config.accountId,
|
|
317
|
+
sessionAccount,
|
|
318
|
+
resolveSessionAccount: async () => {
|
|
319
|
+
if (!client) throw new Error("client not constructed")
|
|
320
|
+
return client.getMe()
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
client = new TelegramClient({
|
|
324
|
+
crypto: createTelegramCryptoProvider(),
|
|
100
325
|
apiId: config.apiId,
|
|
101
326
|
apiHash: config.apiHash,
|
|
102
327
|
storage: config.storage ?? "sai-telegram-admin-check.session",
|
|
328
|
+
network: { middlewares },
|
|
103
329
|
})
|
|
330
|
+
sessionAccounts.set(client, sessionAccount)
|
|
331
|
+
return client
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Creates and authenticates a mtcute client from a Telethon session string.
|
|
336
|
+
*/
|
|
337
|
+
export async function createMtcuteClient(
|
|
338
|
+
config: MtcuteClientConfig,
|
|
339
|
+
telemetry?: ClientTelemetry,
|
|
340
|
+
): Promise<TelegramClient> {
|
|
341
|
+
const client = createMtcuteRuntimeClient(config, telemetry)
|
|
104
342
|
|
|
105
343
|
await client.importSession(convertFromTelethonSession(config.session), true)
|
|
106
344
|
return client
|
|
@@ -226,23 +464,41 @@ export async function findSaiChats(
|
|
|
226
464
|
}
|
|
227
465
|
|
|
228
466
|
/**
|
|
229
|
-
*
|
|
467
|
+
* Call options for writes that may hit an account-wide limit: mtcute never
|
|
468
|
+
* sleeps through a flood wait and resends, so a wait reaches the caller
|
|
469
|
+
* and the flood guard saves it as a hold.
|
|
470
|
+
*/
|
|
471
|
+
export const NO_FLOOD_SLEEP: Readonly<RpcCallOptions> = {
|
|
472
|
+
floodSleepThreshold: 0,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Adds a user or bot peer to a Telegram chat. Never sleeps through a flood
|
|
477
|
+
* wait (`NO_FLOOD_SLEEP`).
|
|
230
478
|
*/
|
|
231
479
|
export async function addTargetToChat(
|
|
232
480
|
client: TelegramClient,
|
|
233
481
|
params: TargetWriteParams,
|
|
234
482
|
): Promise<void> {
|
|
235
|
-
await addChatMembers(
|
|
483
|
+
await addChatMembers(
|
|
484
|
+
client.withParams(NO_FLOOD_SLEEP),
|
|
485
|
+
params.chatId,
|
|
486
|
+
params.targetPeer,
|
|
487
|
+
{},
|
|
488
|
+
)
|
|
236
489
|
}
|
|
237
490
|
|
|
238
491
|
/**
|
|
239
492
|
* Promotes a user or bot peer using the supplied admin-rights profile.
|
|
493
|
+
* Promoting a non-member also adds them, so by default the call never
|
|
494
|
+
* sleeps through a flood wait; pass `{}` for mtcute's usual flood sleep.
|
|
240
495
|
*/
|
|
241
496
|
export async function promoteTargetAdmin(
|
|
242
497
|
client: TelegramClient,
|
|
243
498
|
params: PromoteTargetParams,
|
|
499
|
+
callParams: RpcCallOptions = NO_FLOOD_SLEEP,
|
|
244
500
|
): Promise<void> {
|
|
245
|
-
await editAdminRights(client, {
|
|
501
|
+
await editAdminRights(client.withParams(callParams), {
|
|
246
502
|
chatId: params.chatId,
|
|
247
503
|
userId: params.targetPeer,
|
|
248
504
|
rights: params.rights,
|
package/ops.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { USERS } from "./cfg"
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* One chat entry consumed by command `tg-sai ops sai-bootstrap`.
|
|
3
5
|
*
|
|
@@ -103,3 +105,13 @@ export function parseChatInputFile(contents: string): ChatInput[] {
|
|
|
103
105
|
export function formatSaiChatJsonLine(record: SaiChatRecord): string {
|
|
104
106
|
return JSON.stringify(record)
|
|
105
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolves the owner target for `ops sai-bootstrap`. Defaults to the Sai
|
|
111
|
+
* custody account so a bare run never hands chats to a personal account.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveBootstrapTarget(
|
|
114
|
+
targetUser: number | string | undefined,
|
|
115
|
+
): number | string {
|
|
116
|
+
return targetUser ?? USERS.saiCustody.id
|
|
117
|
+
}
|
package/package.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniquedivine/tg-sai",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Sai Telegram admin audit and bootstrap CLI",
|
|
7
7
|
"engines": {
|
|
8
|
-
"bun": ">=1.
|
|
8
|
+
"bun": ">=1.2.0"
|
|
9
9
|
},
|
|
10
10
|
"bin": {
|
|
11
|
-
"tg-sai": "
|
|
11
|
+
"tg-sai": "main.ts"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
14
|
"start": "bun main.ts",
|
|
15
15
|
"test": "bun test",
|
|
16
|
-
"typecheck": "
|
|
16
|
+
"typecheck": "bun check",
|
|
17
17
|
"py-test": "uv run pytest session_string_generator_test.py"
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
@@ -21,6 +21,14 @@
|
|
|
21
21
|
"!*.test.ts",
|
|
22
22
|
"!adminCheck.ts",
|
|
23
23
|
"!testdata.ts",
|
|
24
|
+
"!testPreload.ts",
|
|
25
|
+
"telemetry/*.ts",
|
|
26
|
+
"!telemetry/*.test.ts",
|
|
27
|
+
"!telemetry/testEvents.ts",
|
|
28
|
+
"telemetry/migrations/*.sql",
|
|
29
|
+
"state/*.ts",
|
|
30
|
+
"!state/*.test.ts",
|
|
31
|
+
"state/migrations/*.sql",
|
|
24
32
|
"*.py",
|
|
25
33
|
"!*_test.py",
|
|
26
34
|
"pyproject.toml",
|
|
@@ -30,12 +38,13 @@
|
|
|
30
38
|
],
|
|
31
39
|
"license": "UNLICENSED",
|
|
32
40
|
"devDependencies": {
|
|
33
|
-
"@types/bun": "^1.3
|
|
41
|
+
"@types/bun": "^1.4.3",
|
|
34
42
|
"typescript": "^6.0.3"
|
|
35
43
|
},
|
|
36
44
|
"dependencies": {
|
|
37
45
|
"@mtcute/bun": "^0.30.1",
|
|
38
46
|
"@mtcute/convert": "^0.30.1",
|
|
47
|
+
"@mtcute/wasm": "^0.29.0",
|
|
39
48
|
"commander": "^15.0.0"
|
|
40
49
|
}
|
|
41
50
|
}
|