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 +23 -3
- package/package.json +1 -1
- package/src/app/accounts.ts +35 -0
- package/src/bot/account-rotator.ts +22 -1
- package/src/bot/handlers/accounts.ts +15 -4
- package/src/bot/session-runtime.ts +46 -23
- package/src/config.ts +8 -4
- package/src/grok/client.ts +34 -3
- package/scripts/_tmp-release.mjs +0 -16
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.
|
|
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
|
+
"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",
|
package/src/app/accounts.ts
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
|
62
|
-
: " \u2514 Turns stay on the active account (
|
|
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
|
-
|
|
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
|
-
|
|
696
|
-
|
|
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
|
-
(
|
|
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 (
|
|
744
|
-
|
|
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
|
|
771
|
-
|
|
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
|
-
!
|
|
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
|
-
|
|
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.
|
|
48
|
-
//
|
|
49
|
-
|
|
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
|
-
|
|
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`).",
|
package/src/grok/client.ts
CHANGED
|
@@ -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
|
-
//
|
|
97
|
-
|
|
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);
|
package/scripts/_tmp-release.mjs
DELETED
|
@@ -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");
|