@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/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
- /** Shared constructor used by offline readiness and authenticated operations. */
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
- return new 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({
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
- * 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`).
238
478
  */
239
479
  export async function addTargetToChat(
240
480
  client: TelegramClient,
241
481
  params: TargetWriteParams,
242
482
  ): Promise<void> {
243
- await addChatMembers(client, params.chatId, params.targetPeer, {})
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.5",
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
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
- export async function resolveTelegramCredentials(
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<TelegramCredentials> {
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,