grok-telegram-bot 2.2.0 → 2.2.1

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,14 @@ The latest section is published verbatim as the GitHub Release notes by
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [2.2.1] - 2026-07-12
13
+
14
+ ### Fixed
15
+
16
+ - **📌 Permission prompts stay pinned** while waiting for Approve/Deny so they
17
+ aren't lost under streaming chat. The pin is removed on approve, deny, or
18
+ timeout, and the status panel is re-pinned afterwards.
19
+
12
20
  ## [2.2.0] - 2026-07-12
13
21
 
14
22
  The **reliable multi-account** release — account switch / auto-rotate now only
@@ -669,6 +677,7 @@ from a single chat and switch between them, on a redesigned, compact menu.
669
677
  diffs, MarkdownV2 rendering, scheduled tasks, multi-image prompts, and a
670
678
  cross-platform 24/7 background service.
671
679
 
680
+ [2.2.1]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.1
672
681
  [2.2.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.0
673
682
  [2.1.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.1.0
674
683
  [2.0.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.0.0
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.1",
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,26 +1,14 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
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
-
4
+ const files = process.argv.slice(2);
5
+ if (files.length < 3) {
6
+ console.error("Usage: node scripts/_push-branch.mjs <branch> <message> <file>...");
7
+ process.exit(1);
8
+ }
9
+ const [branch, message, ...paths] = files;
19
10
  const owner = "artickc";
20
11
  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
12
 
25
13
  // eslint-disable-next-line no-control-regex
26
14
  const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
@@ -43,29 +31,35 @@ function ghApi(method, path, body) {
43
31
  }
44
32
  const text = (r.stdout || "").replace(ANSI_RE, "").trim();
45
33
  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
- }
34
+ return JSON.parse(text);
51
35
  }
52
36
 
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);
37
+ // Ensure branch exists from main if missing.
38
+ try {
39
+ ghApi("GET", `repos/${owner}/${repo}/git/ref/heads/${branch}`);
40
+ } catch {
41
+ const main = ghApi("GET", `repos/${owner}/${repo}/git/ref/heads/main`);
42
+ ghApi("POST", `repos/${owner}/${repo}/git/refs`, {
43
+ ref: `refs/heads/${branch}`,
44
+ sha: main.object.sha,
45
+ });
46
+ console.log("created branch", branch, "from main");
47
+ }
57
48
 
49
+ const ref = ghApi("GET", `repos/${owner}/${repo}/git/ref/heads/${branch}`);
50
+ const baseSha = ref.object.sha;
58
51
  const baseCommit = ghApi("GET", `repos/${owner}/${repo}/git/commits/${baseSha}`);
59
52
  const baseTree = baseCommit.tree.sha;
53
+ console.log("base", baseSha);
60
54
 
61
55
  const tree = [];
