grok-telegram-bot 2.2.4 → 2.3.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.
@@ -56,8 +56,10 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
56
56
  bot.callbackQuery(/^agent:set:(\d+)$/, async (ctx) => {
57
57
  const mode = deps.acp.availableModes[Number(ctx.match![1])];
58
58
  if (!mode) return void ctx.answerCallbackQuery({ text: "Expired, tap Agent again." });
59
+ // Answer before ACP so a slow setMode never expires the callback query.
60
+ await ctx.answerCallbackQuery({ text: `\u{1F916} Agent: ${mode.name}` });
59
61
  await deps.registry.get(ctx.chat!.id).setAgentPref(mode.id);
60
- await confirm(ctx, deps, `\u{1F916} Agent: ${mode.name}`);
62
+ await confirmUi(ctx, deps);
61
63
  });
62
64
 
63
65
  // ── Reasoning ──────────────────────────────────────────────────────────────
@@ -71,12 +73,17 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
71
73
  bot.callbackQuery(/^model:set:(\d+)$/, async (ctx) => {
72
74
  const entry = deps.acp.availableModels[Number(ctx.match![1])];
73
75
  if (!entry) return void ctx.answerCallbackQuery({ text: "Expired, tap Model again." });
76
+ await ctx.answerCallbackQuery({ text: `\u{1F9E9} Model: ${entry.name}` });
74
77
  const res = await deps.registry.get(ctx.chat!.id).setModelPref(entry.modelId);
75
- await confirm(ctx, deps, res.ok ? `\u{1F9E9} Model: ${entry.name}` : `\u26A0\uFE0F Model set failed: ${res.error}`);
78
+ if (!res.ok) {
79
+ await ctx.reply(`\u26A0\uFE0F Model set failed: ${res.error}`).catch(() => {});
80
+ }
81
+ await confirmUi(ctx, deps);
76
82
  });
77
83
  bot.callbackQuery("model:clear", async (ctx) => {
84
+ await ctx.answerCallbackQuery({ text: "\u{1F9E9} Model: default" });
78
85
  await deps.registry.get(ctx.chat!.id).setModelPref("");
79
- await confirm(ctx, deps, "\u{1F9E9} Model: default");
86
+ await confirmUi(ctx, deps);
80
87
  });
81
88
  }
82
89
 
@@ -143,15 +150,25 @@ async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promis
143
150
  } catch (e) {
144
151
  return void ctx.reply(`\u274C ${(e as Error).message}`);
145
152
  }
146
- case "stop":
147
- return void ctx.answerCallbackQuery({ text: (await rt.cancel()) ? "Cancelling\u2026" : "Nothing is running" });
153
+ case "stop": {
154
+ // Answer first so a slow cancel never times out the callback query.
155
+ await ctx.answerCallbackQuery({ text: rt.isBusy ? "Cancelling\u2026" : "Nothing is running" });
156
+ if (rt.isBusy) await rt.cancel();
157
+ return;
158
+ }
148
159
  default:
149
160
  return void ctx.answerCallbackQuery();
150
161
  }
151
162
  }
152
163
 
