@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 +21 -0
- package/RUNBOOK.md +343 -0
- package/actionExecutor.ts +219 -0
- package/adminAudit.ts +147 -0
- package/cfg.ts +29 -0
- package/health.ts +479 -0
- package/main.ts +1290 -0
- package/mtcuteAdapter.ts +268 -0
- package/ops.ts +105 -0
- package/package.json +41 -0
- package/profiles.ts +726 -0
- package/pyproject.toml +20 -0
- package/session_string_generator.py +267 -0
- package/uv.lock +227 -0
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
|
+
}
|