grok-telegram-bot 2.2.1 β 2.2.3
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/CHANGELOG.md +37 -0
- package/README.md +47 -27
- package/package.json +1 -1
- package/scripts/_tmp-release.mjs +16 -0
- package/src/app/accounts.ts +51 -11
- package/src/app/grok-credentials.ts +50 -15
- package/src/bot/account-rotator.ts +17 -9
- package/src/bot/handlers/accounts.ts +21 -5
- package/src/bot/prompt-retry.ts +27 -0
- package/src/bot/session-runtime.ts +42 -14
- package/src/grok/client.ts +36 -0
- package/scripts/_push-branch.mjs +0 -79
- package/scripts/_tag-release.mjs +0 -52
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,42 @@ The latest section is published verbatim as the GitHub Release notes by
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [2.2.3] - 2026-07-13
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **π³ Instant account rotate on 402 balance exhausted.** Grok Build
|
|
17
|
+
`Payment Required` / `usage balance exhausted` errors (usually wrapped as ACP
|
|
18
|
+
Internal error `[-32603]`) are no longer treated as transient. Same-account
|
|
19
|
+
backoff retries are skipped. With **auto-rotate ON**, the bot immediately:
|
|
20
|
+
stops the Grok CLI β swaps `~/.grok/auth.json` β restarts the CLI with
|
|
21
|
+
headless `cached_token` auth β opens a fresh session β retries the same
|
|
22
|
+
prompt. If that account fails, it switches to the next saved account (one
|
|
23
|
+
pass). If all accounts fail (or auto-rotate is OFF), the turn stops with a
|
|
24
|
+
clear error. Telegram shows
|
|
25
|
+
`Account switched to β¦ because of: β¦` plus a CLI-restart notice.
|
|
26
|
+
|
|
27
|
+
## [2.2.2] - 2026-07-12
|
|
28
|
+
|
|
29
|
+
Patch release that hardens multi-account identity and documents the full **2.2.x**
|
|
30
|
+
batch (headless rotation, session auto-approve, pinned permission prompts).
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **π§ Real account emails from `auth.json`.** Modern Grok access tokens often
|
|
35
|
+
omit the `email` claim; the bot now prefers `email` / `first_name` /
|
|
36
|
+
`last_name` written on the auth entry by `grok login`, then falls back to JWT
|
|
37
|
+
claims. `/accounts` and `/usage` show the host login correctly again.
|
|
38
|
+
- **πͺͺ Active account = live token hash.** The marked `activeId` is no longer
|
|
39
|
+
trusted when the host login changed outside the menu (`grok login` / `/reauth`).
|
|
40
|
+
Saving never overwrites a different saved account; mismatch is shown in the
|
|
41
|
+
`/accounts` UI (βHost Grok loginβ vs saved).
|
|
42
|
+
|
|
43
|
+
### Docs
|
|
44
|
+
|
|
45
|
+
- README: headless account switch, device-code `/reauth`, auto-approve + pinned
|
|
46
|
+
permission prompts, `AUTO_APPROVE_PERMISSIONS`.
|
|
47
|
+
|
|
12
48
|
## [2.2.1] - 2026-07-12
|
|
13
49
|
|
|
14
50
|
### Fixed
|
|
@@ -677,6 +713,7 @@ from a single chat and switch between them, on a redesigned, compact menu.
|
|
|
677
713
|
diffs, MarkdownV2 rendering, scheduled tasks, multi-image prompts, and a
|
|
678
714
|
cross-platform 24/7 background service.
|
|
679
715
|
|
|
716
|
+
[2.2.2]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.2
|
|
680
717
|
[2.2.1]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.1
|
|
681
718
|
[2.2.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.0
|
|
682
719
|
[2.1.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.1.0
|
package/README.md
CHANGED
|
@@ -38,9 +38,10 @@ re-architected for the Grok Build CLI and extended into a full multi-session cli
|
|
|
38
38
|
| π§© **MCP control** | `/mcp` lists MCP servers, **health-checks** them (which connected / failed and why), and **enables/disables** them β then restarts the agent to apply. |
|
|
39
39
|
| π₯ **Subagent visibility** | When Grok delegates to subagents and waits on them, you see each one **start / work / finish** plus a live `π€ N running` summary β and subagent permission prompts route to your chat. |
|
|
40
40
|
| π **Task progress bar** | The agent appends a `{progress: N%}` marker; the bot hides it and shows a **green 0β100% loading bar** on the live message, in the status panel, and on session cards (`SHOW_PROGRESS`). |
|
|
41
|
-
| π **Sign in from chat** | `/reauth` signs you in without a terminal β **π Sign in** runs `grok login` (
|
|
42
|
-
| π₯ **Multiple accounts** | `/accounts` saves several Grok **sign-ins** (custom names) and switches between them in a tap β
|
|
43
|
-
| π **Auto-rotate on give-up** | When a turn exhausts its retries, optionally cycle through your other saved accounts once
|
|
41
|
+
| π **Sign in from chat** | `/reauth` signs you in without a terminal β **π Sign in** runs headless `grok login --device-auth` (link/code streams to your chat, no host browser), or **π₯ Import** an existing on-host login; the agent restarts under the new identity. |
|
|
42
|
+
| π₯ **Multiple accounts** | `/accounts` saves several Grok **sign-ins** (custom names) and switches between them in a tap β **stops the agent β replaces `~/.grok/auth.json` β restarts headlessly** (never opens a browser). |
|
|
43
|
+
| π **Auto-rotate on give-up** | When a turn exhausts its retries (or hits **402 balance exhausted** with no same-account retry), optionally cycle through your other saved accounts once: stop CLI β swap `auth.json` β restart + re-auth β retry. First that works wins (toggle in `/accounts`). |
|
|
44
|
+
| β
**Auto-approve tools** | By default, ACP permission prompts are auto-approved for the **session** (`AUTO_APPROVE_PERMISSIONS`). Turn both that and `GROK_TRUST_ALL_TOOLS` off for interactive Approve/Deny β those prompts are **pinned** until you act. |
|
|
44
45
|
| πͺ **Credits & usage** | The `β
Done` line and `/usage` show credits used (when Grok reports them), turns this session, and account info. |
|
|
45
46
|
| β¨οΈ **Typing indicator** | Stays on for the whole turn, even through long tool chains. |
|
|
46
47
|
| π₯ **Queued follow-ups** | Message while Grok is busy β it's queued and runs next. `/btw` runs it ASAP (now if idle, else right after the current task); `/flush` runs the queue now. |
|
|
@@ -62,9 +63,10 @@ re-architected for the Grok Build CLI and extended into a full multi-session cli
|
|
|
62
63
|
| Attach to **live** PC sessions (watch / fork) | β
| β |
|
|
63
64
|
| **Kill a session by PID** (or all at once) | β
| β |
|
|
64
65
|
| **Live task-progress bars** (`{progress: N%}`) | β
| β |
|
|
65
|
-
| **Sign in from chat** (`/reauth
|
|
66
|
-
| **Multiple saved accounts** + one-tap switch (`/accounts`) | β
| β |
|
|
66
|
+
| **Sign in from chat** (`/reauth`, device-code) | β
| β |
|
|
67
|
+
| **Multiple saved accounts** + headless one-tap switch (`/accounts`) | β
| β |
|
|
67
68
|
| **Auto-rotate accounts** when a turn gives up | β
| β |
|
|
69
|
+
| **Session auto-approve** + **pinned** interactive permissions | β
| β |
|
|
68
70
|
| Multiple isolated sessions | β
| β (single shared) |
|
|
69
71
|
| Queued follow-ups while busy | β
| β |
|
|
70
72
|
| **Scheduled tasks** (cron-like) | β
| β |
|
|
@@ -326,9 +328,10 @@ takes precedence and the value never decreases. Disable the fallback with
|
|
|
326
328
|
Grok Build signs in with your **xAI account** (SuperGrok / X Premium+), not an
|
|
327
329
|
API key. Run **`/reauth`** to sign in without touching a terminal:
|
|
328
330
|
|
|
329
|
-
- **π Sign in** β runs `grok login
|
|
330
|
-
streams into the chat so you can
|
|
331
|
-
|
|
331
|
+
- **π Sign in** β runs `grok login --device-auth` (device code, **no browser on
|
|
332
|
+
the host**). The verification link/code streams into the chat so you can
|
|
333
|
+
approve it on any device. The bot then restarts the agent under the new
|
|
334
|
+
identity.
|
|
332
335
|
- **π₯ Import existing** β adopt a `grok login` already done on this machine
|
|
333
336
|
(`~/.grok/auth.json`).
|
|
334
337
|
|
|
@@ -338,16 +341,24 @@ It's refused while a turn is running. You can also sign in up front by running
|
|
|
338
341
|
## π₯ Multiple accounts
|
|
339
342
|
|
|
340
343
|
**`/accounts`** manages several Grok **sign-ins** side by side. Save the current
|
|
341
|
-
login as a named account (auto-named from its email, or
|
|
342
|
-
custom name), rename or delete saved ones, and **switch** in
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
344
|
+
login as a named account (auto-named from its email when available, or
|
|
345
|
+
**Save asβ¦** with a custom name), rename or delete saved ones, and **switch** in
|
|
346
|
+
a tap. Switching is deliberately headless:
|
|
347
|
+
|
|
348
|
+
1. Snapshot the current login (so it isn't lost),
|
|
349
|
+
2. **Stop** the shared agent,
|
|
350
|
+
3. **Replace** `~/.grok/auth.json` with the saved snapshot,
|
|
351
|
+
4. **Start** the agent and authenticate with `cached_token` only β never a
|
|
352
|
+
browser method like `grok.com` (which would hang a service host).
|
|
353
|
+
|
|
354
|
+
The menu shows the **host Grok login** vs which saved account it matches, so a
|
|
355
|
+
manual `grok login` outside the bot doesn't leave a stale βactiveβ label.
|
|
346
356
|
|
|
347
357
|
**π Auto-rotate on errors** (toggle here): when a turn exhausts its retries and
|
|
348
|
-
can't recover, the bot cycles through your other saved logins **once
|
|
349
|
-
|
|
350
|
-
stops after one pass. Handy when an account gets throttled or
|
|
358
|
+
can't recover, the bot cycles through your other saved logins **once** with the
|
|
359
|
+
same stop β auth.json β start path β the first that succeeds stays active; if
|
|
360
|
+
they all fail it stops after one pass. Handy when an account gets throttled or
|
|
361
|
+
runs out of quota.
|
|
351
362
|
|
|
352
363
|
---
|
|
353
364
|
|
|
@@ -390,7 +401,8 @@ Resuming an **idle** session loads it directly so you continue the exact thread.
|
|
|
390
401
|
| `GROK_MODEL` | no | `grok-4.5` | Default model for new sessions. |
|
|
391
402
|
| `GROK_TG_DIR` | no | `~/.grok/tg` | Folder holding this instance's `.env`, `logs/`, `data/`. Resolution: `--instance` β `GROK_TG_DIR` β a `.env` in the current folder β `~/.grok/tg`. So a `.env` created once is loaded from any startup path. |
|
|
392
403
|
| `GROK_AGENT` | no | β | Custom sub-agent name (informational; Grok delegates via its own `task`/`delegate` tools). |
|
|
393
|
-
| `GROK_TRUST_ALL_TOOLS` | no | `true` |
|
|
404
|
+
| `GROK_TRUST_ALL_TOOLS` | no | `true` | Pass `--always-approve` so tools run without prompts. |
|
|
405
|
+
| `AUTO_APPROVE_PERMISSIONS` | no | `true` | Auto-approve ACP `session/request_permission` (prefer βallow for this sessionβ). Set `false` **and** `GROK_TRUST_ALL_TOOLS=false` for interactive Approve/Deny buttons (those prompts are **pinned** until you act or they time out). |
|
|
394
406
|
| `PROJECT_ROOTS` | no | workspace parent + home | Roots for `/projects`. |
|
|
395
407
|
| `STREAM_THROTTLE_MS` | no | `1500` | Live-edit interval while streaming. |
|
|
396
408
|
| `MESSAGE_BATCH_MS` | no | `800` | Window to coalesce rapid text messages (e.g. a long message Telegram split at 4096 chars) into one prompt. `0` disables. |
|
|
@@ -486,13 +498,20 @@ the bot inherits whatever MCP servers Grok CLI is configured with.
|
|
|
486
498
|
|
|
487
499
|
## π Tool approvals
|
|
488
500
|
|
|
489
|
-
Over ACP, Grok honors a permission mode.
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
501
|
+
Over ACP, Grok honors a permission mode. Defaults are built for unattended
|
|
502
|
+
mobile use:
|
|
503
|
+
|
|
504
|
+
| Setting | Default | Effect |
|
|
505
|
+
|---|---|---|
|
|
506
|
+
| `GROK_TRUST_ALL_TOOLS` | `true` | Passes `--always-approve` so tools run without prompts. |
|
|
507
|
+
| `AUTO_APPROVE_PERMISSIONS` | `true` | If a permission request still arrives, auto-pick **allow for this session** / always-allow. |
|
|
508
|
+
|
|
509
|
+
Set **both** to `false` for ACP **"ask"** mode: Grok sends
|
|
510
|
+
`session/request_permission` before risky tools and the bot surfaces
|
|
511
|
+
**Approve / Allow for session / Deny** buttons. Those prompts are **pinned** so
|
|
512
|
+
they aren't buried by streaming output; the pin is removed when you choose,
|
|
513
|
+
or when the request times out (status panel is re-pinned). You can always
|
|
514
|
+
intervene on a live turn with the tool stream + **βΉ Stop** (`/cancel`).
|
|
496
515
|
|
|
497
516
|
## π Security
|
|
498
517
|
|
|
@@ -514,10 +533,11 @@ user. See [SECURITY.md](./SECURITY.md) for the full model.
|
|
|
514
533
|
- [x] Voice messages β speech-to-text β prompt (multi-language)
|
|
515
534
|
- [x] Context-usage % in the status panel
|
|
516
535
|
- [x] Inline approvals β approve/deny risky tools from buttons (non trust-all mode)
|
|
536
|
+
- [x] Session auto-approve + pinned permission prompts
|
|
517
537
|
- [x] Account & context usage (`/usage`)
|
|
518
|
-
- [x] Multiple accounts with one-tap switch + auto-rotate
|
|
519
|
-
- [x]
|
|
520
|
-
- [x]
|
|
538
|
+
- [x] Multiple accounts with headless one-tap switch + auto-rotate (`/accounts`)
|
|
539
|
+
- [x] Device-code `/reauth` (no host browser) + real email labels from auth.json
|
|
540
|
+
- [x] Rich per-kind tool-call detail (search / edit diffs / shell / MCP / β¦)
|
|
521
541
|
- [x] README community sections β Contributors, Top Contributors, Stars, StarMapper
|
|
522
542
|
- [ ] **Token & cost meter** β per-session token counts and an estimated spend tally
|
|
523
543
|
- [ ] **Text-to-speech replies** β optionally speak answers back as voice notes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "grok-telegram-bot",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
4
4
|
"description": "Control the official Grok Build CLI from Telegram over the Agent Client Protocol (ACP). Sign in with your xAI account, switch projects, resume sessions, stream responses with diffs, queue follow-ups, manage multiple sign-ins, and run 24/7 as a cross-platform background service.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
function run(cmd) {
|
|
4
|
+
console.log(`$ ${cmd}`);
|
|
5
|
+
execSync(cmd, { stdio: "inherit" });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// Annotated release tag (triggers GitHub Release workflow on push).
|
|
9
|
+
try {
|
|
10
|
+
run('git tag -a v2.2.3 -m "v2.2.3 β instant account rotate on 402 balance exhausted"');
|
|
11
|
+
} catch {
|
|
12
|
+
console.log("tag v2.2.3 already exists locally β continuing");
|
|
13
|
+
}
|
|
14
|
+
run("git push origin v2.2.3");
|
|
15
|
+
run("npm publish --access public");
|
|
16
|
+
console.log("done");
|
package/src/app/accounts.ts
CHANGED
|
@@ -76,17 +76,44 @@ export class AccountManager {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
* Id of the
|
|
80
|
-
*
|
|
79
|
+
* Id of the saved account that matches the live auth.json (if any).
|
|
80
|
+
* Token hash is authoritative β never trust a stale `activeId` when the host
|
|
81
|
+
* login has changed (e.g. `grok login` / /reauth outside this menu).
|
|
81
82
|
*/
|
|
82
83
|
activeAccountId(): string | undefined {
|
|
83
84
|
const data = this.store.get();
|
|
85
|
+
const lid = loginId();
|
|
86
|
+
if (lid) {
|
|
87
|
+
const byToken = data.accounts.find((a) => a.loginId === lid);
|
|
88
|
+
if (byToken) return byToken.id;
|
|
89
|
+
// Live login differs from every saved account (and any stale activeId).
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
// No readable token (API-key-only / missing file) β last switch target only.
|
|
84
93
|
if (data.activeId && data.accounts.some((a) => a.id === data.activeId)) {
|
|
85
94
|
return data.activeId;
|
|
86
95
|
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Last switch/save target stored by the app, even when the host Grok login
|
|
101
|
+
* no longer matches that snapshot (used only for mismatch UI copy).
|
|
102
|
+
*/
|
|
103
|
+
markedActiveId(): string | undefined {
|
|
104
|
+
const id = this.store.get().activeId;
|
|
105
|
+
if (!id) return undefined;
|
|
106
|
+
return this.store.get().accounts.some((a) => a.id === id) ? id : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Whether the host's live login matches the saved account marked active. */
|
|
110
|
+
liveMatchesActive(): boolean {
|
|
87
111
|
const lid = loginId();
|
|
88
|
-
if (!lid) return
|
|
89
|
-
|
|
112
|
+
if (!lid) return false;
|
|
113
|
+
const marked = this.markedActiveId();
|
|
114
|
+
if (!marked) return this.activeAccountId() !== undefined;
|
|
115
|
+
const meta = this.get(marked);
|
|
116
|
+
return !!meta?.loginId && meta.loginId === lid;
|
|
90
117
|
}
|
|
91
118
|
|
|
92
119
|
get(id: string): StoredAccount | undefined {
|
|
@@ -117,19 +144,32 @@ export class AccountManager {
|
|
|
117
144
|
}
|
|
118
145
|
const label = customLabel?.trim() || loginLabel() || `account ${lid.slice(0, 6)}`;
|
|
119
146
|
const email = loginLabel();
|
|
120
|
-
// Match by token hash first
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
147
|
+
// Match by token hash first. Only reuse the marked active slot when it is
|
|
148
|
+
// the same login (token refresh) or the same email β never overwrite a
|
|
149
|
+
// different saved account when the host is signed in as someone else.
|
|
150
|
+
const accounts = this.store.get().accounts;
|
|
151
|
+
const byToken = accounts.find((a) => a.loginId === lid);
|
|
152
|
+
const active = this.store.get().activeId ? this.get(this.store.get().activeId!) : undefined;
|
|
153
|
+
const emailKey = email?.toLowerCase();
|
|
154
|
+
const byEmail =
|
|
155
|
+
emailKey && emailKey.includes("@")
|
|
156
|
+
? accounts.find((a) => (a.email || a.label || "").toLowerCase() === emailKey)
|
|
157
|
+
: undefined;
|
|
158
|
+
const sameActiveRefresh =
|
|
159
|
+
active &&
|
|
160
|
+
(active.loginId === lid ||
|
|
161
|
+
(!!emailKey &&
|
|
162
|
+
emailKey.includes("@") &&
|
|
163
|
+
[active.email, active.label].some((v) => (v || "").toLowerCase() === emailKey)));
|
|
164
|
+
const existing = byToken ?? (sameActiveRefresh ? active : undefined) ?? byEmail;
|
|
125
165
|
const id = existing?.id ?? makeId();
|
|
126
166
|
await writeFile(this.snapshotPath(id), raw, "utf-8");
|
|
127
167
|
const meta: StoredAccount = {
|
|
128
168
|
id,
|
|
129
169
|
label: customLabel?.trim() || existing?.label || label,
|
|
130
170
|
loginId: lid,
|
|
131
|
-
email: email || existing?.email,
|
|
132
|
-
startUrl: email || existing?.email,
|
|
171
|
+
email: (email && email.includes("@") ? email : undefined) || existing?.email,
|
|
172
|
+
startUrl: (email && email.includes("@") ? email : undefined) || existing?.email || existing?.startUrl,
|
|
133
173
|
savedAt: new Date().toISOString(),
|
|
134
174
|
};
|
|
135
175
|
this.store.update((d) => {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Grok Build login state. The official CLI signs in with your xAI account
|
|
3
3
|
* (`grok login`, browser OIDC) and stores the token in `~/.grok/auth.json`:
|
|
4
4
|
*
|
|
5
|
-
* { "<scope_url>": { "key": "<token>" }, ... }
|
|
5
|
+
* { "<scope_url>": { "key": "<token>", "email"?: "...", ... }, ... }
|
|
6
6
|
*
|
|
7
7
|
* (An `XAI_API_KEY` env var is an alternative for non-browser hosts.) This
|
|
8
8
|
* module reads that file so the bot can show who's signed in, detect a usable
|
|
@@ -36,7 +36,19 @@ export function authFileExists(): boolean {
|
|
|
36
36
|
return existsSync(grokAuthPath());
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
/** One scope entry inside ~/.grok/auth.json (fields beyond `key` are optional). */
|
|
40
|
+
export interface AuthEntry {
|
|
41
|
+
key?: string;
|
|
42
|
+
email?: string;
|
|
43
|
+
first_name?: string;
|
|
44
|
+
last_name?: string;
|
|
45
|
+
user_id?: string;
|
|
46
|
+
principal_id?: string;
|
|
47
|
+
auth_mode?: string;
|
|
48
|
+
[extra: string]: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type AuthFile = Record<string, AuthEntry | undefined>;
|
|
40
52
|
|
|
41
53
|
export function readAuth(): AuthFile {
|
|
42
54
|
try {
|
|
@@ -46,12 +58,18 @@ export function readAuth(): AuthFile {
|
|
|
46
58
|
}
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
/** Active scope entry (OIDC preferred, then legacy). */
|
|
62
|
+
export function currentAuthEntry(auth: AuthFile = readAuth()): AuthEntry | undefined {
|
|
63
|
+
const oidc = auth[OIDC_SCOPE];
|
|
64
|
+
if (oidc?.key?.trim()) return oidc;
|
|
65
|
+
const legacy = auth[LEGACY_SCOPE];
|
|
66
|
+
if (legacy?.key?.trim()) return legacy;
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
49
70
|
/** The active sign-in token from auth.json (OIDC preferred, then legacy). */
|
|
50
71
|
export function currentToken(auth: AuthFile = readAuth()): string | undefined {
|
|
51
|
-
|
|
52
|
-
if (oidc) return oidc;
|
|
53
|
-
const legacy = auth[LEGACY_SCOPE]?.key?.trim();
|
|
54
|
-
return legacy || undefined;
|
|
72
|
+
return currentAuthEntry(auth)?.key?.trim() || undefined;
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
/** Whether a usable login exists (a token in auth.json, or XAI_API_KEY). */
|
|
@@ -81,16 +99,33 @@ function decodeJwtPayload(jwt: string): Record<string, unknown> | undefined {
|
|
|
81
99
|
}
|
|
82
100
|
}
|
|
83
101
|
|
|
84
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* Best-effort identity (email/name). Prefer fields Grok writes on the auth
|
|
104
|
+
* entry (`email`, `first_name`, β¦) β modern access tokens often omit email
|
|
105
|
+
* claims β then fall back to JWT claims when present.
|
|
106
|
+
*/
|
|
85
107
|
export function identityFromAuth(auth: AuthFile = readAuth()): LoginIdentity {
|
|
86
|
-
const
|
|
87
|
-
if (!
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
108
|
+
const entry = currentAuthEntry(auth);
|
|
109
|
+
if (!entry) return {};
|
|
110
|
+
|
|
111
|
+
const entryEmail = typeof entry.email === "string" && entry.email.includes("@") ? entry.email : undefined;
|
|
112
|
+
const entryName = [entry.first_name, entry.last_name]
|
|
113
|
+
.filter((p): p is string => typeof p === "string" && p.trim().length > 0)
|
|
114
|
+
.join(" ")
|
|
115
|
+
.trim() || undefined;
|
|
116
|
+
|
|
117
|
+
const tok = entry.key?.trim();
|
|
118
|
+
const claims = tok ? decodeJwtPayload(tok) : undefined;
|
|
119
|
+
const str = (k: string): string | undefined =>
|
|
120
|
+
claims && typeof claims[k] === "string" ? (claims[k] as string) : undefined;
|
|
121
|
+
const claimEmailish = str("email") || str("preferred_username") || str("upn");
|
|
122
|
+
const claimEmail = claimEmailish && claimEmailish.includes("@") ? claimEmailish : undefined;
|
|
123
|
+
const claimName = str("name") || str("given_name");
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
email: entryEmail || claimEmail,
|
|
127
|
+
name: entryName || claimName,
|
|
128
|
+
};
|
|
94
129
|
}
|
|
95
130
|
|
|
96
131
|
/** A short human label for the active login (email, else a short token hash). */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auto-rotate-on-give-up. When a turn exhausts its retries (
|
|
3
|
-
*
|
|
2
|
+
* Auto-rotate-on-give-up. When a turn exhausts its retries (or fails immediately
|
|
3
|
+
* with a permanent billing error like HTTP 402 balance exhausted) and auto-fork
|
|
4
|
+
* can't recover it, the runtime can cycle through the OTHER saved Grok accounts,
|
|
4
5
|
* retrying the same prompt on each β useful when the active account is
|
|
5
6
|
* throttled, out of quota, or its backend keeps returning "dispatch failure".
|
|
6
7
|
*
|
|
@@ -12,8 +13,11 @@
|
|
|
12
13
|
* a rotation restarts the shared agent and affects every chat β intended, since
|
|
13
14
|
* the whole point is to move everyone onto a working login.
|
|
14
15
|
*
|
|
15
|
-
* CRITICAL:
|
|
16
|
-
*
|
|
16
|
+
* CRITICAL: every activate() MUST fully restart the CLI so the new auth applies:
|
|
17
|
+
* 1. stop agent (`stopAndWait`) β process must exit so it cannot rewrite auth,
|
|
18
|
+
* 2. replace ~/.grok/auth.json with the saved snapshot,
|
|
19
|
+
* 3. start agent + `authenticate({ methodId: "cached_token" })` headlessly.
|
|
20
|
+
* Never opens a browser / never runs `grok login`.
|
|
17
21
|
*/
|
|
18
22
|
import type { GrokClient } from "../grok/client.js";
|
|
19
23
|
import type { AccountManager } from "../app/accounts.js";
|
|
@@ -52,10 +56,11 @@ export class AccountRotatorImpl implements AccountRotator {
|
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
/**
|
|
55
|
-
* Pure file-based account swap
|
|
56
|
-
*
|
|
59
|
+
* Pure file-based account swap with a full CLI restart so the new token is
|
|
60
|
+
* loaded (agent is process-local with `--no-leader`):
|
|
61
|
+
* 1. Stop the shared agent and wait for exit (so it cannot rewrite auth.json),
|
|
57
62
|
* 2. Copy the saved snapshot over ~/.grok/auth.json,
|
|
58
|
-
* 3. Start
|
|
63
|
+
* 3. Start a fresh agent process and authenticate with `cached_token`.
|
|
59
64
|
* Never launches a browser.
|
|
60
65
|
*/
|
|
61
66
|
async activate(id: string): Promise<void> {
|
|
@@ -63,12 +68,15 @@ export class AccountRotatorImpl implements AccountRotator {
|
|
|
63
68
|
await this.accounts.captureCurrent().catch((e) => {
|
|
64
69
|
log.warn("pre-rotate capture failed (continuing):", (e as Error).message);
|
|
65
70
|
});
|
|
66
|
-
log.info(`rotating: stopping
|
|
71
|
+
log.info(`rotating: stopping Grok CLI before auth.json swap (${id})`);
|
|
67
72
|
await this.acp.stopAndWait();
|
|
68
73
|
try {
|
|
69
74
|
const meta = await this.accounts.switchTo(id);
|
|
70
|
-
log.info(`rotating: auth.json now ${meta.label}; starting
|
|
75
|
+
log.info(`rotating: auth.json now ${meta.label}; starting Grok CLI + re-auth`);
|
|
76
|
+
// start() β connect() β initialize + authenticate(cached_token) against
|
|
77
|
+
// the freshly written auth.json. A live process would keep the old token.
|
|
71
78
|
await this.acp.start();
|
|
79
|
+
log.info(`rotating: Grok CLI up on ${meta.label}`);
|
|
72
80
|
} catch (e) {
|
|
73
81
|
// Best-effort recover the agent so the bot stays usable even if the
|
|
74
82
|
// target login was bad.
|
|
@@ -25,14 +25,28 @@ async function view(deps: BotDeps, note?: string): Promise<{ text: string; keybo
|
|
|
25
25
|
const list = deps.accounts.list();
|
|
26
26
|
const acct = await deps.usage.account().catch(() => undefined);
|
|
27
27
|
const active = deps.accounts.activeAccountId();
|
|
28
|
+
const marked = deps.accounts.markedActiveId();
|
|
28
29
|
const loggedIn = await deps.usage.isLoggedIn().catch(() => false);
|
|
30
|
+
const liveLabel = acct?.email?.trim();
|
|
31
|
+
const activeMeta = active ? deps.accounts.get(active) : undefined;
|
|
32
|
+
const markedMeta = marked && marked !== active ? deps.accounts.get(marked) : undefined;
|
|
29
33
|
|
|
30
34
|
const lines = ["\u{1F465} Grok accounts", ""];
|
|
31
|
-
if (loggedIn
|
|
32
|
-
lines.push(`\u{1F7E2} Signed in as ${acct.email}`);
|
|
33
|
-
lines.push(" \u2514 Not saved yet β tap \u201C\u{1F4BE} Save current login\u201D to keep it.", "");
|
|
34
|
-
} else if (!loggedIn) {
|
|
35
|
+
if (!loggedIn) {
|
|
35
36
|
lines.push("\u{1F534} Not signed in β sign in via /reauth.", "");
|
|
37
|
+
} else if (liveLabel) {
|
|
38
|
+
lines.push(`\u{1F7E2} Host Grok login: ${liveLabel}`);
|
|
39
|
+
if (activeMeta) {
|
|
40
|
+
lines.push(` \u2514 Matches saved: ${activeMeta.label}`);
|
|
41
|
+
} else {
|
|
42
|
+
lines.push(" \u2514 Not among saved accounts β tap \u201C\u{1F4BE} Save current login\u201D.");
|
|
43
|
+
if (markedMeta) {
|
|
44
|
+
lines.push(` \u2514 App still has ${markedMeta.label} selected β different from host login.`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
lines.push("");
|
|
48
|
+
} else {
|
|
49
|
+
lines.push("\u{1F7E2} Signed in (identity unknown).", "");
|
|
36
50
|
}
|
|
37
51
|
if (list.length === 0) {
|
|
38
52
|
lines.push("No saved accounts yet.", "", "Save the current login below, or sign in via /reauth.");
|
|
@@ -43,7 +57,9 @@ async function view(deps: BotDeps, note?: string): Promise<{ text: string; keybo
|
|
|
43
57
|
lines.push(
|
|
44
58
|
"",
|
|
45
59
|
`\u{1F501} Auto-rotate on errors: ${rotate ? "ON" : "OFF"}`,
|
|
46
|
-
rotate
|
|
60
|
+
rotate
|
|
61
|
+
? " \u2514 On give-up / 402 balance exhausted: stop CLI, swap auth, restart, retry each account once."
|
|
62
|
+
: " \u2514 Turns stay on the active account (402 balance exhausted stops immediately).",
|
|
47
63
|
);
|
|
48
64
|
if (note) lines.push("", note);
|
|
49
65
|
|
package/src/bot/prompt-retry.ts
CHANGED
|
@@ -68,3 +68,30 @@ export function formatErrorSummary(error: Error, elapsed: string, attempts: numb
|
|
|
68
68
|
}
|
|
69
69
|
return `\u274C Gave up after ${attempts} attempts over ${elapsed}.\nLast error: ${error.message}${tip}`;
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compact, human-facing reason for an account switch (Telegram status line).
|
|
74
|
+
* Prefers a short billing/quota phrase when present; otherwise truncates.
|
|
75
|
+
*/
|
|
76
|
+
export function shortSwitchReason(error: Error, max = 140): string {
|
|
77
|
+
const raw = error.message.replace(/\s+/g, " ").trim();
|
|
78
|
+
const known =
|
|
79
|
+
raw.match(/Grok Build usage balance exhausted/i)?.[0] ??
|
|
80
|
+
raw.match(/usage balance exhausted/i)?.[0] ??
|
|
81
|
+
raw.match(/balance exhausted/i)?.[0] ??
|
|
82
|
+
raw.match(/Payment Required/i)?.[0] ??
|
|
83
|
+
raw.match(/quota exceeded/i)?.[0] ??
|
|
84
|
+
raw.match(/out of (?:credits|quota|balance)/i)?.[0];
|
|
85
|
+
const text = known ?? raw;
|
|
86
|
+
return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Shown when auto-rotate swaps login mid-turn and retries on the new account. */
|
|
90
|
+
export function formatAccountSwitchNotice(label: string, error: Error): string {
|
|
91
|
+
return [
|
|
92
|
+
`\u{1F504} Account switched to ${label}`,
|
|
93
|
+
`because of: ${shortSwitchReason(error)}`,
|
|
94
|
+
"",
|
|
95
|
+
"Restarting Grok CLI with the new login and retrying\u2026",
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { basename } from "node:path";
|
|
8
8
|
import { type Api, InlineKeyboard } from "grammy";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type GrokClient,
|
|
11
|
+
isAccountExhaustedError,
|
|
12
|
+
isContextExhaustedError,
|
|
13
|
+
isTransientError,
|
|
14
|
+
type SessionMetadata,
|
|
15
|
+
} from "../grok/client.js";
|
|
10
16
|
import type { AccountRotator } from "./account-rotator.js";
|
|
11
17
|
import type { ContentBlock, PromptResult, SessionUpdate } from "../grok/types.js";
|
|
12
18
|
import type { AppConfig } from "../config.js";
|
|
@@ -27,7 +33,14 @@ import type { PendingStage, SubagentInfo } from "../grok/types.js";
|
|
|
27
33
|
import { ResponseStreamer } from "../stream/streamer.js";
|
|
28
34
|
import { extractImagePaths, sendImages } from "./image-return.js";
|
|
29
35
|
import { buildContentBlocks, mergeInputs } from "./prompt-content.js";
|
|
30
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
backoffSchedule,
|
|
38
|
+
fmtSeconds,
|
|
39
|
+
formatAccountSwitchNotice,
|
|
40
|
+
formatErrorSummary,
|
|
41
|
+
formatRetryNotice,
|
|
42
|
+
RETRY_BASE_MS,
|
|
43
|
+
} from "./prompt-retry.js";
|
|
31
44
|
import { sendMarkdownDoc } from "./telegram-io.js";
|
|
32
45
|
import { TypingIndicator } from "./typing.js";
|
|
33
46
|
|
|
@@ -661,13 +674,17 @@ export class SessionRuntime {
|
|
|
661
674
|
}
|
|
662
675
|
|
|
663
676
|
/**
|
|
664
|
-
* Auto-rotate-on-give-up. When a turn has failed (retries exhausted
|
|
665
|
-
* didn't recover
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
677
|
+
* Auto-rotate-on-give-up. When a turn has failed (retries exhausted / billing
|
|
678
|
+
* 402 with no same-account retry, auto-fork didn't recover) and nothing was
|
|
679
|
+
* streamed, cycle through the OTHER saved accounts once β each step:
|
|
680
|
+
* stop Grok CLI β replace ~/.grok/auth.json β start CLI + headless auth β
|
|
681
|
+
* open a fresh session β retry the same prompt.
|
|
682
|
+
* The first account that succeeds wins and stays active; if every account
|
|
683
|
+
* fails we stop with a combined error. Bounded to ONE pass (no infinite
|
|
670
684
|
* loop). No-op unless the rotator is enabled and other accounts exist.
|
|
685
|
+
*
|
|
686
|
+
* Billing/quota failures (402 balance exhausted) skip backoff retries on
|
|
687
|
+
* each account and rotate instantly so the turn continues without a long wait.
|
|
671
688
|
*/
|
|
672
689
|
private async maybeRotateAccount(
|
|
673
690
|
input: PromptInput,
|
|
@@ -685,11 +702,13 @@ export class SessionRuntime {
|
|
|
685
702
|
|
|
686
703
|
for (const t of targets) {
|
|
687
704
|
if (this.cancelled) return last;
|
|
705
|
+
const failReason = last.error ?? final.error;
|
|
688
706
|
if (this.foreground) {
|
|
689
|
-
await this.notify(
|
|
707
|
+
await this.notify(formatAccountSwitchNotice(t.label, failReason), { replyTo: this.turnReplyTo });
|
|
690
708
|
}
|
|
691
709
|
try {
|
|
692
|
-
|
|
710
|
+
// stop CLI β swap auth.json β start CLI + authenticate(cached_token)
|
|
711
|
+
await rotator.activate(t.id);
|
|
693
712
|
} catch (e) {
|
|
694
713
|
errors.push(`\u2022 ${t.label}: couldn't switch \u2014 ${(e as Error).message}`);
|
|
695
714
|
continue;
|
|
@@ -709,17 +728,23 @@ export class SessionRuntime {
|
|
|
709
728
|
priming: transcript ? buildPriming(transcript) : undefined,
|
|
710
729
|
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
711
730
|
});
|
|
712
|
-
log.info(
|
|
731
|
+
log.info(
|
|
732
|
+
`chat ${this.chatId} auto-rotating to account ${t.label}` +
|
|
733
|
+
(isAccountExhaustedError(failReason) ? " (billing/quota exhausted on previous)" : ""),
|
|
734
|
+
);
|
|
735
|
+
// runPromptWithRetries already skips backoff for 402 / balance exhausted.
|
|
713
736
|
last = await this.runPromptWithRetries(content);
|
|
714
737
|
if (last.result && !this.cancelled) {
|
|
715
|
-
if (this.foreground)
|
|
738
|
+
if (this.foreground) {
|
|
739
|
+
await this.notify(`\u2705 Recovered on ${t.label}.`, { replyTo: this.turnReplyTo });
|
|
740
|
+
}
|
|
716
741
|
return last;
|
|
717
742
|
}
|
|
718
743
|
if (this.cancelled || (this.streamer?.hasOutput ?? false)) return last;
|
|
719
744
|
errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
|
|
720
745
|
}
|
|
721
746
|
|
|
722
|
-
// One full cycle done and still failing
|
|
747
|
+
// One full cycle done and still failing β stop with a combined report.
|
|
723
748
|
const combined = new Error(`Tried ${targets.length + 1} account(s), all failed:\n${errors.join("\n")}`);
|
|
724
749
|
return { error: combined, attempts: last.attempts };
|
|
725
750
|
}
|
|
@@ -748,12 +773,15 @@ export class SessionRuntime {
|
|
|
748
773
|
const error = err as Error;
|
|
749
774
|
const canRecover = !this.cancelled && !(this.streamer?.hasOutput ?? false);
|
|
750
775
|
// A context-exhausted session won't recover by retrying the same
|
|
751
|
-
// oversized prompt
|
|
776
|
+
// oversized prompt β skip the backoff and let auto-fork compact it now.
|
|
752
777
|
const forkInstead = canRecover && this.cfg.autoForkOnError && this.isContextRelatedFailure(error);
|
|
778
|
+
// Billing/quota 402 (balance exhausted) is permanent for this login β
|
|
779
|
+
// never backoff-retry; surface immediately so auto-rotate can switch.
|
|
753
780
|
const willRetry =
|
|
754
781
|
attempt <= delays.length &&
|
|
755
782
|
canRecover &&
|
|
756
783
|
!forkInstead &&
|
|
784
|
+
!isAccountExhaustedError(error) &&
|
|
757
785
|
isTransientError(error);
|
|
758
786
|
if (!willRetry) return { error, attempts: attempt };
|
|
759
787
|
const waitMs = delays[attempt - 1]!;
|
package/src/grok/client.ts
CHANGED
|
@@ -46,6 +46,14 @@ const TRANSIENT_RE =
|
|
|
46
46
|
/internal error|high volume|experiencing|overloaded|temporar|unavailable|rate.?limit|too many requests|try again|capacity|dispatch failure|response stream|connection (?:reset|closed|refused|error)|reset by peer|broken pipe|socket hang ?up|econnreset|econnrefused|enotfound|eai_again|etimedout|\b50[234]\b|\b429\b/i;
|
|
47
47
|
const CONTEXT_EXHAUSTED_RE =
|
|
48
48
|
/context (?:length|window|limit|size|overflow)|maximum context|input (?:is )?too long|prompt (?:is )?too long|too many (?:input )?tokens|token limit|exceeds? (?:the )?(?:maximum|context|token)|reduce the (?:length|size)|context.{0,24}exhaust/i;
|
|
49
|
+
/**
|
|
50
|
+
* Permanent-for-this-login billing/quota failures (e.g. HTTP 402 "Grok Build
|
|
51
|
+
* usage balance exhausted"). These ride inside ACP Internal error [-32603] but
|
|
52
|
+
* must NOT be backoff-retried on the same account β only account rotation can
|
|
53
|
+
* recover.
|
|
54
|
+
*/
|
|
55
|
+
const ACCOUNT_EXHAUSTED_RE =
|
|
56
|
+
/\b402\b|payment required|balance exhausted|usage balance|out of (?:credits|quota|balance)|insufficient (?:credits|balance|quota)|quota exceeded|no (?:remaining )?credits/i;
|
|
49
57
|
|
|
50
58
|
export class GrokError extends Error {
|
|
51
59
|
constructor(
|
|
@@ -58,7 +66,35 @@ export class GrokError extends Error {
|
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
68
|
|
|
69
|
+
/**
|
|
70
|
+
* True when the failure is a billing/quota exhaustion for the active Grok
|
|
71
|
+
* login (HTTP 402, "balance exhausted", etc.). Same-account retries cannot
|
|
72
|
+
* help; the turn should stop (or auto-rotate to another saved account).
|
|
73
|
+
*/
|
|
74
|
+
export function isAccountExhaustedError(err: Error): boolean {
|
|
75
|
+
if (ACCOUNT_EXHAUSTED_RE.test(err.message)) return true;
|
|
76
|
+
const data = (err as GrokError).data;
|
|
77
|
+
if (!data || typeof data !== "object") return false;
|
|
78
|
+
const d = data as Record<string, unknown>;
|
|
79
|
+
const status = d.http_status ?? d.status ?? d.statusCode;
|
|
80
|
+
if (status === 402 || status === "402") return true;
|
|
81
|
+
if (typeof d.message === "string" && ACCOUNT_EXHAUSTED_RE.test(d.message)) return true;
|
|
82
|
+
// Nested JSON sometimes lands as a stringified payload inside `data`.
|
|
83
|
+
for (const v of Object.values(d)) {
|
|
84
|
+
if (typeof v === "string" && ACCOUNT_EXHAUSTED_RE.test(v)) return true;
|
|
85
|
+
if (v && typeof v === "object") {
|
|
86
|
+
const nested = v as Record<string, unknown>;
|
|
87
|
+
const ns = nested.http_status ?? nested.status ?? nested.statusCode;
|
|
88
|
+
if (ns === 402 || ns === "402") return true;
|
|
89
|
+
if (typeof nested.message === "string" && ACCOUNT_EXHAUSTED_RE.test(nested.message)) return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
61
95
|
export function isTransientError(err: Error): boolean {
|
|
96
|
+
// Balance/quota exhaustion is permanent for this login β never backoff-retry.
|
|
97
|
+
if (isAccountExhaustedError(err)) return false;
|
|
62
98
|
const code = (err as GrokError).code;
|
|
63
99
|
if (typeof code === "number" && TRANSIENT_CODES.has(code)) return true;
|
|
64
100
|
return TRANSIENT_RE.test(err.message);
|
package/scripts/_push-branch.mjs
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
|
|
4
|
-
const files = process.argv.slice(2);
|
|
5
|
-
if (files.length < 3) {
|
|
6
|
-
console.error("Usage: node scripts/_push-branch.mjs <branch> <message> <file>...");
|
|
7
|
-
process.exit(1);
|
|
8
|
-
}
|
|
9
|
-
const [branch, message, ...paths] = files;
|
|
10
|
-
const owner = "artickc";
|
|
11
|
-
const repo = "grok-telegram-bot";
|
|
12
|
-
|
|
13
|
-
// eslint-disable-next-line no-control-regex
|
|
14
|
-
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
15
|
-
|
|
16
|
-
function ghApi(method, path, body) {
|
|
17
|
-
const args = ["api", "-X", method, path];
|
|
18
|
-
const opts = {
|
|
19
|
-
encoding: "utf8",
|
|
20
|
-
maxBuffer: 50 * 1024 * 1024,
|
|
21
|
-
shell: false,
|
|
22
|
-
env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", FORCE_COLOR: "0", GH_FORCE_TTY: "0" },
|
|
23
|
-
};
|
|
24
|
-
if (body !== undefined) {
|
|
25
|
-
args.push("--input", "-");
|
|
26
|
-
opts.input = JSON.stringify(body);
|
|
27
|
-
}
|
|
28
|
-
const r = spawnSync("gh", args, opts);
|
|
29
|
-
if (r.status !== 0) {
|
|
30
|
-
throw new Error(`gh api ${method} ${path} failed (${r.status}): ${r.stderr || r.stdout}`);
|
|
31
|
-
}
|
|
32
|
-
const text = (r.stdout || "").replace(ANSI_RE, "").trim();
|
|
33
|
-
if (!text) return {};
|
|
34
|
-
return JSON.parse(text);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Ensure branch exists from main if missing.
|
|
38
|
-
try {
|
|
39
|
-
ghApi("GET", `repos/${owner}/${repo}/git/ref/heads/${branch}`);
|
|
40
|
-
} catch {
|
|
41
|
-
const main = ghApi("GET", `repos/${owner}/${repo}/git/ref/heads/main`);
|
|
42
|
-
ghApi("POST", `repos/${owner}/${repo}/git/refs`, {
|
|
43
|
-
ref: `refs/heads/${branch}`,
|
|
44
|
-
sha: main.object.sha,
|
|
45
|
-
});
|
|
46
|
-
console.log("created branch", branch, "from main");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const ref = ghApi("GET", `repos/${owner}/${repo}/git/ref/heads/${branch}`);
|
|
50
|
-
const baseSha = ref.object.sha;
|
|
51
|
-
const baseCommit = ghApi("GET", `repos/${owner}/${repo}/git/commits/${baseSha}`);
|
|
52
|
-
const baseTree = baseCommit.tree.sha;
|
|
53
|
-
console.log("base", baseSha);
|
|
54
|
-
|
|
55
|
-
const tree = [];
|
|
56
|
-
for (const path of paths) {
|
|
57
|
-
const content = readFileSync(path, "utf8");
|
|
58
|
-
const blob = ghApi("POST", `repos/${owner}/${repo}/git/blobs`, {
|
|
59
|
-
content: Buffer.from(content, "utf8").toString("base64"),
|
|
60
|
-
encoding: "base64",
|
|
61
|
-
});
|
|
62
|
-
tree.push({ path: path.replace(/\\/g, "/"), mode: "100644", type: "blob", sha: blob.sha });
|
|
63
|
-
console.log("blob", path, blob.sha.slice(0, 8));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const newTree = ghApi("POST", `repos/${owner}/${repo}/git/trees`, {
|
|
67
|
-
base_tree: baseTree,
|
|
68
|
-
tree,
|
|
69
|
-
});
|
|
70
|
-
const commit = ghApi("POST", `repos/${owner}/${repo}/git/commits`, {
|
|
71
|
-
message,
|
|
72
|
-
tree: newTree.sha,
|
|
73
|
-
parents: [baseSha],
|
|
74
|
-
});
|
|
75
|
-
ghApi("PATCH", `repos/${owner}/${repo}/git/refs/heads/${branch}`, {
|
|
76
|
-
sha: commit.sha,
|
|
77
|
-
force: false,
|
|
78
|
-
});
|
|
79
|
-
console.log("updated", branch, "->", commit.sha);
|
package/scripts/_tag-release.mjs
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
|
|
3
|
-
const owner = "artickc";
|
|
4
|
-
const repo = "grok-telegram-bot";
|
|
5
|
-
const version = process.argv[2] || "2.2.1";
|
|
6
|
-
const tag = `v${version}`;
|
|
7
|
-
const message = process.argv[3] || `${tag} β pin permission prompts until resolved`;
|
|
8
|
-
|
|
9
|
-
// eslint-disable-next-line no-control-regex
|
|
10
|
-
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
11
|
-
|
|
12
|
-
function ghApi(method, path, body) {
|
|
13
|
-
const args = ["api", "-X", method, path];
|
|
14
|
-
const opts = {
|
|
15
|
-
encoding: "utf8",
|
|
16
|
-
maxBuffer: 50 * 1024 * 1024,
|
|
17
|
-
shell: false,
|
|
18
|
-
env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", FORCE_COLOR: "0", GH_FORCE_TTY: "0" },
|
|
19
|
-
};
|
|
20
|
-
if (body !== undefined) {
|
|
21
|
-
args.push("--input", "-");
|
|
22
|
-
opts.input = JSON.stringify(body);
|
|
23
|
-
}
|
|
24
|
-
const r = spawnSync("gh", args, opts);
|
|
25
|
-
if (r.status !== 0) {
|
|
26
|
-
throw new Error(`gh api ${method} ${path} failed (${r.status}): ${r.stderr || r.stdout}`);
|
|
27
|
-
}
|
|
28
|
-
const text = (r.stdout || "").replace(ANSI_RE, "").trim();
|
|
29
|
-
if (!text) return {};
|
|
30
|
-
return JSON.parse(text);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const ref = ghApi("GET", `repos/${owner}/${repo}/git/ref/heads/main`);
|
|
34
|
-
const sha = ref.object.sha;
|
|
35
|
-
console.log("main", sha);
|
|
36
|
-
|
|
37
|
-
const tagObj = ghApi("POST", `repos/${owner}/${repo}/git/tags`, {
|
|
38
|
-
tag,
|
|
39
|
-
message,
|
|
40
|
-
object: sha,
|
|
41
|
-
type: "commit",
|
|
42
|
-
tagger: {
|
|
43
|
-
name: "artickc",
|
|
44
|
-
email: "artickc@users.noreply.github.com",
|
|
45
|
-
date: new Date().toISOString(),
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
ghApi("POST", `repos/${owner}/${repo}/git/refs`, {
|
|
49
|
-
ref: `refs/tags/${tag}`,
|
|
50
|
-
sha: tagObj.sha,
|
|
51
|
-
});
|
|
52
|
-
console.log("created tag", tag, "->", sha);
|