grok-telegram-bot 2.3.1 → 2.5.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.
- package/.env.example +64 -2
- package/CHANGELOG.md +156 -1
- package/README.md +58 -15
- package/docs/GROUP.md +225 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +1 -1
- package/src/app/accounts.ts +84 -0
- package/src/app/instance-lock.ts +6 -0
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +30 -2
- package/src/app/updater.ts +38 -6
- package/src/app/usage.ts +204 -7
- package/src/bot/account-rotator.ts +10 -0
- package/src/bot/auth.ts +96 -15
- package/src/bot/bot.ts +154 -11
- package/src/bot/chat-controller.ts +82 -13
- package/src/bot/commands.ts +69 -27
- package/src/bot/complexity-gate.ts +69 -0
- package/src/bot/deps.ts +22 -0
- package/src/bot/group-memory.ts +159 -0
- package/src/bot/handlers/accounts.ts +58 -1
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +207 -0
- package/src/bot/handlers/import-session.ts +290 -0
- package/src/bot/handlers/menu.ts +102 -61
- package/src/bot/handlers/message.ts +102 -21
- package/src/bot/handlers/photo.ts +123 -16
- package/src/bot/handlers/running.ts +172 -16
- package/src/bot/handlers/session-card.ts +20 -0
- package/src/bot/handlers/sessions.ts +76 -15
- package/src/bot/handlers/usage.ts +118 -16
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +8 -5
- package/src/bot/menu/ephemeral.ts +13 -3
- package/src/bot/menu/keyboard.ts +54 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +25 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +300 -0
- package/src/bot/prompt-content.ts +7 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +94 -0
- package/src/bot/session-fork.ts +11 -0
- package/src/bot/session-runtime.ts +1254 -83
- package/src/bot/suggestions.ts +489 -0
- package/src/bot/telegram-actions.ts +440 -0
- package/src/bot/telegram-bots.ts +495 -0
- package/src/bot/telegram-io.ts +94 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +242 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +651 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +16 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +214 -37
- package/src/grok/plan-approval.ts +72 -0
- package/src/grok/session-log.ts +16 -0
- package/src/grok/types.ts +21 -2
- package/src/import/build-import.ts +132 -0
- package/src/import/history-readers.ts +681 -0
- package/src/import/list-running.ts +100 -0
- package/src/import/sources.ts +78 -0
- package/src/index.ts +315 -30
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/diff.ts +11 -2
- package/src/render/file-summary.ts +31 -1
- package/src/render/hashtags.ts +5 -1
- package/src/render/markdown.ts +293 -35
- package/src/render/plan.ts +127 -0
- package/src/render/session-comment.ts +318 -0
- package/src/render/telegram-bridge.ts +360 -0
- package/src/render/tool-call-detail.ts +400 -19
- package/src/render/tool-call-merge.ts +115 -0
- package/src/render/tool-call.ts +444 -162
- package/src/render/truncate.ts +85 -0
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +30 -6
- package/src/sessions/history.ts +98 -0
- package/src/sessions/process.ts +7 -0
- package/src/sessions/store.ts +3 -0
- package/src/sessions/types.ts +5 -0
- package/src/stream/streamer.ts +90 -15
- package/src/tasks/runner.ts +4 -3
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import session — pick a sibling bot (Kiro / OpenCode / Claude / Codex), list
|
|
3
|
+
* its /running (controlled) sessions, and import one into this chat as a new
|
|
4
|
+
* Grok session primed with the full transcript (nothing lost).
|
|
5
|
+
*
|
|
6
|
+
* Flow:
|
|
7
|
+
* Menu → Import session → source → running session cards → Import
|
|
8
|
+
*/
|
|
9
|
+
import { basename, join } from "node:path";
|
|
10
|
+
import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
11
|
+
import { textPrompt } from "../../app/types.js";
|
|
12
|
+
import { buildImportPackage } from "../../import/build-import.js";
|
|
13
|
+
import { listRunningFromSource, type ImportableSession } from "../../import/list-running.js";
|
|
14
|
+
import {
|
|
15
|
+
getImportSource,
|
|
16
|
+
IMPORT_SOURCES,
|
|
17
|
+
sourceAvailable,
|
|
18
|
+
type ImportSourceId,
|
|
19
|
+
} from "../../import/sources.js";
|
|
20
|
+
import { createLogger } from "../../logger.js";
|
|
21
|
+
import type { BotDeps } from "../deps.js";
|
|
22
|
+
import { IMPORT_CONFIRM_PROMPT } from "../session-fork.js";
|
|
23
|
+
import { refreshMenu } from "../menu/refresh.js";
|
|
24
|
+
|
|
25
|
+
const log = createLogger("import-session");
|
|
26
|
+
|
|
27
|
+
const CARD_LIMIT = 20;
|
|
28
|
+
|
|
29
|
+
/** Open the source picker (menu entry / /import). */
|
|
30
|
+
export async function showImportSources(ctx: Context, deps: BotDeps): Promise<void> {
|
|
31
|
+
await deps.ephemeral.open(ctx);
|
|
32
|
+
const kb = new InlineKeyboard();
|
|
33
|
+
for (const src of IMPORT_SOURCES) {
|
|
34
|
+
const ok = sourceAvailable(src);
|
|
35
|
+
const mark = ok ? "" : " \u26A0";
|
|
36
|
+
kb.text(`${labelEmoji(src.id)} ${src.label}${mark}`, `imp:src:${src.id}`).row();
|
|
37
|
+
}
|
|
38
|
+
kb.text("\u2716 Cancel", "imp:cancel");
|
|
39
|
+
await deps.ephemeral.reply(
|
|
40
|
+
ctx,
|
|
41
|
+
[
|
|
42
|
+
"\u{1F4E5} Import session",
|
|
43
|
+
"",
|
|
44
|
+
"Choose the source tool. You will then see its /running sessions",
|
|
45
|
+
"and can import one into Grok with full conversation context.",
|
|
46
|
+
].join("\n"),
|
|
47
|
+
{ reply_markup: kb },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** List /running sessions for a source bot. */
|
|
52
|
+
export async function showImportRunning(
|
|
53
|
+
ctx: Context,
|
|
54
|
+
deps: BotDeps,
|
|
55
|
+
sourceId: ImportSourceId,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const src = getImportSource(sourceId);
|
|
58
|
+
if (!src) {
|
|
59
|
+
await ctx.reply("Unknown source.");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
await deps.ephemeral.open(ctx);
|
|
63
|
+
|
|
64
|
+
if (!sourceAvailable(src)) {
|
|
65
|
+
await deps.ephemeral.reply(
|
|
66
|
+
ctx,
|
|
67
|
+
`\u26A0\uFE0F ${src.label} bot root not found:\n\`${src.botRoot}\``,
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const chatId = ctx.chat!.id;
|
|
73
|
+
let sessions = listRunningFromSource(src, chatId);
|
|
74
|
+
// If this Telegram chat id has no controlled sessions there, union all chats
|
|
75
|
+
// on that bot (same human often uses one chat id everywhere).
|
|
76
|
+
if (sessions.length === 0) sessions = listRunningFromSource(src);
|
|
77
|
+
|
|
78
|
+
if (sessions.length === 0) {
|
|
79
|
+
const kb = new InlineKeyboard().text("\u25C0 Sources", "imp:back").text("\u2716 Cancel", "imp:cancel");
|
|
80
|
+
await deps.ephemeral.reply(
|
|
81
|
+
ctx,
|
|
82
|
+
[
|
|
83
|
+
`\u{1F4E5} ${src.label} \u2014 no /running sessions`,
|
|
84
|
+
"",
|
|
85
|
+
"That bot has no controlled sessions in its settings right now.",
|
|
86
|
+
"Open a session there first (Project / message), then try Import again.",
|
|
87
|
+
].join("\n"),
|
|
88
|
+
{ reply_markup: kb },
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
deps.menuCache.setImportSessions(chatId, sourceId, sessions);
|
|
94
|
+
const shown = sessions.slice(0, CARD_LIMIT);
|
|
95
|
+
await deps.ephemeral.reply(
|
|
96
|
+
ctx,
|
|
97
|
+
`\u{1F4E5} ${src.label} /running \u2014 ${sessions.length} session(s). Tap Import on a card:`,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
for (let i = 0; i < shown.length; i++) {
|
|
102
|
+
const s = shown[i]!;
|
|
103
|
+
const { text, kb } = buildImportCard(s, i, now);
|
|
104
|
+
await deps.ephemeral.reply(ctx, text, { reply_markup: kb });
|
|
105
|
+
}
|
|
106
|
+
if (sessions.length > shown.length) {
|
|
107
|
+
await deps.ephemeral.reply(ctx, `\u2026and ${sessions.length - shown.length} more (not shown).`);
|
|
108
|
+
}
|
|
109
|
+
const nav = new InlineKeyboard().text("\u25C0 Sources", "imp:back").text("\u2716 Cancel", "imp:cancel");
|
|
110
|
+
await deps.ephemeral.reply(ctx, "Pick a session above, or go back.", { reply_markup: nav });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Perform the import into a new Grok /running session. */
|
|
114
|
+
export async function doImportSession(ctx: Context, deps: BotDeps, index: number): Promise<void> {
|
|
115
|
+
const chatId = ctx.chat!.id;
|
|
116
|
+
const cached = deps.menuCache.getImportSessions(chatId);
|
|
117
|
+
const session = cached?.sessions[index];
|
|
118
|
+
if (!session || !cached) {
|
|
119
|
+
await ctx.reply("That import list expired \u2014 open Import session again.");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const src = getImportSource(cached.sourceId);
|
|
123
|
+
if (!src) {
|
|
124
|
+
await ctx.reply("Unknown source.");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await deps.ephemeral.clear(chatId).catch(() => {});
|
|
129
|
+
await ctx.reply(
|
|
130
|
+
`\u23F3 Importing from ${src.label} \u2026\n\`${session.sessionId.slice(0, 12)}\` \u00B7 ${session.projectName ?? (basename(session.cwd || "") || "project")}`,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const cwd = resolveImportCwd(session, deps, chatId);
|
|
134
|
+
const projectName = session.projectName || (cwd ? basename(cwd) : "imported");
|
|
135
|
+
const importsDir = join(deps.cfg.dataDir, "imports");
|
|
136
|
+
|
|
137
|
+
let pkg;
|
|
138
|
+
try {
|
|
139
|
+
pkg = buildImportPackage(src, { ...session, cwd }, importsDir);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
log.error("buildImportPackage failed:", (e as Error).message);
|
|
142
|
+
await ctx.reply(`\u274C Could not read source history: ${(e as Error).message}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (pkg.entryCount === 0) {
|
|
147
|
+
await ctx.reply(
|
|
148
|
+
[
|
|
149
|
+
`\u26A0\uFE0F No history entries found on disk for this session.`,
|
|
150
|
+
`Session id: \`${session.sessionId}\``,
|
|
151
|
+
`I will still open a Grok session in the project, but context may be empty.`,
|
|
152
|
+
`Transcript archive: \`${pkg.transcriptPath}\``,
|
|
153
|
+
].join("\n"),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const rt = await deps.registry.controller(chatId).addImport(cwd, projectName, pkg.priming);
|
|
159
|
+
// Flush priming into the live Grok session immediately so context is not
|
|
160
|
+
// waiting on the user's next free-form message.
|
|
161
|
+
void rt.submit(textPrompt(IMPORT_CONFIRM_PROMPT));
|
|
162
|
+
|
|
163
|
+
const lines = [
|
|
164
|
+
`\u2705 Imported into Grok /running`,
|
|
165
|
+
`\u{1F4E5} ${src.label} \u2192 Grok`,
|
|
166
|
+
`\u{1F4C1} ${projectName}`,
|
|
167
|
+
cwd ? ` ${cwd}` : "",
|
|
168
|
+
`\u{1F4DC} ${pkg.entryCount} history entries \u00B7 ${humanSize(pkg.transcriptChars)} transcript`,
|
|
169
|
+
pkg.truncatedInline
|
|
170
|
+
? `\u2139\uFE0F Full transcript is on disk (inline slice was capped); Grok will use the file if needed.`
|
|
171
|
+
: `\u2705 Full transcript inlined into the first Grok turn.`,
|
|
172
|
+
`\u{1F4C4} ${pkg.transcriptPath}`,
|
|
173
|
+
rt.sessionId ? `\u{1F194} new Grok session ${rt.sessionId.slice(0, 8)}` : "",
|
|
174
|
+
``,
|
|
175
|
+
`Grok is loading the context now. After the short confirmation, send your next instruction to continue.`,
|
|
176
|
+
].filter(Boolean);
|
|
177
|
+
|
|
178
|
+
await refreshMenu(ctx, deps, `\u{1F4E5} Imported ${src.label} \u00B7 ${projectName}`);
|
|
179
|
+
await ctx.reply(lines.join("\n"));
|
|
180
|
+
} catch (e) {
|
|
181
|
+
log.error("import failed:", (e as Error).message);
|
|
182
|
+
await ctx.reply(
|
|
183
|
+
`\u274C Import failed: ${(e as Error).message}\nTranscript (if written): \`${pkg.transcriptPath}\``,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function registerImportSession(bot: Bot, deps: BotDeps): void {
|
|
189
|
+
bot.command("import", (ctx) => showImportSources(ctx, deps));
|
|
190
|
+
|
|
191
|
+
bot.callbackQuery("imp:cancel", async (ctx) => {
|
|
192
|
+
await ctx.answerCallbackQuery();
|
|
193
|
+
await ctx.deleteMessage().catch(() => {});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
bot.callbackQuery("imp:back", async (ctx) => {
|
|
197
|
+
await ctx.answerCallbackQuery();
|
|
198
|
+
await deps.ephemeral.clear(ctx.chat!.id).catch(() => {});
|
|
199
|
+
await showImportSources(ctx, deps);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
bot.callbackQuery(/^imp:src:(kiro|opencode|claude|codex)$/, async (ctx) => {
|
|
203
|
+
await ctx.answerCallbackQuery();
|
|
204
|
+
await deps.ephemeral.clear(ctx.chat!.id).catch(() => {});
|
|
205
|
+
await showImportRunning(ctx, deps, ctx.match![1] as ImportSourceId);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
bot.callbackQuery(/^imp:go:(\d+)$/, async (ctx) => {
|
|
209
|
+
await ctx.answerCallbackQuery({ text: "Importing\u2026" });
|
|
210
|
+
await doImportSession(ctx, deps, Number(ctx.match![1]));
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── cards / helpers ──────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
function buildImportCard(
|
|
217
|
+
s: ImportableSession,
|
|
218
|
+
index: number,
|
|
219
|
+
now: number,
|
|
220
|
+
): { text: string; kb: InlineKeyboard } {
|
|
221
|
+
const proj = s.projectName || (s.cwd ? basename(s.cwd) : "(no project)");
|
|
222
|
+
const when = s.updatedAt ? timeAgo(now - Date.parse(s.updatedAt)) : "unknown";
|
|
223
|
+
const title = cleanTitle(s.title);
|
|
224
|
+
const hist =
|
|
225
|
+
s.historyBytes > 0 ? humanSize(s.historyBytes) : s.historyPath ? "0 B" : "no history file";
|
|
226
|
+
|
|
227
|
+
const lines = [
|
|
228
|
+
`\u{1F4E5} ${proj}`,
|
|
229
|
+
title ? `\u{1F4AC} \u201C${trunc(title, 120)}\u201D` : "\u{1F4AC} (no title)",
|
|
230
|
+
s.cwd ? `\u{1F4C1} ${s.cwd}` : "\u{1F4C1} (no cwd recorded)",
|
|
231
|
+
`\u{1F552} ${when} \u00B7 \u{1F4DC} ${hist}`,
|
|
232
|
+
`\u{1F194} ${s.sessionId.length > 16 ? s.sessionId.slice(0, 12) + "\u2026" : s.sessionId}`,
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const kb = new InlineKeyboard().text("\u{1F4E5} Import into Grok", `imp:go:${index}`);
|
|
236
|
+
return { text: lines.join("\n"), kb };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function resolveImportCwd(session: ImportableSession, deps: BotDeps, chatId: number): string {
|
|
240
|
+
if (session.cwd) return session.cwd;
|
|
241
|
+
// No cwd on the source session — stay on this chat's current project.
|
|
242
|
+
try {
|
|
243
|
+
return deps.registry.get(chatId).cwd || deps.cfg.workspace;
|
|
244
|
+
} catch {
|
|
245
|
+
return deps.cfg.workspace;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function labelEmoji(id: ImportSourceId): string {
|
|
250
|
+
switch (id) {
|
|
251
|
+
case "kiro":
|
|
252
|
+
return "\u{1F3AF}";
|
|
253
|
+
case "opencode":
|
|
254
|
+
return "\u26A1";
|
|
255
|
+
case "claude":
|
|
256
|
+
return "\u{1F9E0}";
|
|
257
|
+
case "codex":
|
|
258
|
+
return "\u{1F4D6}";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function cleanTitle(raw: string): string {
|
|
263
|
+
let t = (raw || "").trim().replace(/^\([^)]*\)\s*/, "");
|
|
264
|
+
const marker = "User's new message:";
|
|
265
|
+
const i = t.lastIndexOf(marker);
|
|
266
|
+
if (i !== -1) t = t.slice(i + marker.length);
|
|
267
|
+
return t.replace(/\s+/g, " ").trim();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function trunc(s: string, n: number): string {
|
|
271
|
+
return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function timeAgo(ms: number): string {
|
|
275
|
+
if (!Number.isFinite(ms) || ms < 0) return "unknown";
|
|
276
|
+
const s = Math.round(ms / 1000);
|
|
277
|
+
if (s < 45) return "just now";
|
|
278
|
+
const m = Math.round(s / 60);
|
|
279
|
+
if (m < 60) return `${m}m ago`;
|
|
280
|
+
const h = Math.round(m / 60);
|
|
281
|
+
if (h < 24) return `${h}h ago`;
|
|
282
|
+
return `${Math.round(h / 24)}d ago`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function humanSize(bytes: number): string {
|
|
286
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
287
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
288
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
289
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
290
|
+
}
|
package/src/bot/handlers/menu.ts
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Menu handler — maps the persistent reply-keyboard buttons (matched by emoji
|
|
3
|
-
* prefix for stateful ones) to actions, and provides inline submenus for
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* prefix for stateful ones) to actions, and provides inline submenus for
|
|
4
|
+
* Reasoning and Model. Changing a value re-renders the keyboard so its labels
|
|
5
|
+
* always reflect the current state.
|
|
6
|
+
*
|
|
7
|
+
* The Agent picker was removed: Grok has no useful headless agent switch, and
|
|
8
|
+
* plan mode is entered automatically when the agent judges a task complex.
|
|
6
9
|
*/
|
|
7
10
|
import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
8
11
|
import { reasoningLabel } from "../../app/reasoning.js";
|
|
9
|
-
import { listAgents } from "../../agents/catalog.js";
|
|
10
12
|
import { REASONING_LEVELS, type ReasoningEffort } from "../../app/types.js";
|
|
11
13
|
import type { BotDeps } from "../deps.js";
|
|
12
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
BAR_LABELS,
|
|
16
|
+
compactKeyboard,
|
|
17
|
+
mainMenuInline,
|
|
18
|
+
MENU_BTN,
|
|
19
|
+
NEW_BTN,
|
|
20
|
+
RUNNING_BTN,
|
|
21
|
+
STOP_BTN,
|
|
22
|
+
} from "../menu/keyboard.js";
|
|
13
23
|
import { refreshMenu } from "../menu/refresh.js";
|
|
24
|
+
import { resolveScope } from "../scope.js";
|
|
25
|
+
import { showImportSources } from "./import-session.js";
|
|
14
26
|
import { showKillConfirm } from "./kill.js";
|
|
15
27
|
import { showMcp } from "./mcp.js";
|
|
16
28
|
import { showProjects } from "./projects.js";
|
|
@@ -20,15 +32,25 @@ import { showSessions } from "./sessions.js";
|
|
|
20
32
|
import { showTasks } from "./tasks.js";
|
|
21
33
|
import { showUsage } from "./usage.js";
|
|
22
34
|
|
|
23
|
-
/** Open the full inline menu, showing the current
|
|
35
|
+
/** Open the full inline menu, showing the current model/reasoning (topic-aware). */
|
|
24
36
|
export async function openMainMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
25
37
|
await deps.ephemeral.open(ctx);
|
|
26
|
-
const
|
|
27
|
-
|
|
38
|
+
const scope = resolveScope(ctx, deps);
|
|
39
|
+
const preferred = scope.rt.preferredAccountId;
|
|
40
|
+
const saved = preferred
|
|
41
|
+
? deps.accounts.list().find((a) => a.id === preferred || a.loginId === preferred)
|
|
42
|
+
: undefined;
|
|
43
|
+
const accountLabel = saved?.label || preferred?.slice(0, 12) || undefined;
|
|
44
|
+
const title = scope.isForum
|
|
45
|
+
? `\u2699\uFE0F Topic menu \u00B7 ${scope.projectName ?? "topic"}`
|
|
46
|
+
: "\u2699\uFE0F Menu";
|
|
47
|
+
await deps.ephemeral.reply(ctx, title, {
|
|
28
48
|
reply_markup: mainMenuInline({
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
49
|
+
model: scope.rt.model || "default",
|
|
50
|
+
reasoning: reasoningLabel(scope.rt.reasoning),
|
|
51
|
+
forumTopic: scope.isForum
|
|
52
|
+
? { name: scope.projectName ?? "Topic", account: accountLabel }
|
|
53
|
+
: undefined,
|
|
32
54
|
}),
|
|
33
55
|
});
|
|
34
56
|
}
|
|
@@ -37,14 +59,27 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
|
|
|
37
59
|
// Compact persistent bar.
|
|
38
60
|
bot.hears(BAR_LABELS, async (ctx) => {
|
|
39
61
|
deps.wizard.abort(ctx.chat.id);
|
|
62
|
+
const scope = resolveScope(ctx, deps);
|
|
40
63
|
switch (ctx.message?.text) {
|
|
41
64
|
case MENU_BTN:
|
|
42
65
|
return openMainMenu(ctx, deps);
|
|
66
|
+
case NEW_BTN: {
|
|
67
|
+
await ctx.reply("\u2728 Creating new session\u2026", scope.threadExtra).catch(() => {});
|
|
68
|
+
try {
|
|
69
|
+
await scope.controller.addNew(scope.rt.cwd, scope.rt.projectName);
|
|
70
|
+
return refreshMenu(ctx, deps, `\u2728 New session in ${scope.rt.projectName ?? scope.rt.cwd}`);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return void ctx.reply(`\u274C ${(e as Error).message}`, scope.threadExtra);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
43
75
|
case RUNNING_BTN:
|
|
44
76
|
return showRunning(ctx, deps);
|
|
45
77
|
case STOP_BTN: {
|
|
46
|
-
const
|
|
47
|
-
return void ctx.reply(
|
|
78
|
+
const cancelled = await scope.rt.cancel();
|
|
79
|
+
return void ctx.reply(
|
|
80
|
+
cancelled ? "\u23F9 Cancelling current turn\u2026" : "Nothing is running.",
|
|
81
|
+
scope.threadExtra,
|
|
82
|
+
);
|
|
48
83
|
}
|
|
49
84
|
}
|
|
50
85
|
});
|
|
@@ -52,20 +87,10 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
|
|
|
52
87
|
// Inline menu actions.
|
|
53
88
|
bot.callbackQuery(/^m:(\w+)$/, (ctx) => dispatchMenu(ctx, deps, ctx.match![1]!));
|
|
54
89
|
|
|
55
|
-
// ── Agent (real Grok modes) ─────────────────────────────────────────────
|
|
56
|
-
bot.callbackQuery(/^agent:set:(\d+)$/, async (ctx) => {
|
|
57
|
-
const mode = deps.acp.availableModes[Number(ctx.match![1])];
|
|
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}` });
|
|
61
|
-
await deps.registry.get(ctx.chat!.id).setAgentPref(mode.id);
|
|
62
|
-
await confirmUi(ctx, deps);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
90
|
// ── Reasoning ──────────────────────────────────────────────────────────────
|
|
66
91
|
bot.callbackQuery(/^reason:(minimal|low|medium|high|max)$/, async (ctx) => {
|
|
67
92
|
const level = ctx.match![1] as ReasoningEffort;
|
|
68
|
-
|
|
93
|
+
resolveScope(ctx, deps).rt.setReasoningPref(level);
|
|
69
94
|
await confirm(ctx, deps, `\u{1F9E0} Reasoning: ${reasoningLabel(level)}`);
|
|
70
95
|
});
|
|
71
96
|
|
|
@@ -74,37 +99,52 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
|
|
|
74
99
|
const entry = deps.acp.availableModels[Number(ctx.match![1])];
|
|
75
100
|
if (!entry) return void ctx.answerCallbackQuery({ text: "Expired, tap Model again." });
|
|
76
101
|
await ctx.answerCallbackQuery({ text: `\u{1F9E9} Model: ${entry.name}` });
|
|
77
|
-
const res = await
|
|
102
|
+
const res = await resolveScope(ctx, deps).rt.setModelPref(entry.modelId);
|
|
78
103
|
if (!res.ok) {
|
|
79
|
-
await ctx.reply(`\u26A0\uFE0F Model set failed: ${res.error}
|
|
104
|
+
await ctx.reply(`\u26A0\uFE0F Model set failed: ${res.error}`, resolveScope(ctx, deps).threadExtra).catch(() => {});
|
|
80
105
|
}
|
|
81
106
|
await confirmUi(ctx, deps);
|
|
82
107
|
});
|
|
83
108
|
bot.callbackQuery("model:clear", async (ctx) => {
|
|
84
109
|
await ctx.answerCallbackQuery({ text: "\u{1F9E9} Model: default" });
|
|
85
|
-
await
|
|
110
|
+
await resolveScope(ctx, deps).rt.setModelPref("");
|
|
86
111
|
await confirmUi(ctx, deps);
|
|
87
112
|
});
|
|
88
113
|
}
|
|
89
114
|
|
|
90
115
|
/** Dispatch an inline-menu action (`m:<action>`). */
|
|
91
116
|
async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promise<void> {
|
|
92
|
-
const
|
|
93
|
-
const rt =
|
|
117
|
+
const scope = resolveScope(ctx, deps);
|
|
118
|
+
const { chatId, rt, controller, threadExtra, isForum, projectName, projectPath } = scope;
|
|
94
119
|
switch (action) {
|
|
95
120
|
case "close":
|
|
96
121
|
await ctx.answerCallbackQuery();
|
|
97
122
|
return void ctx.deleteMessage().catch(() => {});
|
|
123
|
+
case "topicinfo":
|
|
124
|
+
await ctx.answerCallbackQuery();
|
|
125
|
+
return void deps.ephemeral.reply(
|
|
126
|
+
ctx,
|
|
127
|
+
`\u{1F4C1} **Topic project**\n${projectName ?? "?"}\n\`${projectPath ?? rt.cwd}\`\n\n` +
|
|
128
|
+
`Model / reasoning / running sessions on this menu are for **this topic only**.`,
|
|
129
|
+
);
|
|
98
130
|
case "hidebar":
|
|
99
131
|
await ctx.answerCallbackQuery();
|
|
100
132
|
await ctx.deleteMessage().catch(() => {});
|
|
101
133
|
return void ctx.reply("\u{1F648} Bar hidden \u2014 send /menu to bring it back.", {
|
|
102
134
|
reply_markup: { remove_keyboard: true },
|
|
135
|
+
...threadExtra,
|
|
103
136
|
});
|
|
104
137
|
case "showbar":
|
|
105
138
|
await ctx.answerCallbackQuery();
|
|
106
|
-
return void ctx.reply("\u2328\uFE0F Bar restored.", {
|
|
139
|
+
return void ctx.reply("\u2328\uFE0F Bar restored.", {
|
|
140
|
+
reply_markup: compactKeyboard(),
|
|
141
|
+
...threadExtra,
|
|
142
|
+
});
|
|
107
143
|
case "project":
|
|
144
|
+
if (isForum) {
|
|
145
|
+
await ctx.answerCallbackQuery({ text: "Project is fixed to this topic", show_alert: true });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
108
148
|
await ctx.answerCallbackQuery();
|
|
109
149
|
return showProjects(ctx, deps);
|
|
110
150
|
case "running":
|
|
@@ -113,12 +153,19 @@ async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promis
|
|
|
113
153
|
case "sessions":
|
|
114
154
|
await ctx.answerCallbackQuery();
|
|
115
155
|
return showSessions(ctx, deps);
|
|
156
|
+
case "import":
|
|
157
|
+
await ctx.answerCallbackQuery();
|
|
158
|
+
return showImportSources(ctx, deps);
|
|
116
159
|
case "tasks":
|
|
117
160
|
await ctx.answerCallbackQuery();
|
|
118
161
|
return showTasks(ctx, deps);
|
|
119
162
|
case "agent":
|
|
120
|
-
|
|
121
|
-
|
|
163
|
+
// Legacy callback from old keyboards.
|
|
164
|
+
await ctx.answerCallbackQuery({
|
|
165
|
+
text: "Agent menu removed — Grok picks sub-agents automatically",
|
|
166
|
+
show_alert: true,
|
|
167
|
+
});
|
|
168
|
+
return openMainMenu(ctx, deps);
|
|
122
169
|
case "model":
|
|
123
170
|
await ctx.answerCallbackQuery();
|
|
124
171
|
return showModelMenu(ctx, deps);
|
|
@@ -143,17 +190,26 @@ async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promis
|
|
|
143
190
|
await ctx.answerCallbackQuery();
|
|
144
191
|
return showKillConfirm(ctx, deps);
|
|
145
192
|
case "new":
|
|
146
|
-
await ctx.answerCallbackQuery();
|
|
193
|
+
await ctx.answerCallbackQuery({ text: "Creating session\u2026" });
|
|
194
|
+
await ctx.reply("\u2728 Creating new session\u2026", threadExtra).catch(() => {});
|
|
147
195
|
try {
|
|
148
|
-
await
|
|
196
|
+
await controller.addNew(rt.cwd, rt.projectName);
|
|
149
197
|
return refreshMenu(ctx, deps, `\u2728 New session in ${rt.projectName ?? rt.cwd}`);
|
|
150
198
|
} catch (e) {
|
|
151
|
-
return void ctx.reply(`\u274C ${(e as Error).message}
|
|
199
|
+
return void ctx.reply(`\u274C ${(e as Error).message}`, threadExtra);
|
|
152
200
|
}
|
|
153
201
|
case "stop": {
|
|
154
202
|
// Answer first so a slow cancel never times out the callback query.
|
|
155
|
-
|
|
156
|
-
|
|
203
|
+
const busy = rt.isBusy;
|
|
204
|
+
await ctx.answerCallbackQuery({ text: busy ? "Cancelling\u2026" : "Nothing is running" });
|
|
205
|
+
const cancelled = busy ? await rt.cancel() : false;
|
|
206
|
+
// Visible in-topic feedback (callback toasts are easy to miss in groups).
|
|
207
|
+
await ctx
|
|
208
|
+
.reply(
|
|
209
|
+
cancelled || busy ? "\u23F9 Cancelling current turn\u2026" : "Nothing is running.",
|
|
210
|
+
threadExtra,
|
|
211
|
+
)
|
|
212
|
+
.catch(() => {});
|
|
157
213
|
return;
|
|
158
214
|
}
|
|
159
215
|
default:
|
|
@@ -178,41 +234,26 @@ async function confirmUi(ctx: Context, deps: BotDeps): Promise<void> {
|
|
|
178
234
|
await openMainMenu(ctx, deps); // reopen so the new value is visible
|
|
179
235
|
}
|
|
180
236
|
|
|
181
|
-
async function showAgentMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
182
|
-
const rt = deps.registry.get(ctx.chat!.id);
|
|
183
|
-
await ensureReady(ctx, rt);
|
|
184
|
-
await deps.ephemeral.open(ctx);
|
|
185
|
-
const modes = deps.acp.availableModes.slice(0, 60);
|
|
186
|
-
if (modes.length === 0) {
|
|
187
|
-
// Grok has no headless agent switch; surface the sub-agents it can delegate
|
|
188
|
-
// to (built-ins + any custom ones in ~/.grok/user-settings.json) as info.
|
|
189
|
-
const subs = listAgents(rt.cwd).map((s) => s.name);
|
|
190
|
-
const info = subs.length
|
|
191
|
-
? `Grok sub-agents available: ${subs.join(", ")}.\nGrok delegates to these automatically during a turn (via its task/delegate tools) — there's no headless agent switch. Use \u{1F9E9} Model to change the model.`
|
|
192
|
-
: "Grok delegates to built-in sub-agents automatically in headless mode; there's no agent to select. Use \u{1F9E9} Model to change the model.";
|
|
193
|
-
await deps.ephemeral.reply(ctx, `Current agent: ${rt.agent || "default"}\n${info}`);
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
const kb = new InlineKeyboard();
|
|
197
|
-
modes.forEach((m, i) => kb.text(`${m.id === rt.agent ? "\u2713 " : ""}${m.name}`, `agent:set:${i}`).row());
|
|
198
|
-
await deps.ephemeral.reply(ctx, `Current agent: ${rt.agent || "default"}\nChoose an agent:`, { reply_markup: kb });
|
|
199
|
-
}
|
|
200
|
-
|
|
201
237
|
async function showReasoningMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
202
|
-
const rt =
|
|
238
|
+
const rt = resolveScope(ctx, deps).rt;
|
|
203
239
|
await deps.ephemeral.open(ctx);
|
|
204
240
|
const kb = new InlineKeyboard();
|
|
205
241
|
REASONING_LEVELS.forEach((l) => kb.text(`${l === rt.reasoning ? "\u2713 " : ""}${reasoningLabel(l)}`, `reason:${l}`));
|
|
206
|
-
await deps.ephemeral.reply(ctx, `Current reasoning: ${reasoningLabel(rt.reasoning)}\nChoose effort:`, {
|
|
242
|
+
await deps.ephemeral.reply(ctx, `Current reasoning: ${reasoningLabel(rt.reasoning)}\nChoose effort:`, {
|
|
243
|
+
reply_markup: kb,
|
|
244
|
+
});
|
|
207
245
|
}
|
|
208
246
|
|
|
209
247
|
async function showModelMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
210
|
-
const rt =
|
|
248
|
+
const rt = resolveScope(ctx, deps).rt;
|
|
211
249
|
await ensureReady(ctx, rt);
|
|
212
250
|
await deps.ephemeral.open(ctx);
|
|
213
251
|
const models = deps.acp.availableModels;
|
|
214
252
|
if (models.length === 0) {
|
|
215
|
-
await deps.ephemeral.reply(
|
|
253
|
+
await deps.ephemeral.reply(
|
|
254
|
+
ctx,
|
|
255
|
+
"No selectable models reported by Grok yet \u2014 send a message first, then try again.",
|
|
256
|
+
);
|
|
216
257
|
return;
|
|
217
258
|
}
|
|
218
259
|
const current = rt.model || deps.acp.currentModelId;
|