grok-telegram-bot 2.3.1 → 2.5.0

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.
Files changed (88) hide show
  1. package/.env.example +64 -2
  2. package/CHANGELOG.md +156 -1
  3. package/README.md +58 -15
  4. package/docs/GROUP.md +225 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +1 -1
  7. package/src/app/accounts.ts +84 -0
  8. package/src/app/instance-lock.ts +6 -0
  9. package/src/app/lifetime-flag.ts +20 -0
  10. package/src/app/settings-store.ts +47 -8
  11. package/src/app/types.ts +30 -2
  12. package/src/app/updater.ts +38 -6
  13. package/src/app/usage.ts +204 -7
  14. package/src/bot/account-rotator.ts +10 -0
  15. package/src/bot/auth.ts +96 -15
  16. package/src/bot/bot.ts +154 -11
  17. package/src/bot/chat-controller.ts +82 -13
  18. package/src/bot/commands.ts +69 -27
  19. package/src/bot/complexity-gate.ts +69 -0
  20. package/src/bot/deps.ts +22 -0
  21. package/src/bot/group-memory.ts +159 -0
  22. package/src/bot/handlers/accounts.ts +58 -1
  23. package/src/bot/handlers/control.ts +85 -32
  24. package/src/bot/handlers/document.ts +31 -4
  25. package/src/bot/handlers/forum.ts +207 -0
  26. package/src/bot/handlers/import-session.ts +290 -0
  27. package/src/bot/handlers/menu.ts +102 -61
  28. package/src/bot/handlers/message.ts +102 -21
  29. package/src/bot/handlers/photo.ts +123 -16
  30. package/src/bot/handlers/running.ts +172 -16
  31. package/src/bot/handlers/session-card.ts +20 -0
  32. package/src/bot/handlers/sessions.ts +76 -15
  33. package/src/bot/handlers/usage.ts +118 -16
  34. package/src/bot/handlers/voice.ts +52 -7
  35. package/src/bot/image-return.ts +8 -5
  36. package/src/bot/menu/ephemeral.ts +13 -3
  37. package/src/bot/menu/keyboard.ts +54 -14
  38. package/src/bot/menu/refresh.ts +3 -1
  39. package/src/bot/menu/status-panel.ts +25 -6
  40. package/src/bot/permission-service.ts +19 -0
  41. package/src/bot/prompt-anchor.ts +300 -0
  42. package/src/bot/prompt-content.ts +7 -0
  43. package/src/bot/registry.ts +94 -1
  44. package/src/bot/scope.ts +94 -0
  45. package/src/bot/session-fork.ts +11 -0
  46. package/src/bot/session-runtime.ts +1254 -83
  47. package/src/bot/suggestions.ts +489 -0
  48. package/src/bot/telegram-actions.ts +440 -0
  49. package/src/bot/telegram-bots.ts +495 -0
  50. package/src/bot/telegram-io.ts +94 -10
  51. package/src/cli.ts +2 -0
  52. package/src/config.ts +242 -2
  53. package/src/forum/bind-path.ts +146 -0
  54. package/src/forum/manager.ts +651 -0
  55. package/src/forum/project-icon.ts +142 -0
  56. package/src/forum/thread.ts +16 -0
  57. package/src/forum/topic-store.ts +114 -0
  58. package/src/forum/types.ts +29 -0
  59. package/src/grok/client.ts +214 -37
  60. package/src/grok/plan-approval.ts +72 -0
  61. package/src/grok/session-log.ts +16 -0
  62. package/src/grok/types.ts +21 -2
  63. package/src/import/build-import.ts +132 -0
  64. package/src/import/history-readers.ts +681 -0
  65. package/src/import/list-running.ts +100 -0
  66. package/src/import/sources.ts +78 -0
  67. package/src/index.ts +315 -30
  68. package/src/projects/manager.ts +16 -3
  69. package/src/render/chunk.ts +17 -10
  70. package/src/render/diff.ts +11 -2
  71. package/src/render/file-summary.ts +31 -1
  72. package/src/render/hashtags.ts +5 -1
  73. package/src/render/markdown.ts +293 -35
  74. package/src/render/plan.ts +127 -0
  75. package/src/render/session-comment.ts +318 -0
  76. package/src/render/telegram-bridge.ts +360 -0
  77. package/src/render/tool-call-detail.ts +400 -19
  78. package/src/render/tool-call-merge.ts +115 -0
  79. package/src/render/tool-call.ts +444 -162
  80. package/src/render/truncate.ts +85 -0
  81. package/src/service/platform.ts +44 -7
  82. package/src/service/windows.ts +30 -6
  83. package/src/sessions/history.ts +98 -0
  84. package/src/sessions/process.ts +7 -0
  85. package/src/sessions/store.ts +3 -0
  86. package/src/sessions/types.ts +5 -0
  87. package/src/stream/streamer.ts +90 -15
  88. package/src/tasks/runner.ts +4 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-telegram-bot",
