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
package/src/bot/bot.ts ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Assemble the grammY bot: dependencies, middleware, handlers, persistent menu,
3
+ * status panel, and the task scheduler. Handler registration order matters:
4
+ * auth -> menu buttons -> wizard input -> commands -> photos -> text prompt.
5
+ */
6
+ import { Bot } from "grammy";
7
+ import type { GrokClient } from "../grok/client.js";
8
+ import { AccountManager } from "../app/accounts.js";
9
+ import { AccountRotatorImpl } from "./account-rotator.js";
10
+ import { SettingsStore } from "../app/settings-store.js";
11
+ import { SttService } from "../app/stt.js";
12
+ import { Updater } from "../app/updater.js";
13
+ import { UsageService } from "../app/usage.js";
14
+ import type { AppConfig } from "../config.js";
15
+ import { INSTANCE_DIR } from "../config.js";
16
+ import { createLogger } from "../logger.js";
17
+ import { ProjectManager } from "../projects/manager.js";
18
+ import { SessionStore } from "../sessions/store.js";
19
+ import { TaskRunner } from "../tasks/runner.js";
20
+ import { Scheduler } from "../tasks/scheduler.js";
21
+ import { TaskStore } from "../tasks/store.js";
22
+ import { createAuthMiddleware } from "./auth.js";
23
+ import { COMMANDS } from "./commands.js";
24
+ import { type BotDeps, MenuCache } from "./deps.js";
25
+ import { registerControl } from "./handlers/control.js";
26
+ import { registerDocuments } from "./handlers/document.js";
27
+ import { registerHistory } from "./handlers/history.js";
28
+ import { registerKill } from "./handlers/kill.js";
29
+ import { registerMcp } from "./handlers/mcp.js";
30
+ import { registerMenu } from "./handlers/menu.js";
31
+ import { registerMessages } from "./handlers/message.js";
32
+ import { registerPhotos } from "./handlers/photo.js";
33
+ import { registerProjects } from "./handlers/projects.js";
34
+ import { registerRunning, switchAndShow } from "./handlers/running.js";
35
+ import { registerSessions } from "./handlers/sessions.js";
36
+ import { registerSessionKill } from "./handlers/session-kill.js";
37
+ import { registerAccounts } from "./handlers/accounts.js";
38
+ import { registerReauth } from "./handlers/auth.js";
39
+ import { registerSystem } from "./handlers/system.js";
40
+ import { registerTasks, registerWizardInput } from "./handlers/tasks.js";
41
+ import { registerUsage } from "./handlers/usage.js";
42
+ import { registerVoice } from "./handlers/voice.js";
43
+ import { StatusPanel } from "./menu/status-panel.js";
44
+ import { sendMarkdownDoc } from "./telegram-io.js";
45
+ import { Ephemeral } from "./menu/ephemeral.js";
46
+ import { BAR_LABELS } from "./menu/keyboard.js";
47
+ import { PermissionService } from "./permission-service.js";
48
+ import { RuntimeRegistry } from "./registry.js";
49
+ import { TaskWizard } from "./wizard/task-wizard.js";
50
+
51
+ const log = createLogger("bot");
52
+
53
+ /** Telegram methods that support disable_notification (silenced in quiet mode). */
54
+ const SILENCEABLE = new Set([
55
+ "sendMessage",
56
+ "sendPhoto",
57
+ "sendDocument",
58
+ "sendAudio",
59
+ "sendVoice",
60
+ "sendVideo",
61
+ "sendAnimation",
62
+ "sendMediaGroup",
63
+ "copyMessage",
64
+ "forwardMessage",
65
+ ]);
66
+
67
+ export interface BotBundle {
68
+ bot: Bot;
69
+ registry: RuntimeRegistry;
70
+ scheduler: Scheduler;
71
+ updater: Updater;
72
+ }
73
+
74
+ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBundle> {
75
+ const bot = new Bot(cfg.token);
76
+
77
+ // Quiet mode (default): silence every outgoing message unless the caller
78
+ // explicitly set disable_notification:false (turn completion, permission
79
+ // prompts, task results). Edits never notify, so they're unaffected.
80
+ if (cfg.quietNotifications) {
81
+ bot.api.config.use(async (prev, method, payload, signal) => {
82
+ if (SILENCEABLE.has(method)) {
83
+ const p = payload as { disable_notification?: boolean };
84
+ if (p.disable_notification === undefined) p.disable_notification = true;
85
+ }
86
+ return prev(method, payload, signal);
87
+ });
88
+ }
89
+
90
+ const settings = new SettingsStore(cfg.dataDir);
91
+ const store = new SessionStore(cfg.sessionsDir);
92
+ const registry = new RuntimeRegistry(bot.api, acp, cfg, settings, store);
93
+ const tasks = new TaskStore(cfg.dataDir);
94
+ const taskRunner = new TaskRunner(bot.api, acp);
95
+ const wizard = new TaskWizard(tasks);
96
+ const statusPanel = new StatusPanel(bot.api, settings, registry);
97
+ registry.setRefresher((chatId) => void statusPanel.refresh(chatId));
98
+
99
+ const deps: BotDeps = {
100
+ api: bot.api,
101
+ cfg,
102
+ acp,
103
+ registry,
104
+ store,
105
+ projects: new ProjectManager(cfg.projectRoots),
106
+ menuCache: new MenuCache(),
107
+ settings,
108
+ statusPanel,
109
+ ephemeral: new Ephemeral(bot.api, cfg.dataDir),
110
+ tasks,
111
+ taskRunner,
112
+ wizard,
113
+ stt: new SttService({
114
+ apiUrl: cfg.sttApiUrl,
115
+ apiKey: cfg.sttApiKey,
116
+ model: cfg.sttModel,
117
+ language: cfg.sttLanguage,
118
+ }),
119
+ usage: new UsageService(cfg.grokCliPath),
120
+ accounts: new AccountManager(cfg.dataDir),
121
+ };
122
+
123
+ // Auto-rotate-on-give-up: let a stuck turn cycle through other saved logins.
124
+ registry.setAccountRotator(new AccountRotatorImpl(deps.accounts, acp));
125
+
126
+ // Inline approvals: when NOT in trust-all mode, Grok asks before risky tools.
127
+ const permissions = new PermissionService(bot.api, registry);
128
+ acp.permissionHandler = (p) => permissions.handle(p);
129
+
130
+ // The bot pins/unpins the status panel, and Telegram emits a "pinned a
131
+ // message" service message for each pin. Delete those so the chat stays clean
132
+ // — registered BEFORE auth so these bot-authored updates never reach the gate.
133
+ bot.on("message:pinned_message", (ctx) => void ctx.deleteMessage().catch(() => {}));
134
+
135
+ bot.use(createAuthMiddleware(cfg));
136
+
137
+ // Keep history clean: after handling, delete the user's command (/…) and
138
+ // persistent-bar button taps. Plain prompts and wizard input are kept.
139
+ bot.on("message:text", async (ctx, next) => {
140
+ await next();
141
+ const text = ctx.message?.text ?? "";
142
+ if (text.startsWith("/") || BAR_LABELS.includes(text)) {
143
+ await ctx.deleteMessage().catch(() => {});
144
+ }
145
+ });
146
+
147
+ bot.callbackQuery(/^perm:(\d+):(\d+)$/, async (ctx) => {
148
+ const label = permissions.resolveChoice(ctx.match![1]!, Number(ctx.match![2]));
149
+ await ctx.answerCallbackQuery({ text: label ?? "Expired" });
150
+ await ctx.editMessageText(label ? `\u{1F510} ${label}` : "\u{1F510} (expired)").catch(() => {});
151
+ });
152
+
153
+ bot.callbackQuery(/^permsw:(\d+)$/, async (ctx) => {
154
+ await ctx.answerCallbackQuery();
155
+ const sid = permissions.sessionFor(ctx.match![1]!);
156
+ if (sid) await switchAndShow(ctx, deps, sid);
157
+ });
158
+
159
+ registerMenu(bot, deps); // persistent-keyboard buttons (hears)
160
+ registerWizardInput(bot, deps); // wizard text input (before commands)
161
+ registerControl(bot, deps);
162
+ registerProjects(bot, deps);
163
+ registerSessions(bot, deps);
164
+ registerSessionKill(bot, deps);
165
+ registerRunning(bot, deps);
166
+ registerHistory(bot, deps);
167
+ registerSystem(bot, deps);
168
+ registerReauth(bot, deps);
169
+ registerAccounts(bot, deps);
170
+ registerUsage(bot, deps);
171
+ registerKill(bot, deps);
172
+ registerMcp(bot, deps);
173
+ registerTasks(bot, deps);
174
+ registerPhotos(bot, deps); // photos & image documents
175
+ registerDocuments(bot, deps); // non-image files (text inlined, binaries saved)
176
+ registerVoice(bot, deps); // voice / audio -> transcription -> prompt
177
+ registerMessages(bot, deps); // catch-all text prompt — keep last
178
+
179
+ bot.catch((err) => {
180
+ log.error("unhandled bot error:", err.error instanceof Error ? err.error.message : err.error);
181
+ });
182
+
183
+ try {
184
+ await bot.api.setMyCommands(COMMANDS);
185
+ } catch (e) {
186
+ log.warn("setMyCommands failed:", (e as Error).message);
187
+ }
188
+
189
+ const updater = new Updater({
190
+ enabled: cfg.autoUpdate,
191
+ intervalMs: cfg.updateCheckMs,
192
+ projectRoot: cfg.projectRoot,
193
+ instanceDir: INSTANCE_DIR,
194
+ dataDir: cfg.dataDir,
195
+ isPromptInFlight: () => acp.hasInflightPrompt(),
196
+ otherActiveSessions: () => store.listActive().filter((s) => s.lockPid !== acp.pid).length,
197
+ announce: async (text, markdown) => {
198
+ for (const id of settings.chatIds()) {
199
+ try {
200
+ if (markdown) await sendMarkdownDoc(bot.api, id, text, { loud: true });
201
+ else await bot.api.sendMessage(id, text, { disable_notification: false });
202
+ } catch {
203
+ /* per-chat best-effort */
204
+ }
205
+ }
206
+ },
207
+ shutdown: async () => {
208
+ try {
209
+ await bot.stop();
210
+ } catch {
211
+ /* ignore */
212
+ }
213
+ try {
214
+ acp.stop();
215
+ } catch {
216
+ /* ignore */
217
+ }
218
+ },
219
+ });
220
+
221
+ // Remove any navigation surface left over from before a restart.
222
+ void deps.ephemeral.cleanupAll().catch(() => {});
223
+
224
+ return { bot, registry, scheduler: new Scheduler(tasks, taskRunner), updater };
225
+ }
@@ -0,0 +1,317 @@
1
+ /**
2
+ * ChatController — manages the set of Grok sessions a single Telegram chat is
3
+ * controlling, with exactly one "foreground" session streaming live. Other
4
+ * (background) sessions keep running quietly; their output lands in the
5
+ * session's .jsonl and is replayed as "unread" when you switch to them.
6
+ */
7
+ import { basename } from "node:path";
8
+ import type { Api } from "grammy";
9
+ import type { GrokClient } from "../grok/client.js";
10
+ import type { SettingsStore } from "../app/settings-store.js";
11
+ import type { AppConfig } from "../config.js";
12
+ import { jsonlSize, readEntriesFrom, readHistory } from "../sessions/history.js";
13
+ import type { SessionStore } from "../sessions/store.js";
14
+ import type { HistoryEntry } from "../sessions/types.js";
15
+ import type { AccountRotator } from "./account-rotator.js";
16
+ import { SessionRuntime } from "./session-runtime.js";
17
+
18
+ export interface RunningSession {
19
+ sessionId?: string;
20
+ projectName: string;
21
+ busy: boolean;
22
+ foreground: boolean;
23
+ unread: number;
24
+ /** Latest task-completion % (0–100) for this session, if known. */
25
+ progress?: number;
26
+ }
27
+
28
+ export interface SwitchResult {
29
+ rt: SessionRuntime;
30
+ sessionId?: string;
31
+ projectName?: string;
32
+ busy: boolean;
33
+ unread: HistoryEntry[];
34
+ firstView: boolean;
35
+ alreadyForeground: boolean;
36
+ }
37
+
38
+ export class ChatController {
39
+ private readonly runtimes: SessionRuntime[] = [];
40
+ private fg: SessionRuntime | undefined;
41
+ private readonly lastRead = new Map<string, number>();
42
+ private restored = false;
43
+
44
+ constructor(
45
+ private readonly api: Api,
46
+ private readonly chatId: number,
47
+ private readonly acp: GrokClient,
48
+ private readonly cfg: AppConfig,
49
+ private readonly settings: SettingsStore,
50
+ private readonly store: SessionStore,
51
+ private readonly refresh: (chatId: number) => void,
52
+ private readonly notifyActivity: (busy: boolean) => void,
53
+ private readonly getRotator?: () => AccountRotator | undefined,
54
+ ) {}
55
+
56
+ /** The current foreground runtime (created/restored lazily). */
57
+ foreground(): SessionRuntime {
58
+ this.ensureRestored();
59
+ if (!this.fg) {
60
+ const s = this.settings.get(this.chatId);
61
+ const rt = this.create({ cwd: s.projectPath ?? this.cfg.workspace, projectName: s.projectName, sessionId: s.sessionId });
62
+ this.runtimes.push(rt);
63
+ this.fg = rt;
64
+ }
65
+ return this.fg;
66
+ }
67
+
68
+ /** List the controlled sessions (for /running). */
69
+ list(): RunningSession[] {
70
+ this.ensureRestored();
71
+ this.pruneDuplicates();
72
+ return this.runtimes.map((rt) => ({
73
+ sessionId: rt.sessionId,
74
+ projectName: rt.projectName ?? basename(rt.cwd),
75
+ busy: rt.isBusy,
76
+ foreground: rt.isForeground,
77
+ unread: this.unreadCount(rt),
78
+ progress: rt.taskProgress,
79
+ }));
80
+ }
81
+
82
+ /** Start a brand-new session and bring it to the foreground. */
83
+ async addNew(cwd: string, projectName?: string): Promise<SessionRuntime> {
84
+ this.ensureRestored();
85
+ const prevFg = this.fg;
86
+ const rt = this.create({ cwd, projectName });
87
+ this.runtimes.push(rt);
88
+ this.fg = rt;
89
+ await this.background(prevFg);
90
+ await rt.startNewSession(cwd, projectName);
91
+ this.markSeen(rt);
92
+ this.persist();
93
+ return rt;
94
+ }
95
+
96
+ /**
97
+ * Connect to a session with resume-or-fork semantics (used by /sessions),
98
+ * adding it as a controlled session and bringing it to the foreground.
99
+ */
100
+ async addAttach(
101
+ sessionId: string,
102
+ cwd: string,
103
+ projectName: string | undefined,
104
+ priorEntries: HistoryEntry[],
105
+ ): Promise<{ rt: SessionRuntime; result: "resumed" | "forked"; alreadyControlled: boolean }> {
106
+ this.ensureRestored();
107
+ if (this.runtimes.some((r) => r.sessionId === sessionId)) {
108
+ const sw = await this.switchTo(sessionId);
109
+ return { rt: sw!.rt, result: "resumed", alreadyControlled: true };
110
+ }
111
+ // Reserve the runtime synchronously (before any await) so a concurrent tap
112
+ // on the same session finds it and switches instead of creating a duplicate.
113
+ const prevFg = this.fg;
114
+ const rt = this.create({ cwd, projectName, sessionId });
115
+ this.runtimes.push(rt);
116
+ this.fg = rt;
117
+ await this.background(prevFg);
118
+ const result = await rt.attach(sessionId, cwd, projectName, priorEntries);
119
+ this.markSeen(rt);
120
+ this.persist();
121
+ return { rt, result, alreadyControlled: false };
122
+ }
123
+
124
+ /** Connect to an existing session: switch if already controlled, else add it. */
125
+ async addResume(sessionId: string, cwd: string, projectName?: string): Promise<SwitchResult> {
126
+ this.ensureRestored();
127
+ if (this.runtimes.some((r) => r.sessionId === sessionId)) {
128
+ return (await this.switchTo(sessionId))!;
129
+ }
130
+ const prevFg = this.fg;
131
+ const rt = this.create({ cwd, projectName, sessionId });
132
+ this.runtimes.push(rt);
133
+ this.fg = rt;
134
+ await this.background(prevFg);
135
+ await rt.prepare().catch(() => {});
136
+ const path = this.store.jsonlPath(sessionId);
137
+ const unread = readHistory(path, 12);
138
+ this.lastRead.set(sessionId, jsonlSize(path));
139
+ this.persist();
140
+ return { rt, sessionId, projectName, busy: rt.isBusy, unread, firstView: true, alreadyForeground: false };
141
+ }
142
+
143
+ /** Switch the foreground to an already-controlled session. */
144
+ async switchTo(sessionId: string): Promise<SwitchResult | undefined> {
145
+ this.ensureRestored();
146
+ const rt = this.runtimes.find((r) => r.sessionId === sessionId);
147
+ if (!rt) return undefined;
148
+ if (rt === this.fg) {
149
+ return { rt, sessionId, projectName: rt.projectName, busy: rt.isBusy, unread: [], firstView: false, alreadyForeground: true };
150
+ }
151
+ await this.background(this.fg);
152
+ this.fg = rt;
153
+ await rt.setForeground(true);
154
+ await rt.prepare().catch(() => {});
155
+
156
+ const path = this.store.jsonlPath(sessionId);
157
+ const seen = this.lastRead.get(sessionId);
158
+ let unread: HistoryEntry[] = [];
159
+ let firstView = false;
160
+ if (seen !== undefined) {
161
+ unread = readEntriesFrom(path, seen).entries;
162
+ } else {
163
+ unread = readHistory(path, 12);
164
+ firstView = true;
165
+ }
166
+ this.lastRead.set(sessionId, jsonlSize(path));
167
+ // No tail-watch here: setForeground(true) above already resumed RICH live
168
+ // streaming for the in-flight turn via the agent's own session/update
169
+ // events. Tailing the .jsonl too would double-render every update.
170
+ this.persist();
171
+ return { rt, sessionId, projectName: rt.projectName, busy: rt.isBusy, unread, firstView, alreadyForeground: false };
172
+ }
173
+
174
+ /** Stop controlling a session (does not kill it). */
175
+ async close(sessionId: string): Promise<boolean> {
176
+ this.ensureRestored();
177
+ const idx = this.runtimes.findIndex((r) => r.sessionId === sessionId);
178
+ if (idx === -1) return false;
179
+ const rt = this.runtimes[idx]!;
180
+ rt.dispose();
181
+ this.runtimes.splice(idx, 1);
182
+ this.lastRead.delete(sessionId);
183
+ if (this.fg === rt) {
184
+ this.fg = this.runtimes[0];
185
+ if (this.fg) await this.fg.setForeground(true);
186
+ }
187
+ this.persist();
188
+ return true;
189
+ }
190
+
191
+ count(): number {
192
+ this.ensureRestored();
193
+ return this.runtimes.length;
194
+ }
195
+
196
+ /** Latest task-progress % for a controlled session id, if this chat runs it. */
197
+ progressFor(sessionId?: string): number | undefined {
198
+ if (!sessionId) return undefined;
199
+ this.ensureRestored();
200
+ return this.runtimes.find((r) => r.sessionId === sessionId)?.taskProgress;
201
+ }
202
+
203
+ findBySession(sessionId: string): boolean {
204
+ return this.runtimes.some((r) => r.sessionId === sessionId);
205
+ }
206
+
207
+ dispose(): void {
208
+ for (const rt of this.runtimes) rt.dispose();
209
+ this.runtimes.length = 0;
210
+ this.fg = undefined;
211
+ }
212
+
213
+ // ── internals ──────────────────────────────────────────────────────────────
214
+
215
+ private ensureRestored(): void {
216
+ if (this.restored) return;
217
+ this.restored = true;
218
+ const s = this.settings.get(this.chatId);
219
+ const seen = new Set<string>();
220
+ for (const cs of s.controlledSessions ?? []) {
221
+ if (!cs.sessionId || seen.has(cs.sessionId)) continue; // never restore the same session twice
222
+ seen.add(cs.sessionId);
223
+ this.runtimes.push(this.create({ cwd: cs.projectPath, projectName: cs.projectName, sessionId: cs.sessionId }));
224
+ }
225
+ if (this.runtimes.length > 0) {
226
+ const fg = this.runtimes.find((r) => r.sessionId === s.foregroundSessionId) ?? this.runtimes[0]!;
227
+ for (const r of this.runtimes) void r.setForeground(r === fg);
228
+ this.fg = fg;
229
+ }
230
+ }
231
+
232
+ /** Drop any runtime that duplicates another's sessionId (keeping the
233
+ * foreground one), healing a state where two runtimes wrap one session. */
234
+ private pruneDuplicates(): void {
235
+ const byId = new Map<string, SessionRuntime>();
236
+ const kept: SessionRuntime[] = [];
237
+ for (const rt of this.runtimes) {
238
+ const id = rt.sessionId;
239
+ if (!id) {
240
+ kept.push(rt);
241
+ continue;
242
+ }
243
+ const prev = byId.get(id);
244
+ if (!prev) {
245
+ byId.set(id, rt);
246
+ kept.push(rt);
247
+ continue;
248
+ }
249
+ const loser = prev.isForeground || !rt.isForeground ? rt : prev;
250
+ const winner = loser === rt ? prev : rt;
251
+ if (winner !== prev) {
252
+ byId.set(id, winner);
253
+ const i = kept.indexOf(prev);
254
+ if (i !== -1) kept[i] = winner;
255
+ }
256
+ if (this.fg === loser) this.fg = winner;
257
+ loser.dispose();
258
+ }
259
+ if (kept.length !== this.runtimes.length) {
260
+ this.runtimes.length = 0;
261
+ this.runtimes.push(...kept);
262
+ this.persist();
263
+ }
264
+ }
265
+
266
+ private create(init: { cwd: string; projectName?: string; sessionId?: string }): SessionRuntime {
267
+ const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, init);
268
+ rt.onStateChange = () => this.refresh(this.chatId);
269
+ rt.onActivity = (busy) => this.notifyActivity(busy);
270
+ rt.accountRotator = this.getRotator?.();
271
+ // A logical fork (auto-fork-on-error / lost-session recovery) swaps the
272
+ // runtime's session id in place — re-persist the controlled list with the
273
+ // new id and treat the fresh session as already-seen.
274
+ rt.onSessionChange = () => {
275
+ this.markSeen(rt);
276
+ this.persist();
277
+ };
278
+ return rt;
279
+ }
280
+
281
+ private async background(rt: SessionRuntime | undefined): Promise<void> {
282
+ if (!rt) return;
283
+ this.markSeen(rt);
284
+ await rt.setForeground(false);
285
+ }
286
+
287
+ private markSeen(rt: SessionRuntime): void {
288
+ if (rt.sessionId) this.lastRead.set(rt.sessionId, jsonlSize(this.store.jsonlPath(rt.sessionId)));
289
+ }
290
+
291
+ private unreadCount(rt: SessionRuntime): number {
292
+ if (!rt.sessionId || rt.isForeground) return 0;
293
+ const seen = this.lastRead.get(rt.sessionId);
294
+ if (seen === undefined) return 0;
295
+ return readEntriesFrom(this.store.jsonlPath(rt.sessionId), seen).entries.length;
296
+ }
297
+
298
+ private persist(): void {
299
+ const seen = new Set<string>();
300
+ const controlled: { sessionId?: string; projectPath: string; projectName?: string }[] = [];
301
+ for (const r of this.runtimes) {
302
+ if (!r.sessionId || seen.has(r.sessionId)) continue;
303
+ seen.add(r.sessionId);
304
+ controlled.push({ sessionId: r.sessionId, projectPath: r.cwd, projectName: r.projectName });
305
+ }
306
+ this.settings.update(this.chatId, {
307
+ controlledSessions: controlled,
308
+ foregroundSessionId: this.fg?.sessionId,
309
+ // Keep the single-session restore fields aligned with the foreground so
310
+ // the pinned status panel and a fresh restore never show a project that
311
+ // belongs to a different (previously-foreground) session.
312
+ sessionId: this.fg?.sessionId,
313
+ projectPath: this.fg?.cwd,
314
+ projectName: this.fg?.projectName,
315
+ });
316
+ }
317
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Bot command definitions (for the Telegram command menu) and help text.
3
+ */
4
+ export const COMMANDS: { command: string; description: string }[] = [
5
+ { command: "start", description: "Welcome, menu & status panel" },
6
+ { command: "menu", description: "Show the menu keyboard" },
7
+ { command: "projects", description: "Projects: list / search <q> / open <path> / new <name>" },
8
+ { command: "sessions", description: "List/resume sessions (active first) \u00b7 /sessions <q>" },
9
+ { command: "active", description: "Sessions running now on the PC" },
10
+ { command: "running", description: "Sessions this chat controls \u2014 switch between them" },
11
+ { command: "killall", description: "Kill all active sessions on the PC" },
12
+ { command: "mcp", description: "Inspect & toggle MCP servers \u00b7 health-check" },
13
+ { command: "tasks", description: "Manage scheduled tasks" },
14
+ { command: "newtask", description: "Create a scheduled task" },
15
+ { command: "history", description: "Show recent conversation history" },
16
+ { command: "new", description: "Start a fresh session here" },
17
+ { command: "status", description: "Current session, project & queue" },
18
+ { command: "usage", description: "Account & context usage" },
19
+ { command: "btw", description: "Run ASAP (now if idle, else next): /btw <text>" },
20
+ { command: "flush", description: "Send queued follow-ups now" },
21
+ { command: "queue", description: "Show queued follow-ups" },
22
+ { command: "cancel", description: "Stop the current turn" },
23
+ { command: "unwatch", description: "Stop following a live session" },
24
+ { command: "model", description: "Switch model: /model <id>" },
25
+ { command: "restart", description: "Restart the Grok agent" },
26
+ { command: "reauth", description: "Sign in to Grok (login or import)" },
27
+ { command: "accounts", description: "Switch between saved Grok accounts" },
28
+ { command: "help", description: "Show help" },
29
+ ];
30
+
31
+ export const HELP_TEXT = [
32
+ "\u{1F916} Grok Telegram Bot",
33
+ "Drive Grok CLI from your phone \u2014 projects, resume, live sessions, diffs.",
34
+ "",
35
+ "HOW IT WORKS",
36
+ "\u2022 Just send a message to chat with Grok in the current project.",
37
+ "\u2022 While Grok is working, anything you send is queued and runs",
38
+ " automatically when the current turn finishes.",
39
+ "",
40
+ "COMMANDS",
41
+ "/projects \u2014 choose which folder Grok works in",
42
+ "/sessions \u2014 resume one of your recent Grok sessions",
43
+ "/active \u2014 attach to a session currently running on the PC",
44
+ "/history \u2014 show the latest messages of the current session",
45
+ "/new \u2014 start a brand-new session in the current project",
46
+ "/btw <text> \u2014 run it now if idle, otherwise right after the current task",
47
+ "/flush \u2014 run queued follow-ups immediately",
48
+ "/cancel \u2014 stop the current turn",
49
+ "/status \u2014 show session, project and queue size",
50
+ "/reauth \u2014 sign in to Grok (grok login, or import an existing login)",
51
+ "/accounts \u2014 switch between saved Grok accounts",
52
+ ].join("\n");
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Shared dependencies passed to all handlers, plus a small per-chat cache for
3
+ * mapping inline-keyboard buttons back to long values (project paths).
4
+ */
5
+ import type { Api } from "grammy";
6
+ import type { GrokClient } from "../grok/client.js";
7
+ import type { AccountManager } from "../app/accounts.js";
8
+ import type { SettingsStore } from "../app/settings-store.js";
9
+ import type { AppConfig } from "../config.js";
10
+ import type { SttService } from "../app/stt.js";
11
+ import type { UsageService } from "../app/usage.js";
12
+ import type { ProjectEntry, ProjectManager } from "../projects/manager.js";
13
+ import type { SessionMeta } from "../sessions/types.js";
14
+ import type { SessionStore } from "../sessions/store.js";
15
+ import type { TaskRunner } from "../tasks/runner.js";
16
+ import type { TaskStore } from "../tasks/store.js";
17
+ import type { StatusPanel } from "./menu/status-panel.js";
18
+ import type { Ephemeral } from "./menu/ephemeral.js";
19
+ import type { RuntimeRegistry } from "./registry.js";
20
+ import type { TaskWizard } from "./wizard/task-wizard.js";
21
+
22
+ export interface BotDeps {
23
+ api: Api;
24
+ cfg: AppConfig;
25
+ acp: GrokClient;
26
+ registry: RuntimeRegistry;
27
+ store: SessionStore;
28
+ projects: ProjectManager;
29
+ menuCache: MenuCache;
30
+ settings: SettingsStore;
31
+ statusPanel: StatusPanel;
32
+ ephemeral: Ephemeral;
33
+ tasks: TaskStore;
34
+ taskRunner: TaskRunner;
35
+ wizard: TaskWizard;
36
+ stt: SttService;
37
+ usage: UsageService;
38
+ accounts: AccountManager;
39
+ }
40
+
41
+ /** Caches the last project list shown per chat for callback resolution. */
42
+ export class MenuCache {
43
+ private readonly projectLists = new Map<number, ProjectEntry[]>();
44
+ private readonly sessionLists = new Map<number, { metas: SessionMeta[]; heading: string }>();
45
+
46
+ setProjects(chatId: number, list: ProjectEntry[]): void {
47
+ this.projectLists.set(chatId, list);
48
+ }
49
+
50
+ getProject(chatId: number, index: number): ProjectEntry | undefined {
51
+ return this.projectLists.get(chatId)?.[index];
52
+ }
53
+
54
+ /** The full (sorted) project list, for paging the picker. */
55
+ getProjects(chatId: number): ProjectEntry[] | undefined {
56
+ return this.projectLists.get(chatId);
57
+ }
58
+
59
+ /** Remember the session set + heading currently being paged for a chat. */
60
+ setSessions(chatId: number, metas: SessionMeta[], heading: string): void {
61
+ this.sessionLists.set(chatId, { metas, heading });
62
+ }
63
+
64
+ getSessions(chatId: number): { metas: SessionMeta[]; heading: string } | undefined {
65
+ return this.sessionLists.get(chatId);
66
+ }
67
+ }