grok-telegram-bot 2.2.0 β†’ 2.2.2

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,35 @@ The latest section is published verbatim as the GitHub Release notes by
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [2.2.2] - 2026-07-12
13
+
14
+ Patch release that hardens multi-account identity and documents the full **2.2.x**
15
+ batch (headless rotation, session auto-approve, pinned permission prompts).
16
+
17
+ ### Fixed
18
+
19
+ - **πŸ“§ Real account emails from `auth.json`.** Modern Grok access tokens often
20
+ omit the `email` claim; the bot now prefers `email` / `first_name` /
21
+ `last_name` written on the auth entry by `grok login`, then falls back to JWT
22
+ claims. `/accounts` and `/usage` show the host login correctly again.
23
+ - **πŸͺͺ Active account = live token hash.** The marked `activeId` is no longer
24
+ trusted when the host login changed outside the menu (`grok login` / `/reauth`).
25
+ Saving never overwrites a different saved account; mismatch is shown in the
26
+ `/accounts` UI (β€œHost Grok login” vs saved).
27
+
28
+ ### Docs
29
+
30
+ - README: headless account switch, device-code `/reauth`, auto-approve + pinned
31
+ permission prompts, `AUTO_APPROVE_PERMISSIONS`.
32
+
33
+ ## [2.2.1] - 2026-07-12
34
+
35
+ ### Fixed
36
+
37
+ - **πŸ“Œ Permission prompts stay pinned** while waiting for Approve/Deny so they
38
+ aren't lost under streaming chat. The pin is removed on approve, deny, or
39
+ timeout, and the status panel is re-pinned afterwards.
40
+
12
41
  ## [2.2.0] - 2026-07-12
13
42
 
14
43
  The **reliable multi-account** release β€” account switch / auto-rotate now only
@@ -669,6 +698,8 @@ from a single chat and switch between them, on a redesigned, compact menu.
669
698
  diffs, MarkdownV2 rendering, scheduled tasks, multi-image prompts, and a
670
699
  cross-platform 24/7 background service.
671
700
 