62
- for (const path of files) {
56
+ for (const path of paths) {
63
57
  const content = readFileSync(path, "utf8");
64
58
  const blob = ghApi("POST", `repos/${owner}/${repo}/git/blobs`, {
65
59
  content: Buffer.from(content, "utf8").toString("base64"),
66
60
  encoding: "base64",
67
61
  });
68
- tree.push({ path, mode: "100644", type: "blob", sha: blob.sha });
62
+ tree.push({ path: path.replace(/\\/g, "/"), mode: "100644", type: "blob", sha: blob.sha });
69
63
  console.log("blob", path, blob.sha.slice(0, 8));
70
64
  }
71
65
 
@@ -73,15 +67,11 @@ const newTree = ghApi("POST", `repos/${owner}/${repo}/git/trees`, {
73
67
  base_tree: baseTree,
74
68
  tree,
75
69
  });
76
- console.log("tree", newTree.sha);
77
-
78
70
  const commit = ghApi("POST", `repos/${owner}/${repo}/git/commits`, {
79
71
  message,
80
72
  tree: newTree.sha,
81
73
  parents: [baseSha],
82
74
  });
83
- console.log("commit", commit.sha);
84
-
85
75
  ghApi("PATCH", `repos/${owner}/${repo}/git/refs/heads/${branch}`, {
86
76
  sha: commit.sha,
87
77
  force: false,
@@ -0,0 +1,52 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ const owner = "artickc";
4
+ const repo = "grok-telegram-bot";
5
+ const version = process.argv[2] || "2.2.1";
6
+ const tag = `v${version}`;
7
+ const message = process.argv[3] || `${tag} — pin permission prompts until resolved`;
8
+
9
+ // eslint-disable-next-line no-control-regex
10
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
11
+
12
+ function ghApi(method, path, body) {
13
+ const args = ["api", "-X", method, path];
14
+ const opts = {
15
+ encoding: "utf8",
16
+ maxBuffer: 50 * 1024 * 1024,
17
+ shell: false,
18
+ env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", FORCE_COLOR: "0", GH_FORCE_TTY: "0" },
19
+ };
20
+ if (body !== undefined) {
21
+ args.push("--input", "-");
22
+ opts.input = JSON.stringify(body);
23
+ }
24
+ const r = spawnSync("gh", args, opts);
25
+ if (r.status !== 0) {
26
+ throw new Error(`gh api ${method} ${path} failed (${r.status}): ${r.stderr || r.stdout}`);
27
+ }
28
+ const text = (r.stdout || "").replace(ANSI_RE, "").trim();
29
+ if (!text) return {};
30
+ return JSON.parse(text);
31
+ }
32
+
33
+ const ref = ghApi("GET", `repos/${owner}/${repo}/git/ref/heads/main`);
34
+ const sha = ref.object.sha;
35
+ console.log("main", sha);
36
+
37
+ const tagObj = ghApi("POST", `repos/${owner}/${repo}/git/tags`, {
38
+ tag,
39
+ message,
40
+ object: sha,
41
+ type: "commit",
42
+ tagger: {
43
+ name: "artickc",
44
+ email: "artickc@users.noreply.github.com",
45
+ date: new Date().toISOString(),
46
+ },
47
+ });
48
+ ghApi("POST", `repos/${owner}/${repo}/git/refs`, {
49
+ ref: `refs/tags/${tag}`,
50
+ sha: tagObj.sha,
51
+ });
52
+ console.log("created tag", tag, "->", sha);
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) => {
@@ -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,87 +0,0 @@
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).
4
- */
5
- import { readFileSync } from "node:fs";
6
- import { spawnSync } from "node:child_process";
7
-
8
- const owner = "artickc";
9
- const repo = "grok-telegram-bot";
10
- const branch = "main";
11
- const version = "2.2.0";
12
- const tag = `v${version}`;
13
- const message = `v${version} — headless account rotation + auto-approve session permissions`;
14
-
15
- // eslint-disable-next-line no-control-regex
16
- const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
17
-
18
- function ghApi(method, path, body) {
19
- const args = ["api", "-X", method, path];
20
- const opts = {
21
- encoding: "utf8",
22
- maxBuffer: 50 * 1024 * 1024,
23
- shell: false,
24
- env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", FORCE_COLOR: "0", GH_FORCE_TTY: "0" },
25
- };
26
- if (body !== undefined) {
27
- args.push("--input", "-");
28
- opts.input = JSON.stringify(body);
29
- }
30
- const r = spawnSync("gh", args, opts);
31
- if (r.status !== 0) {
32
- throw new Error(`gh api ${method} ${path} failed (${r.status}): ${r.stderr || r.stdout}`);
33
- }
34
- const text = (r.stdout || "").replace(ANSI_RE, "").trim();
35
- if (!text) return {};
36
- return JSON.parse(text);
37
- }
38
-
39
- const ref = ghApi("GET", `repos/${owner}/${repo}/git/ref/heads/${branch}`);
40
- const baseSha = ref.object.sha;
41
- const baseCommit = ghApi("GET", `repos/${owner}/${repo}/git/commits/${baseSha}`);
42
- const baseTree = baseCommit.tree.sha;
43
- console.log("base", baseSha);
44
-
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
- });
50
-
51
- const newTree = ghApi("POST", `repos/${owner}/${repo}/git/trees`, {
52
- base_tree: baseTree,
53
- tree: [{ path: "package.json", mode: "100644", type: "blob", sha: blob.sha }],
54
- });
55
-
56
- const commit = ghApi("POST", `repos/${owner}/${repo}/git/commits`, {
57
- message,
58
- tree: newTree.sha,
59
- parents: [baseSha],
60
- });
61
- console.log("commit", commit.sha);
62
-
63
- ghApi("PATCH", `repos/${owner}/${repo}/git/refs/heads/${branch}`, {
64
- sha: commit.sha,
65
- force: false,
66
- });
67
- console.log("updated", branch, "->", commit.sha);
68
-
69
- // Annotated tag for release workflow.
70
- const tagObj = ghApi("POST", `repos/${owner}/${repo}/git/tags`, {
71
- tag,
72
- message: message,
73
- object: commit.sha,
74
- type: "commit",
75
- tagger: {
76
- name: "artickc",
77
- email: "artickc@users.noreply.github.com",
78
- date: new Date().toISOString(),
79
- },
80
- });
81
- console.log("tag object", tagObj.sha);
82
-
83
- ghApi("POST", `repos/${owner}/${repo}/git/refs`, {
84
- ref: `refs/tags/${tag}`,
85
- sha: tagObj.sha,
86
- });
87
- console.log("created tag", tag);