153
164
  async function confirm(ctx: Context, deps: BotDeps, text: string): Promise<void> {
165
+ // Toast first (callback must be answered within ~seconds), then UI updates.
154
166
  await ctx.answerCallbackQuery({ text });
167
+ await confirmUi(ctx, deps);
168
+ }
169
+
170
+ /** Refresh status + reopen the main menu after a preference change. */
171
+ async function confirmUi(ctx: Context, deps: BotDeps): Promise<void> {
155
172
  try {
156
173
  await ctx.deleteMessage();
157
174
  } catch {
@@ -60,8 +60,11 @@ export async function sendProjectMenu(
60
60
  * "last used" is the latest of its directory mtime and the newest session
61
61
  * opened in it, so the project you worked in most recently floats to the top. */
62
62
  function sortByRecency(entries: ProjectEntry[], deps: BotDeps): ProjectEntry[] {
63
+ if (entries.length === 0) return entries;
64
+ // Cap the session scan — full directory walks get expensive as history grows,
65
+ // and only the freshest sessions matter for ranking.
63
66
  const recencyByCwd = new Map<string, number>();
64
- for (const s of deps.store.list(300)) {
67
+ for (const s of deps.store.list(80)) {
65
68
  const key = normCwd(s.cwd);
66
69
  if (!key) continue;
67
70
  const ms = Date.parse(s.updatedAt);
@@ -87,7 +90,8 @@ export async function showProjects(ctx: Context, deps: BotDeps, query?: string):
87
90
  if (create) {
88
91
  try {
89
92
  const entry = deps.projects.create(create[1]!);
90
- await deps.registry.controller(ctx.chat!.id).addNew(entry.path, entry.name);
93
+ // Instant switch — ACP session is created on the first message.
94
+ await deps.registry.controller(ctx.chat!.id).switchProject(entry.path, entry.name);
91
95
  await refreshMenu(ctx, deps, `\u2705 Created and opened ${entry.name}\n${entry.path} \u2014 send a message.`);
92
96
  } catch (e) {
93
97
  await deps.ephemeral.open(ctx);
@@ -132,7 +136,7 @@ async function openProjectPath(ctx: Context, deps: BotDeps, raw: string): Promis
132
136
  await deps.ephemeral.open(ctx);
133
137
  const name = basename(dir) || dir;
134
138
  try {
135
- await deps.registry.controller(ctx.chat!.id).addNew(dir, name);
139
+ await deps.registry.controller(ctx.chat!.id).switchProject(dir, name);
136
140
  await refreshMenu(ctx, deps, `\u{1F4C1} Now working in ${name}\n${dir} \u2014 send a message.`);
137
141
  } catch (e) {
138
142
  await deps.ephemeral.reply(ctx, `\u274C Could not open ${dir}: ${(e as Error).message}`);
@@ -171,10 +175,12 @@ export function registerProjects(bot: Bot, deps: BotDeps): void {
171
175
  await ctx.answerCallbackQuery({ text: "Selection expired, run /projects again." });
172
176
  return;
173
177
  }
174
- await ctx.answerCallbackQuery();
178
+ // Answer immediately (before any work) so Telegram never times out the query.
179
+ await ctx.answerCallbackQuery({ text: `Opening ${entry.name}\u2026` });
175
180
  await deps.ephemeral.clear(ctx.chat!.id); // remove the project picker
176
181
  try {
177
- await deps.registry.controller(ctx.chat!.id).addNew(entry.path, entry.name);
182
+ // Instant: no ACP session/new — live session is created on first message.
183
+ await deps.registry.controller(ctx.chat!.id).switchProject(entry.path, entry.name);
178
184
  await refreshMenu(ctx, deps, `\u{1F4C1} Now working in ${entry.name} \u2014 send a message.`);
179
185
  } catch (err) {
180
186
  await ctx.reply(`\u274C Could not open ${entry.name}: ${(err as Error).message}`);
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * Voice & audio handler — transcribes Telegram voice notes / audio files to
3
3
  * text (any language) and submits them as prompts.
4
+ *
5
+ * Requires STT_API_URL (and optionally STT_API_KEY). Grok Build CLI over ACP
6
+ * does not accept audio content blocks, so without an STT endpoint voice is
7
+ * disabled rather than attaching raw audio the agent cannot hear.
4
8
  */
5
9
  import type { Bot, Context } from "grammy";
6
10
  import { textPrompt } from "../../app/types.js";
@@ -18,7 +22,9 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
18
22
  return;
19
23
  }
20
24
  if (!deps.stt.enabled) {
21
- await ctx.reply("\u{1F399} Voice isn't configured. Set STT_API_URL (and STT_API_KEY) in .env.");
25
+ await ctx.reply(
26
+ "\u{1F399} Voice isn't configured. Set STT_API_URL (and STT_API_KEY if needed) in .env.",
27
+ );
22
28
  return;
23
29
  }
24
30
  await ctx.replyWithChatAction("typing").catch(() => {});
@@ -41,9 +47,20 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
41
47
  }
42
48
  };
43
49
 
44
- bot.on("message:voice", (ctx) => handle(ctx, ctx.message.voice.file_id, ctx.message.voice.mime_type || "audio/ogg", "voice.ogg"));
45
- bot.on("message:audio", (ctx) => handle(ctx, ctx.message.audio.file_id, ctx.message.audio.mime_type || "audio/mpeg", ctx.message.audio.file_name || "audio.mp3"));
46
- bot.on("message:video_note", (ctx) => handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4"));
50
+ bot.on("message:voice", (ctx) =>
51
+ handle(ctx, ctx.message.voice.file_id, ctx.message.voice.mime_type || "audio/ogg", "voice.ogg"),
52
+ );
53
+ bot.on("message:audio", (ctx) =>
54
+ handle(
55
+ ctx,
56
+ ctx.message.audio.file_id,
57
+ ctx.message.audio.mime_type || "audio/mpeg",
58
+ ctx.message.audio.file_name || "audio.mp3",
59
+ ),
60
+ );
61
+ bot.on("message:video_note", (ctx) =>
62
+ handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4"),
63
+ );
47
64
  }
48
65
 
49
66
  async function download(ctx: Context, fileId: string, token: string): Promise<Buffer | undefined> {
@@ -1,30 +1,125 @@
1
1
  /**
2
- * Agent image return — detects image files the agent produced this turn
3
- * (screenshots, diagrams…) from its output and tool inputs, and sends them
4
- * back to Telegram. Only fresh files (modified during the turn) are sent.
2
+ * Agent image return — finds image files the agent produced this turn
3
+ * (Imagine `image_gen` / `image_edit`, screenshots, diagrams…) and sends them
4
+ * back to Telegram as **downloadable files** (`sendDocument`).
5
+ *
6
+ * Discovery sources:
7
+ * 1. Paths mentioned in agent text / tool inputs (project-relative or absolute)
8
+ * 2. Fresh files under the Grok session media dirs
9
+ * (`~/.grok/sessions/<encoded-cwd>/<sessionId>/images/` and `…/assets/`)
10
+ * 3. Fresh files under `<cwd>/images/` (common short path reported by tools)
5
11
  */
6
12
  import { type Api, InputFile } from "grammy";
7
- import { existsSync, statSync } from "node:fs";
13
+ import { existsSync, readdirSync, statSync } from "node:fs";
14
+ import { homedir } from "node:os";
8
15
  import { basename, isAbsolute, join } from "node:path";
9
16
  import { createLogger } from "../logger.js";
10
17
 
18
+ // Re-export so existing import paths (`./image-return.js`) keep working.
19
+ export { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
20
+
11
21
  const log = createLogger("image-return");
12
22
 
13
- const PATH_RE = /[^\s"'`<>|()*\[\]]+\.(?:png|jpe?g|gif|webp|bmp)/gi;
14
- const PHOTO_EXT = new Set(["png", "jpg", "jpeg", "webp"]);
15
- const MAX_PHOTO_BYTES = 10 * 1024 * 1024;
23
+ /** Absolute / relative image path tokens in free text (Unix + Windows). */
24
+ const PATH_RE =
25
+ /(?:[A-Za-z]:[\\/]|\/|~[\\/]|\.{1,2}[\\/])?[^\s"'`<>|()*\[\]{}]+\.(?:png|jpe?g|gif|webp|bmp)/gi;
26
+ const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
16
27
  const MAX_FILE_BYTES = 45 * 1024 * 1024;
17
28
 
18
29
  /** Pull candidate image paths out of arbitrary text, resolved against cwd. */
19
30
  export function extractImagePaths(text: string, cwd: string): string[] {
20
31
  const out = new Set<string>();
21
32
  for (const m of text.matchAll(PATH_RE)) {
22
- const raw = m[0].replace(/[).,;:]+$/, "");
33
+ let raw = m[0].replace(/[).,;:]+$/, "");
34
+ if (raw.startsWith("~/") || raw.startsWith("~\\")) {
35
+ raw = join(homedir(), raw.slice(2));
36
+ }
23
37
  out.add(isAbsolute(raw) ? raw : join(cwd, raw));
24
38
  }
25
39
  return [...out];
26
40
  }
27
41
 
42
+ /**
43
+ * Grok stores per-session media under:
44
+ * ~/.grok/sessions/<encodeURIComponent(cwd)>/<sessionId>/{images,assets}/
45
+ * Imagine `image_gen` currently prefers `images/`; older runs used `assets/`.
46
+ */
47
+ export function grokSessionMediaRoot(cwd: string, sessionId: string): string {
48
+ return join(homedir(), ".grok", "sessions", encodeURIComponent(cwd), sessionId);
49
+ }
50
+
51
+ /** @deprecated Prefer grokSessionMediaDirs — kept for callers/tests that used assets. */
52
+ export function grokSessionAssetsDir(cwd: string, sessionId: string): string {
53
+ return join(grokSessionMediaRoot(cwd, sessionId), "assets");
54
+ }
55
+
56
+ /** Session folders where Imagine / tools drop generated images. */
57
+ export function grokSessionMediaDirs(cwd: string, sessionId: string): string[] {
58
+ const root = grokSessionMediaRoot(cwd, sessionId);
59
+ return [join(root, "images"), join(root, "assets")];
60
+ }
61
+
62
+ /** List image files under `dir` modified at/after `since` (non-recursive). */
63
+ export function listFreshImagesInDir(dir: string, since: number): string[] {
64
+ if (!existsSync(dir)) return [];
65
+ let names: string[];
66
+ try {
67
+ names = readdirSync(dir);
68
+ } catch {
69
+ return [];
70
+ }
71
+ const out: string[] = [];
72
+ for (const name of names) {
73
+ const path = join(dir, name);
74
+ const ext = name.toLowerCase().split(".").pop() ?? "";
75
+ if (!IMAGE_EXT.has(ext)) continue;
76
+ try {
77
+ const st = statSync(path);
78
+ if (!st.isFile() || st.size === 0 || st.size > MAX_FILE_BYTES) continue;
79
+ // 2s slack for clock skew / write completion.
80
+ if (st.mtimeMs < since - 2000) continue;
81
+ out.push(path);
82
+ } catch {
83
+ /* skip */
84
+ }
85
+ }
86
+ // Newest first so max-cap still keeps the latest gens.
87
+ return out.sort((a, b) => {
88
+ try {
89
+ return statSync(b).mtimeMs - statSync(a).mtimeMs;
90
+ } catch {
91
+ return 0;
92
+ }
93
+ });
94
+ }
95
+
96
+ /** Collect all image candidates for a turn from text + known asset locations. */
97
+ export function collectTurnImagePaths(opts: {
98
+ scanText: string;
99
+ cwd: string;
100
+ sessionId?: string;
101
+ since: number;
102
+ }): string[] {
103
+ const seen = new Set<string>();
104
+ const out: string[] = [];
105
+ const add = (paths: string[]) => {
106
+ for (const p of paths) {
107
+ if (seen.has(p)) continue;
108
+ seen.add(p);
109
+ out.push(p);
110
+ }
111
+ };
112
+
113
+ add(extractImagePaths(opts.scanText, opts.cwd));
114
+ add(listFreshImagesInDir(join(opts.cwd, "images"), opts.since));
115
+ if (opts.sessionId) {
116
+ for (const dir of grokSessionMediaDirs(opts.cwd, opts.sessionId)) {
117
+ add(listFreshImagesInDir(dir, opts.since));
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
28
123
  export interface SendImagesOptions {
29
124
  /** Only send files modified at/after this epoch ms (fresh this turn). */
30
125
  since: number;
@@ -32,9 +127,11 @@ export interface SendImagesOptions {
32
127
  already: Set<string>;
33
128
  /** Max images to send in this call. */
34
129
  max: number;
130
+ /** Optional Telegram message id to thread replies under. */
131
+ replyTo?: number;
35
132
  }
36
133
 
37
- /** Send the valid, fresh, not-yet-sent images. Returns how many were sent. */
134
+ /** Send the valid, fresh, not-yet-sent images as documents. Returns how many were sent. */
38
135
  export async function sendImages(
39
136
  api: Api,
40
137
  chatId: number,
@@ -42,6 +139,10 @@ export async function sendImages(
42
139
  opts: SendImagesOptions,
43
140
  ): Promise<number> {
44
141
  let sent = 0;
142
+ const replyExtra =
143
+ opts.replyTo !== undefined
144
+ ? { reply_parameters: { message_id: opts.replyTo, allow_sending_without_reply: true } }
145
+ : {};
45
146
  for (const path of paths) {
46
147
  if (sent >= opts.max) break;
47
148
  if (opts.already.has(path)) continue;
@@ -55,12 +156,15 @@ export async function sendImages(
55
156
  if (st.mtimeMs < opts.since - 2000) continue; // skip pre-existing files
56
157
  opts.already.add(path);
57
158
  try {
58
- const ext = path.toLowerCase().split(".").pop() ?? "";
59
- const asPhoto = PHOTO_EXT.has(ext) && st.size <= MAX_PHOTO_BYTES;
60
- const file = new InputFile(path);
61
- if (asPhoto) await api.sendPhoto(chatId, file, { caption: basename(path) });
62
- else await api.sendDocument(chatId, file, { caption: basename(path) });
159
+ // Always send as a document so Telegram delivers a downloadable file
160
+ // (not a compressed photo bubble).
161
+ const file = new InputFile(path, basename(path));
162
+ await api.sendDocument(chatId, file, {
163
+ caption: basename(path),
164
+ ...replyExtra,
165
+ });
63
166
  sent++;
167
+ log.debug(`sent document ${path}`);
64
168
  } catch (e) {
65
169
  log.debug(`failed to send ${path}:`, (e as Error).message);
66
170
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Build ACP prompt content blocks from a PromptInput (text + images), applying
3
- * the reasoning directive and any fork-priming context. Also merges multiple
4
- * queued inputs into one.
2
+ * Build ACP prompt content blocks from a PromptInput (text + images + optional
3
+ * resource links), applying the reasoning directive and any fork-priming
4
+ * context. Also merges multiple queued inputs into one.
5
5
  */
6
6
  import type { ContentBlock } from "../grok/types.js";
7
7
  import type { PromptInput } from "../app/types.js";
@@ -9,8 +9,10 @@ import type { PromptInput } from "../app/types.js";
9
9
  export interface ContentOptions {
10
10
  reasoning?: string;
11
11
  priming?: string;
12
- /** Appended at the very bottom so the agent emits a `{progress: N%}` marker. */
12
+ /** Appended so the agent emits a `{progress: N%}` marker. */
13
13
  progress?: string;
14
+ /** Appended so the agent keeps generated images in the session media folder. */
15
+ imageOutput?: string;
14
16
  }
15
17
 
16
18
  export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}): ContentBlock[] {
@@ -19,11 +21,23 @@ export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}
19
21
  for (const img of input.images) {
20
22
  blocks.push({ type: "image", data: img.data, mimeType: img.mimeType });
21
23
  }
24
+ for (const link of input.resourceLinks ?? []) {
25
+ blocks.push({
26
+ type: "resource_link",
27
+ uri: link.uri,
28
+ name: link.name,
29
+ mimeType: link.mimeType,
30
+ size: link.size,
31
+ });
32
+ }
22
33
 
23
34
  let text = input.text.trim();
24
35
  if (!text && input.images.length > 0) {
25
36
  text = input.images.length === 1 ? "Please analyze the attached image." : "Please analyze the attached images.";
26
37
  }
38
+ if (!text && (input.resourceLinks?.length ?? 0) > 0) {
39
+ text = "Please process the attached file(s).";
40
+ }
27
41
  if (input.quotedText?.trim()) {
28
42
  const quoted = input.quotedText.trim();
29
43
  const body = text || "(the user's reply carried no additional text)";
@@ -35,7 +49,11 @@ export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}
35
49
  if (opts.reasoning) {
36
50
  text = `(${opts.reasoning})\n\n${text}`;
37
51
  }
52
+ if (opts.imageOutput) {
53
+ text = `${text}\n\n${opts.imageOutput}`;
54
+ }
38
55
  if (opts.progress) {
56
+ // Progress last so its "marker is the final line" rule stays true.
39
57
  text = `${text}\n\n${opts.progress}`;
40
58
  }
41
59
 
@@ -43,7 +61,7 @@ export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}
43
61
  return blocks;
44
62
  }
45
63
 
46
- /** Merge queued inputs into a single prompt (concatenated text, all images). */
64
+ /** Merge queued inputs into a single prompt (concatenated text, all images/links). */
47
65
  export function mergeInputs(inputs: PromptInput[]): PromptInput {
48
66
  const quotes = inputs
49
67
  .map((i) => i.quotedText?.trim())
@@ -54,6 +72,7 @@ export function mergeInputs(inputs: PromptInput[]): PromptInput {
54
72
  .filter((t) => t.trim().length > 0)
55
73
  .join("\n\n"),
56
74
  images: inputs.flatMap((i) => i.images),
75
+ resourceLinks: inputs.flatMap((i) => i.resourceLinks ?? []),
57
76
  replyTo: inputs.find((i) => i.replyTo !== undefined)?.replyTo,
58
77
  quotedText: quotes.length > 0 ? [...new Set(quotes)].join("\n\n---\n\n") : undefined,
59
78
  };
@@ -180,7 +180,7 @@ export class ReauthController {
180
180
  }
181
181
  s.phase = "restarting";
182
182
  await this.render(s);
183
- await this.grok.start();
183
+ await this.grok.start(true);
184
184
  agentDown = false;
185
185
  s.accountLabel = accountLabel(await this.getAccount?.().catch(() => undefined));
186
186
  s.phase = "done";
@@ -191,7 +191,7 @@ export class ReauthController {
191
191
  } finally {
192
192
  s.abort = undefined;
193
193
  this.stopAnim(s);
194
- if (agentDown) await this.grok.start().catch((e) => log.warn("post-reauth restart failed:", (e as Error).message));
194
+ if (agentDown) await this.grok.start(true).catch((e) => log.warn("post-reauth restart failed:", (e as Error).message));
195
195
  await this.render(s);
196
196
  }
197
197
  }