grok-telegram-bot 2.0.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 (106) hide show
  1. package/.env.example +135 -0
  2. package/CHANGELOG.md +598 -0
  3. package/LICENSE +21 -0
  4. package/README.md +644 -0
  5. package/bin/grok-tg.mjs +21 -0
  6. package/docs/INSTALL.md +153 -0
  7. package/docs/UPGRADE.md +253 -0
  8. package/docs/ops/RELEASE_CHECKLIST.md +39 -0
  9. package/package.json +74 -0
  10. package/scripts/setup.mjs +116 -0
  11. package/src/agents/catalog.ts +58 -0
  12. package/src/app/accounts.ts +162 -0
  13. package/src/app/auth-service.ts +136 -0
  14. package/src/app/grok-credentials.ts +103 -0
  15. package/src/app/instance-lock.ts +139 -0
  16. package/src/app/json-store.ts +54 -0
  17. package/src/app/reasoning.ts +30 -0
  18. package/src/app/settings-store.ts +38 -0
  19. package/src/app/stt.ts +53 -0
  20. package/src/app/types.ts +56 -0
  21. package/src/app/updater.ts +234 -0
  22. package/src/app/usage.ts +38 -0
  23. package/src/app/version.ts +41 -0
  24. package/src/bot/account-rotator.ts +52 -0
  25. package/src/bot/auth.ts +38 -0
  26. package/src/bot/bot.ts +225 -0
  27. package/src/bot/chat-controller.ts +317 -0
  28. package/src/bot/commands.ts +52 -0
  29. package/src/bot/deps.ts +67 -0
  30. package/src/bot/file-ingest.ts +190 -0
  31. package/src/bot/handlers/accounts.ts +220 -0
  32. package/src/bot/handlers/auth.ts +64 -0
  33. package/src/bot/handlers/control.ts +103 -0
  34. package/src/bot/handlers/document.ts +112 -0
  35. package/src/bot/handlers/history.ts +63 -0
  36. package/src/bot/handlers/kill.ts +54 -0
  37. package/src/bot/handlers/mcp.ts +206 -0
  38. package/src/bot/handlers/menu.ts +220 -0
  39. package/src/bot/handlers/message.ts +103 -0
  40. package/src/bot/handlers/photo.ts +123 -0
  41. package/src/bot/handlers/projects.ts +183 -0
  42. package/src/bot/handlers/running.ts +181 -0
  43. package/src/bot/handlers/session-card.ts +81 -0
  44. package/src/bot/handlers/session-kill.ts +95 -0
  45. package/src/bot/handlers/sessions.ts +148 -0
  46. package/src/bot/handlers/system.ts +51 -0
  47. package/src/bot/handlers/tasks.ts +224 -0
  48. package/src/bot/handlers/usage.ts +38 -0
  49. package/src/bot/handlers/voice.ts +55 -0
  50. package/src/bot/image-return.ts +69 -0
  51. package/src/bot/menu/ephemeral.ts +117 -0
  52. package/src/bot/menu/keyboard.ts +49 -0
  53. package/src/bot/menu/refresh.ts +13 -0
  54. package/src/bot/menu/status-panel.ts +173 -0
  55. package/src/bot/permission-service.ts +149 -0
  56. package/src/bot/prompt-content.ts +64 -0
  57. package/src/bot/prompt-retry.ts +70 -0
  58. package/src/bot/reauth-controller.ts +297 -0
  59. package/src/bot/registry.ts +186 -0
  60. package/src/bot/reply-context.ts +77 -0
  61. package/src/bot/session-fork.ts +35 -0
  62. package/src/bot/session-runtime.ts +1048 -0
  63. package/src/bot/telegram-io.ts +109 -0
  64. package/src/bot/typing.ts +35 -0
  65. package/src/bot/wizard/task-wizard.ts +214 -0
  66. package/src/cli.ts +126 -0
  67. package/src/config.ts +248 -0
  68. package/src/grok/client.ts +617 -0
  69. package/src/grok/models.ts +50 -0
  70. package/src/grok/session-log.ts +148 -0
  71. package/src/grok/transport.ts +51 -0
  72. package/src/grok/types.ts +136 -0
  73. package/src/index.ts +84 -0
  74. package/src/logger.ts +78 -0
  75. package/src/mcp/config.ts +120 -0
  76. package/src/mcp/probe.ts +218 -0
  77. package/src/mcp/types.ts +68 -0
  78. package/src/projects/manager.ts +99 -0
  79. package/src/render/chunk.ts +57 -0
  80. package/src/render/diff.ts +48 -0
  81. package/src/render/escape.ts +22 -0
  82. package/src/render/file-summary.ts +111 -0
  83. package/src/render/hashtags.ts +34 -0
  84. package/src/render/markdown.ts +130 -0
  85. package/src/render/progress-estimate.ts +63 -0
  86. package/src/render/progress.ts +80 -0
  87. package/src/render/subagent.ts +75 -0
  88. package/src/render/tool-call.ts +196 -0
  89. package/src/service/index.ts +24 -0
  90. package/src/service/linux.ts +85 -0
  91. package/src/service/macos.ts +101 -0
  92. package/src/service/platform.ts +64 -0
  93. package/src/service/types.ts +36 -0
  94. package/src/service/windows.ts +198 -0
  95. package/src/sessions/history.ts +225 -0
  96. package/src/sessions/process.ts +30 -0
  97. package/src/sessions/store.ts +133 -0
  98. package/src/sessions/tail.ts +86 -0
  99. package/src/sessions/types.ts +26 -0
  100. package/src/stream/streamer.ts +261 -0
  101. package/src/tasks/runner.ts +82 -0
  102. package/src/tasks/schedule.ts +142 -0
  103. package/src/tasks/scheduler.ts +53 -0
  104. package/src/tasks/store.ts +80 -0
  105. package/src/tasks/types.ts +33 -0
  106. package/tsconfig.json +19 -0
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Document ingestion helpers.
3
+ *
4
+ * Telegram delivers non-photo attachments as *documents*. The most common case
5
+ * for this bot is a **long message that a Telegram client turned into a `.txt`
6
+ * file** (Desktop does this when you paste more than a few thousand characters),
7
+ * but users also drop code, logs, JSON, CSV, etc. We want the agent to actually
8
+ * *read* those, so this module:
9
+ * • decides whether a downloaded file is text (mime + extension hints, plus a
10
+ * content sniff so mislabeled `application/octet-stream` code files work),
11
+ * • decodes text (stripping BOMs), and
12
+ * • formats a clear prompt for either a text or a binary attachment.
13
+ *
14
+ * Image documents are handled separately (see handlers/photo.ts).
15
+ */
16
+
17
+ /** MIME prefixes that are always textual. */
18
+ const TEXT_MIME_PREFIXES = ["text/"];
19
+
20
+ /** Exact MIME types that are textual despite an `application/*` namespace. */
21
+ const TEXT_MIME_EXACT = new Set([
22
+ "application/json",
23
+ "application/ld+json",
24
+ "application/xml",
25
+ "application/javascript",
26
+ "application/x-javascript",
27
+ "application/typescript",
28
+ "application/x-typescript",
29
+ "application/x-yaml",
30
+ "application/yaml",
31
+ "application/toml",
32
+ "application/x-toml",
33
+ "application/x-sh",
34
+ "application/x-shellscript",
35
+ "application/x-httpd-php",
36
+ "application/sql",
37
+ "application/graphql",
38
+ "application/x-ndjson",
39
+ "application/csv",
40
+ "application/x-tex",
41
+ "application/x-latex",
42
+ "image/svg+xml",
43
+ ]);
44
+
45
+ /** File extensions we treat as text (used when the MIME type is missing/generic). */
46
+ const TEXT_EXTENSIONS = new Set([
47
+ "txt", "text", "md", "markdown", "mdx", "rst", "adoc", "log", "csv", "tsv",
48
+ "json", "json5", "jsonl", "ndjson", "xml", "yaml", "yml", "toml", "ini", "cfg",
49
+ "conf", "config", "env", "properties", "editorconfig", "gitignore", "gitattributes",
50
+ "js", "mjs", "cjs", "jsx", "ts", "tsx", "mts", "cts", "py", "pyi", "rb", "php",
51
+ "java", "kt", "kts", "go", "rs", "c", "h", "cpp", "cxx", "cc", "hpp", "hxx", "cs",
52
+ "swift", "m", "mm", "sh", "bash", "zsh", "fish", "ps1", "psm1", "bat", "cmd",
53
+ "sql", "graphql", "gql", "html", "htm", "xhtml", "css", "scss", "sass", "less",
54
+ "vue", "svelte", "astro", "gradle", "groovy", "lua", "pl", "pm", "r", "dart",
55
+ "scala", "clj", "cljs", "edn", "ex", "exs", "erl", "hrl", "hs", "ml", "mli",
56
+ "fs", "fsx", "vb", "asm", "s", "proto", "tf", "tfvars", "hcl", "svg", "patch",
57
+ "diff", "tex", "bib", "csv", "cmake", "dockerfile", "makefile", "rake", "gemfile",
58
+ ]);
59
+
60
+ /** Extensions that are always binary even if the sniff is inconclusive. */
61
+ const BINARY_EXTENSIONS = new Set([
62
+ "zip", "gz", "tar", "tgz", "rar", "7z", "bz2", "xz", "pdf", "doc", "docx",
63
+ "xls", "xlsx", "ppt", "pptx", "png", "jpg", "jpeg", "gif", "bmp", "webp",
64
+ "ico", "tiff", "mp3", "wav", "ogg", "flac", "mp4", "mkv", "mov", "avi", "webm",
65
+ "exe", "dll", "so", "dylib", "bin", "dat", "db", "sqlite", "class", "jar",
66
+ "woff", "woff2", "ttf", "otf", "eot", "psd", "ai", "sketch",
67
+ ]);
68
+
69
+ const MAX_SNIFF_BYTES = 8192;
70
+
71
+ /** Lowercased extension (or a bare well-known filename like `dockerfile`). */
72
+ function extensionOf(name?: string): string {
73
+ if (!name) return "";
74
+ const base = name.toLowerCase().replace(/^.*[\\/]/, "");
75
+ if (base === "dockerfile" || base === "makefile" || base === "gemfile" || base === "rakefile") return base;
76
+ const dot = base.lastIndexOf(".");
77
+ return dot > 0 ? base.slice(dot + 1) : "";
78
+ }
79
+
80
+ /**
81
+ * Content sniff: NUL byte or a high proportion of control characters marks a
82
+ * buffer as binary. UTF-8 multibyte bytes (>= 0x80) are allowed, so
83
+ * non-English text is not misclassified.
84
+ */
85
+ function sniff(buf: Buffer): "text" | "binary" {
86
+ const n = Math.min(buf.length, MAX_SNIFF_BYTES);
87
+ let control = 0;
88
+ for (let i = 0; i < n; i++) {
89
+ const b = buf[i]!;
90
+ if (b === 0) return "binary";
91
+ // Allow TAB(9), LF(10), CR(13), FF(12), and everything from 0x20 up.
92
+ if ((b < 9 || (b > 13 && b < 32)) && b !== 27 /* ESC, common in logs */) control++;
93
+ }
94
+ return control / n > 0.1 ? "binary" : "text";
95
+ }
96
+
97
+ /**
98
+ * Decide whether a downloaded document is text we can inline. Combines MIME and
99
+ * extension hints with a content sniff; the sniff is authoritative for NUL
100
+ * bytes so a `.txt` full of binary can't slip through.
101
+ */
102
+ export function looksLikeText(buf: Buffer, mimeType?: string, fileName?: string): boolean {
103
+ if (buf.length === 0) return true; // empty file — harmless to inline as ""
104
+ const mime = (mimeType ?? "").toLowerCase();
105
+ const ext = extensionOf(fileName);
106
+
107
+ const hintedText =
108
+ TEXT_MIME_PREFIXES.some((p) => mime.startsWith(p)) || TEXT_MIME_EXACT.has(mime) || TEXT_EXTENSIONS.has(ext);
109
+ const hintedBinary =
110
+ mime.startsWith("image/") ||
111
+ mime.startsWith("audio/") ||
112
+ mime.startsWith("video/") ||
113
+ mime.startsWith("font/") ||
114
+ BINARY_EXTENSIONS.has(ext);
115
+
116
+ const content = sniff(buf);
117
+ if (content === "binary") return false; // NUL / control-heavy → never text
118
+ if (hintedText) return true;
119
+ if (hintedBinary) return false;
120
+ // Unknown type (e.g. application/octet-stream with no extension): trust sniff.
121
+ return content === "text";
122
+ }
123
+
124
+ /** Decode a text buffer as UTF-8 / UTF-16, stripping a leading BOM. */
125
+ export function decodeText(buf: Buffer): string {
126
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
127
+ return buf.subarray(3).toString("utf8");
128
+ }
129
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
130
+ return buf.subarray(2).toString("utf16le");
131
+ }
132
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
133
+ // UTF-16 BE — swap to LE for Node's decoder.
134
+ return buf.subarray(2).swap16().toString("utf16le");
135
+ }
136
+ return buf.toString("utf8");
137
+ }
138
+
139
+ /** A fenced block that won't collide with backticks already in the content. */
140
+ function pickFence(content: string): string {
141
+ let fence = "```";
142
+ while (content.includes(fence)) fence += "`";
143
+ return fence;
144
+ }
145
+
146
+ /** Format a human-readable byte count. */
147
+ export function formatBytes(bytes: number): string {
148
+ if (bytes < 1024) return `${bytes} B`;
149
+ const units = ["KB", "MB", "GB"];
150
+ let v = bytes / 1024;
151
+ let i = 0;
152
+ while (v >= 1024 && i < units.length - 1) {
153
+ v /= 1024;
154
+ i++;
155
+ }
156
+ return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
157
+ }
158
+
159
+ /**
160
+ * Build the prompt text for a text document. With a caption the file is framed
161
+ * as an attachment to act on; without one it's treated as the user's message
162
+ * itself (the "long message became a .txt" case).
163
+ */
164
+ export function formatTextFilePrompt(name: string, content: string, caption: string, truncated: boolean): string {
165
+ const fence = pickFence(content);
166
+ const block = `${fence}\n${content}\n${fence}`;
167
+ const note = truncated ? "\n\n(Note: the file was long and has been truncated above.)" : "";
168
+ const cap = caption.trim();
169
+ if (cap) {
170
+ return `${cap}\n\nAttached file "${name}":\n${block}${note}`;
171
+ }
172
+ return `The user's message was sent as a file "${name}" (Telegram turns long messages into files). Its contents:\n${block}${note}`;
173
+ }
174
+
175
+ /** Build the prompt text for a binary document we can't inline. */
176
+ export function formatBinaryFilePrompt(
177
+ name: string,
178
+ mimeType: string | undefined,
179
+ size: number,
180
+ caption: string,
181
+ savedPath: string | undefined,
182
+ ): string {
183
+ const meta = `name "${name}", type ${mimeType || "unknown"}, ${formatBytes(size)}`;
184
+ const loc = savedPath
185
+ ? ` It has been saved to: ${savedPath} — open it with your file tools if that helps.`
186
+ : "";
187
+ const cap = caption.trim();
188
+ const head = cap ? `${cap}\n\n` : "";
189
+ return `${head}The user sent a binary file (${meta}) whose contents can't be shown as text.${loc}`;
190
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * /accounts — manage several Grok sign-ins and switch between them.
3
+ *
4
+ * Grok has one active sign-in (`~/.grok/auth.json`); this menu snapshots the
5
+ * current login as a named account, imports a login already on the machine, and
6
+ * swaps the active identity in one tap (copies the saved snapshot back and
7
+ * restarts the agent so sessions re-bind). Switching is serialised with the
8
+ * shared agent and refused while a prompt is in flight.
9
+ */
10
+ import { type Bot, type Context, InlineKeyboard } from "grammy";
11
+ import { AuthService } from "../../app/auth-service.js";
12
+ import type { StoredAccount } from "../../app/accounts.js";
13
+ import { UNSUPPORTED_LOGIN_HELP } from "../../app/grok-credentials.js";
14
+ import { createLogger } from "../../logger.js";
15
+ import type { BotDeps } from "../deps.js";
16
+
17
+ const log = createLogger("accounts");
18
+
19
+ function accountLine(a: StoredAccount, active: boolean): string {
20
+ const mark = active ? "\u2705 " : "\u{1F464} ";
21
+ return `${mark}${a.label}`;
22
+ }
23
+
24
+ async function view(deps: BotDeps, note?: string): Promise<{ text: string; keyboard: InlineKeyboard }> {
25
+ const list = deps.accounts.list();
26
+ const acct = await deps.usage.account().catch(() => undefined);
27
+ const active = deps.accounts.activeAccountId();
28
+ const loggedIn = await deps.usage.isLoggedIn().catch(() => false);
29
+
30
+ 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
+ lines.push("\u{1F534} Not signed in — sign in via /reauth.", "");
36
+ }
37
+ if (list.length === 0) {
38
+ lines.push("No saved accounts yet.", "", "Save the current login below, or sign in via /reauth.");
39
+ } else {
40
+ for (const a of list) lines.push(accountLine(a, a.id === active));
41
+ }
42
+ const rotate = deps.accounts.autoRotateEnabled();
43
+ lines.push(
44
+ "",
45
+ `\u{1F501} Auto-rotate on errors: ${rotate ? "ON" : "OFF"}`,
46
+ rotate ? " \u2514 If a turn gives up, it cycles through the other accounts once." : " \u2514 Turns stay on the active account.",
47
+ );
48
+ if (note) lines.push("", note);
49
+
50
+ const kb = new InlineKeyboard();
51
+ for (const a of list) {
52
+ const sw = a.id === active ? `\u2705 ${trim(a.label)} (active)` : `\u{1F504} ${trim(a.label)}`;
53
+ kb.text(sw, a.id === active ? "acct:noop" : `acct:switch:${a.id}`)
54
+ .text("\u270F\uFE0F", `acct:rename:${a.id}`)
55
+ .text("\u{1F5D1}", `acct:del:${a.id}`)
56
+ .row();
57
+ }
58
+ kb.text("\u{1F4BE} Save current login", "acct:save").text("\u270F\uFE0F Save as\u2026", "acct:saveas").row();
59
+ kb.text("\u{1F4E5} Import existing", "acct:import").text("\u{1F511} Sign in\u2026", "acct:login").row();
60
+ kb.text(`\u{1F501} Auto-rotate: ${rotate ? "ON" : "OFF"}`, "acct:rotate").row();
61
+ kb.text("\u2716 Close", "acct:close");
62
+ return { text: lines.join("\n"), keyboard: kb };
63
+ }
64
+
65
+ function trim(s: string, n = 22): string {
66
+ return s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
67
+ }
68
+
69
+ export async function showAccounts(ctx: Context, deps: BotDeps): Promise<void> {
70
+ const { text, keyboard } = await view(deps);
71
+ await deps.ephemeral.open(ctx);
72
+ await deps.ephemeral.reply(ctx, text, { reply_markup: keyboard });
73
+ }
74
+
75
+ async function rerender(ctx: Context, deps: BotDeps, note?: string): Promise<void> {
76
+ const { text, keyboard } = await view(deps, note);
77
+ await ctx.editMessageText(text, { reply_markup: keyboard }).catch(() => {});
78
+ }
79
+
80
+ function busyReason(deps: BotDeps): string | undefined {
81
+ if (deps.acp.hasInflightPrompt()) return "\u23F3 Grok is running a turn — try again when idle (or /cancel first).";
82
+ return undefined;
83
+ }
84
+
85
+ export function registerAccounts(bot: Bot, deps: BotDeps): void {
86
+ const auth = new AuthService(deps.cfg.grokCliPath);
87
+ const pending = new Map<number, { mode: "save" | "rename"; id?: string }>();
88
+
89
+ const promptName = async (ctx: Context, mode: "save" | "rename", id?: string): Promise<void> => {
90
+ const chatId = ctx.chat?.id;
91
+ if (chatId === undefined) return;
92
+ const ask =
93
+ mode === "save"
94
+ ? "\u270F\uFE0F Send a name for the current login (e.g. \u201CWork\u201D or \u201CPersonal\u201D)."
95
+ : "\u270F\uFE0F Send a new name for this account.";
96
+ await deps.ephemeral.reply(ctx, ask);
97
+ pending.set(chatId, { mode, id });
98
+ };
99
+
100
+ bot.on("message:text", async (ctx, next) => {
101
+ const chatId = ctx.chat.id;
102
+ const p = pending.get(chatId);
103
+ if (!p) return next();
104
+ const text = ctx.message.text;
105
+ if (text.startsWith("/")) return next();
106
+ pending.delete(chatId);
107
+ await ctx.deleteMessage().catch(() => {});
108
+ const name = text.trim().slice(0, 60);
109
+ let note: string;
110
+ try {
111
+ if (p.mode === "rename" && p.id) {
112
+ const meta = deps.accounts.rename(p.id, name);
113
+ note = meta ? `\u270F\uFE0F Renamed to ${meta.label}` : "That account is no longer saved.";
114
+ } else if (!(await deps.usage.isLoggedIn())) {
115
+ note = `\u274C ${UNSUPPORTED_LOGIN_HELP}`;
116
+ } else {
117
+ const saved = await deps.accounts.captureCurrent(undefined, name);
118
+ note = `\u{1F4BE} Saved: ${saved.label}`;
119
+ }
120
+ } catch (e) {
121
+ note = `\u274C ${(e as Error).message}`;
122
+ }
123
+ await deps.ephemeral.open(ctx);
124
+ const { text: t, keyboard } = await view(deps, note);
125
+ await deps.ephemeral.reply(ctx, t, { reply_markup: keyboard });
126
+ });
127
+
128
+ bot.command("accounts", (ctx) => showAccounts(ctx, deps));
129
+
130
+ bot.callbackQuery("acct:noop", (ctx) => ctx.answerCallbackQuery({ text: "Already active" }));
131
+
132
+ bot.callbackQuery("acct:saveas", async (ctx) => {
133
+ await ctx.answerCallbackQuery();
134
+ await promptName(ctx, "save");
135
+ });
136
+
137
+ bot.callbackQuery("acct:rotate", async (ctx) => {
138
+ const on = deps.accounts.setAutoRotate();
139
+ await ctx.answerCallbackQuery({ text: `Auto-rotate ${on ? "on" : "off"}` });
140
+ await rerender(ctx, deps, on ? "\u{1F501} Auto-rotate enabled." : "\u{1F501} Auto-rotate disabled.");
141
+ });
142
+
143
+ bot.callbackQuery(/^acct:rename:(.+)$/, async (ctx) => {
144
+ await ctx.answerCallbackQuery();
145
+ await promptName(ctx, "rename", ctx.match![1]!);
146
+ });
147
+
148
+ bot.callbackQuery("acct:close", async (ctx) => {
149
+ await ctx.answerCallbackQuery();
150
+ await deps.ephemeral.drop(ctx);
151
+ });
152
+
153
+ bot.callbackQuery("acct:save", async (ctx) => {
154
+ if (!(await deps.usage.isLoggedIn())) {
155
+ await ctx.answerCallbackQuery({ text: "Not signed in", show_alert: true });
156
+ return void rerender(ctx, deps, `\u274C ${UNSUPPORTED_LOGIN_HELP}`);
157
+ }
158
+ try {
159
+ const saved = await deps.accounts.captureCurrent();
160
+ await ctx.answerCallbackQuery({ text: `Saved ${saved.label}` });
161
+ await rerender(ctx, deps, `\u{1F4BE} Saved: ${saved.label}`);
162
+ } catch (e) {
163
+ await ctx.answerCallbackQuery({ text: (e as Error).message.slice(0, 190), show_alert: true });
164
+ }
165
+ });
166
+
167
+ bot.callbackQuery("acct:login", async (ctx) => {
168
+ await ctx.answerCallbackQuery();
169
+ await rerender(ctx, deps, "\u{1F511} Run /reauth to sign in, then tap \u201CSave current login\u201D.");
170
+ });
171
+
172
+ bot.callbackQuery("acct:import", async (ctx) => {
173
+ const reason = busyReason(deps);
174
+ if (reason) return void ctx.answerCallbackQuery({ text: reason, show_alert: true });
175
+ await ctx.answerCallbackQuery({ text: "Importing…" });
176
+ const res = await auth.importExisting();
177
+ if (!res.ok) return void rerender(ctx, deps, `\u274C ${res.error ?? "Import failed."}`);
178
+ try {
179
+ await deps.acp.restart();
180
+ } catch (e) {
181
+ return void rerender(ctx, deps, `\u26A0\uFE0F Imported, but re-bind failed: ${(e as Error).message}`);
182
+ }
183
+ let note = `\u2705 Imported the current login${res.label ? ` (${res.label})` : ""}.`;
184
+ try {
185
+ const saved = await deps.accounts.captureCurrent();
186
+ note = `\u2705 Imported & saved ${saved.label}.`;
187
+ } catch {
188
+ /* best-effort */
189
+ }
190
+ await rerender(ctx, deps, note);
191
+ });
192
+
193
+ bot.callbackQuery(/^acct:switch:(.+)$/, async (ctx) => {
194
+ const id = ctx.match![1]!;
195
+ const reason = busyReason(deps);
196
+ if (reason) return void ctx.answerCallbackQuery({ text: reason, show_alert: true });
197
+ await ctx.answerCallbackQuery({ text: "Switching…" });
198
+ try {
199
+ await deps.accounts.captureCurrent().catch(() => {}); // don't lose the current login
200
+ const meta = await deps.accounts.switchTo(id);
201
+ await ctx.editMessageText(`\u{1F504} Switching to ${meta.label}\u2026 restarting agent`).catch(() => {});
202
+ await deps.acp.restart();
203
+ const note = (await deps.usage.isLoggedIn())
204
+ ? `\u2705 Now signed in as ${meta.label}. Your next message runs on this account.`
205
+ : `\u26A0\uFE0F Switched to ${meta.label}, but no usable login is active. ${UNSUPPORTED_LOGIN_HELP}`;
206
+ await rerender(ctx, deps, note);
207
+ } catch (e) {
208
+ log.warn("account switch failed:", (e as Error).message);
209
+ await rerender(ctx, deps, `\u274C ${(e as Error).message}`);
210
+ }
211
+ });
212
+
213
+ bot.callbackQuery(/^acct:del:(.+)$/, async (ctx) => {
214
+ const id = ctx.match![1]!;
215
+ const meta = deps.accounts.get(id);
216
+ await deps.accounts.forget(id);
217
+ await ctx.answerCallbackQuery({ text: meta ? `Removed ${meta.label}` : "Removed" });
218
+ await rerender(ctx, deps);
219
+ });
220
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * /reauth — sign in to Grok from chat. Shows Sign in (runs `grok login`,
3
+ * streaming any URL/code) or Import existing, then re-binds the agent. Guarded:
4
+ * refused while a turn is in flight.
5
+ */
6
+ import type { Bot } from "grammy";
7
+ import type { BotDeps } from "../deps.js";
8
+ import { ReauthController } from "../reauth-controller.js";
9
+
10
+ export function registerReauth(bot: Bot, deps: BotDeps): void {
11
+ const controller = new ReauthController(
12
+ deps.api,
13
+ deps.acp,
14
+ deps.cfg.grokCliPath,
15
+ () => deps.usage.account(),
16
+ () => deps.usage.isLoggedIn(),
17
+ );
18
+
19
+ bot.command("reauth", async (ctx) => {
20
+ if (controller.isBusy(ctx.chat.id)) {
21
+ await ctx.reply("\u{1F510} A sign-in is already in progress.");
22
+ return;
23
+ }
24
+ if (deps.acp.hasInflightPrompt()) {
25
+ await ctx.reply("\u23F3 Grok is busy running a turn — try /reauth when idle (or /cancel first).");
26
+ return;
27
+ }
28
+ await controller.chooseMethod(ctx.chat.id);
29
+ });
30
+
31
+ bot.callbackQuery("reauth:login", async (ctx) => {
32
+ await ctx.answerCallbackQuery({ text: "Signing in…" });
33
+ const chatId = ctx.chat?.id;
34
+ const messageId = ctx.callbackQuery.message?.message_id;
35
+ if (chatId !== undefined && messageId !== undefined) await controller.beginLogin(chatId, messageId);
36
+ });
37
+
38
+ bot.callbackQuery("reauth:import", async (ctx) => {
39
+ await ctx.answerCallbackQuery({ text: "Importing…" });
40
+ const chatId = ctx.chat?.id;
41
+ const messageId = ctx.callbackQuery.message?.message_id;
42
+ if (chatId !== undefined && messageId !== undefined) await controller.importExisting(chatId, messageId);
43
+ });
44
+
45
+ bot.callbackQuery("reauth:choose-cancel", async (ctx) => {
46
+ await ctx.answerCallbackQuery({ text: "Cancelled" });
47
+ const chatId = ctx.chat?.id;
48
+ const messageId = ctx.callbackQuery.message?.message_id;
49
+ if (chatId !== undefined && messageId !== undefined) await controller.cancelChoice(chatId, messageId);
50
+ });
51
+
52
+ bot.callbackQuery("reauth:cancel", async (ctx) => {
53
+ const chatId = ctx.chat?.id;
54
+ const ok = chatId !== undefined && controller.cancel(chatId);
55
+ await ctx.answerCallbackQuery({ text: ok ? "Cancelling…" : "Nothing to cancel" });
56
+ });
57
+
58
+ bot.callbackQuery("reauth:retry", async (ctx) => {
59
+ await ctx.answerCallbackQuery({ text: "Retry" });
60
+ const chatId = ctx.chat?.id;
61
+ const messageId = ctx.callbackQuery.message?.message_id;
62
+ if (chatId !== undefined && messageId !== undefined) await controller.retry(chatId, messageId);
63
+ });
64
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Control commands: /start /help /status /new /cancel /btw /flush.
3
+ */
4
+ import type { Bot } from "grammy";
5
+ import { basename } from "node:path";
6
+ import { textPrompt } from "../../app/types.js";
7
+ import type { BotDeps } from "../deps.js";
8
+ import { HELP_TEXT } from "../commands.js";
9
+ import { compactKeyboard } from "../menu/keyboard.js";
10
+ import { refreshMenu } from "../menu/refresh.js";
11
+ import { extractReplyContext } from "../reply-context.js";
12
+ import { openMainMenu } from "./menu.js";
13
+
14
+ export function registerControl(bot: Bot, deps: BotDeps): void {
15
+ bot.command("start", async (ctx) => {
16
+ const rt = deps.registry.get(ctx.chat.id);
17
+ const agent = deps.acp.agentInfo;
18
+ const lines = [
19
+ "\u{1F44B} Welcome! I bridge Telegram to Grok Build over ACP.",
20
+ agent?.name ? `Connected to ${agent.name} ${agent.version ?? ""}`.trim() : "",
21
+ "",
22
+ "Tap \u2630 Menu for everything. A live status panel appears while I work",
23
+ "(\u2630 Menu \u2192 Status shows it anytime). Just send a message to start.",
24
+ ].filter(Boolean);
25
+ await ctx.reply(lines.join("\n"), { reply_markup: compactKeyboard() });
26
+ await deps.statusPanel.refresh(ctx.chat.id);
27
+ });
28
+
29
+ bot.command("menu", async (ctx) => {
30
+ await openMainMenu(ctx, deps);
31
+ await deps.statusPanel.refresh(ctx.chat.id);
32
+ });
33
+
34
+ bot.command("help", async (ctx) => {
35
+ await ctx.reply(HELP_TEXT);
36
+ });
37
+
38
+ bot.command("status", async (ctx) => {
39
+ const rt = deps.registry.get(ctx.chat.id);
40
+ const lines = [
41
+ "\u{1F4CA} Status",
42
+ `Project: ${rt.projectName ?? (basename(rt.cwd) || rt.cwd)}`,
43
+ `Folder: ${rt.cwd}`,
44
+ `Session: ${rt.sessionId ?? "(none yet)"}`,
45
+ `State: ${rt.isBusy ? "\u23F3 working" : "\u2705 idle"}`,
46
+ `Queued follow-ups: ${rt.queueLength}`,
47
+ ];
48
+ const subagents = deps.registry.subagentSummaryForChat(ctx.chat.id);
49
+ if (subagents) lines.push(`Subagents: ${subagents}`);
50
+ await ctx.reply(lines.join("\n"));
51
+ });
52
+
53
+ bot.command("new", async (ctx) => {
54
+ const rt = deps.registry.get(ctx.chat.id);
55
+ try {
56
+ await deps.registry.controller(ctx.chat.id).addNew(rt.cwd, rt.projectName);
57
+ await refreshMenu(ctx, deps, `\u2728 New session started in ${rt.projectName ?? rt.cwd}`);
58
+ } catch (err) {
59
+ await ctx.reply(`\u274C Could not start session: ${(err as Error).message}`);
60
+ }
61
+ });
62
+
63
+ bot.command("cancel", async (ctx) => {
64
+ const rt = deps.registry.get(ctx.chat.id);
65
+ const cancelled = await rt.cancel();
66
+ await ctx.reply(cancelled ? "\u23F9 Cancelling current turn\u2026" : "Nothing is running.");
67
+ });
68
+
69
+ bot.command("btw", async (ctx) => {
70
+ const text = (ctx.match || "").toString().trim();
71
+ if (!text) {
72
+ await ctx.reply("Usage: /btw <something for the agent to do — now if idle, otherwise next>");
73
+ return;
74
+ }
75
+ const rt = deps.registry.get(ctx.chat.id);
76
+ // Run it right away when idle; otherwise queue it to run automatically the
77
+ // moment the current turn finishes (can't interrupt an in-flight agent turn).
78
+ const outcome = await rt.submit(textPrompt(text, undefined, extractReplyContext(ctx)));
79
+ if (outcome === "queued") {
80
+ await ctx.reply(
81
+ `\u{1F4E5} Queued (position ${rt.queueLength}) \u2014 it'll run automatically as soon as the current task finishes.`,
82
+ );
83
+ } else {
84
+ await ctx.reply("\u25B6\uFE0F On it\u2026");
85
+ }
86
+ });
87
+
88
+ bot.command("flush", async (ctx) => {
89
+ const rt = deps.registry.get(ctx.chat.id);
90
+ if (rt.queueLength === 0) {
91
+ await ctx.reply("Queue is empty.");
92
+ return;
93
+ }
94
+ if (rt.isBusy) {
95
+ await ctx.reply(`\u23F3 ${rt.queueLength} queued \u2014 they'll run automatically when the current turn ends.`);
96
+ return;
97
+ }
98
+ // Idle: drain the queue by submitting an empty trigger that flushes.
99
+ await ctx.reply("\u25B6\uFE0F Running queued follow-ups\u2026");
100
+ const drained = rt.drainQueueToPrompt();
101
+ if (drained) await rt.submit(drained);
102
+ });
103
+ }