701
+ [2.2.2]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.2
702
+ [2.2.1]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.1
672
703
  [2.2.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.0
673
704
  [2.1.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.1.0
674
705
  [2.0.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.0.0
package/README.md CHANGED
@@ -38,9 +38,10 @@ re-architected for the Grok Build CLI and extended into a full multi-session cli
38
38
  | 🧩 **MCP control** | `/mcp` lists MCP servers, **health-checks** them (which connected / failed and why), and **enables/disables** them β€” then restarts the agent to apply. |
39
39
  | πŸ‘₯ **Subagent visibility** | When Grok delegates to subagents and waits on them, you see each one **start / work / finish** plus a live `πŸ€– N running` summary β€” and subagent permission prompts route to your chat. |
40
40
  | πŸ“ˆ **Task progress bar** | The agent appends a `{progress: N%}` marker; the bot hides it and shows a **green 0–100% loading bar** on the live message, in the status panel, and on session cards (`SHOW_PROGRESS`). |
41
- | πŸ” **Sign in from chat** | `/reauth` signs you in without a terminal β€” **πŸ”‘ Sign in** runs `grok login` (any verification link/code streams to your chat), or **πŸ“₯ Import** an existing on-host login; the agent restarts under the new identity. |
42
- | πŸ‘₯ **Multiple accounts** | `/accounts` saves several Grok **sign-ins** (custom names) and switches between them in a tap β€” copies that login back over `~/.grok/auth.json` and restarts so sessions re-bind under the new account. |
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`). |
41
+ | πŸ” **Sign in from chat** | `/reauth` signs you in without a terminal β€” **πŸ”‘ Sign in** runs headless `grok login --device-auth` (link/code streams to your chat, no host browser), or **πŸ“₯ Import** an existing on-host login; the agent restarts under the new identity. |
42
+ | πŸ‘₯ **Multiple accounts** | `/accounts` saves several Grok **sign-ins** (custom names) and switches between them in a tap β€” **stops the agent β†’ replaces `~/.grok/auth.json` β†’ restarts headlessly** (never opens a browser). |
43
+ | πŸ” **Auto-rotate on give-up** | When a turn exhausts its retries, 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. |
44
+ | βœ… **Auto-approve tools** | By default, ACP permission prompts are auto-approved for the **session** (`AUTO_APPROVE_PERMISSIONS`). Turn both that and `GROK_TRUST_ALL_TOOLS` off for interactive Approve/Deny β€” those prompts are **pinned** until you act. |
44
45
  | πŸͺ™ **Credits & usage** | The `βœ… Done` line and `/usage` show credits used (when Grok reports them), turns this session, and account info. |
45
46
  | ⌨️ **Typing indicator** | Stays on for the whole turn, even through long tool chains. |
46
47
  | πŸ“₯ **Queued follow-ups** | Message while Grok is busy β€” it's queued and runs next. `/btw` runs it ASAP (now if idle, else right after the current task); `/flush` runs the queue now. |
@@ -62,9 +63,10 @@ re-architected for the Grok Build CLI and extended into a full multi-session cli
62
63
  | Attach to **live** PC sessions (watch / fork) | βœ… | ❌ |
63
64
  | **Kill a session by PID** (or all at once) | βœ… | ❌ |
64
65
  | **Live task-progress bars** (`{progress: N%}`) | βœ… | ❌ |
65
- | **Sign in from chat** (`/reauth`) | βœ… | ❌ |
66
- | **Multiple saved accounts** + one-tap switch (`/accounts`) | βœ… | ❌ |
66
+ | **Sign in from chat** (`/reauth`, device-code) | βœ… | ❌ |
67
+ | **Multiple saved accounts** + headless one-tap switch (`/accounts`) | βœ… | ❌ |
67
68
  | **Auto-rotate accounts** when a turn gives up | βœ… | ❌ |
69
+ | **Session auto-approve** + **pinned** interactive permissions | βœ… | ❌ |
68
70
  | Multiple isolated sessions | βœ… | ❌ (single shared) |
69
71
  | Queued follow-ups while busy | βœ… | ❌ |
70
72
  | **Scheduled tasks** (cron-like) | βœ… | ❌ |
@@ -326,9 +328,10 @@ takes precedence and the value never decreases. Disable the fallback with
326
328
  Grok Build signs in with your **xAI account** (SuperGrok / X Premium+), not an
327
329
  API key. Run **`/reauth`** to sign in without touching a terminal:
328
330
 
329
- - **πŸ”‘ Sign in** β€” runs `grok login`; if a verification link/code appears it
330
- streams into the chat so you can approve it on any device. The bot then
331
- restarts the agent so your next turn runs on the new identity.
331
+ - **πŸ”‘ Sign in** β€” runs `grok login --device-auth` (device code, **no browser on
332
+ the host**). The verification link/code streams into the chat so you can
333
+ approve it on any device. The bot then restarts the agent under the new
334
+ identity.
332
335
  - **πŸ“₯ Import existing** β€” adopt a `grok login` already done on this machine
333
336
  (`~/.grok/auth.json`).
334
337
 
@@ -338,16 +341,24 @@ It's refused while a turn is running. You can also sign in up front by running
338
341
  ## πŸ‘₯ Multiple accounts
339
342
 
340
343
  **`/accounts`** manages several Grok **sign-ins** side by side. Save the current
341
- login as a named account (auto-named from its email, or **Save as…** with a
342
- custom name), rename or delete saved ones, and **switch** in a tap β€” the bot
343
- copies that account's token back over `~/.grok/auth.json` and restarts the agent
344
- so your next turn runs under it (the current login is snapshotted first so it's
345
- never lost). Snapshots live only in git-ignored per-account files.
344
+ login as a named account (auto-named from its email when available, or
345
+ **Save as…** with a custom name), rename or delete saved ones, and **switch** in
346
+ a tap. Switching is deliberately headless:
347
+
348
+ 1. Snapshot the current login (so it isn't lost),
349
+ 2. **Stop** the shared agent,
350
+ 3. **Replace** `~/.grok/auth.json` with the saved snapshot,
351
+ 4. **Start** the agent and authenticate with `cached_token` only β€” never a
352
+ browser method like `grok.com` (which would hang a service host).
353
+
354
+ The menu shows the **host Grok login** vs which saved account it matches, so a
355
+ manual `grok login` outside the bot doesn't leave a stale β€œactive” label.
346
356
 
347
357
  **πŸ” Auto-rotate on errors** (toggle here): when a turn exhausts its retries and
348
- can't recover, the bot cycles through your other saved logins **once**, retrying
349
- the prompt on each β€” the first that succeeds stays active; if they all fail it
350
- stops after one pass. Handy when an account gets throttled or runs out of quota.
358
+ can't recover, the bot cycles through your other saved logins **once** with the
359
+ same stop β†’ auth.json β†’ start path β€” the first that succeeds stays active; if
360
+ they all fail it stops after one pass. Handy when an account gets throttled or
361
+ runs out of quota.
351
362
 
352
363
  ---
353
364
 
@@ -390,7 +401,8 @@ Resuming an **idle** session loads it directly so you continue the exact thread.
390
401
  | `GROK_MODEL` | no | `grok-4.5` | Default model for new sessions. |
391
402
  | `GROK_TG_DIR` | no | `~/.grok/tg` | Folder holding this instance's `.env`, `logs/`, `data/`. Resolution: `--instance` β†’ `GROK_TG_DIR` β†’ a `.env` in the current folder β†’ `~/.grok/tg`. So a `.env` created once is loaded from any startup path. |
392
403
  | `GROK_AGENT` | no | β€” | Custom sub-agent name (informational; Grok delegates via its own `task`/`delegate` tools). |
393
- | `GROK_TRUST_ALL_TOOLS` | no | `true` | Run tools without prompts. |
404
+ | `GROK_TRUST_ALL_TOOLS` | no | `true` | Pass `--always-approve` so tools run without prompts. |
405
+ | `AUTO_APPROVE_PERMISSIONS` | no | `true` | Auto-approve ACP `session/request_permission` (prefer β€œallow for this session”). Set `false` **and** `GROK_TRUST_ALL_TOOLS=false` for interactive Approve/Deny buttons (those prompts are **pinned** until you act or they time out). |
394
406
  | `PROJECT_ROOTS` | no | workspace parent + home | Roots for `/projects`. |
395
407
  | `STREAM_THROTTLE_MS` | no | `1500` | Live-edit interval while streaming. |
396
408
  | `MESSAGE_BATCH_MS` | no | `800` | Window to coalesce rapid text messages (e.g. a long message Telegram split at 4096 chars) into one prompt. `0` disables. |
@@ -486,13 +498,20 @@ the bot inherits whatever MCP servers Grok CLI is configured with.
486
498
 
487
499
  ## πŸ” Tool approvals
488
500
 
489
- Over ACP, Grok honors a permission mode. With `GROK_TRUST_ALL_TOOLS=true`
490
- (default) the bot passes `--always-approve`, so tools run without prompts. Set it
491
- to `false` to run in ACP **"ask"** mode: Grok sends `session/request_permission`
492
- before risky tools (file writes, shell commands) and the bot surfaces
493
- **Approve / Approve always / Deny** buttons in Telegram, sending your choice back
494
- (unanswered prompts eventually cancel). You can also intervene on any live turn
495
- with the tool stream + **⏹ Stop** (`/cancel`), which cancels that session's turn.
501
+ Over ACP, Grok honors a permission mode. Defaults are built for unattended
502
+ mobile use:
503
+
504
+ | Setting | Default | Effect |
505
+ |---|---|---|
506
+ | `GROK_TRUST_ALL_TOOLS` | `true` | Passes `--always-approve` so tools run without prompts. |
507
+ | `AUTO_APPROVE_PERMISSIONS` | `true` | If a permission request still arrives, auto-pick **allow for this session** / always-allow. |
508
+
509
+ Set **both** to `false` for ACP **"ask"** mode: Grok sends
510
+ `session/request_permission` before risky tools and the bot surfaces
511
+ **Approve / Allow for session / Deny** buttons. Those prompts are **pinned** so
512
+ they aren't buried by streaming output; the pin is removed when you choose,
513
+ or when the request times out (status panel is re-pinned). You can always
514
+ intervene on a live turn with the tool stream + **⏹ Stop** (`/cancel`).
496
515
 
497
516
  ## πŸ” Security
498
517
 
@@ -514,10 +533,11 @@ user. See [SECURITY.md](./SECURITY.md) for the full model.
514
533
  - [x] Voice messages β†’ speech-to-text β†’ prompt (multi-language)
515
534
  - [x] Context-usage % in the status panel
516
535
  - [x] Inline approvals β€” approve/deny risky tools from buttons (non trust-all mode)
536
+ - [x] Session auto-approve + pinned permission prompts
517
537
  - [x] Account & context usage (`/usage`)
518
- - [x] Multiple accounts with one-tap switch + auto-rotate on errors (`/accounts`)
519
- - [x] Organization / Import-from-Grok-IDE login + credits on the Done line
520
- - [x] Release automation β€” downloadable zip + CHANGELOG-driven notes on tag push
538
+ - [x] Multiple accounts with headless one-tap switch + auto-rotate (`/accounts`)
539
+ - [x] Device-code `/reauth` (no host browser) + real email labels from auth.json
540
+ - [x] Rich per-kind tool-call detail (search / edit diffs / shell / MCP / …)
521
541
  - [x] README community sections β€” Contributors, Top Contributors, Stars, StarMapper
522
542
  - [ ] **Token & cost meter** β€” per-session token counts and an estimated spend tally
523
543
  - [ ] **Text-to-speech replies** β€” optionally speak answers back as voice notes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-telegram-bot",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
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",
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Bump package.json on main + create vX.Y.Z tag via the GitHub API
3
- * (local git push/commit is restricted in this environment).
2
+ * Push current working-tree release files to main + create annotated tag.
3
+ * (Local git push is restricted in this environment.)
4
4
  */
5
5
  import { readFileSync } from "node:fs";
6
6
  import { spawnSync } from "node:child_process";
@@ -8,9 +8,22 @@ import { spawnSync } from "node:child_process";
8
8
  const owner = "artickc";
9
9
  const repo = "grok-telegram-bot";
10
10
  const branch = "main";
11
- const version = "2.2.0";
11
+ const version = "2.2.2";
12
12
  const tag = `v${version}`;
13
- const message = `v${version} β€” headless account rotation + auto-approve session permissions`;
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
+ ];
14
27
 
15
28
  // eslint-disable-next-line no-control-regex
16
29
  const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
@@ -42,34 +55,35 @@ const baseCommit = ghApi("GET", `repos/${owner}/${repo}/git/commits/${baseSha}`)
42
55
  const baseTree = baseCommit.tree.sha;
43
56
  console.log("base", baseSha);
44
57
 
45
- const content = readFileSync("package.json", "utf8");
46
- const blob = ghApi("POST", `repos/${owner}/${repo}/git/blobs`, {
47
- content: Buffer.from(content, "utf8").toString("base64"),
48
- encoding: "base64",
49
- });
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
+ }
50
68
 
51
69
  const newTree = ghApi("POST", `repos/${owner}/${repo}/git/trees`, {
52
70
  base_tree: baseTree,
53
- tree: [{ path: "package.json", mode: "100644", type: "blob", sha: blob.sha }],
71
+ tree,
54
72
  });
55
-
56
73
  const commit = ghApi("POST", `repos/${owner}/${repo}/git/commits`, {
57
74
  message,
58
75
  tree: newTree.sha,
59
76
  parents: [baseSha],
60
77
  });
61
- console.log("commit", commit.sha);
62
-
63
78
  ghApi("PATCH", `repos/${owner}/${repo}/git/refs/heads/${branch}`, {
64
79
  sha: commit.sha,
65
80
  force: false,
66
81
  });
67
- console.log("updated", branch, "->", commit.sha);
82
+ console.log("main ->", commit.sha);
68
83
 
69
- // Annotated tag for release workflow.
70
84
  const tagObj = ghApi("POST", `repos/${owner}/${repo}/git/tags`, {
71
85
  tag,
72
- message: message,
86
+ message: tagMessage,
73
87
  object: commit.sha,
74
88
  type: "commit",
75
89
  tagger: {
@@ -78,10 +92,8 @@ const tagObj = ghApi("POST", `repos/${owner}/${repo}/git/tags`, {
78
92
  date: new Date().toISOString(),
79
93
  },
80
94
  });
81
- console.log("tag object", tagObj.sha);
82
-
83
95
  ghApi("POST", `repos/${owner}/${repo}/git/refs`, {
84
96
  ref: `refs/tags/${tag}`,
85
97
  sha: tagObj.sha,
86
98
  });
87
- console.log("created tag", tag);
99
+ console.log("tag", tag, "->", commit.sha);
@@ -76,17 +76,44 @@ export class AccountManager {
76
76
  }
77
77
 
78
78
  /**
79
- * Id of the currently active saved account. Prefers the last switch target
80
- * (`activeId`), then falls back to matching the live auth.json token hash.
79
+ * Id of the saved account that matches the live auth.json (if any).
80
+ * Token hash is authoritative β€” never trust a stale `activeId` when the host
81
+ * login has changed (e.g. `grok login` / /reauth outside this menu).
81
82
  */
82
83
  activeAccountId(): string | undefined {
83
84
  const data = this.store.get();
85
+ const lid = loginId();
86
+ if (lid) {
87
+ const byToken = data.accounts.find((a) => a.loginId === lid);
88
+ if (byToken) return byToken.id;
89
+ // Live login differs from every saved account (and any stale activeId).
90
+ return undefined;
91
+ }
92
+ // No readable token (API-key-only / missing file) β€” last switch target only.
84
93
  if (data.activeId && data.accounts.some((a) => a.id === data.activeId)) {
85
94
  return data.activeId;
86
95
  }
96
+ return undefined;
97
+ }
98
+
99
+ /**
100
+ * Last switch/save target stored by the app, even when the host Grok login
101
+ * no longer matches that snapshot (used only for mismatch UI copy).
102
+ */
103
+ markedActiveId(): string | undefined {
104
+ const id = this.store.get().activeId;
105
+ if (!id) return undefined;
106
+ return this.store.get().accounts.some((a) => a.id === id) ? id : undefined;
107
+ }
108
+
109
+ /** Whether the host's live login matches the saved account marked active. */
110
+ liveMatchesActive(): boolean {
87
111
  const lid = loginId();
88
- if (!lid) return undefined;
89
- return data.accounts.find((a) => a.loginId === lid)?.id;
112
+ if (!lid) return false;
113
+ const marked = this.markedActiveId();
114
+ if (!marked) return this.activeAccountId() !== undefined;
115
+ const meta = this.get(marked);
116
+ return !!meta?.loginId && meta.loginId === lid;
90
117
  }
91
118
 
92
119
  get(id: string): StoredAccount | undefined {
@@ -117,19 +144,32 @@ export class AccountManager {
117
144
  }
118
145
  const label = customLabel?.trim() || loginLabel() || `account ${lid.slice(0, 6)}`;
119
146
  const email = loginLabel();
120
- // Match by token hash first; fall back to the currently marked active slot
121
- // when the user is re-saving after a silent token refresh.
122
- const existing =
123
- this.store.get().accounts.find((a) => a.loginId === lid) ??
124
- (this.store.get().activeId ? this.get(this.store.get().activeId!) : undefined);
147
+ // Match by token hash first. Only reuse the marked active slot when it is
148
+ // the same login (token refresh) or the same email β€” never overwrite a
149
+ // different saved account when the host is signed in as someone else.
150
+ const accounts = this.store.get().accounts;
151
+ const byToken = accounts.find((a) => a.loginId === lid);
152
+ const active = this.store.get().activeId ? this.get(this.store.get().activeId!) : undefined;
153
+ const emailKey = email?.toLowerCase();
154
+ const byEmail =
155
+ emailKey && emailKey.includes("@")
156
+ ? accounts.find((a) => (a.email || a.label || "").toLowerCase() === emailKey)
157
+ : undefined;
158
+ const sameActiveRefresh =
159
+ active &&
160
+ (active.loginId === lid ||
161
+ (!!emailKey &&
162
+ emailKey.includes("@") &&
163
+ [active.email, active.label].some((v) => (v || "").toLowerCase() === emailKey)));
164
+ const existing = byToken ?? (sameActiveRefresh ? active : undefined) ?? byEmail;
125
165
  const id = existing?.id ?? makeId();
126
166
  await writeFile(this.snapshotPath(id), raw, "utf-8");
127
167
  const meta: StoredAccount = {
128
168
  id,
129
169
  label: customLabel?.trim() || existing?.label || label,
130
170
  loginId: lid,
131
- email: email || existing?.email,
132
- startUrl: email || existing?.email,
171
+ email: (email && email.includes("@") ? email : undefined) || existing?.email,
172
+ startUrl: (email && email.includes("@") ? email : undefined) || existing?.email || existing?.startUrl,
133
173
  savedAt: new Date().toISOString(),
134
174
  };
135
175
  this.store.update((d) => {
@@ -2,7 +2,7 @@
2
2
  * Grok Build login state. The official CLI signs in with your xAI account
3
3
  * (`grok login`, browser OIDC) and stores the token in `~/.grok/auth.json`:
4
4
  *
5
- * { "<scope_url>": { "key": "<token>" }, ... }
5
+ * { "<scope_url>": { "key": "<token>", "email"?: "...", ... }, ... }
6
6
  *
7
7
  * (An `XAI_API_KEY` env var is an alternative for non-browser hosts.) This
8
8
  * module reads that file so the bot can show who's signed in, detect a usable
@@ -36,7 +36,19 @@ export function authFileExists(): boolean {
36
36
  return existsSync(grokAuthPath());
37
37
  }
38
38
 
39
- type AuthFile = Record<string, { key?: string } | undefined>;
39
+ /** One scope entry inside ~/.grok/auth.json (fields beyond `key` are optional). */
40
+ export interface AuthEntry {
41
+ key?: string;
42
+ email?: string;
43
+ first_name?: string;
44
+ last_name?: string;
45
+ user_id?: string;
46
+ principal_id?: string;
47
+ auth_mode?: string;
48
+ [extra: string]: unknown;
49
+ }
50
+
51
+ export type AuthFile = Record<string, AuthEntry | undefined>;
40
52
 
41
53
  export function readAuth(): AuthFile {
42
54
  try {
@@ -46,12 +58,18 @@ export function readAuth(): AuthFile {
46
58
  }
47
59
  }
48
60
 
61
+ /** Active scope entry (OIDC preferred, then legacy). */
62
+ export function currentAuthEntry(auth: AuthFile = readAuth()): AuthEntry | undefined {
63
+ const oidc = auth[OIDC_SCOPE];
64
+ if (oidc?.key?.trim()) return oidc;
65
+ const legacy = auth[LEGACY_SCOPE];
66
+ if (legacy?.key?.trim()) return legacy;
67
+ return undefined;
68
+ }
69
+
49
70
  /** The active sign-in token from auth.json (OIDC preferred, then legacy). */
50
71
  export function currentToken(auth: AuthFile = readAuth()): string | undefined {
51
- const oidc = auth[OIDC_SCOPE]?.key?.trim();
52
- if (oidc) return oidc;
53
- const legacy = auth[LEGACY_SCOPE]?.key?.trim();
54
- return legacy || undefined;
72
+ return currentAuthEntry(auth)?.key?.trim() || undefined;
55
73
  }
56
74
 
57
75
  /** Whether a usable login exists (a token in auth.json, or XAI_API_KEY). */
@@ -81,16 +99,33 @@ function decodeJwtPayload(jwt: string): Record<string, unknown> | undefined {
81
99
  }
82
100
  }
83
101
 
84
- /** Best-effort identity (email/name) decoded from the sign-in JWT. */
102
+ /**
103
+ * Best-effort identity (email/name). Prefer fields Grok writes on the auth
104
+ * entry (`email`, `first_name`, …) β€” modern access tokens often omit email
105
+ * claims β€” then fall back to JWT claims when present.
106
+ */
85
107
  export function identityFromAuth(auth: AuthFile = readAuth()): LoginIdentity {
86
- const tok = currentToken(auth);
87
- if (!tok) return {};
88
- const claims = decodeJwtPayload(tok);
89
- if (!claims) return {};
90
- const str = (k: string): string | undefined => (typeof claims[k] === "string" ? (claims[k] as string) : undefined);
91
- const emailish = str("email") || str("preferred_username") || str("upn");
92
- const email = emailish && emailish.includes("@") ? emailish : undefined;
93
- return { email, name: str("name") || str("given_name") };
108
+ const entry = currentAuthEntry(auth);
109
+ if (!entry) return {};
110
+
111
+ const entryEmail = typeof entry.email === "string" && entry.email.includes("@") ? entry.email : undefined;
112
+ const entryName = [entry.first_name, entry.last_name]
113
+ .filter((p): p is string => typeof p === "string" && p.trim().length > 0)
114
+ .join(" ")
115
+ .trim() || undefined;
116
+
117
+ const tok = entry.key?.trim();
118
+ const claims = tok ? decodeJwtPayload(tok) : undefined;
119
+ const str = (k: string): string | undefined =>
120
+ claims && typeof claims[k] === "string" ? (claims[k] as string) : undefined;
121
+ const claimEmailish = str("email") || str("preferred_username") || str("upn");
122
+ const claimEmail = claimEmailish && claimEmailish.includes("@") ? claimEmailish : undefined;
123
+ const claimName = str("name") || str("given_name");
124
+
125
+ return {
126
+ email: entryEmail || claimEmail,
127
+ name: entryName || claimName,
128
+ };
94
129
  }
95
130
 
96
131
  /** A short human label for the active login (email, else a short token hash). */
package/src/bot/bot.ts CHANGED
@@ -125,8 +125,12 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
125
125
 
126
126
  // Permission handling: default is auto-approve (prefer "this session" / always).
127
127
  // Interactive Approve/Deny buttons only when both trust-all and auto-approve are off.
128
+ // Interactive prompts are pinned so they aren't lost in a busy chat; on
129
+ // settle we re-pin the status panel (private chats keep a single pin).
128
130
  const autoApprovePerms = cfg.autoApprovePermissions || cfg.trustAllTools;
129
- const permissions = new PermissionService(bot.api, registry, autoApprovePerms);
131
+ const permissions = new PermissionService(bot.api, registry, autoApprovePerms, {
132
+ onUnpinned: (chatId) => statusPanel.ensurePinned(chatId),
133
+ });
130
134
  acp.permissionHandler = (p) => permissions.handle(p);
131
135
 
132
136
  // The bot pins/unpins the status panel, and Telegram emits a "pinned a
@@ -147,9 +151,14 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
147
151
  });
148
152
 
149
153
  bot.callbackQuery(/^perm:(\d+):(\d+)$/, async (ctx) => {
154
+ // resolveChoice unpins the prompt; we then rewrite it to the chosen label.
150
155
  const label = permissions.resolveChoice(ctx.match![1]!, Number(ctx.match![2]));
151
156
  await ctx.answerCallbackQuery({ text: label ?? "Expired" });
152
- await ctx.editMessageText(label ? `\u{1F510} ${label}` : "\u{1F510} (expired)").catch(() => {});
157
+ await ctx
158
+ .editMessageText(label ? `\u{1F510} ${label}` : "\u{1F510} (expired)", {
159
+ reply_markup: { inline_keyboard: [] },
160
+ })
161
+ .catch(() => {});
153
162
  });
154
163
 
155
164
  bot.callbackQuery(/^permsw:(\d+)$/, async (ctx) => {
@@ -25,14 +25,28 @@ async function view(deps: BotDeps, note?: string): Promise<{ text: string; keybo
25
25
  const list = deps.accounts.list();
26
26
  const acct = await deps.usage.account().catch(() => undefined);
27
27
  const active = deps.accounts.activeAccountId();
28
+ const marked = deps.accounts.markedActiveId();
28
29
  const loggedIn = await deps.usage.isLoggedIn().catch(() => false);
30
+ const liveLabel = acct?.email?.trim();
31
+ const activeMeta = active ? deps.accounts.get(active) : undefined;
32
+ const markedMeta = marked && marked !== active ? deps.accounts.get(marked) : undefined;
29
33
 
30
34
  const lines = ["\u{1F465} Grok accounts", ""];
31
- if (loggedIn && acct?.email && !active) {
32
- lines.push(`\u{1F7E2} Signed in as ${acct.email}`);
33
- lines.push(" \u2514 Not saved yet β€” tap \u201C\u{1F4BE} Save current login\u201D to keep it.", "");
34
- } else if (!loggedIn) {
35
+ if (!loggedIn) {
35
36
  lines.push("\u{1F534} Not signed in β€” sign in via /reauth.", "");
37
+ } else if (liveLabel) {
38
+ lines.push(`\u{1F7E2} Host Grok login: ${liveLabel}`);
39
+ if (activeMeta) {
40
+ lines.push(` \u2514 Matches saved: ${activeMeta.label}`);
41
+ } else {
42
+ lines.push(" \u2514 Not among saved accounts β€” tap \u201C\u{1F4BE} Save current login\u201D.");
43
+ if (markedMeta) {
44
+ lines.push(` \u2514 App still has ${markedMeta.label} selected β€” different from host login.`);
45
+ }
46
+ }
47
+ lines.push("");
48
+ } else {
49
+ lines.push("\u{1F7E2} Signed in (identity unknown).", "");
36
50
  }
37
51
  if (list.length === 0) {
38
52
  lines.push("No saved accounts yet.", "", "Save the current login below, or sign in via /reauth.");
@@ -153,6 +153,20 @@ export class StatusPanel {
153
153
  log.debug("status create/pin failed:", (err as Error).message);
154
154
  }
155
155
  }
156
+
157
+ /**
158
+ * Re-pin the existing status panel (if any). Used after a temporary pin
159
+ * (e.g. a permission prompt) is unpinned so the status panel stays visible.
160
+ */
161
+ async ensurePinned(chatId: number): Promise<void> {
162
+ const id = this.settings.get(chatId).statusMessageId;
163
+ if (!id) return;
164
+ try {
165
+ await this.api.pinChatMessage(chatId, id, { disable_notification: true });
166
+ } catch (err) {
167
+ log.debug("status re-pin failed:", (err as Error).message);
168
+ }
169
+ }
156
170
  }
157
171
 
158
172
  function isNotModified(err: unknown): boolean {
@@ -2,6 +2,9 @@
2
2
  * PermissionService β€” turns Grok's ACP `session/request_permission` into either
3
3
  * an automatic session-level approval (default) or inline Approve/Deny buttons.
4
4
  *
5
+ * Interactive prompts are **pinned** so they stay visible while the chat is
6
+ * busy streaming other messages, then **unpinned** on approve / deny / timeout.
7
+ *
5
8
  * Auto-approve prefers "allow for this session" / "always allow" options so the
6
9
  * agent stops re-prompting mid-turn. Interactive mode is only used when
7
10
  * auto-approve is off (GROK_TRUST_ALL_TOOLS=false and AUTO_APPROVE_PERMISSIONS=false).
@@ -31,6 +34,16 @@ interface Pending {
31
34
  sessionId: string;
32
35
  messageId?: number;
33
36
  timer: NodeJS.Timeout;
37
+ /** True once the message has been pinned (so we know to unpin on settle). */
38
+ pinned: boolean;
39
+ }
40
+
41
+ export interface PermissionServiceOptions {
42
+ /**
43
+ * Called after a permission prompt is unpinned (approve / deny / timeout).
44
+ * Use to re-pin the status panel if Telegram only keeps one pin per chat.
45
+ */
46
+ onUnpinned?: (chatId: number) => void | Promise<void>;
34
47
  }
35
48
 
36
49
  export class PermissionService {
@@ -38,13 +51,16 @@ export class PermissionService {
38
51
  private seq = 0;
39
52
  /** When true, every permission request is auto-approved (session-scope preferred). */
40
53
  autoApprove: boolean;
54
+ private readonly onUnpinned?: (chatId: number) => void | Promise<void>;
41
55
 
42
56
  constructor(
43
57
  private readonly api: Api,
44
58
  private readonly registry: RuntimeRegistry,
45
59
  autoApprove = true,
60
+ opts?: PermissionServiceOptions,
46
61
  ) {
47
62
  this.autoApprove = autoApprove;
63
+ this.onUnpinned = opts?.onUnpinned;
48
64
  }
49
65
 
50
66
  /** Handle a permission request: auto-approve (default), ask the chat, or allow if unattended. */
@@ -78,6 +94,7 @@ export class PermissionService {
78
94
  if (canSwitch) kb.text(`\u{1F500} Switch to ${label}`, `permsw:${reqId}`);
79
95
 
80
96
  let messageId: number | undefined;
97
+ let pinned = false;
81
98
  try {
82
99
  const msg = await this.api.sendMessage(
83
100
  chatId,
@@ -88,6 +105,14 @@ export class PermissionService {
88
105
  },
89
106
  );
90
107
  messageId = msg.message_id;
108
+ // Pin so the prompt stays visible while the chat streams other messages.
109
+ // disable_notification:true β€” the send already notified; no second ping.
110
+ try {
111
+ await this.api.pinChatMessage(chatId, messageId, { disable_notification: true });
112
+ pinned = true;
113
+ } catch (e) {
114
+ log.warn("failed to pin permission prompt:", (e as Error).message);
115
+ }
91
116
  } catch (e) {
92
117
  log.warn("failed to send permission prompt:", (e as Error).message);
93
118
  return autoDecideSession(params);
@@ -95,11 +120,21 @@ export class PermissionService {
95
120
 
96
121
  return new Promise<PermissionOutcome>((resolve) => {
97
122
  const timer = setTimeout(() => {
123
+ const p = this.pending.get(reqId);
124
+ if (!p) return;
98
125
  this.pending.delete(reqId);
99
- void this.api.editMessageText(chatId, messageId!, "\u231B Approval timed out \u2014 denied.").catch(() => {});
126
+ void this.finishPrompt(p, "\u231B Approval timed out \u2014 denied.");
100
127
  resolve({ outcome: { outcome: "cancelled" } });
101
128
  }, TIMEOUT_MS);
102
- this.pending.set(reqId, { resolve, options: params.options, chatId, sessionId: params.sessionId, messageId, timer });
129
+ this.pending.set(reqId, {
130
+ resolve,
131
+ options: params.options,
132
+ chatId,
133
+ sessionId: params.sessionId,
134
+ messageId,
135
+ timer,
136
+ pinned,
137
+ });
103
138
  });
104
139
  }
105
140
 
@@ -111,9 +146,12 @@ export class PermissionService {
111
146
  this.pending.delete(reqId);
112
147
  const opt = p.options[index];
113
148
  if (!opt) {
149
+ void this.finishPrompt(p, "\u{1F510} (cancelled)");
114
150
  p.resolve({ outcome: { outcome: "cancelled" } });
115
151
  return undefined;
116
152
  }
153
+ // Unpin + mark resolved (caller typically edits the message text too).
154
+ void this.unpinOnly(p);
117
155
  p.resolve({ outcome: { outcome: "selected", optionId: opt.optionId } });
118
156
  return opt.name;
119
157
  }
@@ -122,6 +160,31 @@ export class PermissionService {
122
160
  sessionFor(reqId: string): string | undefined {
123
161
  return this.pending.get(reqId)?.sessionId;
124
162
  }
163
+
164
+ /** Unpin (if pinned) and optionally rewrite the prompt message. */
165
+ private async finishPrompt(p: Pending, text?: string): Promise<void> {
166
+ if (p.messageId !== undefined && text) {
167
+ await this.api.editMessageText(p.chatId, p.messageId, text, { reply_markup: { inline_keyboard: [] } }).catch(() => {});
168
+ }
169
+ await this.unpinOnly(p);
170
+ }
171
+
172
+ /** Unpin the permission prompt and restore the status pin if any. */
173
+ private async unpinOnly(p: Pending): Promise<void> {
174
+ if (p.pinned && p.messageId !== undefined) {
175
+ p.pinned = false;
176
+ await this.api.unpinChatMessage(p.chatId, p.messageId).catch((e) => {
177
+ log.debug("unpin permission prompt failed:", (e as Error).message);
178
+ });
179
+ }
180
+ if (this.onUnpinned) {
181
+ try {
182
+ await this.onUnpinned(p.chatId);
183
+ } catch (e) {
184
+ log.debug("onUnpinned hook failed:", (e as Error).message);
185
+ }
186
+ }
187
+ }
125
188
  }
126
189
 
127
190
  function describe(
@@ -1,89 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { spawnSync } from "node:child_process";
3
-
4
- const files = [
5
- ".env.example",
6
- "CHANGELOG.md",
7
- "src/app/accounts.ts",
8
- "src/app/auth-service.ts",
9
- "src/bot/account-rotator.ts",
10
- "src/bot/bot.ts",
11
- "src/bot/handlers/accounts.ts",
12
- "src/bot/permission-service.ts",
13
- "src/bot/reauth-controller.ts",
14
- "src/config.ts",
15
- "src/grok/client.ts",
16
- "test/auth-and-permissions.test.ts",
17
- ];
18
-
19
- const owner = "artickc";
20
- const repo = "grok-telegram-bot";
21
- const branch = "feat/fix-rotation-and-auto-approve";
22
- const message =
23
- "fix: account rotation swaps auth.json headlessly; auto-approve session permissions";
24
-
25
- // eslint-disable-next-line no-control-regex
26
- const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
27
-
28
- function ghApi(method, path, body) {
29
- const args = ["api", "-X", method, path];
30
- const opts = {
31
- encoding: "utf8",
32
- maxBuffer: 50 * 1024 * 1024,
33
- shell: false,
34
- env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", FORCE_COLOR: "0", GH_FORCE_TTY: "0" },
35
- };
36
- if (body !== undefined) {
37
- args.push("--input", "-");
38
- opts.input = JSON.stringify(body);
39
- }
40
- const r = spawnSync("gh", args, opts);
41
- if (r.status !== 0) {
42
- throw new Error(`gh api ${method} ${path} failed (${r.status}): ${r.stderr || r.stdout}`);
43
- }
44
- const text = (r.stdout || "").replace(ANSI_RE, "").trim();
45
- if (!text) return {};
46
- try {
47
- return JSON.parse(text);
48
- } catch (e) {
49
- throw new Error(`JSON parse failed for ${method} ${path}: ${text.slice(0, 200)}`);
50
- }
51
- }
52
-
53
- const refPath = `repos/${owner}/${repo}/git/ref/heads/${branch}`;
54
- const ref = ghApi("GET", refPath);
55
- const baseSha = ref.object.sha;
56
- console.log("base", baseSha);
57
-
58
- const baseCommit = ghApi("GET", `repos/${owner}/${repo}/git/commits/${baseSha}`);
59
- const baseTree = baseCommit.tree.sha;
60
-
61
- const tree = [];
62
- for (const path of files) {
63
- const content = readFileSync(path, "utf8");
64
- const blob = ghApi("POST", `repos/${owner}/${repo}/git/blobs`, {
65
- content: Buffer.from(content, "utf8").toString("base64"),
66
- encoding: "base64",
67
- });
68
- tree.push({ path, mode: "100644", type: "blob", sha: blob.sha });
69
- console.log("blob", path, blob.sha.slice(0, 8));
70
- }
71
-
72
- const newTree = ghApi("POST", `repos/${owner}/${repo}/git/trees`, {
73
- base_tree: baseTree,
74
- tree,
75
- });
76
- console.log("tree", newTree.sha);
77
-
78
- const commit = ghApi("POST", `repos/${owner}/${repo}/git/commits`, {
79
- message,
80
- tree: newTree.sha,
81
- parents: [baseSha],
82
- });
83
- console.log("commit", commit.sha);
84
-
85
- ghApi("PATCH", `repos/${owner}/${repo}/git/refs/heads/${branch}`, {
86
- sha: commit.sha,
87
- force: false,
88
- });
89
- console.log("updated", branch, "->", commit.sha);