@uniquedivine/tg-sai 0.0.5 → 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 +17 -0
- package/RUNBOOK.md +67 -8
- package/actionExecutor.ts +157 -105
- package/custodyWorker.ts +274 -32
- package/custodyWorkerCli.ts +7 -1
- package/main.ts +422 -118
- package/mtcuteAdapter.ts +255 -7
- package/package.json +10 -2
- package/profiles.ts +23 -5
- package/provisioning.ts +175 -44
- package/provisioningCli.ts +8 -1
- package/provisioningRuntime.ts +42 -5
- package/runtimeHealth.ts +8 -1
- package/runtimeHealthCli.ts +3 -1
- 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/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,9 @@
|
|
|
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"
|
|
2
7
|
import { createTelegramCryptoProvider } from "./telegramRuntime"
|
|
3
8
|
import {
|
|
4
9
|
addChatMembers,
|
|
@@ -11,6 +16,17 @@ import { convertFromTelethonSession } from "@mtcute/convert"
|
|
|
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,16 +117,218 @@ function isPeerNotFound(error: unknown): boolean {
|
|
|
90
117
|
)
|
|
91
118
|
}
|
|
92
119
|
|
|
93
|
-
/**
|
|
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
|
+
|
|
125
|
+
/**
|
|
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`.
|
|
130
|
+
*/
|
|
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
|
+
*/
|
|
94
309
|
export function createMtcuteRuntimeClient(
|
|
95
310
|
config: Omit<MtcuteClientConfig, "session">,
|
|
311
|
+
telemetry?: ClientTelemetry,
|
|
96
312
|
): TelegramClient {
|
|
97
|
-
|
|
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({
|
|
98
324
|
crypto: createTelegramCryptoProvider(),
|
|
99
325
|
apiId: config.apiId,
|
|
100
326
|
apiHash: config.apiHash,
|
|
101
327
|
storage: config.storage ?? "sai-telegram-admin-check.session",
|
|
328
|
+
network: { middlewares },
|
|
102
329
|
})
|
|
330
|
+
sessionAccounts.set(client, sessionAccount)
|
|
331
|
+
return client
|
|
103
332
|
}
|
|
104
333
|
|
|
105
334
|
/**
|
|
@@ -107,8 +336,9 @@ export function createMtcuteRuntimeClient(
|
|
|
107
336
|
*/
|
|
108
337
|
export async function createMtcuteClient(
|
|
109
338
|
config: MtcuteClientConfig,
|
|
339
|
+
telemetry?: ClientTelemetry,
|
|
110
340
|
): Promise<TelegramClient> {
|
|
111
|
-
const client = createMtcuteRuntimeClient(config)
|
|
341
|
+
const client = createMtcuteRuntimeClient(config, telemetry)
|
|
112
342
|
|
|
113
343
|
await client.importSession(convertFromTelethonSession(config.session), true)
|
|
114
344
|
return client
|
|
@@ -234,23 +464,41 @@ export async function findSaiChats(
|
|
|
234
464
|
}
|
|
235
465
|
|
|
236
466
|
/**
|
|
237
|
-
*
|
|
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`).
|
|
238
478
|
*/
|
|
239
479
|
export async function addTargetToChat(
|
|
240
480
|
client: TelegramClient,
|
|
241
481
|
params: TargetWriteParams,
|
|
242
482
|
): Promise<void> {
|
|
243
|
-
await addChatMembers(
|
|
483
|
+
await addChatMembers(
|
|
484
|
+
client.withParams(NO_FLOOD_SLEEP),
|
|
485
|
+
params.chatId,
|
|
486
|
+
params.targetPeer,
|
|
487
|
+
{},
|
|
488
|
+
)
|
|
244
489
|
}
|
|
245
490
|
|
|
246
491
|
/**
|
|
247
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.
|
|
248
495
|
*/
|
|
249
496
|
export async function promoteTargetAdmin(
|
|
250
497
|
client: TelegramClient,
|
|
251
498
|
params: PromoteTargetParams,
|
|
499
|
+
callParams: RpcCallOptions = NO_FLOOD_SLEEP,
|
|
252
500
|
): Promise<void> {
|
|
253
|
-
await editAdminRights(client, {
|
|
501
|
+
await editAdminRights(client.withParams(callParams), {
|
|
254
502
|
chatId: params.chatId,
|
|
255
503
|
userId: params.targetPeer,
|
|
256
504
|
rights: params.rights,
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
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
11
|
"tg-sai": "main.ts"
|
|
@@ -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",
|
package/profiles.ts
CHANGED
|
@@ -771,13 +771,24 @@ export async function credentialsFromProfile(
|
|
|
771
771
|
return credentialsFromProfileRecord(profile)
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
-
|
|
774
|
+
/** Where `resolveTelegramCredentialSource` found the credentials. */
|
|
775
|
+
export type TelegramCredentialSource = "env" | "profile" | "known_file"
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Credentials for `profileName` and where they came from: `TELEGRAM_*`
|
|
779
|
+
* environment variables, the selected profile, or a known credential file.
|
|
780
|
+
* Only `profile` credentials match the profile's saved `userId`.
|
|
781
|
+
*/
|
|
782
|
+
export async function resolveTelegramCredentialSource(
|
|
775
783
|
profileName: string | undefined,
|
|
776
784
|
env: ProfileStoreEnv = process.env,
|
|
777
|
-
): Promise<
|
|
785
|
+
): Promise<{
|
|
786
|
+
credentials: TelegramCredentials
|
|
787
|
+
source: TelegramCredentialSource
|
|
788
|
+
}> {
|
|
778
789
|
const envCredentials = credentialsFromEnv(env)
|
|
779
790
|
if (envCredentials) {
|
|
780
|
-
return envCredentials
|
|
791
|
+
return { credentials: envCredentials, source: "env" }
|
|
781
792
|
}
|
|
782
793
|
|
|
783
794
|
const config = await readConfig(env)
|
|
@@ -786,7 +797,7 @@ export async function resolveTelegramCredentials(
|
|
|
786
797
|
? credentialsFromProfileRecord(selectedProfile)
|
|
787
798
|
: null
|
|
788
799
|
if (profileCredentials) {
|
|
789
|
-
return profileCredentials
|
|
800
|
+
return { credentials: profileCredentials, source: "profile" }
|
|
790
801
|
}
|
|
791
802
|
if (selectedProfile) {
|
|
792
803
|
throw new Error(
|
|
@@ -796,7 +807,7 @@ export async function resolveTelegramCredentials(
|
|
|
796
807
|
|
|
797
808
|
const knownFileCredentials = await credentialsFromKnownFiles(env)
|
|
798
809
|
if (knownFileCredentials) {
|
|
799
|
-
return knownFileCredentials
|
|
810
|
+
return { credentials: knownFileCredentials, source: "known_file" }
|
|
800
811
|
}
|
|
801
812
|
|
|
802
813
|
throw new Error(
|
|
@@ -804,6 +815,13 @@ export async function resolveTelegramCredentials(
|
|
|
804
815
|
)
|
|
805
816
|
}
|
|
806
817
|
|
|
818
|
+
export async function resolveTelegramCredentials(
|
|
819
|
+
profileName: string | undefined,
|
|
820
|
+
env: ProfileStoreEnv = process.env,
|
|
821
|
+
): Promise<TelegramCredentials> {
|
|
822
|
+
return (await resolveTelegramCredentialSource(profileName, env)).credentials
|
|
823
|
+
}
|
|
824
|
+
|
|
807
825
|
export function profileInputFromUser(
|
|
808
826
|
name: string | undefined,
|
|
809
827
|
credentials: TelegramCredentials,
|