@uniquedivine/tg-sai 0.0.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ # tg-sai
2
+
3
+ Sai Telegram admin audit and bootstrap CLI.
4
+
5
+ See file `RUNBOOK.md` for operator setup, profile management, and safe
6
+ execution workflows.
7
+
8
+ ## Development
9
+
10
+ ```bash
11
+ bun install
12
+ bun test
13
+ bun run typecheck
14
+ uv run pytest session_string_generator_test.py
15
+ ```
16
+
17
+ Run the CLI from this package directory:
18
+
19
+ ```bash
20
+ bun main.ts --help
21
+ ```
package/RUNBOOK.md ADDED
@@ -0,0 +1,343 @@
1
+ # Sai Telegram admin bootstrap runbook
2
+
3
+ Use this when Claude web + MCP is not enough. MCP admin tools can mis-report
4
+ roles on **Group (Basic)** chats. This folder holds a TypeScript + mtcute tool
5
+ that uses the correct Telegram API paths and keeps decision logic testable
6
+ offline.
7
+
8
+ Location: `jiyuu/ts-pkg/tg-sai/`
9
+
10
+ ## What the tool does today
11
+
12
+ Command `just tg-sai` exposes a Commander CLI for one selected Telegram chat at
13
+ a time. The command prints help and exits with status code `0` when called with
14
+ no arguments.
15
+
16
+ Implemented read-only behavior:
17
+
18
+ - Command `tg-sai audit` verifies the active session user with `getMe()`.
19
+ - Command `tg-sai audit` checks the session user's role in the target chat.
20
+ - Command `tg-sai audit` checks a target user or bot role in the target chat.
21
+ - Function `planAdminBootstrap` returns proposed add, promote, skip, and noop
22
+ actions without Telegram writes.
23
+
24
+ Implemented write behavior:
25
+
26
+ 1. Command `tg-sai add-bot --run` adds bot `SaiTeam_bot` if missing.
27
+ 2. Command `tg-sai add-bot --run` promotes bot `SaiTeam_bot` to admin when
28
+ needed.
29
+ 3. Command `tg-sai add-owner --run` adds a target user if missing.
30
+ 4. Command `tg-sai add-owner --run` promotes a target user to admin when needed.
31
+ 5. Command `tg-sai add-owner --run` transfers ownership only when environment
32
+ variable `TELEGRAM_PASSWORD` exists and the target user is eligible.
33
+
34
+ Credential profile behavior:
35
+
36
+ - Command `tg-sai profile find` reports discovered Telegram credential sources
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.
40
+ - Commands `tg-sai profile list`, `tg-sai profile show`, `tg-sai profile use`,
41
+ and `tg-sai profile remove` manage local session profiles.
42
+
43
+ Default target identities live in file `cfg.ts`: constant `saiBot` and
44
+ constant `uniqueDivine`.
45
+
46
+ ## Credentials
47
+
48
+ The CLI resolves Telegram credentials in this order:
49
+
50
+ 1. Command flags where supported.
51
+ 2. Environment variables `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, and
52
+ `TELEGRAM_SESSION_STRING`.
53
+ 3. Active profile in file `~/.tg-sai/profiles.json`.
54
+ 4. Repo-local file `jiyuu/ts-pkg/tg-sai/.env` and user-local file
55
+ `~/.tg-sai/.env`.
56
+ 5. OS-aware discovery from known MCP config files.
57
+
58
+ The tool stores profiles, logs, and generated operator files under directory
59
+ `~/.tg-sai/` by default. Tests and one-off runs can override the tool directory
60
+ with environment variable `TG_SAI_DIR`.
61
+
62
+ Discovery checks the two `tg-sai` `.env` files first, then these user-level
63
+ config paths by platform:
64
+
65
+ - macOS: Claude Desktop file
66
+ `~/Library/Application Support/Claude/claude_desktop_config.json`, Cursor
67
+ file `~/.cursor/mcp.json`, and Claude Code file `~/.claude.json`.
68
+ - Linux: Cursor file `~/.cursor/mcp.json` and Claude Code file
69
+ `~/.claude.json`.
70
+ - Windows: Cursor file `~/AppData/Roaming/Cursor/User/mcp.json` and Claude Code
71
+ file `~/.claude.json`.
72
+
73
+ Discovery first parses config files as JSON and searches for objects containing
74
+ Telegram credential keys. If JSON parsing fails or finds no complete object, it
75
+ falls back to line-by-line scanning for JSON-like and shell-like assignments.
76
+
77
+ Optional elevated mode value: `TELEGRAM_PASSWORD`. Profiles can store the
78
+ Telegram 2FA password in file `~/.tg-sai/profiles.json`, and environment
79
+ variable `TELEGRAM_PASSWORD` overrides the stored profile value for one command.
80
+ When a password is available, command `tg-sai ops sai-bootstrap --run` attempts
81
+ ownership transfer by default for eligible chats after the target user is admin.
82
+ Pass flag `--no-transfer-owner` to skip ownership transfer.
83
+
84
+ ## Generate a Telegram session string
85
+
86
+ Use this flow when the operator does not already have a
87
+ `TELEGRAM_SESSION_STRING` in an MCP config file. File
88
+ `session_string_generator.py` generates the session string with QR login, then
89
+ the value is saved into a local `tg-sai` profile.
90
+
91
+ Install the local command runtimes:
92
+
93
+ ```bash
94
+ curl -fsSL https://bun.sh/install | bash
95
+ curl -LsSf https://astral.sh/uv/install.sh | sh
96
+ ```
97
+
98
+ Install the Python helper dependencies:
99
+
100
+ ```bash
101
+ cd /path/to/tg-sai
102
+ uv sync
103
+ ```
104
+
105
+ Create file `.env` in the `tg-sai` directory with Telegram app
106
+ credentials:
107
+
108
+ ```bash
109
+ cat > .env <<'EOF'
110
+ TELEGRAM_API_ID=<api id from https://my.telegram.org/apps>
111
+ TELEGRAM_API_HASH=<api hash from https://my.telegram.org/apps>
112
+ EOF
113
+ ```
114
+
115
+ To get those values, open `https://my.telegram.org/apps`, log in with the
116
+ Telegram phone number, create an app if needed, then copy field `api_id` into
117
+ environment variable `TELEGRAM_API_ID` and field `api_hash` into environment
118
+ variable `TELEGRAM_API_HASH`. These app credentials are not the session string.
119
+ The session string is generated after QR login.
120
+
121
+ Generate the session string and save it directly into a `tg-sai` profile:
122
+
123
+ ```bash
124
+ tg-sai profile add --name operator --src 1 --login --qr
125
+ ```
126
+
127
+ 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`.
130
+
131
+ If you generated the session string manually, save it with explicit flags:
132
+
133
+ ```bash
134
+ tg-sai profile add \
135
+ --name operator \
136
+ --api-id <api id> \
137
+ --api-hash <api hash> \
138
+ --session-string '<session string>'
139
+ ```
140
+
141
+ Store the Telegram 2FA password in the same profile when ownership transfer is
142
+ needed:
143
+
144
+ ```bash
145
+ tg-sai profile add --name operator --password '<password>'
146
+ ```
147
+
148
+ Check the local setup:
149
+
150
+ ```bash
151
+ tg-sai health
152
+ ```
153
+
154
+ ## Commands
155
+
156
+ Show help:
157
+
158
+ ```bash
159
+ tg-sai
160
+ ```
161
+
162
+ Find Telegram credential sources:
163
+
164
+ ```bash
165
+ tg-sai profile find
166
+ ```
167
+
168
+ Add a local profile from a numbered source shown by command
169
+ `tg-sai profile find`:
170
+
171
+ ```bash
172
+ tg-sai profile add --name operator --src 1
173
+ ```
174
+
175
+ Command `tg-sai profile add` can store the Telegram 2FA password with flag
176
+ `--password`. Command `tg-sai profile show` redacts stored `apiHash`,
177
+ `sessionString`, and `password`.
178
+
179
+ Create a blank draft profile for onboarding:
180
+
181
+ ```bash
182
+ tg-sai profile add --name operator
183
+ ```
184
+
185
+ Blank or partial profiles are useful while collecting `apiId`, `apiHash`, and
186
+ `sessionString`. Commands that connect to Telegram require all three fields to
187
+ be filled.
188
+
189
+ Add a local profile from explicit values:
190
+
191
+ ```bash
192
+ tg-sai profile add \
193
+ --name operator \
194
+ --handle <telegram-handle> \
195
+ --user-id <telegram-user-id> \
196
+ --display-name "<display name>"
197
+ ```
198
+
199
+ List local profiles:
200
+
201
+ ```bash
202
+ tg-sai profile list
203
+ ```
204
+
205
+ Find Sai-related chats visible to the active Telegram session:
206
+
207
+ ```bash
208
+ tg-sai ops sai-find
209
+ ```
210
+
211
+ Command `tg-sai ops sai-find` always writes a JSONL file under directory
212
+ `~/.tg-sai/logs/`, using the next available filename such as
213
+ `sai-find-00.jsonl` or `sai-find-01.jsonl`. Use flag `--out <path>` to also
214
+ write a copy somewhere else.
215
+
216
+ Dry-run Sai bot and owner-target updates from a reviewed chat file:
217
+
218
+ ```bash
219
+ tg-sai ops sai-bootstrap --file ~/.tg-sai/logs/sai-find-00.jsonl
220
+ ```
221
+
222
+ Command `tg-sai ops sai-bootstrap` always writes a JSONL run log under directory
223
+ `~/.tg-sai/logs/`, using the next available filename such as
224
+ `sai-bootstrap-00.jsonl` or `sai-bootstrap-01.jsonl`. The command waits one
225
+ second between chats by default. Use flag `--delay-ms <ms>` to change that
226
+ delay. When environment variable `TELEGRAM_PASSWORD` is available, execution
227
+ mode attempts ownership transfer by default for eligible chats.
228
+
229
+ Run a live read-only audit:
230
+
231
+ ```bash
232
+ tg-sai audit --chat <chat-id>
233
+ ```
234
+
235
+ Offline unit tests (no Telegram network):
236
+
237
+ ```bash
238
+ bun test adminAudit.test.ts profileCli.test.ts profiles.test.ts
239
+ ```
240
+
241
+ Add-bot dry-run for one selected chat:
242
+
243
+ ```bash
244
+ tg-sai add-bot --chat <chat-id>
245
+ ```
246
+
247
+ Execute bot add/promote for one selected chat:
248
+
249
+ ```bash
250
+ tg-sai add-bot --chat <chat-id> --run
251
+ ```
252
+
253
+ Add-owner dry-run for one selected chat:
254
+
255
+ ```bash
256
+ tg-sai add-owner \
257
+ --chat <chat-id> \
258
+ --target-user <target-user>
259
+ ```
260
+
261
+ Execute add-owner add/promote for one selected chat:
262
+
263
+ ```bash
264
+ tg-sai add-owner \
265
+ --chat <chat-id> \
266
+ --target-user <target-user> \
267
+ --run
268
+ ```
269
+
270
+ Execute add-owner with ownership transfer enabled:
271
+
272
+ ```bash
273
+ TELEGRAM_PASSWORD="<2fa password>" tg-sai add-owner \
274
+ --chat <chat-id> \
275
+ --target-user <target-user> \
276
+ --run
277
+ ```
278
+
279
+ ## File roles
280
+
281
+ | File | Role |
282
+ | --- | --- |
283
+ | `cfg.ts` | Production bot/user IDs and handles |
284
+ | `main.ts` | Commander CLI for audit, add-bot, and add-owner commands |
285
+ | `adminCheck.ts` | Legacy direct audit runner |
286
+ | `mtcuteAdapter.ts` | Live mtcute boundary; normalizes chat/member data |
287
+ | `adminAudit.ts` | Pure types and decision logic (testable) |
288
+ | `profiles.ts` | Profile storage and OS-aware credential discovery |
289
+ | `testdata.ts` | Captured fixtures from live runs |
290
+ | `adminAudit.test.ts` | Offline tests for roles, authority, proposed actions |
291
+ | `profileCli.test.ts` | Offline tests for profile CLI behavior |
292
+ | `profiles.test.ts` | Offline tests for profile storage and discovery parsing |
293
+
294
+ ## How to read audit output
295
+
296
+ Key fields from the TypeScript audit:
297
+
298
+ | Field | Meaning |
299
+ | --- | --- |
300
+ | `actorCanApply` | Actor is creator (basic group) or admin with promote rights |
301
+ | `actorCanTransferOwnership` | Actor could transfer ownership (precheck only) |
302
+ | `targetCanReceiveOwnership` | Target user eligible to receive ownership |
303
+ | `proposedActions` | Planned steps (`noop`, `add_user`, `promote_admin`, …) |
304
+
305
+ If `actorCanApply` is false, the logged-in account cannot change admins in that
306
+ chat. For example, auditing a chat from a non-owner session shows false when a
307
+ different user is the creator.
308
+
309
+ ## Suggested workflow
310
+
311
+ 1. Run command `just tg-sai profile find` to inspect available Telegram
312
+ credential sources.
313
+ 2. Run command `just tg-sai profile add --name <name> --src <number>` to
314
+ save a local profile from discovery output.
315
+ 3. Run command `tg-sai audit --chat <id>` before writes.
316
+ 4. Run command `tg-sai add-bot --chat <id>` or command
317
+ `tg-sai add-owner --chat <id> --target-user <user>` as a dry-run.
318
+ 5. Add flag `--run` after reviewing the proposed action.
319
+ 6. Set environment variable `TELEGRAM_PASSWORD` only for owner transfer runs.
320
+
321
+ ## Why not MCP `get_admins`
322
+
323
+ MCP `get_admins` uses the supergroup admin filter even on basic groups. That can
324
+ list regular members as admins. This tool uses per-member lookups
325
+ (`getChatMember` / permissions / full chat info), which match Telegram UI roles.
326
+
327
+ ## Limits
328
+
329
+ - Bot and owner add/promote execution is wired only for one explicit chat at a
330
+ time.
331
+ - Basic groups: admin is on/off only.
332
+ - Supergroups/channels: granular admin rights are hard-coded in command
333
+ `add-bot` and command `add-owner`; the named rights policy still needs review.
334
+ - Batch audit and batch execution are not implemented.
335
+ - Official Sai channel (`saidotfun`) may need a separate policy review.
336
+
337
+ ## Roadmap
338
+
339
+ - Add batch audit input from file
340
+ `epics/comms/telegram-mcp-sai-target-candidates.md` or a generated JSON file.
341
+ - Emit a reviewable table or JSON artifact for batch dry-runs.
342
+ - Add named rights profiles for bot admin and owner-target admin promotion.
343
+ - Add fixtures for a completed ownership transfer.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Structured event emitted by the batch action executor.
3
+ *
4
+ * These objects are the source of truth for both test assertions and production
5
+ * JSONL logs. Keep event payloads stable enough that a failed or interrupted
6
+ * operator run can be inspected after the fact.
7
+ */
8
+ export type BatchActionEvent =
9
+ | {
10
+ /** Operation attempt started. */
11
+ type: "action_start";
12
+ /** Telegram chat ID or peer for the attempted action. */
13
+ chatId: number | string;
14
+ /** Stable action name, for example `bot_add`. */
15
+ action: string;
16
+ /** One-based attempt number for this action. */
17
+ attempt: number;
18
+ }
19
+ | {
20
+ /** Telegram returned `FLOOD_WAIT_X` and the executor will retry later. */
21
+ type: "flood_wait";
22
+ /** Telegram chat ID or peer for the rate-limited action. */
23
+ chatId: number | string;
24
+ /** Stable action name, for example `bot_promote_admin`. */
25
+ action: string;
26
+ /** One-based attempt number that received the flood-wait error. */
27
+ attempt: number;
28
+ /** Seconds Telegram requested in `FLOOD_WAIT_X`. */
29
+ waitSeconds: number;
30
+ /** Milliseconds the executor sleeps, including configured buffer. */
31
+ sleepMs: number;
32
+ }
33
+ | {
34
+ /** Operation completed successfully. */
35
+ type: "action_success";
36
+ /** Telegram chat ID or peer for the successful action. */
37
+ chatId: number | string;
38
+ /** Stable action name, for example `owner_target_add`. */
39
+ action: string;
40
+ /** Number of attempts required before success. */
41
+ attempts: number;
42
+ /** Elapsed milliseconds from first attempt to success. */
43
+ elapsedMs: number;
44
+ }
45
+ | {
46
+ /** Operation failed with a non-retryable error or exhausted retries. */
47
+ type: "action_error";
48
+ /** Telegram chat ID or peer for the failed action. */
49
+ chatId: number | string;
50
+ /** Stable action name, for example `transfer_owner`. */
51
+ action: string;
52
+ /** One-based attempt number that produced the final error. */
53
+ attempt: number;
54
+ /** Error name normalized for logs. */
55
+ errorName: string;
56
+ /** Error message normalized for logs. */
57
+ errorMessage: string;
58
+ };
59
+
60
+ /**
61
+ * Receives structured batch action events.
62
+ *
63
+ * Production implementations can write events to stdout, a JSONL file, or both.
64
+ * Tests can use `MemoryBatchLogger` to inspect event order without parsing text.
65
+ */
66
+ export interface BatchLogger {
67
+ event(event: BatchActionEvent): void | Promise<void>;
68
+ }
69
+
70
+ /**
71
+ * Identifies the Telegram action being executed for log and retry events.
72
+ */
73
+ export interface TelegramActionContext {
74
+ /** Telegram chat ID or resolvable chat peer for the action. */
75
+ chatId: number | string;
76
+ /** Stable action name such as `bot_add` or `owner_target_promote_admin`. */
77
+ action: string;
78
+ }
79
+
80
+ /**
81
+ * Runtime dependencies for `withFloodWaitRetry`.
82
+ *
83
+ * Passing these in keeps retry behavior deterministic in tests: sleep, time, and
84
+ * logging can all be faked without importing mtcute or touching Telegram.
85
+ */
86
+ export interface TelegramActionExecutorDeps {
87
+ /** Event sink for action start, success, error, and flood-wait events. */
88
+ logger: BatchLogger;
89
+ /** Sleep function used after Telegram returns `FLOOD_WAIT_X`. */
90
+ sleep: (ms: number) => Promise<void>;
91
+ /** Clock function used to compute elapsed action time. */
92
+ now: () => number;
93
+ /** Number of retries after the first failed attempt. */
94
+ maxRetries: number;
95
+ /** Extra milliseconds added to Telegram's required flood-wait duration. */
96
+ bufferMs: number;
97
+ }
98
+
99
+ /**
100
+ * Test logger that stores every event in insertion order.
101
+ */
102
+ export class MemoryBatchLogger implements BatchLogger {
103
+ readonly events: BatchActionEvent[] = [];
104
+
105
+ /**
106
+ * Appends an event to the in-memory event list.
107
+ */
108
+ event(event: BatchActionEvent): void {
109
+ this.events.push(event);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Extracts the wait duration from Telegram-style `FLOOD_WAIT_X` errors.
115
+ *
116
+ * mtcute and related libraries may expose the Telegram error string in the error
117
+ * name, message, or both. This helper checks both fields and returns `null` for
118
+ * non-flood errors so callers can distinguish retryable rate limits from normal
119
+ * failures.
120
+ */
121
+ export function parseFloodWaitSeconds(error: unknown): number | null {
122
+ if (!(error instanceof Error)) {
123
+ return null;
124
+ }
125
+
126
+ const haystack = `${error.name} ${error.message}`;
127
+ const match = haystack.match(/FLOOD_WAIT_(\d+)/);
128
+ if (!match) {
129
+ return null;
130
+ }
131
+
132
+ return Number(match[1]);
133
+ }
134
+
135
+ /**
136
+ * Returns a stable display name for unknown thrown values.
137
+ */
138
+ export function errorName(error: unknown): string {
139
+ if (error instanceof Error && error.name) {
140
+ return error.name;
141
+ }
142
+ return "UnknownError";
143
+ }
144
+
145
+ /**
146
+ * Returns a stable display message for unknown thrown values.
147
+ */
148
+ export function errorMessage(error: unknown): string {
149
+ if (error instanceof Error) {
150
+ return error.message;
151
+ }
152
+ return String(error);
153
+ }
154
+
155
+ /**
156
+ * Runs one Telegram operation with `FLOOD_WAIT_X` retry handling.
157
+ *
158
+ * The operation is passed as a thunk so the executor can call the exact same
159
+ * Telegram action after waiting. Non-flood errors and exhausted retry attempts
160
+ * are logged as `action_error` and then rethrown for the batch runner to record
161
+ * at the chat level.
162
+ */
163
+ export async function withFloodWaitRetry<T>(
164
+ context: TelegramActionContext,
165
+ operation: () => Promise<T>,
166
+ deps: TelegramActionExecutorDeps,
167
+ ): Promise<T> {
168
+ const startedAt = deps.now();
169
+ const maxAttempts = deps.maxRetries + 1;
170
+
171
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
172
+ await deps.logger.event({
173
+ type: "action_start",
174
+ chatId: context.chatId,
175
+ action: context.action,
176
+ attempt,
177
+ });
178
+
179
+ try {
180
+ const result = await operation();
181
+ await deps.logger.event({
182
+ type: "action_success",
183
+ chatId: context.chatId,
184
+ action: context.action,
185
+ attempts: attempt,
186
+ elapsedMs: deps.now() - startedAt,
187
+ });
188
+ return result;
189
+ } catch (error) {
190
+ const waitSeconds = parseFloodWaitSeconds(error);
191
+ const canRetry = waitSeconds !== null && attempt < maxAttempts;
192
+
193
+ if (!canRetry) {
194
+ await deps.logger.event({
195
+ type: "action_error",
196
+ chatId: context.chatId,
197
+ action: context.action,
198
+ attempt,
199
+ errorName: errorName(error),
200
+ errorMessage: errorMessage(error),
201
+ });
202
+ throw error;
203
+ }
204
+
205
+ const sleepMs = waitSeconds * 1000 + deps.bufferMs;
206
+ await deps.logger.event({
207
+ type: "flood_wait",
208
+ chatId: context.chatId,
209
+ action: context.action,
210
+ attempt,
211
+ waitSeconds,
212
+ sleepMs,
213
+ });
214
+ await deps.sleep(sleepMs);
215
+ }
216
+ }
217
+
218
+ throw new Error(`unreachable action executor state: ${context.action}`);
219
+ }