@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/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
- * Creates and authenticates a mtcute client from a Telethon session string.
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 async function createMtcuteClient(
97
- config: MtcuteClientConfig,
98
- ): Promise<TelegramClient> {
99
- const client = new TelegramClient({
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
- * Adds a user or bot peer to a Telegram chat.
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(client, params.chatId, params.targetPeer, {})
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.4",
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.0.0"
8
+ "bun": ">=1.2.0"
9
9
  },
10
10
  "bin": {
11
- "tg-sai": "./main.ts"
11
+ "tg-sai": "main.ts"
12
12
  },
13
13
  "scripts": {
14
14
  "start": "bun main.ts",
15
15
  "test": "bun test",
16
- "typecheck": "tsc --noEmit",
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.14",
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
  }