grok-telegram-bot 2.2.3 โ†’ 2.2.4

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
@@ -7,9 +7,29 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  The latest section is published verbatim as the GitHub Release notes by
8
8
  `.github/workflows/release.yml` when a `vX.Y.Z` tag is pushed.
9
9
 
10
- ## [Unreleased]
11
-
12
- ## [2.2.3] - 2026-07-13
10
+ ## [Unreleased]
11
+
12
+ ## [2.2.4] - 2026-07-16
13
+
14
+ ### Fixed
15
+
16
+ - **๐Ÿšซ Rotate immediately on Grok access denial.** API `403 Forbidden` / `Access
17
+ denied` responses now bypass same-account retry backoff, mark the failed
18
+ login with `โš ๏ธ`, and rotate to the next eligible saved account. If the active
19
+ host login was not already saved (for example after an external sign-in or
20
+ token refresh), it is captured first so the warning is visible and persists.
21
+ - **๐Ÿ” Isolated Grok Telegram identity.** The Grok bot now takes its Telegram
22
+ token from its own instance `.env` before considering an inherited process
23
+ environment value. This prevents a machine-wide token for a sibling bot from
24
+ making Grok poll as Codex/Kiro/OpenCode and causing Telegram conflicts.
25
+ - **โš ๏ธ Persistent account warnings.** Account access/quota failures are shown
26
+ in `/accounts`, excluded from later automatic rotations, and can be manually
27
+ re-enabled after the account is repaired.
28
+ - **๐Ÿ›Ÿ No false success for silent ACP turns.** A Grok ACP completion with no
29
+ text, thought, or tool update is now treated as a recoverable agent failure
30
+ instead of reporting `Done ยท no text output`.
31
+
32
+ ## [2.2.3] - 2026-07-13
13
33
 
14
34
  ### Fixed
15
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-telegram-bot",
3
- "version": "2.2.3",
3
+ "version": "2.2.4",
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",
@@ -32,6 +32,11 @@ export interface StoredAccount {
32
32
  startUrl?: string;
33
33
  accountType?: string;
34
34
  region?: string;
35
+ /** Excluded from automatic rotation after an account-specific quota/billing failure. */
36
+ warning?: {
37
+ reason: string;
38
+ markedAt: string;
39
+ };
35
40
  }
36
41
 
