@uniquedivine/tg-sai 0.0.3 → 0.0.5

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 CHANGED
@@ -5,6 +5,30 @@ Sai Telegram admin audit and bootstrap CLI.
5
5
  See file `RUNBOOK.md` for operator setup, profile management, and safe
6
6
  execution workflows.
7
7
 
8
+ ## Install
9
+
10
+ Requires [Bun](https://bun.sh). No clone needed:
11
+
12
+ ```bash
13
+ bunx @uniquedivine/tg-sai --help # run once
14
+ bun add -g @uniquedivine/tg-sai # or install the `tg-sai` command
15
+ tg-sai profile add --name me --login --qr --interactive
16
+ ```
17
+
18
+ Move Sai chats you own to the custody account and add SaiTeam_bot as admin.
19
+ The command is a dry run until you add `--run`:
20
+
21
+ ```bash
22
+ tg-sai ops sai-bootstrap --file chats.txt # plan only
23
+ tg-sai ops sai-bootstrap --file chats.txt --run # apply
24
+ ```
25
+
26
+ File `chats.txt` holds one chat ID per line. Ownership transfer needs your
27
+ Telegram 2FA password in `TELEGRAM_PASSWORD` or the saved profile. The owner
28
+ target defaults to the Sai custody account,
29
+ [@SaiTeam_AI](https://t.me/SaiTeam_AI) (user 8274304175), and the bot is
30
+ [@SaiTeam_bot](https://t.me/SaiTeam_bot). `--target-user` overrides the owner.
31
+
8
32
  ## Development
9
33
 
10
34
  ```bash
package/RUNBOOK.md CHANGED
@@ -35,8 +35,15 @@ Credential profile behavior:
35
35
 
36
36
  - Command `tg-sai profile find` reports discovered Telegram credential sources
37
37
  without writing profile state.
38
- - Command `tg-sai profile add` stores a local profile from explicit flags,
39
- process environment variables, or one unambiguous discovered source.
38
+ - Bare commands `tg-sai profile` and `tg-sai profile add` show help and configured
39
+ profiles without saving state or connecting to Telegram.
40
+ - Command `tg-sai profile add` with explicit options stores a local profile from
41
+ flags, process environment variables, or merged non-conflicting discovered
42
+ sources. Use a distinct `--name` for another account; without it, explicit
43
+ options update the active/default profile.
44
+ - Explicit `--qr`, `--phone`, or `--login` starts a fresh account login and
45
+ ignores discovered/environment sessions and account passwords. App credentials
46
+ can still come from discovery, environment, or interactive input.
40
47
  - Commands `tg-sai profile list`, `tg-sai profile show`, `tg-sai profile use`,
41
48
  and `tg-sai profile remove` manage local session profiles.
42
49
 
@@ -102,11 +109,12 @@ cd /path/to/tg-sai
102
109
  uv sync
103
110
  ```
104
111
 
105
- Create file `.env` in the `tg-sai` directory with Telegram app
106
- credentials:
112
+ Create user-local file `~/.tg-sai/.env` with Telegram app credentials:
107
113
 
108
114
  ```bash
109
- cat > .env <<'EOF'
115
+ mkdir -p ~/.tg-sai
116
+ chmod 700 ~/.tg-sai
117
+ cat > ~/.tg-sai/.env <<'EOF'
110
118
  TELEGRAM_API_ID=<api id from https://my.telegram.org/apps>
111
119
  TELEGRAM_API_HASH=<api hash from https://my.telegram.org/apps>
112
120
  EOF
@@ -121,18 +129,20 @@ The session string is generated after QR login.
121
129
  Generate the session string and save it directly into a `tg-sai` profile:
122
130
 
123
131
  ```bash
124
- tg-sai profile add --name operator --src 1 --login --qr
132
+ tg-sai profile add --name sai-custody --login --qr --interactive
125
133
  ```
126
134
 
127
135
  Scan the QR code in Telegram with **Settings > Devices > Link Desktop Device**.
128
- After login succeeds, command `tg-sai profile add` saves the generated
129
- `sessionString` into profile `operator`.
136
+ Scan while signed into the intended account. After login succeeds, this command
137
+ saves its verified identity and session in the separate `sai-custody` profile.
138
+ The existing active profile stays selected. Inspect the result with command
139
+ `tg-sai profile show sai-custody`.
130
140
 
131
141
  If you generated the session string manually, save it with explicit flags:
132
142
 
133
143
  ```bash
134
144
  tg-sai profile add \
135
- --name operator \
145
+ --name sai-custody \
136
146
  --api-id <api id> \
137
147
  --api-hash <api hash> \
138
148
  --session-string '<session string>'
@@ -142,7 +152,7 @@ Store the Telegram 2FA password in the same profile when ownership transfer is
142
152
  needed:
143
153
 
144
154
  ```bash
145
- tg-sai profile add --name operator --password '<password>'
155
+ tg-sai profile add --name sai-custody --password '<password>'
146
156
  ```
147
157
 
148
158
  Check the local setup:
@@ -169,17 +179,26 @@ Add a local profile from a numbered source shown by command
169
179
  `tg-sai profile find`:
170
180
 
171
181
  ```bash
172
- tg-sai profile add --name operator --src 1
182
+ tg-sai profile add --name another-account --src 1
173
183
  ```
174
184
 
175
185
  Command `tg-sai profile add` can store the Telegram 2FA password with flag
176
186
  `--password`. Command `tg-sai profile show` redacts stored `apiHash`,
177
187
  `sessionString`, and `password`.
178
188
 
189
+ For password or metadata updates, `--name` selects an existing saved profile by
190
+ name, handle, numeric user ID, or saved-list index. The update preserves that
191
+ profile's account identity and session without importing environment or discovered
192
+ credentials. An unknown password-update selector fails instead of creating a
193
+ profile. Explicit login/import cannot replace a known profile with a different
194
+ account; use a new name for another account, or remove the old profile first.
195
+ Flag `--src` selects a credential source numbered by `tg-sai profile find`, not
196
+ the saved-profile index printed by `tg-sai profile list`.
197
+
179
198
  Create a blank draft profile for onboarding:
180
199
 
181
200
  ```bash
182
- tg-sai profile add --name operator
201
+ tg-sai profile add --name draft
183
202
  ```
184
203
 
185
204
  Blank or partial profiles are useful while collecting `apiId`, `apiHash`, and
@@ -190,7 +209,7 @@ Add a local profile from explicit values:
190
209
 
191
210
  ```bash
192
211
  tg-sai profile add \
193
- --name operator \
212
+ --name another-account \
194
213
  --handle <telegram-handle> \
195
214
  --user-id <telegram-user-id> \
196
215
  --display-name "<display name>"
@@ -202,6 +221,11 @@ List local profiles:
202
221
  tg-sai profile list
203
222
  ```
204
223
 
224
+ Command `tg-sai profile` also shows configured profiles beneath its command
225
+ list. Bare command `tg-sai profile add` explains fresh QR enrollment and how
226
+ named accounts differ from updates to the active profile. Neither changes the
227
+ saved configuration.
228
+
205
229
  Find Sai-related chats visible to the active Telegram session:
206
230
 
207
231
  ```bash
@@ -308,10 +332,10 @@ different user is the creator.
308
332
 
309
333
  ## Suggested workflow
310
334
 
311
- 1. Run command `just tg-sai profile find` to inspect available Telegram
335
+ 1. Run command `tg-sai profile find` to inspect available Telegram
312
336
  credential sources.
313
- 2. Run command `just tg-sai profile add --name <name> --src <number>` to
314
- save a local profile from discovery output.
337
+ 2. Run command `tg-sai profile add --qr` to save a local profile and generate a
338
+ missing session string.
315
339
  3. Run command `tg-sai audit --chat <id>` before writes.
316
340
  4. Run command `tg-sai add-bot --chat <id>` or command
317
341
  `tg-sai add-owner --chat <id> --target-user <user>` as a dry-run.
@@ -341,3 +365,100 @@ list regular members as admins. This tool uses per-member lookups
341
365
  - Emit a reviewable table or JSON artifact for batch dry-runs.
342
366
  - Add named rights profiles for bot admin and owner-target admin promotion.
343
367
  - Add fixtures for a completed ownership transfer.
368
+
369
+ ## Custody Chat Provisioning
370
+
371
+ SaiTeam_bot's private `/newdealchat` flow submits an approved request to the
372
+ Deal Desk API. A separate worker creates the private supergroup as custody
373
+ user `8274304175`, profile `SaiTeam_AI`. The bot, Unique, and the requester
374
+ become admins. August and Erick are ordinary default members. The deployed bot
375
+ configuration controls the requester allowlist and defaults.
376
+
377
+ When adding an operator, configure their numeric Telegram user ID in the bot
378
+ allowlist. A fresh custody session may not know that ID's access hash. Add a
379
+ verified handle alias in `provisioningRuntime.ts` for new team operators and
380
+ deploy the worker with it. Resolve the handle through the custody account first
381
+ and verify the exact numeric ID and that it is a user account. The runtime
382
+ rejects handles that later resolve to a different account.
383
+
384
+ For explicit CLI requests, prepare a JSON file with a stable `requestId`,
385
+ `custodyProfile`, `expectedUserId`, `title`, `participants`, `admins`,
386
+ `botRightsProfile`, and `dealIds`. Each admin uses `rightsProfile: "team"`;
387
+ the bot uses `botRightsProfile: "baseline"`. `optionalParticipants` must be a
388
+ subset of ordinary participants. `dealIds: []` registers a chat without
389
+ creating or linking a commercial deal.
390
+
391
+ ```sh
392
+ tg-sai chat provision prepare --file request.json
393
+ tg-sai chat provision prepare --file request.json --run
394
+ tg-sai chat provision run --request-id <request-id>
395
+ tg-sai chat provision run --request-id <request-id> --run
396
+ tg-sai chat provision status --request-id <request-id>
397
+ ```
398
+
399
+ The cloud CLI credential needs the corresponding chat workflow capabilities.
400
+ The background worker uses a dedicated read-only record credential with only
401
+ `chats:work` granted for workflow writes. The worker service gets only the
402
+ custody profile, without personal sessions or ownership-transfer passwords.
403
+
404
+ ```sh
405
+ tg-sai worker --custody-profile SaiTeam_AI --once
406
+ tg-sai worker --custody-profile SaiTeam_AI --once --run
407
+ tg-sai worker --custody-profile SaiTeam_AI --run --interval-ms 5000
408
+ ```
409
+
410
+ Without `--run`, the worker reads one queue page and does not connect to
411
+ Telegram. Execution uses a durable worker identity, account lock, and local
412
+ receipts. Never delete these files to force a retry. An uncertain creation
413
+ blocks further creation until an operator reconciles the original request.
414
+ Use `chat provision resolve --help` to adopt a verified group with its original
415
+ creation marker. Known-group partial setup resumes against that same group.
416
+
417
+ Required admins must be members before setup becomes ready. An ordinary member
418
+ blocked by privacy settings can remain pending with a 24-hour, approval-required
419
+ invite link. A link does not mean the person joined. Handle-based resolution of
420
+ fixed team defaults must verify the expected numeric ID before any invitation.
421
+
422
+ New ready receipts include an `openGroupUrl` anchored to a message actually
423
+ observed in the group and the existing `primaryInviteLink`, when available.
424
+ These read-only lookups run together with a 10-second limit each. A missing link
425
+ does not undo verified setup. Existing primary invitations are never rotated or
426
+ created by this step, and privacy-blocked members keep their separate controlled
427
+ invitations. The bot displays the saved links in its completion reply and
428
+ `/chatstatus`. Completed requests are not rewritten to refresh missing links.
429
+
430
+ The source bot configuration starts with admission in `observe` mode. Check
431
+ `~/ki/boku/epics/comms/telegram-bot.md` and its dated rollout evidence for
432
+ the actual deployed state before testing bot commands.
433
+
434
+
435
+ ## Runtime Health and Deployment Readiness
436
+
437
+ `tg-sai health` now defaults to offline runtime checks. It verifies both bundled
438
+ WASM assets, crypto and compression operations, and actual Telegram client
439
+ preparation with a temporary SQLite store. It does not read profiles or contact
440
+ Telegram. `--json` provides a machine-readable result and failures exit nonzero.
441
+
442
+ ```sh
443
+ tg-sai health --offline --json
444
+ tg-sai health --online --custody-profile SaiTeam_AI --json
445
+ ```
446
+
447
+ Online mode additionally strict-loads custody user `8274304175`, acquires the
448
+ same account lock used by provisioning, and performs bounded `getMe` validation.
449
+ It cannot fall back to a personal account and does not create or modify chats.
450
+ An account-lock conflict fails the check; run it while the worker is stopped or
451
+ idle. The earlier active-profile/workstation diagnostics remain available with
452
+ `tg-sai health --local` and are separate from custody-worker readiness.
453
+
454
+ The deployed executable includes a worker command and the same health commands.
455
+ A release candidate must pass offline health as the worker Unix user before
456
+ installation selects it. The worker systemd unit runs offline and online checks
457
+ before starting the queue loop. A service being alive, `--help`, or an empty
458
+ queue preview does not prove Telegram client initialization.
459
+
460
+ From the Boku Deal Desk source directory, `just bot-health` checks the deployed
461
+ runtime under its service sandbox. `just bot-health --online` also validates
462
+ custody identity. These are operator probes, not the API's `/health` endpoint.
463
+ That endpoint reports API health and whether bot ingress is configured; it does
464
+ not certify Telegram custody readiness.
package/actionExecutor.ts CHANGED
@@ -8,54 +8,54 @@
8
8
  export type BatchActionEvent =
9
9
  | {
10
10
  /** Operation attempt started. */
11
- type: "action_start";
11
+ type: "action_start"
12
12
  /** Telegram chat ID or peer for the attempted action. */
13
- chatId: number | string;
13
+ chatId: number | string
14
14
  /** Stable action name, for example `bot_add`. */
15
- action: string;
15
+ action: string
16
16
  /** One-based attempt number for this action. */
17
- attempt: number;
17
+ attempt: number
18
18
  }
19
19
  | {
20
20
  /** Telegram returned `FLOOD_WAIT_X` and the executor will retry later. */
21
- type: "flood_wait";
21
+ type: "flood_wait"
22
22
  /** Telegram chat ID or peer for the rate-limited action. */
23
- chatId: number | string;
23
+ chatId: number | string
24
24
  /** Stable action name, for example `bot_promote_admin`. */
25
- action: string;
25
+ action: string
26
26
  /** One-based attempt number that received the flood-wait error. */
27
- attempt: number;
27
+ attempt: number
28
28
  /** Seconds Telegram requested in `FLOOD_WAIT_X`. */
29
- waitSeconds: number;
29
+ waitSeconds: number
30
30
  /** Milliseconds the executor sleeps, including configured buffer. */
31
- sleepMs: number;
31
+ sleepMs: number
32
32
  }
33
33
  | {
34
34
  /** Operation completed successfully. */
35
- type: "action_success";
35
+ type: "action_success"
36
36
  /** Telegram chat ID or peer for the successful action. */
37
- chatId: number | string;
37
+ chatId: number | string
38
38
  /** Stable action name, for example `owner_target_add`. */
39
- action: string;
39
+ action: string
40
40
  /** Number of attempts required before success. */
41
- attempts: number;
41
+ attempts: number
42
42
  /** Elapsed milliseconds from first attempt to success. */
43
- elapsedMs: number;
43
+ elapsedMs: number
44
44
  }
45
45
  | {
46
46
  /** Operation failed with a non-retryable error or exhausted retries. */
47
- type: "action_error";
47
+ type: "action_error"
48
48
  /** Telegram chat ID or peer for the failed action. */
49
- chatId: number | string;
49
+ chatId: number | string
50
50
  /** Stable action name, for example `transfer_owner`. */
51
- action: string;
51
+ action: string
52
52
  /** One-based attempt number that produced the final error. */
53
- attempt: number;
53
+ attempt: number
54
54
  /** Error name normalized for logs. */
55
- errorName: string;
55
+ errorName: string
56
56
  /** Error message normalized for logs. */
57
- errorMessage: string;
58
- };
57
+ errorMessage: string
58
+ }
59
59
 
60
60
  /**
61
61
  * Receives structured batch action events.
@@ -64,7 +64,7 @@ export type BatchActionEvent =
64
64
  * Tests can use `MemoryBatchLogger` to inspect event order without parsing text.
65
65
  */
66
66
  export interface BatchLogger {
67
- event(event: BatchActionEvent): void | Promise<void>;
67
+ event(event: BatchActionEvent): void | Promise<void>
68
68
  }
69
69
 
70
70
  /**
@@ -72,9 +72,9 @@ export interface BatchLogger {
72
72
  */
73
73
  export interface TelegramActionContext {
74
74
  /** Telegram chat ID or resolvable chat peer for the action. */
75
- chatId: number | string;
75
+ chatId: number | string
76
76
  /** Stable action name such as `bot_add` or `owner_target_promote_admin`. */
77
- action: string;
77
+ action: string
78
78
  }
79
79
 
80
80
  /**
@@ -85,28 +85,28 @@ export interface TelegramActionContext {
85
85
  */
86
86
  export interface TelegramActionExecutorDeps {
87
87
  /** Event sink for action start, success, error, and flood-wait events. */
88
- logger: BatchLogger;
88
+ logger: BatchLogger
89
89
  /** Sleep function used after Telegram returns `FLOOD_WAIT_X`. */
90
- sleep: (ms: number) => Promise<void>;
90
+ sleep: (ms: number) => Promise<void>
91
91
  /** Clock function used to compute elapsed action time. */
92
- now: () => number;
92
+ now: () => number
93
93
  /** Number of retries after the first failed attempt. */
94
- maxRetries: number;
94
+ maxRetries: number
95
95
  /** Extra milliseconds added to Telegram's required flood-wait duration. */
96
- bufferMs: number;
96
+ bufferMs: number
97
97
  }
98
98
 
99
99
  /**
100
100
  * Test logger that stores every event in insertion order.
101
101
  */
102
102
  export class MemoryBatchLogger implements BatchLogger {
103
- readonly events: BatchActionEvent[] = [];
103
+ readonly events: BatchActionEvent[] = []
104
104
 
105
105
  /**
106
106
  * Appends an event to the in-memory event list.
107
107
  */
108
108
  event(event: BatchActionEvent): void {
109
- this.events.push(event);
109
+ this.events.push(event)
110
110
  }
111
111
  }
112
112
 
@@ -120,16 +120,16 @@ export class MemoryBatchLogger implements BatchLogger {
120
120
  */
121
121
  export function parseFloodWaitSeconds(error: unknown): number | null {
122
122
  if (!(error instanceof Error)) {
123
- return null;
123
+ return null
124
124
  }
125
125
 
126
- const haystack = `${error.name} ${error.message}`;
127
- const match = haystack.match(/FLOOD_WAIT_(\d+)/);
126
+ const haystack = `${error.name} ${error.message}`
127
+ const match = haystack.match(/FLOOD_WAIT_(\d+)/)
128
128
  if (!match) {
129
- return null;
129
+ return null
130
130
  }
131
131
 
132
- return Number(match[1]);
132
+ return Number(match[1])
133
133
  }
134
134
 
135
135
  /**
@@ -137,9 +137,9 @@ export function parseFloodWaitSeconds(error: unknown): number | null {
137
137
  */
138
138
  export function errorName(error: unknown): string {
139
139
  if (error instanceof Error && error.name) {
140
- return error.name;
140
+ return error.name
141
141
  }
142
- return "UnknownError";
142
+ return "UnknownError"
143
143
  }
144
144
 
145
145
  /**
@@ -147,9 +147,9 @@ export function errorName(error: unknown): string {
147
147
  */
148
148
  export function errorMessage(error: unknown): string {
149
149
  if (error instanceof Error) {
150
- return error.message;
150
+ return error.message
151
151
  }
152
- return String(error);
152
+ return String(error)
153
153
  }
154
154
 
155
155
  /**
@@ -165,8 +165,8 @@ export async function withFloodWaitRetry<T>(
165
165
  operation: () => Promise<T>,
166
166
  deps: TelegramActionExecutorDeps,
167
167
  ): Promise<T> {
168
- const startedAt = deps.now();
169
- const maxAttempts = deps.maxRetries + 1;
168
+ const startedAt = deps.now()
169
+ const maxAttempts = deps.maxRetries + 1
170
170
 
171
171
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
172
172
  await deps.logger.event({
@@ -174,21 +174,21 @@ export async function withFloodWaitRetry<T>(
174
174
  chatId: context.chatId,
175
175
  action: context.action,
176
176
  attempt,
177
- });
177
+ })
178
178
 
179
179
  try {
180
- const result = await operation();
180
+ const result = await operation()
181
181
  await deps.logger.event({
182
182
  type: "action_success",
183
183
  chatId: context.chatId,
184
184
  action: context.action,
185
185
  attempts: attempt,
186
186
  elapsedMs: deps.now() - startedAt,
187
- });
188
- return result;
187
+ })
188
+ return result
189
189
  } catch (error) {
190
- const waitSeconds = parseFloodWaitSeconds(error);
191
- const canRetry = waitSeconds !== null && attempt < maxAttempts;
190
+ const waitSeconds = parseFloodWaitSeconds(error)
191
+ const canRetry = waitSeconds !== null && attempt < maxAttempts
192
192
 
193
193
  if (!canRetry) {
194
194
  await deps.logger.event({
@@ -198,11 +198,11 @@ export async function withFloodWaitRetry<T>(
198
198
  attempt,
199
199
  errorName: errorName(error),
200
200
  errorMessage: errorMessage(error),
201
- });
202
- throw error;
201
+ })
202
+ throw error
203
203
  }
204
204
 
205
- const sleepMs = waitSeconds * 1000 + deps.bufferMs;
205
+ const sleepMs = waitSeconds * 1000 + deps.bufferMs
206
206
  await deps.logger.event({
207
207
  type: "flood_wait",
208
208
  chatId: context.chatId,
@@ -210,10 +210,10 @@ export async function withFloodWaitRetry<T>(
210
210
  attempt,
211
211
  waitSeconds,
212
212
  sleepMs,
213
- });
214
- await deps.sleep(sleepMs);
213
+ })
214
+ await deps.sleep(sleepMs)
215
215
  }
216
216
  }
217
217
 
218
- throw new Error(`unreachable action executor state: ${context.action}`);
218
+ throw new Error(`unreachable action executor state: ${context.action}`)
219
219
  }