grok-telegram-bot 2.2.2 → 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 CHANGED
@@ -9,6 +9,21 @@ 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
+
12
27
  ## [2.2.2] - 2026-07-12
13
28
 
14
29
  Patch release that hardens multi-account identity and documents the full **2.2.x**
package/README.md CHANGED
@@ -40,7 +40,7 @@ re-architected for the Grok Build CLI and extended into a full multi-session cli
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
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
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, optionally cycle through your other saved accounts once and retry on each the first that works wins (toggle in `/accounts`). Same headless auth.json swap. |
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
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. |
45
45
  | 🪙 **Credits & usage** | The `✅ Done` line and `/usage` show credits used (when Grok reports them), turns this session, and account info. |
46
46
  | ⌨️ **Typing indicator** | Stays on for the whole turn, even through long tool chains. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-telegram-bot",
3
- "version": "2.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");
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Auto-rotate-on-give-up. When a turn exhausts its retries (and auto-fork can't
3
- * recover it), the runtime can cycle through the OTHER saved Grok accounts,
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: switch = stop agent replace ~/.grok/auth.json start agent with
16
- * headless `cached_token` auth. Never opens a browser / never runs `grok login`.
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
- * 1. Stop the shared agent (so it cannot rewrite auth.json),
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 the agent and authenticate with `cached_token` (headless).
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 agent before auth.json swap (${id})`);
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 agent`);
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.
@@ -57,7 +57,9 @@ async function view(deps: BotDeps, note?: string): Promise<{ text: string; keybo
57
57
  lines.push(
58
58
  "",
59
59
  `\u{1F501} Auto-rotate on errors: ${rotate ? "ON" : "OFF"}`,
60
- rotate ? " \u2514 If a turn gives up, it cycles through the other accounts once." : " \u2514 Turns stay on the active account.",
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).",
61
63
  );
62
64
  if (note) lines.push("", note);
63
65
 
@@ -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 { type GrokClient, isContextExhaustedError, isTransientError, type SessionMetadata } from "../grok/client.js";
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 { backoffSchedule, fmtSeconds, formatErrorSummary, formatRetryNotice, RETRY_BASE_MS } from "./prompt-retry.js";
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, auto-fork
665
- * didn't recover it) and nothing was streamed, cycle through the OTHER saved
666
- * accounts once — switching login + restarting the agent, then retrying the
667
- * same prompt on a fresh session for each. The first account that succeeds
668
- * wins and stays active; if every account fails we return a single combined
669
- * error listing what each one reported. Bounded to ONE pass (no infinite
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(`\u{1F501} Auto-rotating accounts \u2014 trying ${t.label}\u2026`, { replyTo: this.turnReplyTo });
707
+ await this.notify(formatAccountSwitchNotice(t.label, failReason), { replyTo: this.turnReplyTo });
690
708
  }
691
709
  try {
692
- await rotator.activate(t.id); // switch login + restart the shared agent
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(`chat ${this.chatId} auto-rotating to account ${t.label}`);
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) await this.notify(`\u2705 Recovered on ${t.label}.`, { replyTo: this.turnReplyTo });
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 — stop with a combined report.
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 — skip the backoff and let auto-fork compact it now.
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]!;
@@ -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);
@@ -1,99 +0,0 @@
1
- /**
2
- * Push current working-tree release files to main + create annotated tag.
3
- * (Local git push is restricted in this environment.)
4
- */
5
- import { readFileSync } from "node:fs";
6
- import { spawnSync } from "node:child_process";
7
-
8
- const owner = "artickc";
9
- const repo = "grok-telegram-bot";
10
- const branch = "main";
11
- const version = "2.2.2";
12
- const tag = `v${version}`;
13
- const message = "release: v2.2.2 — account identity, docs, 2.2.x multi-account batch";
14
- const tagMessage =
15
- "v2.2.2 — multi-account headless rotation, session auto-approve, pinned permissions, account identity";
16
-
17
- const files = [
18
- ".gitignore",
19
- "CHANGELOG.md",
20
- "README.md",
21
- "package.json",
22
- "src/app/accounts.ts",
23
- "src/app/grok-credentials.ts",
24
- "src/bot/handlers/accounts.ts",
25
- "test/credentials.test.ts",
26
- ];
27
-
28
- // eslint-disable-next-line no-control-regex
29
- const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
30
-
31
- function ghApi(method, path, body) {
32
- const args = ["api", "-X", method, path];
33
- const opts = {
34
- encoding: "utf8",
35
- maxBuffer: 50 * 1024 * 1024,
36
- shell: false,
37
- env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", FORCE_COLOR: "0", GH_FORCE_TTY: "0" },
38
- };
39
- if (body !== undefined) {
40
- args.push("--input", "-");
41
- opts.input = JSON.stringify(body);
42
- }
43
- const r = spawnSync("gh", args, opts);
44
- if (r.status !== 0) {
45
- throw new Error(`gh api ${method} ${path} failed (${r.status}): ${r.stderr || r.stdout}`);
46
- }
47
- const text = (r.stdout || "").replace(ANSI_RE, "").trim();
48
- if (!text) return {};
49
- return JSON.parse(text);
50
- }
51
-
52
- const ref = ghApi("GET", `repos/${owner}/${repo}/git/ref/heads/${branch}`);
53
- const baseSha = ref.object.sha;
54
- const baseCommit = ghApi("GET", `repos/${owner}/${repo}/git/commits/${baseSha}`);
55
- const baseTree = baseCommit.tree.sha;
56
- console.log("base", baseSha);
57
-
58
- const tree = [];
59
- for (const path of files) {
60
- const content = readFileSync(path, "utf8");
61
- const blob = ghApi("POST", `repos/${owner}/${repo}/git/blobs`, {
62
- content: Buffer.from(content, "utf8").toString("base64"),
63
- encoding: "base64",
64
- });
65
- tree.push({ path, mode: "100644", type: "blob", sha: blob.sha });
66
- console.log("blob", path, blob.sha.slice(0, 8));
67
- }
68
-
69
- const newTree = ghApi("POST", `repos/${owner}/${repo}/git/trees`, {
70
- base_tree: baseTree,
71
- tree,
72
- });
73
- const commit = ghApi("POST", `repos/${owner}/${repo}/git/commits`, {
74
- message,
75
- tree: newTree.sha,
76
- parents: [baseSha],
77
- });
78
- ghApi("PATCH", `repos/${owner}/${repo}/git/refs/heads/${branch}`, {
79
- sha: commit.sha,
80
- force: false,
81
- });
82
- console.log("main ->", commit.sha);
83
-
84
- const tagObj = ghApi("POST", `repos/${owner}/${repo}/git/tags`, {
85
- tag,
86
- message: tagMessage,
87
- object: commit.sha,
88
- type: "commit",
89
- tagger: {
90
- name: "artickc",
91
- email: "artickc@users.noreply.github.com",
92
- date: new Date().toISOString(),
93
- },
94
- });
95
- ghApi("POST", `repos/${owner}/${repo}/git/refs`, {
96
- ref: `refs/tags/${tag}`,
97
- sha: tagObj.sha,
98
- });
99
- console.log("tag", tag, "->", commit.sha);