37
42
  interface AccountsData {
@@ -171,6 +176,9 @@ export class AccountManager {
171
176
  email: (email && email.includes("@") ? email : undefined) || existing?.email,
172
177
  startUrl: (email && email.includes("@") ? email : undefined) || existing?.email || existing?.startUrl,
173
178
  savedAt: new Date().toISOString(),
179
+ // Automatic pre-rotation snapshots must not silently re-enable an
180
+ // account that was quarantined after a quota/billing failure.
181
+ warning: existing?.warning,
174
182
  };
175
183
  this.store.update((d) => {
176
184
  const idx = d.accounts.findIndex((a) => a.id === id);
@@ -227,6 +235,33 @@ export class AccountManager {
227
235
  return updated;
228
236
  }
229
237
 
238
+ /** Mark an account as unsuitable for future automatic rotations. */
239
+ markWarning(id: string, reason: string): StoredAccount | undefined {
240
+ let updated: StoredAccount | undefined;
241
+ this.store.update((d) => {
242
+ const account = d.accounts.find((a) => a.id === id);
243
+ if (account) {
244
+ account.warning = { reason, markedAt: new Date().toISOString() };
245
+ updated = account;
246
+ }
247
+ });
248
+ if (updated) log.warn(`marked account ${updated.label} with rotation warning: ${reason}`);
249
+ return updated;
250
+ }
251
+
252
+ /** Re-allow a manually restored account to participate in auto-rotation. */
253
+ clearWarning(id: string): StoredAccount | undefined {
254
+ let updated: StoredAccount | undefined;
255
+ this.store.update((d) => {
256
+ const account = d.accounts.find((a) => a.id === id);
257
+ if (account?.warning) {
258
+ delete account.warning;
259
+ updated = account;
260
+ }
261
+ });
262
+ return updated;
263
+ }
264
+
230
265
  async forget(id: string): Promise<boolean> {
231
266
  const existed = !!this.get(id);
232
267
  await rm(this.snapshotPath(id), { force: true }).catch(() => {});
@@ -37,6 +37,8 @@ export interface AccountRotator {
37
37
  targets(): Promise<RotationTarget[]>;
38
38
  /** Make a saved account active (swap auth.json + re-bind). Throws on error. */
39
39
  activate(id: string): Promise<void>;
40
+ /** Quarantine an account after an account-specific failure. Undefined means active. */
41
+ markFailed(id: string | undefined, reason: string): Promise<void>;
40
42
  }
41
43
 
42
44
  export class AccountRotatorImpl implements AccountRotator {
@@ -52,7 +54,26 @@ export class AccountRotatorImpl implements AccountRotator {
52
54
  async targets(): Promise<RotationTarget[]> {
53
55
  const list = this.accounts.list();
54
56
  const activeId = this.accounts.activeAccountId();
55
- return list.filter((a) => a.id !== activeId).map((a) => ({ id: a.id, label: a.label }));
57
+ return list
58
+ .filter((a) => a.id !== activeId && !a.warning)
59
+ .map((a) => ({ id: a.id, label: a.label }));
60
+ }
61
+
62
+ async markFailed(id: string | undefined, reason: string): Promise<void> {
63
+ let accountId = id ?? this.accounts.activeAccountId();
64
+ if (!accountId) {
65
+ // The host may have been signed in or had its token refreshed outside the
66
+ // bot, so no saved snapshot matches yet. Capture it before marking; this
67
+ // guarantees the actual failed login appears with a warning in /accounts
68
+ // and is excluded from the target list below.
69
+ try {
70
+ accountId = (await this.accounts.captureCurrent()).id;
71
+ } catch (error) {
72
+ log.warn("could not capture active account for rotation warning:", (error as Error).message);
73
+ return;
74
+ }
75
+ }
76
+ this.accounts.markWarning(accountId, reason);
56
77
  }
57
78
 
58
79
  /**
@@ -17,7 +17,7 @@ import type { BotDeps } from "../deps.js";
17
17
  const log = createLogger("accounts");
18
18
 
19
19
  function accountLine(a: StoredAccount, active: boolean): string {
20
- const mark = active ? "\u2705 " : "\u{1F464} ";
20
+ const mark = a.warning ? "\u26A0\uFE0F " : active ? "\u2705 " : "\u{1F464} ";
21
21
  return `${mark}${a.label}`;
22
22
  }
23
23
 
@@ -51,15 +51,18 @@ async function view(deps: BotDeps, note?: string): Promise<{ text: string; keybo
51
51
  if (list.length === 0) {
52
52
  lines.push("No saved accounts yet.", "", "Save the current login below, or sign in via /reauth.");
53
53
  } else {
54
- for (const a of list) lines.push(accountLine(a, a.id === active));
54
+ for (const a of list) {
55
+ lines.push(accountLine(a, a.id === active));
56
+ if (a.warning) lines.push(" \u2514 Skipped by auto-rotate after an account access or quota error.");
57
+ }
55
58
  }
56
59
  const rotate = deps.accounts.autoRotateEnabled();
57
60
  lines.push(
58
61
  "",
59
62
  `\u{1F501} Auto-rotate on errors: ${rotate ? "ON" : "OFF"}`,
60
63
  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).",
64
+ ? " \u2514 On account access/quota errors: stop CLI, swap auth, restart, retry each eligible account once. \u26A0\uFE0F accounts are skipped."
65
+ : " \u2514 Turns stay on the active account (account access/quota errors stop immediately).",
63
66
  );
64
67
  if (note) lines.push("", note);
65
68
 
@@ -70,6 +73,7 @@ async function view(deps: BotDeps, note?: string): Promise<{ text: string; keybo
70
73
  .text("\u270F\uFE0F", `acct:rename:${a.id}`)
71
74
  .text("\u{1F5D1}", `acct:del:${a.id}`)
72
75
  .row();
76
+ if (a.warning) kb.text(`\u26A0\uFE0F Re-enable ${trim(a.label)}`, `acct:clearwarning:${a.id}`).row();
73
77
  }
74
78
  kb.text("\u{1F4BE} Save current login", "acct:save").text("\u270F\uFE0F Save as\u2026", "acct:saveas").row();
75
79
  kb.text("\u{1F4E5} Import existing", "acct:import").text("\u{1F511} Sign in\u2026", "acct:login").row();
@@ -161,6 +165,13 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
161
165
  await promptName(ctx, "rename", ctx.match![1]!);
162
166
  });
163
167
 
168
+ bot.callbackQuery(/^acct:clearwarning:(.+)$/, async (ctx) => {
169
+ const id = ctx.match![1]!;
170
+ const account = deps.accounts.clearWarning(id);
171
+ await ctx.answerCallbackQuery({ text: account ? "Account re-enabled for auto-rotate" : "No warning to clear" });
172
+ await rerender(ctx, deps, account ? `\u2705 ${account.label} can be used by auto-rotate again.` : undefined);
173
+ });
174
+
164
175
  bot.callbackQuery("acct:close", async (ctx) => {
165
176
  await ctx.answerCallbackQuery();
166
177
  await deps.ephemeral.drop(ctx);
@@ -7,8 +7,8 @@
7
7
  import { basename } from "node:path";
8
8
  import { type Api, InlineKeyboard } from "grammy";
9
9
  import {
10
- type GrokClient,
11
- isAccountExhaustedError,
10
+ type GrokClient,
11
+ isAccountRotationError,
12
12
  isContextExhaustedError,
13
13
  isTransientError,
14
14
  type SessionMetadata,
@@ -99,8 +99,10 @@ export class SessionRuntime {
99
99
  private turnCount = 0;
100
100
  /** Telegram message id of the current turn's prompt, so replies thread to it. */
101
101
  private turnReplyTo: number | undefined;
102
- private imageScanText = "";
103
- private sentImagesThisTurn = new Set<string>();
102
+ private imageScanText = "";
103
+ private sentImagesThisTurn = new Set<string>();
104
+ /** Monotonic count used to reject ACP "success" responses with no turn updates. */
105
+ private sessionUpdateCount = 0;
104
106
  private readonly listener: (sessionId: string, update: SessionUpdate) => void;
105
107
  private primingContext: string | undefined;
106
108
  private watcher: TailWatcher | undefined;
@@ -690,10 +692,18 @@ export class SessionRuntime {
690
692
  input: PromptInput,
691
693
  final: { result?: PromptResult; error?: Error; attempts: number },
692
694
  ): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
693
- const rotator = this.accountRotator;
694
- if (!rotator?.enabled() || !final.error || this.cancelled) return undefined;
695
- if (this.streamer?.hasOutput ?? false) return undefined;
696
- const targets = await rotator.targets().catch(() => [] as { id: string; label: string }[]);
695
+ const rotator = this.accountRotator;
696
+ if (!rotator?.enabled() || !final.error || this.cancelled) return undefined;
697
+ // A quota-exhausted or access-denied response cannot be recovered by retrying
698
+ // this login. Quarantine it before choosing targets, so later rotations do not
699
+ // cycle back to a known-bad account. This intentionally happens before the
700
+ // partial-stream guard: we must not retry/rotate a partial reply, but its
701
+ // account still needs to be skipped during a future rotation.
702
+ if (isAccountRotationError(final.error)) {
703
+ await rotator.markFailed(undefined, final.error.message);
704
+ }
705
+ if (this.streamer?.hasOutput ?? false) return undefined;
706
+ const targets = await rotator.targets().catch(() => [] as { id: string; label: string }[]);
697
707
  if (targets.length === 0) return undefined;
698
708
 
699
709
  const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
@@ -730,18 +740,21 @@ export class SessionRuntime {
730
740
  });
731
741
  log.info(
732
742
  `chat ${this.chatId} auto-rotating to account ${t.label}` +
733
- (isAccountExhaustedError(failReason) ? " (billing/quota exhausted on previous)" : ""),
743
+ (isAccountRotationError(failReason) ? " (previous account unavailable)" : ""),
734
744
  );
735
745
  // runPromptWithRetries already skips backoff for 402 / balance exhausted.
736
746
  last = await this.runPromptWithRetries(content);
737
- if (last.result && !this.cancelled) {
747
+ if (last.result && !this.cancelled) {
738
748
  if (this.foreground) {
739
749
  await this.notify(`\u2705 Recovered on ${t.label}.`, { replyTo: this.turnReplyTo });
740
750
  }
741
751
  return last;
742
- }
743
- if (this.cancelled || (this.streamer?.hasOutput ?? false)) return last;
744
- errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
752
+ }
753
+ if (last.error && isAccountRotationError(last.error)) {
754
+ await rotator.markFailed(t.id, last.error.message);
755
+ }
756
+ if (this.cancelled || (this.streamer?.hasOutput ?? false)) return last;
757
+ errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
745
758
  }
746
759
 
747
760
  // One full cycle done and still failing โ€” stop with a combined report.
@@ -763,12 +776,21 @@ export class SessionRuntime {
763
776
  ): Promise<{ result?: PromptResult; error?: Error; attempts: number }> {
764
777
  const delays = this.cfg.promptRetryAttempts > 0 ? backoffSchedule(this.cfg.promptRetryAttempts) : [];
765
778
  const totalAttempts = delays.length + 1;
766
- let attempt = 0;
767
- for (;;) {
768
- attempt++;
769
- try {
770
- const result = await this.acp.prompt(this.sessionId!, content);
771
- return { result, attempts: attempt };
779
+ let attempt = 0;
780
+ for (;;) {
781
+ attempt++;
782
+ try {
783
+ const updatesBeforePrompt = this.sessionUpdateCount;
784
+ const result = await this.acp.prompt(this.sessionId!, content);
785
+ // A healthy ACP turn emits at least one session/update (text, thought,
786
+ // or tool event) before resolving session/prompt. Grok can otherwise
787
+ // report a successful end-turn after an upstream model failure; never
788
+ // present that as a completed user request.
789
+ await sleep(0);
790
+ if (this.sessionUpdateCount === updatesBeforePrompt) {
791
+ throw new Error("Empty agent response โ€” Grok ended the turn without any output or tool activity");
792
+ }
793
+ return { result, attempts: attempt };
772
794
  } catch (err) {
773
795
  const error = err as Error;
774
796
  const canRecover = !this.cancelled && !(this.streamer?.hasOutput ?? false);
@@ -781,7 +803,7 @@ export class SessionRuntime {
781
803
  attempt <= delays.length &&
782
804
  canRecover &&
783
805
  !forkInstead &&
784
- !isAccountExhaustedError(error) &&
806
+ !isAccountRotationError(error) &&
785
807
  isTransientError(error);
786
808
  if (!willRetry) return { error, attempts: attempt };
787
809
  const waitMs = delays[attempt - 1]!;
@@ -958,9 +980,10 @@ export class SessionRuntime {
958
980
  void this.runTurn(batch);
959
981
  }
960
982
 
961
- private onUpdate(sessionId: string, update: SessionUpdate): void {
962
- if (!this.busy || sessionId !== this.sessionId) return;
963
- const kind = update.sessionUpdate;
983
+ private onUpdate(sessionId: string, update: SessionUpdate): void {
984
+ if (!this.busy || sessionId !== this.sessionId) return;
985
+ this.sessionUpdateCount++;
986
+ const kind = update.sessionUpdate;
964
987
 
965
988
  // Accumulate the turn's file-change summary + image-scan text even when this
966
989
  // session is in the background (its output isn't streamed here, but the
package/src/config.ts CHANGED
@@ -44,9 +44,10 @@ function resolveInstanceDir(): string {
44
44
  return CANONICAL_DIR;
45
45
  }
46
46
 
47
- // Load .env from the resolved instance directory. dotenv does NOT override
48
- // variables already present in the environment (the launcher/service env wins).
49
- loadDotenv({ path: ENV_PATH });
47
+ // Load .env from the resolved instance directory. Keep the parsed values as
48
+ // well: a machine-wide TELEGRAM_BOT_TOKEN may belong to a sibling bot (Codex,
49
+ // Kiro, etc.) and must never override this Grok instance's identity.
50
+ const instanceEnv = loadDotenv({ path: ENV_PATH }).parsed ?? {};
50
51
 
51
52
  function expandHome(p: string): string {
52
53
  if (p === "~") return homedir();
@@ -146,7 +147,10 @@ export interface AppConfig {
146
147
  }
147
148
 
148
149
  export function loadConfig(): AppConfig {
149
- const token = (process.env.TELEGRAM_BOT_TOKEN || "").trim();
150
+ // Telegram long polling permits one consumer per token. Prefer the token in
151
+ // this bot's own instance file over a globally inherited environment value,
152
+ // otherwise a Grok process can accidentally poll as a sibling bot.
153
+ const token = (instanceEnv.TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN || "").trim();
150
154
  if (!token) {
151
155
  throw new Error(
152
156
  "TELEGRAM_BOT_TOKEN is missing. Copy .env.example to .env and set it (run `npm run setup`).",
@@ -43,7 +43,7 @@ export interface SessionMetadata {
43
43
 
44
44
  const TRANSIENT_CODES = new Set([-32603, -32500, -32000, 500, 502, 503, 504, 429]);
45
45
  const TRANSIENT_RE =
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;
46
+ /internal error|high volume|experiencing|overloaded|temporar|unavailable|rate.?limit|too many requests|try again|capacity|dispatch failure|response stream|empty agent response|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
49
  /**
@@ -54,6 +54,10 @@ const CONTEXT_EXHAUSTED_RE =
54
54
  */
55
55
  const ACCOUNT_EXHAUSTED_RE =
56
56
  /\b402\b|payment required|balance exhausted|usage balance|out of (?:credits|quota|balance)|insufficient (?:credits|balance|quota)|quota exceeded|no (?:remaining )?credits/i;
57
+ /** Account-level authorization failures from the Grok CLI proxy. A different
58
+ * saved login may be permitted, while same-account retries cannot help. */
59
+ const ACCOUNT_ACCESS_DENIED_RE =
60
+ /\b403\b|forbidden|access denied/i;
57
61
 
58
62
  export class GrokError extends Error {
59
63
  constructor(
@@ -92,9 +96,36 @@ export function isAccountExhaustedError(err: Error): boolean {
92
96
  return false;
93
97
  }
94
98
 
99
+ /**
100
+ * True when the active saved login cannot serve the request: either its Grok
101
+ * Build quota is exhausted (402), or the proxy rejects it as unauthorized
102
+ * (403 / Forbidden / Access denied). Both must skip same-account backoff and
103
+ * trigger account rotation when enabled.
104
+ */
105
+ export function isAccountRotationError(err: Error): boolean {
106
+ if (isAccountExhaustedError(err) || ACCOUNT_ACCESS_DENIED_RE.test(err.message)) return true;
107
+ const data = (err as GrokError).data;
108
+ if (!data || typeof data !== "object") return false;
109
+ const d = data as Record<string, unknown>;
110
+ const status = d.http_status ?? d.status ?? d.statusCode;
111
+ if (status === 403 || status === "403") return true;
112
+ if (typeof d.message === "string" && ACCOUNT_ACCESS_DENIED_RE.test(d.message)) return true;
113
+ for (const value of Object.values(d)) {
114
+ if (typeof value === "string" && ACCOUNT_ACCESS_DENIED_RE.test(value)) return true;
115
+ if (value && typeof value === "object") {
116
+ const nested = value as Record<string, unknown>;
117
+ const nestedStatus = nested.http_status ?? nested.status ?? nested.statusCode;
118
+ if (nestedStatus === 403 || nestedStatus === "403") return true;
119
+ if (typeof nested.message === "string" && ACCOUNT_ACCESS_DENIED_RE.test(nested.message)) return true;
120
+ }
121
+ }
122
+ return false;
123
+ }
124
+
95
125
  export function isTransientError(err: Error): boolean {
96
- // Balance/quota exhaustion is permanent for this login โ€” never backoff-retry.
97
- if (isAccountExhaustedError(err)) return false;
126
+ // Quota exhaustion and access denial are permanent for this login โ€” rotate,
127
+ // never back off and retry the same credentials.
128
+ if (isAccountRotationError(err)) return false;
98
129
  const code = (err as GrokError).code;
99
130
  if (typeof code === "number" && TRANSIENT_CODES.has(code)) return true;
100
131
  return TRANSIENT_RE.test(err.message);
@@ -1,16 +0,0 @@
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");