3
- "version": "2.3.1",
3
+ "version": "2.5.0",
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",
@@ -20,6 +20,23 @@ import type { AccountInfo } from "./usage.js";
20
20
 
21
21
  const log = createLogger("accounts");
22
22
 
23
+ /** Real usage counters recorded by the bot from completed turns (not billing API). */
24
+ export interface AccountUsage {
25
+ /** Completed (non-cancelled) turns on this account. */
26
+ turns: number;
27
+ /**
28
+ * Sum of per-turn credit figures Grok reported via `_grok.dev/metadata`.
29
+ * Only increments when the agent actually sends a credits value.
30
+ */
31
+ credits: number;
32
+ /** ISO timestamp of the last successful turn on this account. */
33
+ lastUsedAt?: string;
34
+ /** Credits reported on the most recent turn (if any). */
35
+ lastTurnCredits?: number;
36
+ /** Context-window % from the most recent turn that reported it. */
37
+ lastContextPct?: number;
38
+ }
39
+
23
40
  /** Persisted, non-secret metadata about a saved account. */
24
41
  export interface StoredAccount {
25
42
  id: string;
@@ -32,6 +49,8 @@ export interface StoredAccount {
32
49
  startUrl?: string;
33
50
  accountType?: string;
34
51
  region?: string;
52
+ /** Live usage stats accumulated while this account was active. */
53
+ usage?: AccountUsage;
35
54
  /** Excluded from automatic rotation after an account-specific quota/billing failure. */
36
55
  warning?: {
37
56
  reason: string;
@@ -262,6 +281,53 @@ export class AccountManager {
262
281
  return updated;
263
282
  }
264
283
 
284
+ /**
285
+ * Record a completed turn against the active (or given) saved account.
286
+ * Credits are only added when Grok reported a figure for the turn; turns
287
+ * always increment so /accounts shows real activity even without credits.
288
+ */
289
+ recordTurnUsage(
290
+ stats: { credits?: number; contextPct?: number },
291
+ accountId?: string,
292
+ ): StoredAccount | undefined {
293
+ const id = accountId ?? this.activeAccountId() ?? this.markedActiveId();
294
+ if (!id) return undefined;
295
+ let updated: StoredAccount | undefined;
296
+ this.store.update((d) => {
297
+ const account = d.accounts.find((a) => a.id === id);
298
+ if (!account) return;
299
+ const prev = account.usage ?? { turns: 0, credits: 0 };
300
+ const credits =
301
+ typeof stats.credits === "number" && Number.isFinite(stats.credits) && stats.credits > 0
302
+ ? stats.credits
303
+ : undefined;
304
+ const next: AccountUsage = {
305
+ turns: (prev.turns || 0) + 1,
306
+ credits: (prev.credits || 0) + (credits ?? 0),
307
+ lastUsedAt: new Date().toISOString(),
308
+ lastTurnCredits: credits ?? prev.lastTurnCredits,
309
+ lastContextPct:
310
+ typeof stats.contextPct === "number" && Number.isFinite(stats.contextPct)
311
+ ? stats.contextPct
312
+ : prev.lastContextPct,
313
+ };
314
+ account.usage = next;
315
+ updated = account;
316
+ });
317
+ return updated;
318
+ }
319
+
320
+ /** Compact one-line usage summary for menus (empty when no stats yet). */
321
+ formatUsageLine(account: StoredAccount): string {
322
+ const u = account.usage;
323
+ if (!u || (u.turns <= 0 && u.credits <= 0 && !u.lastUsedAt)) return "";
324
+ const parts: string[] = [];
325
+ if (u.turns > 0) parts.push(`${u.turns} turn${u.turns === 1 ? "" : "s"}`);
326
+ if (u.credits > 0) parts.push(`${fmtUsageNumber(u.credits)} credits`);
327
+ if (u.lastUsedAt) parts.push(`last ${fmtRelative(u.lastUsedAt)}`);
328
+ return parts.join(" \u00B7 ");
329
+ }
330
+
265
331
  async forget(id: string): Promise<boolean> {
266
332
  const existed = !!this.get(id);
267
333
  await rm(this.snapshotPath(id), { force: true }).catch(() => {});
@@ -272,3 +338,21 @@ export class AccountManager {
272
338
  return existed;
273
339
  }
274
340
  }
341
+
342
+ function fmtUsageNumber(n: number): string {
343
+ if (!Number.isFinite(n)) return String(n);
344
+ if (Number.isInteger(n)) return n.toLocaleString("en-US");
345
+ return n.toFixed(2);
346
+ }
347
+
348
+ /** Short relative time for usage lines ("2h ago", "just now"). */
349
+ function fmtRelative(iso: string): string {
350
+ const t = Date.parse(iso);
351
+ if (!Number.isFinite(t)) return iso.slice(0, 10);
352
+ const sec = Math.max(0, Math.round((Date.now() - t) / 1000));
353
+ if (sec < 60) return "just now";
354
+ if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
355
+ if (sec < 86_400) return `${Math.floor(sec / 3600)}h ago`;
356
+ if (sec < 86_400 * 14) return `${Math.floor(sec / 86_400)}d ago`;
357
+ return new Date(t).toISOString().slice(0, 10);
358
+ }
@@ -88,6 +88,12 @@ export class InstanceLock {
88
88
  }
89
89
  }
90
90
 
91
+ /** Refresh lock metadata while we still hold it (lifetime heartbeat). */
92
+ touch(): void {
93
+ if (!this.held) return;
94
+ this.write();
95
+ }
96
+
91
97
  private write(): void {
92
98
  const data: LockData = { pid: process.pid, startedAt: Date.now(), supervised: this.supervised };
93
99
  try {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Cross-module intentional-exit flag so beforeExit keep-alive and the polling
3
+ * loop do not fight updater re-exec / fatal exits / SIGINT.
4
+ */
5
+ let intentional = false;
6
+ let reason = "";
7
+
8
+ /** Mark that the process is exiting on purpose (do not keep-alive). */
9
+ export function markIntentionalShutdown(why: string): void {
10
+ intentional = true;
11
+ reason = why;
12
+ }
13
+
14
+ export function isIntentionalShutdown(): boolean {
15
+ return intentional;
16
+ }
17
+
18
+ export function intentionalShutdownReason(): string {
19
+ return reason;
20
+ }
@@ -1,6 +1,8 @@
1
1
  /**
2
- * Per-chat settings persistence (project, agent, model, reasoning, pinned
3
- * status message id). Backed by a single JSON file so state survives restarts.
2
+ * Per-chat / per-forum-topic settings persistence (project, agent, model,
3
+ * reasoning, pinned status message id, controlled sessions).
4
+ * Keys: `"12345"` for private chats, `"12345:t7"` for forum topic thread 7.
5
+ * Backed by a single JSON file so state survives restarts.
4
6
  */
5
7
  import { join } from "node:path";
6
8
  import { JsonStore } from "./json-store.js";
@@ -15,24 +17,61 @@ export class SettingsStore {
15
17
  this.store = new JsonStore<SettingsMap>(join(dataDir, "settings.json"), {});
16
18
  }
17
19
 
20
+ /** Settings for a private chat (or legacy callers). */
18
21
  get(chatId: number): ChatSettings {
19
- const existing = this.store.get()[String(chatId)];
22
+ return this.getKey(String(chatId));
23
+ }
24
+
25
+ /** Settings by storage key (`chatId` or `chatId:t{threadId}`). */
26
+ getKey(key: string): ChatSettings {
27
+ const existing = this.store.get()[key];
20
28
  return existing ?? defaultSettings();
21
29
  }
22
30
 
23
31
  update(chatId: number, patch: Partial<ChatSettings>): ChatSettings {
24
- const key = String(chatId);
25
- const next = { ...this.get(chatId), ...patch };
32
+ return this.updateKey(String(chatId), patch);
33
+ }
34
+
35
+ updateKey(key: string, patch: Partial<ChatSettings>): ChatSettings {
36
+ const next = { ...this.getKey(key), ...patch };
26
37
  this.store.update((m) => {
27
38
  m[key] = next;
28
39
  });
29
40
  return next;
30
41
  }
31
42
 
43
+ /**
44
+ * All settings entries whose projectPath matches (for bidirectional
45
+ * bot ↔ forum session discovery).
46
+ */
47
+ entriesForProject(projectPath: string): Array<{ key: string; settings: ChatSettings }> {
48
+ const want = normPath(projectPath);
49
+ const out: Array<{ key: string; settings: ChatSettings }> = [];
50
+ for (const [key, s] of Object.entries(this.store.get())) {
51
+ if (s.projectPath && normPath(s.projectPath) === want) {
52
+ out.push({ key, settings: s });
53
+ }
54
+ for (const cs of s.controlledSessions ?? []) {
55
+ if (cs.projectPath && normPath(cs.projectPath) === want) {
56
+ out.push({ key, settings: s });
57
+ break;
58
+ }
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
32
64
  /** All chat ids that have interacted (for broadcast announcements). */
33
65
  chatIds(): number[] {
34
- return Object.keys(this.store.get())
35
- .map(Number)
36
- .filter((n) => Number.isFinite(n));
66
+ const ids = new Set<number>();
67
+ for (const key of Object.keys(this.store.get())) {
68
+ const n = Number(key.split(":")[0]);
69
+ if (Number.isFinite(n)) ids.add(n);
70
+ }
71
+ return [...ids];
37
72
  }
38
73
  }
74
+
75
+ function normPath(p: string): string {
76
+ return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
77
+ }
package/src/app/types.ts CHANGED
@@ -13,6 +13,11 @@ export interface ChatSettings {
13
13
  agent?: string;
14
14
  model?: string;
15
15
  reasoning: ReasoningEffort;
16
+ /**
17
+ * Preferred saved Grok account login id for this chat/topic (optional).
18
+ * Applied when starting turns if different from the process-active account.
19
+ */
20
+ preferredAccountId?: string;
16
21
  /** Telegram message id of the pinned status panel, if any. */
17
22
  statusMessageId?: number;
18
23
  /** Sessions this chat controls (for multi-session switching). */
@@ -57,14 +62,37 @@ export interface PromptInput {
57
62
  resourceLinks?: PromptResourceLink[];
58
63
  /** Telegram message id of the prompt, so the reply threads to it. */
59
64
  replyTo?: number;
65
+ /**
66
+ * Short id for the bot-owned prompt anchor (`#prompt_<id>`). All AI messages
67
+ * for this turn carry the same tag so the user can search related replies.
68
+ */
69
+ promptId?: string;
60
70
  /**
61
71
  * Content of the message the user was replying to (or the portion they
62
72
  * quoted). Injected as context so the agent sees what the user is responding
63
73
  * to. See {@link ../bot/reply-context.ts}.
64
74
  */
65
75
  quotedText?: string;
76
+ /**
77
+ * System/meta turns (self-recheck, auto-approved suggestion batches) must not
78
+ * trigger another self-recheck — only real user prompts do (once each).
79
+ */
80
+ skipSelfRecheck?: boolean;
66
81
  }
67
82
 
68
- export function textPrompt(text: string, replyTo?: number, quotedText?: string): PromptInput {
69
- return { text, images: [], resourceLinks: [], replyTo, quotedText };
83
+ export function textPrompt(
84
+ text: string,
85
+ replyTo?: number,
86
+ quotedText?: string,
87
+ opts?: { skipSelfRecheck?: boolean; promptId?: string },
88
+ ): PromptInput {
89
+ return {
90
+ text,
91
+ images: [],
92
+ resourceLinks: [],
93
+ replyTo,
94
+ promptId: opts?.promptId,
95
+ quotedText,
96
+ skipSelfRecheck: opts?.skipSelfRecheck,
97
+ };
70
98
  }
@@ -17,6 +17,7 @@ import { get } from "node:https";
17
17
  import { readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { JsonStore } from "./json-store.js";
20
+ import { markIntentionalShutdown } from "./lifetime-flag.js";
20
21
  import { createLogger } from "../logger.js";
21
22
  import { extractChangelog, isNewer, isSafeVersion } from "./version.js";
22
23
 
@@ -145,21 +146,52 @@ export class Updater {
145
146
  }
146
147
 
147
148
  private async restart(): Promise<void> {
149
+ // Signal main/beforeExit that this exit is intentional (avoid keep-alive race).
150
+ markIntentionalShutdown("updater-reexec");
148
151
  await this.opts.shutdown().catch(() => {});
149
152
  // Under systemd/launchd, a clean exit triggers a managed relaunch (no double
150
153
  // instance). On Windows / foreground there is no supervisor, so re-exec.
151
154
  if (process.env.GROK_TG_SUPERVISED === "1") {
152
155
  log.info("exiting for supervisor to relaunch the updated bot");
156
+ try {
157
+ process.stderr.write("[updater] supervised exit for relaunch\n");
158
+ } catch {
159
+ /* ignore */
160
+ }
153
161
  setTimeout(() => process.exit(0), 250);
154
162
  return;
155
163
  }
156
164
  log.info("re-executing the updated bot");
157
- const child = spawn(
158
- process.execPath,
159
- ["--import", "tsx", join(this.opts.projectRoot, "src", "index.ts"), "--instance", this.opts.instanceDir],
160
- { detached: true, stdio: "ignore", cwd: this.opts.projectRoot, env: process.env },
161
- );
162
- child.unref();
165
+ try {
166
+ // TTY: inherit stdio so `npm start` doesn't look like a silent death.
167
+ // Non-TTY (service): detach and ignore stdio.
168
+ const inherit = Boolean(process.stdout.isTTY);
169
+ const child = spawn(
170
+ process.execPath,
171
+ ["--import", "tsx", join(this.opts.projectRoot, "src", "index.ts"), "--instance", this.opts.instanceDir],
172
+ {
173
+ detached: !inherit,
174
+ stdio: inherit ? "inherit" : "ignore",
175
+ cwd: this.opts.projectRoot,
176
+ env: process.env,
177
+ },
178
+ );
179
+ if (!inherit) child.unref();
180
+ if (!child.pid) {
181
+ log.error("re-exec spawn produced no pid — staying alive");
182
+ return;
183
+ }
184
+ log.info(`re-exec child pid ${child.pid} (stdio=${inherit ? "inherit" : "ignore"})`);
185
+ try {
186
+ process.stderr.write(`[updater] re-exec child pid ${child.pid}; parent exiting\n`);
187
+ } catch {
188
+ /* ignore */
189
+ }
190
+ } catch (e) {
191
+ // Never exit if replacement failed — silent death is worse than stale code.
192
+ log.error(`re-exec failed: ${(e as Error).message} — staying alive`);
193
+ return;
194
+ }
163
195
  setTimeout(() => process.exit(0), 500);
164
196
  }
165
197
 
package/src/app/usage.ts CHANGED
@@ -1,10 +1,27 @@
1
1
  /**
2
- * Account info for Grok Build. Grok signs in with your xAI account
3
- * (`grok login`), so /usage surfaces the signed-in identity (from the token in
4
- * ~/.grok/auth.json) plus the live per-session context usage the ACP agent
5
- * reports.
2
+ * Account + Grok CLI billing usage.
3
+ *
4
+ * Identity comes from ~/.grok/auth.json. Live Grok Build quota is fetched from
5
+ * the same CLI chat proxy OmniRoute-style clients use:
6
+ * GET https://cli-chat-proxy.grok.com/v1/billing
7
+ * with the OIDC token from `grok login` (Bearer).
8
+ *
9
+ * Session-level context/credits still come from ACP `_grok.dev/metadata`.
6
10
  */
7
- import { hasLogin, identityFromAuth, loginLabel } from "./grok-credentials.js";
11
+ import { createLogger } from "../logger.js";
12
+ import {
13
+ currentAuthEntry,
14
+ currentToken,
15
+ hasLogin,
16
+ identityFromAuth,
17
+ loginLabel,
18
+ } from "./grok-credentials.js";
19
+
20
+ const log = createLogger("usage");
21
+
22
+ const BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing";
23
+ /** Cache live billing for a short window so /usage + /accounts don't spam the API. */
24
+ const BILLING_TTL_MS = 30_000;
8
25
 
9
26
  export interface AccountInfo {
10
27
  /** Signed-in identity (email when the token carries one, else a label). */
@@ -14,25 +31,205 @@ export interface AccountInfo {
14
31
  region?: string;
15
32
  /** Stable identifier for matching saved accounts. */
16
33
  startUrl?: string;
34
+ /** JWT tier claim when present (Grok CLI subscription tier). */
35
+ tier?: string | number;
36
+ teamId?: string;
37
+ }
38
+
39
+ /** Live Grok CLI monthly quota from cli-chat-proxy. */
40
+ export interface GrokCliBilling {
41
+ /** Included monthly allowance (raw units from the API). */
42
+ monthlyLimit: number;
43
+ /** Used so far this billing period. */
44
+ used: number;
45
+ /** Remaining = max(0, limit - used). */
46
+ remaining: number;
47
+ /** 0–100 percent of monthly limit consumed. */
48
+ usedPct: number;
49
+ onDemandCap: number;
50
+ billingPeriodStart?: string;
51
+ billingPeriodEnd?: string;
52
+ /** Prior cycles when the API returns them. */
53
+ history?: Array<{ year: number; month: number; totalUsed: number }>;
54
+ fetchedAt: string;
55
+ }
56
+
57
+ interface BillingCache {
58
+ at: number;
59
+ data?: GrokCliBilling;
60
+ error?: string;
17
61
  }
18
62
 
19
63
  export class UsageService {
64
+ private billingCache: BillingCache | undefined;
65
+
20
66
  // Kept for signature compatibility; Grok state lives in ~/.grok/auth.json.
21
67
  constructor(private readonly grokCliPath: string) {}
22
68
 
23
69
  async account(): Promise<AccountInfo | undefined> {
24
70
  if (!hasLogin()) {
25
- // XAI_API_KEY with no browser login still counts as usable.
26
71
  if (process.env.XAI_API_KEY?.trim()) return { email: "XAI_API_KEY", accountType: "api key" };
27
72
  return undefined;
28
73
  }
29
74
  const id = identityFromAuth();
30
75
  const label = loginLabel();
31
- return { email: id.email || label, accountType: undefined, startUrl: label };
76
+ const entry = currentAuthEntry();
77
+ const tok = currentToken();
78
+ let tier: string | number | undefined;
79
+ let teamId = typeof entry?.team_id === "string" ? entry.team_id : undefined;
80
+ if (tok) {
81
+ const claims = decodeJwt(tok);
82
+ if (claims?.tier !== undefined) tier = claims.tier as string | number;
83
+ if (!teamId && typeof claims?.team_id === "string") teamId = claims.team_id;
84
+ }
85
+ return {
86
+ email: id.email || label,
87
+ accountType: tier !== undefined ? `tier ${tier}` : undefined,
88
+ startUrl: label,
89
+ tier,
90
+ teamId,
91
+ };
32
92
  }
33
93
 
34
94
  /** Whether Grok has a usable sign-in (browser token or XAI_API_KEY). */
35
95
  async isLoggedIn(): Promise<boolean> {
36
96
  return hasLogin();
37
97
  }
98
+
99
+ /**
100
+ * Live Grok Build monthly quota for the active CLI login.
101
+ * Same endpoint OmniRoute uses for grok-cli remaining % dashboards.
102
+ */
103
+ async cliBilling(force = false): Promise<{ billing?: GrokCliBilling; error?: string }> {
104
+ const now = Date.now();
105
+ if (
106
+ !force &&
107
+ this.billingCache &&
108
+ now - this.billingCache.at < BILLING_TTL_MS
109
+ ) {
110
+ return { billing: this.billingCache.data, error: this.billingCache.error };
111
+ }
112
+
113
+ const token = currentToken();
114
+ if (!token) {
115
+ if (process.env.XAI_API_KEY?.trim()) {
116
+ return { error: "XAI_API_KEY mode — CLI monthly quota is only available after `grok login`" };
117
+ }
118
+ return { error: "Not signed in (no grok login token)" };
119
+ }
120
+
121
+ try {
122
+ const res = await fetch(BILLING_URL, {
123
+ method: "GET",
124
+ headers: {
125
+ Authorization: `Bearer ${token}`,
126
+ Accept: "application/json",
127
+ },
128
+ signal: AbortSignal.timeout(12_000),
129
+ });
130
+ if (!res.ok) {
131
+ const err = `billing HTTP ${res.status}`;
132
+ log.warn(err);
133
+ this.billingCache = { at: now, error: err };
134
+ return { error: err };
135
+ }
136
+ const json = (await res.json()) as BillingWire;
137
+ const billing = parseBilling(json);
138
+ this.billingCache = { at: now, data: billing };
139
+ return { billing };
140
+ } catch (e) {
141
+ const err = (e as Error).message || "billing fetch failed";
142
+ log.warn("cli billing:", err);
143
+ this.billingCache = { at: now, error: err };
144
+ return { error: err };
145
+ }
146
+ }
147
+ }
148
+
149
+ interface BillingWire {
150
+ config?: {
151
+ monthlyLimit?: { val?: number };
152
+ used?: { val?: number };
153
+ onDemandCap?: { val?: number };
154
+ billingPeriodStart?: string;
155
+ billingPeriodEnd?: string;
156
+ history?: Array<{
157
+ billingCycle?: { year?: number; month?: number };
158
+ totalUsed?: { val?: number };
159
+ includedUsed?: { val?: number };
160
+ onDemandUsed?: { val?: number };
161
+ }>;
162
+ };
163
+ }
164
+
165
+ function parseBilling(json: BillingWire): GrokCliBilling {
166
+ const c = json.config ?? {};
167
+ const monthlyLimit = num(c.monthlyLimit?.val);
168
+ const used = num(c.used?.val);
169
+ const remaining = Math.max(0, monthlyLimit - used);
170
+ const usedPct = monthlyLimit > 0 ? Math.min(100, Math.round((used / monthlyLimit) * 1000) / 10) : 0;
171
+ const history = (c.history ?? [])
172
+ .map((h) => ({
173
+ year: h.billingCycle?.year ?? 0,
174
+ month: h.billingCycle?.month ?? 0,
175
+ totalUsed: num(h.totalUsed?.val ?? h.includedUsed?.val),
176
+ }))
177
+ .filter((h) => h.year > 0);
178
+ return {
179
+ monthlyLimit,
180
+ used,
181
+ remaining,
182
+ usedPct,
183
+ onDemandCap: num(c.onDemandCap?.val),
184
+ billingPeriodStart: c.billingPeriodStart,
185
+ billingPeriodEnd: c.billingPeriodEnd,
186
+ history: history.length ? history : undefined,
187
+ fetchedAt: new Date().toISOString(),
188
+ };
189
+ }
190
+
191
+ function num(v: unknown): number {
192
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
193
+ }
194
+
195
+ function decodeJwt(jwt: string): Record<string, unknown> | undefined {
196
+ const parts = jwt.split(".");
197
+ if (parts.length < 2) return undefined;
198
+ try {
199
+ return JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf-8")) as Record<string, unknown>;
200
+ } catch {
201
+ return undefined;
202
+ }
203
+ }
204
+
205
+ /** Format billing for Telegram plain-text UIs. */
206
+ export function formatCliBillingLines(b: GrokCliBilling): string[] {
207
+ const lines = [
208
+ "\u{1F4B3} Grok CLI monthly quota (cli-chat-proxy)",
209
+ ` Used: ${fmt(b.used)} / ${fmt(b.monthlyLimit)} (${b.usedPct}%)`,
210
+ ` Remaining: ${fmt(b.remaining)}`,
211
+ ];
212
+ if (b.billingPeriodStart || b.billingPeriodEnd) {
213
+ lines.push(
214
+ ` Period: ${fmtDate(b.billingPeriodStart)} \u2192 ${fmtDate(b.billingPeriodEnd)}`,
215
+ );
216
+ }
217
+ if (b.onDemandCap > 0) lines.push(` On-demand cap: ${fmt(b.onDemandCap)}`);
218
+ if (b.history?.length) {
219
+ const prev = b.history
220
+ .slice(0, 3)
221
+ .map((h) => `${h.year}-${String(h.month).padStart(2, "0")}: ${fmt(h.totalUsed)}`)
222
+ .join(" \u00B7 ");
223
+ lines.push(` History: ${prev}`);
224
+ }
225
+ return lines;
226
+ }
227
+
228
+ function fmt(n: number): string {
229
+ return n.toLocaleString("en-US");
230
+ }
231
+
232
+ function fmtDate(iso?: string): string {
233
+ if (!iso) return "\u2014";
234
+ return iso.slice(0, 10);
38
235
  }
@@ -52,6 +52,8 @@ export interface AccountRotator {
52
52
  withRotationLock<T>(observed: RotationState, run: (changed: boolean) => Promise<T>): Promise<T>;
53
53
  /** Wait for an in-progress rotation probe before re-binding a stale session. */
54
54
  waitForIdle(): Promise<void>;
55
+ /** Record a completed turn's usage against the active saved account. */
56
+ recordTurnUsage(stats: { credits?: number; contextPct?: number }): void;
55
57
  }
56
58
 
57
59
  export class AccountRotatorImpl implements AccountRotator {
@@ -67,6 +69,14 @@ export class AccountRotatorImpl implements AccountRotator {
67
69
  return this.accounts.autoRotateEnabled();
68
70
  }
69
71
 
72
+ recordTurnUsage(stats: { credits?: number; contextPct?: number }): void {
73
+ try {
74
+ this.accounts.recordTurnUsage(stats);
75
+ } catch (e) {
76
+ log.debug("recordTurnUsage failed:", (e as Error).message);
77
+ }
78
+ }
79
+
70
80
  state(): RotationState {
71
81
  const activeId = this.accounts.activeAccountId();
72
82
  return {