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.
- package/.env.example +135 -0
- package/CHANGELOG.md +598 -0
- package/LICENSE +21 -0
- package/README.md +644 -0
- package/bin/grok-tg.mjs +21 -0
- package/docs/INSTALL.md +153 -0
- package/docs/UPGRADE.md +253 -0
- package/docs/ops/RELEASE_CHECKLIST.md +39 -0
- package/package.json +74 -0
- package/scripts/setup.mjs +116 -0
- package/src/agents/catalog.ts +58 -0
- package/src/app/accounts.ts +162 -0
- package/src/app/auth-service.ts +136 -0
- package/src/app/grok-credentials.ts +103 -0
- package/src/app/instance-lock.ts +139 -0
- package/src/app/json-store.ts +54 -0
- package/src/app/reasoning.ts +30 -0
- package/src/app/settings-store.ts +38 -0
- package/src/app/stt.ts +53 -0
- package/src/app/types.ts +56 -0
- package/src/app/updater.ts +234 -0
- package/src/app/usage.ts +38 -0
- package/src/app/version.ts +41 -0
- package/src/bot/account-rotator.ts +52 -0
- package/src/bot/auth.ts +38 -0
- package/src/bot/bot.ts +225 -0
- package/src/bot/chat-controller.ts +317 -0
- package/src/bot/commands.ts +52 -0
- package/src/bot/deps.ts +67 -0
- package/src/bot/file-ingest.ts +190 -0
- package/src/bot/handlers/accounts.ts +220 -0
- package/src/bot/handlers/auth.ts +64 -0
- package/src/bot/handlers/control.ts +103 -0
- package/src/bot/handlers/document.ts +112 -0
- package/src/bot/handlers/history.ts +63 -0
- package/src/bot/handlers/kill.ts +54 -0
- package/src/bot/handlers/mcp.ts +206 -0
- package/src/bot/handlers/menu.ts +220 -0
- package/src/bot/handlers/message.ts +103 -0
- package/src/bot/handlers/photo.ts +123 -0
- package/src/bot/handlers/projects.ts +183 -0
- package/src/bot/handlers/running.ts +181 -0
- package/src/bot/handlers/session-card.ts +81 -0
- package/src/bot/handlers/session-kill.ts +95 -0
- package/src/bot/handlers/sessions.ts +148 -0
- package/src/bot/handlers/system.ts +51 -0
- package/src/bot/handlers/tasks.ts +224 -0
- package/src/bot/handlers/usage.ts +38 -0
- package/src/bot/handlers/voice.ts +55 -0
- package/src/bot/image-return.ts +69 -0
- package/src/bot/menu/ephemeral.ts +117 -0
- package/src/bot/menu/keyboard.ts +49 -0
- package/src/bot/menu/refresh.ts +13 -0
- package/src/bot/menu/status-panel.ts +173 -0
- package/src/bot/permission-service.ts +149 -0
- package/src/bot/prompt-content.ts +64 -0
- package/src/bot/prompt-retry.ts +70 -0
- package/src/bot/reauth-controller.ts +297 -0
- package/src/bot/registry.ts +186 -0
- package/src/bot/reply-context.ts +77 -0
- package/src/bot/session-fork.ts +35 -0
- package/src/bot/session-runtime.ts +1048 -0
- package/src/bot/telegram-io.ts +109 -0
- package/src/bot/typing.ts +35 -0
- package/src/bot/wizard/task-wizard.ts +214 -0
- package/src/cli.ts +126 -0
- package/src/config.ts +248 -0
- package/src/grok/client.ts +617 -0
- package/src/grok/models.ts +50 -0
- package/src/grok/session-log.ts +148 -0
- package/src/grok/transport.ts +51 -0
- package/src/grok/types.ts +136 -0
- package/src/index.ts +84 -0
- package/src/logger.ts +78 -0
- package/src/mcp/config.ts +120 -0
- package/src/mcp/probe.ts +218 -0
- package/src/mcp/types.ts +68 -0
- package/src/projects/manager.ts +99 -0
- package/src/render/chunk.ts +57 -0
- package/src/render/diff.ts +48 -0
- package/src/render/escape.ts +22 -0
- package/src/render/file-summary.ts +111 -0
- package/src/render/hashtags.ts +34 -0
- package/src/render/markdown.ts +130 -0
- package/src/render/progress-estimate.ts +63 -0
- package/src/render/progress.ts +80 -0
- package/src/render/subagent.ts +75 -0
- package/src/render/tool-call.ts +196 -0
- package/src/service/index.ts +24 -0
- package/src/service/linux.ts +85 -0
- package/src/service/macos.ts +101 -0
- package/src/service/platform.ts +64 -0
- package/src/service/types.ts +36 -0
- package/src/service/windows.ts +198 -0
- package/src/sessions/history.ts +225 -0
- package/src/sessions/process.ts +30 -0
- package/src/sessions/store.ts +133 -0
- package/src/sessions/tail.ts +86 -0
- package/src/sessions/types.ts +26 -0
- package/src/stream/streamer.ts +261 -0
- package/src/tasks/runner.ts +82 -0
- package/src/tasks/schedule.ts +142 -0
- package/src/tasks/scheduler.ts +53 -0
- package/src/tasks/store.ts +80 -0
- package/src/tasks/types.ts +33 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document handler — non-image file attachments.
|
|
3
|
+
*
|
|
4
|
+
* The photo handler (registered earlier) claims image documents and passes
|
|
5
|
+
* everything else through to here. We download the file and either:
|
|
6
|
+
* • inline its text (the common "long message became a .txt" case, plus code,
|
|
7
|
+
* logs, JSON, CSV, …), truncated to `DOC_MAX_CHARS`, or
|
|
8
|
+
* • for a binary file, save it under `<dataDir>/downloads` and tell the agent
|
|
9
|
+
* the path so it can open it with its own tools.
|
|
10
|
+
*
|
|
11
|
+
* Like photos/voice, submissions respect the follow-up queue and any reply
|
|
12
|
+
* context (see reply-context.ts).
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
15
|
+
import { basename, join } from "node:path";
|
|
16
|
+
import type { Bot, Context } from "grammy";
|
|
17
|
+
import { textPrompt } from "../../app/types.js";
|
|
18
|
+
import { createLogger } from "../../logger.js";
|
|
19
|
+
import type { BotDeps } from "../deps.js";
|
|
20
|
+
import {
|
|
21
|
+
decodeText,
|
|
22
|
+
formatBinaryFilePrompt,
|
|
23
|
+
formatTextFilePrompt,
|
|
24
|
+
looksLikeText,
|
|
25
|
+
} from "../file-ingest.js";
|
|
26
|
+
import { extractReplyContext } from "../reply-context.js";
|
|
27
|
+
|
|
28
|
+
const log = createLogger("document");
|
|
29
|
+
|
|
30
|
+
export function registerDocuments(bot: Bot, deps: BotDeps): void {
|
|
31
|
+
bot.on("message:document", async (ctx) => {
|
|
32
|
+
const doc = ctx.message.document;
|
|
33
|
+
// Image documents are downloaded & attached by the photo handler; it only
|
|
34
|
+
// forwards non-image documents here. This guard is a defensive no-op.
|
|
35
|
+
if (doc.mime_type?.startsWith("image/")) return;
|
|
36
|
+
|
|
37
|
+
const chatId = ctx.chat.id;
|
|
38
|
+
if (deps.wizard.isActive(chatId)) {
|
|
39
|
+
await ctx.reply("Finish or /cancel the current task wizard before sending files.");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const name = doc.file_name?.trim() || "file";
|
|
44
|
+
await ctx.replyWithChatAction("typing").catch(() => {});
|
|
45
|
+
|
|
46
|
+
let buf: Buffer;
|
|
47
|
+
try {
|
|
48
|
+
buf = await download(ctx, doc.file_id, deps.cfg.token);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
log.warn(`download failed for "${name}":`, (e as Error).message);
|
|
51
|
+
await ctx.reply(
|
|
52
|
+
`\u274C I couldn't download "${name}": ${(e as Error).message}. ` +
|
|
53
|
+
`(Telegram bots can fetch files up to 20 MB.)`,
|
|
54
|
+
);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const caption = ctx.message.caption ?? "";
|
|
59
|
+
const quoted = extractReplyContext(ctx);
|
|
60
|
+
const replyTo = ctx.message.message_id;
|
|
61
|
+
|
|
62
|
+
let promptText: string;
|
|
63
|
+
if (looksLikeText(buf, doc.mime_type, name)) {
|
|
64
|
+
const full = decodeText(buf);
|
|
65
|
+
const max = deps.cfg.docMaxChars;
|
|
66
|
+
const truncated = max > 0 && full.length > max;
|
|
67
|
+
const content = truncated ? full.slice(0, max) : full;
|
|
68
|
+
promptText = formatTextFilePrompt(name, content, caption, truncated);
|
|
69
|
+
} else {
|
|
70
|
+
const savedPath = await persistBinary(deps.cfg.dataDir, name, buf).catch((e) => {
|
|
71
|
+
log.warn("saving binary failed:", (e as Error).message);
|
|
72
|
+
return undefined;
|
|
73
|
+
});
|
|
74
|
+
promptText = formatBinaryFilePrompt(name, doc.mime_type, buf.length, caption, savedPath);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const rt = deps.registry.get(chatId);
|
|
79
|
+
const outcome = await rt.submit(textPrompt(promptText, replyTo, quoted));
|
|
80
|
+
if (outcome === "queued") {
|
|
81
|
+
await ctx.reply(`\u{1F4E5} Queued "${name}" \u2014 will run after the current task.`);
|
|
82
|
+
}
|
|
83
|
+
} catch (e) {
|
|
84
|
+
log.warn(`submit failed for "${name}":`, (e as Error).message);
|
|
85
|
+
await ctx.reply(`\u274C Couldn't process "${name}": ${(e as Error).message}`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Download a Telegram file to a Buffer (subject to the 20 MB Bot API limit). */
|
|
91
|
+
async function download(ctx: Context, fileId: string, token: string): Promise<Buffer> {
|
|
92
|
+
const file = await ctx.api.getFile(fileId);
|
|
93
|
+
if (!file.file_path) throw new Error("no file path returned");
|
|
94
|
+
const res = await fetch(`https://api.telegram.org/file/bot${token}/${file.file_path}`);
|
|
95
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
96
|
+
return Buffer.from(await res.arrayBuffer());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Persist a binary attachment under `<dataDir>/downloads`, returning its path. */
|
|
100
|
+
async function persistBinary(dataDir: string, name: string, buf: Buffer): Promise<string> {
|
|
101
|
+
const dir = join(dataDir, "downloads");
|
|
102
|
+
await mkdir(dir, { recursive: true });
|
|
103
|
+
const path = join(dir, `${Date.now()}-${sanitize(name)}`);
|
|
104
|
+
await writeFile(path, buf);
|
|
105
|
+
return path;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** basename() strips any directory components; then keep only safe characters. */
|
|
109
|
+
function sanitize(name: string): string {
|
|
110
|
+
const safe = basename(name).replace(/[^\w.\-]+/g, "_").slice(0, 120);
|
|
111
|
+
return safe.replace(/^\.+/, "") || "file";
|
|
112
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /history — show the latest messages of the current (or a chosen) session.
|
|
3
|
+
*/
|
|
4
|
+
import type { Bot } from "grammy";
|
|
5
|
+
import { basename } from "node:path";
|
|
6
|
+
import type { BotDeps } from "../deps.js";
|
|
7
|
+
import { readHistory } from "../../sessions/history.js";
|
|
8
|
+
import { sessionHashtags } from "../../render/hashtags.js";
|
|
9
|
+
import type { SessionMeta } from "../../sessions/types.js";
|
|
10
|
+
import { sendMarkdownDoc } from "../telegram-io.js";
|
|
11
|
+
|
|
12
|
+
const ENTRY_MAX = 700;
|
|
13
|
+
const ROLE_ICON: Record<string, string> = {
|
|
14
|
+
user: "\u{1F464}",
|
|
15
|
+
assistant: "\u{1F916}",
|
|
16
|
+
tool: "\u{1F527}",
|
|
17
|
+
system: "\u2139\uFE0F",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function registerHistory(bot: Bot, deps: BotDeps): void {
|
|
21
|
+
bot.command("history", async (ctx) => {
|
|
22
|
+
const rt = deps.registry.get(ctx.chat.id);
|
|
23
|
+
if (!rt.sessionId) {
|
|
24
|
+
await ctx.reply("No active session. Use /sessions or send a message first.");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const meta = deps.store.get(rt.sessionId);
|
|
28
|
+
await showHistory(deps, ctx.chat.id, rt.sessionId, meta, 16, rt.tags);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Render and send the recent history of a session. */
|
|
33
|
+
export async function showHistory(
|
|
34
|
+
deps: BotDeps,
|
|
35
|
+
chatId: number,
|
|
36
|
+
sessionId: string,
|
|
37
|
+
meta?: SessionMeta,
|
|
38
|
+
count = 16,
|
|
39
|
+
tags?: string,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const entries = readHistory(deps.store.jsonlPath(sessionId), count);
|
|
42
|
+
if (entries.length === 0) {
|
|
43
|
+
await deps.api.sendMessage(chatId, "No history found for this session yet.");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const title = meta?.title || sessionId.slice(0, 8);
|
|
47
|
+
const proj = meta?.cwd ? basename(meta.cwd) : "";
|
|
48
|
+
const header = `\u{1F4DC} **History** \u2014 ${title}${proj ? ` (${proj})` : ""}`;
|
|
49
|
+
|
|
50
|
+
const body = entries
|
|
51
|
+
.map((e) => {
|
|
52
|
+
const icon = ROLE_ICON[e.role] ?? "\u2022";
|
|
53
|
+
let text = e.text.length > ENTRY_MAX ? e.text.slice(0, ENTRY_MAX) + " …" : e.text;
|
|
54
|
+
if (e.role === "tool" && e.tool) text = `\`${e.tool}\` ${text}`;
|
|
55
|
+
return `${icon} ${text}`;
|
|
56
|
+
})
|
|
57
|
+
.join("\n\n");
|
|
58
|
+
|
|
59
|
+
// Every AI-output surface carries the session's searchable hashtags. A static
|
|
60
|
+
// view (no live runtime) tags at least project + session id.
|
|
61
|
+
const footer = tags ?? sessionHashtags({ cwd: meta?.cwd, sessionId });
|
|
62
|
+
await sendMarkdownDoc(deps.api, chatId, `${header}\n\n${body}\n\n${footer}`);
|
|
63
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /killall — terminate all active Grok sessions running on this PC (the ones
|
|
3
|
+
* holding a live session lock), excluding the bot's own agent process. Guarded
|
|
4
|
+
* by an inline confirmation since it kills processes.
|
|
5
|
+
*/
|
|
6
|
+
import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
7
|
+
import { killPid } from "../../sessions/process.js";
|
|
8
|
+
import type { SessionMeta } from "../../sessions/types.js";
|
|
9
|
+
import type { BotDeps } from "../deps.js";
|
|
10
|
+
|
|
11
|
+
function targets(deps: BotDeps): SessionMeta[] {
|
|
12
|
+
const self = deps.acp.pid;
|
|
13
|
+
return deps.store.listActive().filter((s) => s.lockPid && s.lockPid !== self);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function showKillConfirm(ctx: Context, deps: BotDeps): Promise<void> {
|
|
17
|
+
await deps.ephemeral.open(ctx);
|
|
18
|
+
const active = targets(deps);
|
|
19
|
+
if (active.length === 0) {
|
|
20
|
+
await deps.ephemeral.reply(ctx, "\u2705 No other active Grok sessions to kill.");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const list = active
|
|
24
|
+
.slice(0, 12)
|
|
25
|
+
.map((s) => `\u2022 ${s.title.slice(0, 40)} (pid ${s.lockPid})`)
|
|
26
|
+
.join("\n");
|
|
27
|
+
const kb = new InlineKeyboard()
|
|
28
|
+
.text(`\u{1F6D1} Kill ${active.length}`, "killall:confirm")
|
|
29
|
+
.text("Cancel", "killall:cancel");
|
|
30
|
+
await deps.ephemeral.reply(
|
|
31
|
+
ctx,
|
|
32
|
+
`\u{1F6D1} Kill ${active.length} active session(s)?\n${list}\n\n(The bot's own session is excluded.)`,
|
|
33
|
+
{ reply_markup: kb },
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registerKill(bot: Bot, deps: BotDeps): void {
|
|
38
|
+
bot.command("killall", (ctx) => showKillConfirm(ctx, deps));
|
|
39
|
+
|
|
40
|
+
bot.callbackQuery("killall:cancel", async (ctx) => {
|
|
41
|
+
await ctx.answerCallbackQuery();
|
|
42
|
+
await ctx.editMessageText("Cancelled.").catch(() => {});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
bot.callbackQuery("killall:confirm", async (ctx) => {
|
|
46
|
+
await ctx.answerCallbackQuery();
|
|
47
|
+
const active = targets(deps);
|
|
48
|
+
let killed = 0;
|
|
49
|
+
for (const s of active) {
|
|
50
|
+
if (s.lockPid && killPid(s.lockPid)) killed++;
|
|
51
|
+
}
|
|
52
|
+
await ctx.editMessageText(`\u{1F6D1} Killed ${killed} of ${active.length} active session(s).`).catch(() => {});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /mcp — inspect and control the Grok agent's MCP servers from Telegram.
|
|
3
|
+
*
|
|
4
|
+
* • Lists every configured server with its enabled/disabled state, transport
|
|
5
|
+
* (stdio/http) and scope (global/workspace).
|
|
6
|
+
* • 🩺 Health-check runs a real MCP `initialize` handshake against each enabled
|
|
7
|
+
* server and reports which connected and which failed (and why).
|
|
8
|
+
* • 🔧 Enable/Disable toggles a server's `disabled` flag in its mcp.json. The
|
|
9
|
+
* change applies when the agent next loads servers, so a 🔄 Restart button
|
|
10
|
+
* is offered to apply it immediately.
|
|
11
|
+
*/
|
|
12
|
+
import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
13
|
+
import type { BotDeps } from "../deps.js";
|
|
14
|
+
import { listMcpServers, setMcpDisabled } from "../../mcp/config.js";
|
|
15
|
+
import { probeAll } from "../../mcp/probe.js";
|
|
16
|
+
import type { McpProbeResult, McpServer } from "../../mcp/types.js";
|
|
17
|
+
|
|
18
|
+
const PAGE_SIZE = 10;
|
|
19
|
+
|
|
20
|
+
/** Per-chat snapshot of the last listed servers, for index-based callbacks. */
|
|
21
|
+
const snapshots = new Map<number, McpServer[]>();
|
|
22
|
+
|
|
23
|
+
function snapshot(chatId: number, deps: BotDeps): McpServer[] {
|
|
24
|
+
const cwd = deps.registry.get(chatId).cwd;
|
|
25
|
+
const list = listMcpServers(cwd);
|
|
26
|
+
snapshots.set(chatId, list);
|
|
27
|
+
return list;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function trunc(s: string, n: number): string {
|
|
31
|
+
return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TRANSPORT_ICON: Record<string, string> = { http: "\u{1F310}", stdio: "\u{1F5A5}\uFE0F", unknown: "\u2753" };
|
|
35
|
+
|
|
36
|
+
/** Build the main MCP panel text + keyboard. */
|
|
37
|
+
function mainPanel(list: McpServer[]): { text: string; kb: InlineKeyboard } {
|
|
38
|
+
const enabled = list.filter((s) => !s.disabled);
|
|
39
|
+
const disabled = list.filter((s) => s.disabled);
|
|
40
|
+
const lines = [`\u{1F9E9} MCP servers \u2014 ${list.length} total \u00B7 \u2705 ${enabled.length} enabled \u00B7 \u26D4 ${disabled.length} disabled`, ""];
|
|
41
|
+
if (list.length === 0) {
|
|
42
|
+
lines.push("No MCP servers configured in ~/.grok/settings/mcp.json.");
|
|
43
|
+
} else {
|
|
44
|
+
const LIST_CAP = 60; // keep the message well under Telegram's 4096-char limit
|
|
45
|
+
for (const s of list.slice(0, LIST_CAP)) {
|
|
46
|
+
const mark = s.disabled ? "\u26D4" : "\u2705";
|
|
47
|
+
const ti = TRANSPORT_ICON[s.transport] ?? "";
|
|
48
|
+
const scope = s.scope === "workspace" ? " \u00B7 ws" : "";
|
|
49
|
+
lines.push(`${mark} ${ti} ${trunc(s.name, 28)}${scope}`);
|
|
50
|
+
}
|
|
51
|
+
if (list.length > LIST_CAP) lines.push(`\u2026and ${list.length - LIST_CAP} more (use \u{1F527} Enable/Disable to browse).`);
|
|
52
|
+
}
|
|
53
|
+
lines.push("", "\u{1F9EA} Health-check runs a live connection test on enabled servers.");
|
|
54
|
+
const kb = new InlineKeyboard()
|
|
55
|
+
.text("\u{1F9EA} Health-check", "mcp:health")
|
|
56
|
+
.text("\u{1F527} Enable/Disable", "mcp:tog:0")
|
|
57
|
+
.row()
|
|
58
|
+
.text("\u{1F504} Restart agent", "mcp:restart")
|
|
59
|
+
.text("\u{1F501} Refresh", "mcp:refresh")
|
|
60
|
+
.row()
|
|
61
|
+
.text("\u2716 Close", "mcp:close");
|
|
62
|
+
return { text: lines.join("\n"), kb };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Build a paginated enable/disable view. */
|
|
66
|
+
function togglePanel(list: McpServer[], page: number): { text: string; kb: InlineKeyboard } {
|
|
67
|
+
const pages = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
|
|
68
|
+
const p = Math.min(Math.max(0, page), pages - 1);
|
|
69
|
+
const slice = list.slice(p * PAGE_SIZE, p * PAGE_SIZE + PAGE_SIZE);
|
|
70
|
+
const kb = new InlineKeyboard();
|
|
71
|
+
slice.forEach((s) => {
|
|
72
|
+
const idx = list.indexOf(s);
|
|
73
|
+
const label = s.disabled ? `\u2705 Enable ${trunc(s.name, 24)}` : `\u26D4 Disable ${trunc(s.name, 24)}`;
|
|
74
|
+
kb.text(label, `mcp:set:${idx}`).row();
|
|
75
|
+
});
|
|
76
|
+
if (pages > 1) {
|
|
77
|
+
if (p > 0) kb.text("\u25C0 Prev", `mcp:tog:${p - 1}`);
|
|
78
|
+
kb.text(`Page ${p + 1}/${pages}`, "mcp:noop");
|
|
79
|
+
if (p < pages - 1) kb.text("Next \u25B6", `mcp:tog:${p + 1}`);
|
|
80
|
+
kb.row();
|
|
81
|
+
}
|
|
82
|
+
kb.text("\u2B05 Back", "mcp:refresh");
|
|
83
|
+
const text = `\u{1F527} Enable/Disable MCP servers (${list.length})\nTap to toggle. Changes apply after \u{1F504} Restart agent.`;
|
|
84
|
+
return { text, kb };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function showMcp(ctx: Context, deps: BotDeps): Promise<void> {
|
|
88
|
+
const list = snapshot(ctx.chat!.id, deps);
|
|
89
|
+
const { text, kb } = mainPanel(list);
|
|
90
|
+
await deps.ephemeral.open(ctx);
|
|
91
|
+
await deps.ephemeral.reply(ctx, text, { reply_markup: kb });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function fmtProbe(r: McpProbeResult): string {
|
|
95
|
+
if (r.ok) {
|
|
96
|
+
const who = r.serverName ? ` \u00B7 ${trunc(r.serverName, 30)}` : "";
|
|
97
|
+
return `\u2705 ${trunc(r.name, 26)} ${r.ms ?? 0}ms${who}`;
|
|
98
|
+
}
|
|
99
|
+
return `\u274C ${trunc(r.name, 26)} ${trunc(r.error ?? "failed", 60)}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function runHealthCheck(ctx: Context, deps: BotDeps): Promise<void> {
|
|
103
|
+
const list = snapshot(ctx.chat!.id, deps);
|
|
104
|
+
const enabled = list.filter((s) => !s.disabled);
|
|
105
|
+
if (enabled.length === 0) {
|
|
106
|
+
await ctx.editMessageText("No enabled MCP servers to check.", {
|
|
107
|
+
reply_markup: new InlineKeyboard().text("\u2B05 Back", "mcp:refresh"),
|
|
108
|
+
}).catch(() => {});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const header = `\u{1F9EA} Health-check \u2014 probing ${enabled.length} enabled server(s)\u2026`;
|
|
112
|
+
await ctx.editMessageText(header).catch(() => {});
|
|
113
|
+
|
|
114
|
+
let lastEdit = 0;
|
|
115
|
+
const results = await probeAll(
|
|
116
|
+
enabled,
|
|
117
|
+
{ timeoutMs: deps.cfg.mcpProbeTimeoutMs, concurrency: deps.cfg.mcpProbeConcurrency },
|
|
118
|
+
(_r, done, total) => {
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
if (now - lastEdit < 1200 && done < total) return; // throttle progress edits
|
|
121
|
+
lastEdit = now;
|
|
122
|
+
void ctx.editMessageText(`${header}\n\nProgress: ${done}/${total}`).catch(() => {});
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const ok = results.filter((r) => r.ok).length;
|
|
127
|
+
const bad = results.length - ok;
|
|
128
|
+
const body = results
|
|
129
|
+
.slice()
|
|
130
|
+
.sort((a, b) => Number(a.ok) - Number(b.ok) || a.name.localeCompare(b.name))
|
|
131
|
+
.map(fmtProbe)
|
|
132
|
+
.join("\n");
|
|
133
|
+
const text = `\u{1F9EA} Health-check \u2014 \u2705 ${ok} connected \u00B7 \u274C ${bad} failed\n\n${trunc(body, 3500)}`;
|
|
134
|
+
const kb = new InlineKeyboard().text("\u{1F501} Re-check", "mcp:health").row().text("\u2B05 Back", "mcp:refresh");
|
|
135
|
+
await ctx.editMessageText(text, { reply_markup: kb }).catch(() => {});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function registerMcp(bot: Bot, deps: BotDeps): void {
|
|
139
|
+
bot.command("mcp", (ctx) => showMcp(ctx, deps));
|
|
140
|
+
|
|
141
|
+
bot.callbackQuery("mcp:noop", (ctx) => ctx.answerCallbackQuery());
|
|
142
|
+
|
|
143
|
+
bot.callbackQuery("mcp:close", async (ctx) => {
|
|
144
|
+
await ctx.answerCallbackQuery();
|
|
145
|
+
await ctx.deleteMessage().catch(() => {});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
bot.callbackQuery("mcp:refresh", async (ctx) => {
|
|
149
|
+
await ctx.answerCallbackQuery();
|
|
150
|
+
const list = snapshot(ctx.chat!.id, deps);
|
|
151
|
+
const { text, kb } = mainPanel(list);
|
|
152
|
+
await ctx.editMessageText(text, { reply_markup: kb }).catch(() => {});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
bot.callbackQuery("mcp:health", async (ctx) => {
|
|
156
|
+
await ctx.answerCallbackQuery({ text: "Checking\u2026" });
|
|
157
|
+
await runHealthCheck(ctx, deps);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
bot.callbackQuery(/^mcp:tog:(\d+)$/, async (ctx) => {
|
|
161
|
+
await ctx.answerCallbackQuery();
|
|
162
|
+
const list = snapshot(ctx.chat!.id, deps);
|
|
163
|
+
const { text, kb } = togglePanel(list, Number(ctx.match![1]));
|
|
164
|
+
await ctx.editMessageText(text, { reply_markup: kb }).catch(() => {});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
bot.callbackQuery(/^mcp:set:(\d+)$/, async (ctx) => {
|
|
168
|
+
const idx = Number(ctx.match![1]);
|
|
169
|
+
const cached = snapshots.get(ctx.chat!.id);
|
|
170
|
+
const server = cached?.[idx];
|
|
171
|
+
if (!server) {
|
|
172
|
+
await ctx.answerCallbackQuery({ text: "Expired \u2014 reopen /mcp." });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const res = setMcpDisabled(server, !server.disabled);
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
await ctx.answerCallbackQuery({ text: `Failed: ${res.error}`, show_alert: true });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
await ctx.answerCallbackQuery({ text: res.disabled ? `Disabled ${server.name}` : `Enabled ${server.name}` });
|
|
181
|
+
const page = Math.floor(idx / PAGE_SIZE);
|
|
182
|
+
const list = snapshot(ctx.chat!.id, deps); // re-list to reflect the change
|
|
183
|
+
const { text, kb } = togglePanel(list, page);
|
|
184
|
+
await ctx.editMessageText(`${text}\n\n\u26A0\uFE0F Tap \u{1F504} Restart agent (on the main panel) to apply.`, {
|
|
185
|
+
reply_markup: kb,
|
|
186
|
+
}).catch(() => {});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
bot.callbackQuery("mcp:restart", async (ctx) => {
|
|
190
|
+
await ctx.answerCallbackQuery({ text: "Restarting agent\u2026" });
|
|
191
|
+
await ctx.editMessageText("\u{1F504} Restarting the Grok agent to apply MCP changes\u2026").catch(() => {});
|
|
192
|
+
try {
|
|
193
|
+
await deps.acp.restart();
|
|
194
|
+
const list = snapshot(ctx.chat!.id, deps);
|
|
195
|
+
const { text, kb } = mainPanel(list);
|
|
196
|
+
await ctx.editMessageText(`\u2705 Agent restarted \u2014 MCP changes applied.\n\n${text}`, {
|
|
197
|
+
reply_markup: kb,
|
|
198
|
+
}).catch(() => {});
|
|
199
|
+
} catch (err) {
|
|
200
|
+
await ctx.editMessageText(`\u274C Restart failed: ${(err as Error).message}`, {
|
|
201
|
+
reply_markup: new InlineKeyboard().text("\u2B05 Back", "mcp:refresh"),
|
|
202
|
+
}).catch(() => {});
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu handler — maps the persistent reply-keyboard buttons (matched by emoji
|
|
3
|
+
* prefix for stateful ones) to actions, and provides inline submenus for Agent
|
|
4
|
+
* (real Grok modes), Reasoning, and Model. Changing a value re-renders the
|
|
5
|
+
* keyboard so its labels always reflect the current state.
|
|
6
|
+
*/
|
|
7
|
+
import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
8
|
+
import { reasoningLabel } from "../../app/reasoning.js";
|
|
9
|
+
import { listAgents } from "../../agents/catalog.js";
|
|
10
|
+
import { REASONING_LEVELS, type ReasoningEffort } from "../../app/types.js";
|
|
11
|
+
import type { BotDeps } from "../deps.js";
|
|
12
|
+
import { BAR_LABELS, compactKeyboard, mainMenuInline, MENU_BTN, RUNNING_BTN, STOP_BTN } from "../menu/keyboard.js";
|
|
13
|
+
import { refreshMenu } from "../menu/refresh.js";
|
|
14
|
+
import { showKillConfirm } from "./kill.js";
|
|
15
|
+
import { showMcp } from "./mcp.js";
|
|
16
|
+
import { showProjects } from "./projects.js";
|
|
17
|
+
import { showRunning } from "./running.js";
|
|
18
|
+
import { showAccounts } from "./accounts.js";
|
|
19
|
+
import { showSessions } from "./sessions.js";
|
|
20
|
+
import { showTasks } from "./tasks.js";
|
|
21
|
+
import { showUsage } from "./usage.js";
|
|
22
|
+
|
|
23
|
+
/** Open the full inline menu, showing the current agent/model/reasoning. */
|
|
24
|
+
export async function openMainMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
25
|
+
await deps.ephemeral.open(ctx);
|
|
26
|
+
const rt = deps.registry.get(ctx.chat!.id);
|
|
27
|
+
await deps.ephemeral.reply(ctx, "\u2699\uFE0F Menu", {
|
|
28
|
+
reply_markup: mainMenuInline({
|
|
29
|
+
agent: rt.agent || "default",
|
|
30
|
+
model: rt.model || "default",
|
|
31
|
+
reasoning: reasoningLabel(rt.reasoning),
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerMenu(bot: Bot, deps: BotDeps): void {
|
|
37
|
+
// Compact persistent bar.
|
|
38
|
+
bot.hears(BAR_LABELS, async (ctx) => {
|
|
39
|
+
deps.wizard.abort(ctx.chat.id);
|
|
40
|
+
switch (ctx.message?.text) {
|
|
41
|
+
case MENU_BTN:
|
|
42
|
+
return openMainMenu(ctx, deps);
|
|
43
|
+
case RUNNING_BTN:
|
|
44
|
+
return showRunning(ctx, deps);
|
|
45
|
+
case STOP_BTN: {
|
|
46
|
+
const rt = deps.registry.get(ctx.chat.id);
|
|
47
|
+
return void ctx.reply((await rt.cancel()) ? "\u23F9 Cancelling\u2026" : "Nothing is running.");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Inline menu actions.
|
|
53
|
+
bot.callbackQuery(/^m:(\w+)$/, (ctx) => dispatchMenu(ctx, deps, ctx.match![1]!));
|
|
54
|
+
|
|
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
|
+
await deps.registry.get(ctx.chat!.id).setAgentPref(mode.id);
|
|
60
|
+
await confirm(ctx, deps, `\u{1F916} Agent: ${mode.name}`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── Reasoning ──────────────────────────────────────────────────────────────
|
|
64
|
+
bot.callbackQuery(/^reason:(minimal|low|medium|high|max)$/, async (ctx) => {
|
|
65
|
+
const level = ctx.match![1] as ReasoningEffort;
|
|
66
|
+
deps.registry.get(ctx.chat!.id).setReasoningPref(level);
|
|
67
|
+
await confirm(ctx, deps, `\u{1F9E0} Reasoning: ${reasoningLabel(level)}`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── Model ────────────────────────────────────────────────────────────────
|
|
71
|
+
bot.callbackQuery(/^model:set:(\d+)$/, async (ctx) => {
|
|
72
|
+
const entry = deps.acp.availableModels[Number(ctx.match![1])];
|
|
73
|
+
if (!entry) return void ctx.answerCallbackQuery({ text: "Expired, tap Model again." });
|
|
74
|
+
const res = await deps.registry.get(ctx.chat!.id).setModelPref(entry.modelId);
|
|
75
|
+
await confirm(ctx, deps, res.ok ? `\u{1F9E9} Model: ${entry.name}` : `\u26A0\uFE0F Model set failed: ${res.error}`);
|
|
76
|
+
});
|
|
77
|
+
bot.callbackQuery("model:clear", async (ctx) => {
|
|
78
|
+
await deps.registry.get(ctx.chat!.id).setModelPref("");
|
|
79
|
+
await confirm(ctx, deps, "\u{1F9E9} Model: default");
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Dispatch an inline-menu action (`m:<action>`). */
|
|
84
|
+
async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promise<void> {
|
|
85
|
+
const chatId = ctx.chat!.id;
|
|
86
|
+
const rt = deps.registry.get(chatId);
|
|
87
|
+
switch (action) {
|
|
88
|
+
case "close":
|
|
89
|
+
await ctx.answerCallbackQuery();
|
|
90
|
+
return void ctx.deleteMessage().catch(() => {});
|
|
91
|
+
case "hidebar":
|
|
92
|
+
await ctx.answerCallbackQuery();
|
|
93
|
+
await ctx.deleteMessage().catch(() => {});
|
|
94
|
+
return void ctx.reply("\u{1F648} Bar hidden \u2014 send /menu to bring it back.", {
|
|
95
|
+
reply_markup: { remove_keyboard: true },
|
|
96
|
+
});
|
|
97
|
+
case "showbar":
|
|
98
|
+
await ctx.answerCallbackQuery();
|
|
99
|
+
return void ctx.reply("\u2328\uFE0F Bar restored.", { reply_markup: compactKeyboard() });
|
|
100
|
+
case "project":
|
|
101
|
+
await ctx.answerCallbackQuery();
|
|
102
|
+
return showProjects(ctx, deps);
|
|
103
|
+
case "running":
|
|
104
|
+
await ctx.answerCallbackQuery();
|
|
105
|
+
return showRunning(ctx, deps);
|
|
106
|
+
case "sessions":
|
|
107
|
+
await ctx.answerCallbackQuery();
|
|
108
|
+
return showSessions(ctx, deps);
|
|
109
|
+
case "tasks":
|
|
110
|
+
await ctx.answerCallbackQuery();
|
|
111
|
+
return showTasks(ctx, deps);
|
|
112
|
+
case "agent":
|
|
113
|
+
await ctx.answerCallbackQuery();
|
|
114
|
+
return showAgentMenu(ctx, deps);
|
|
115
|
+
case "model":
|
|
116
|
+
await ctx.answerCallbackQuery();
|
|
117
|
+
return showModelMenu(ctx, deps);
|
|
118
|
+
case "reasoning":
|
|
119
|
+
await ctx.answerCallbackQuery();
|
|
120
|
+
return showReasoningMenu(ctx, deps);
|
|
121
|
+
case "status":
|
|
122
|
+
await ctx.answerCallbackQuery();
|
|
123
|
+
await deps.statusPanel.refresh(chatId);
|
|
124
|
+
await deps.ephemeral.open(ctx);
|
|
125
|
+
return void deps.ephemeral.reply(ctx, deps.statusPanel.render(chatId));
|
|
126
|
+
case "usage":
|
|
127
|
+
await ctx.answerCallbackQuery();
|
|
128
|
+
return showUsage(ctx, deps);
|
|
129
|
+
case "accounts":
|
|
130
|
+
await ctx.answerCallbackQuery();
|
|
131
|
+
return showAccounts(ctx, deps);
|
|
132
|
+
case "mcp":
|
|
133
|
+
await ctx.answerCallbackQuery();
|
|
134
|
+
return showMcp(ctx, deps);
|
|
135
|
+
case "killall":
|
|
136
|
+
await ctx.answerCallbackQuery();
|
|
137
|
+
return showKillConfirm(ctx, deps);
|
|
138
|
+
case "new":
|
|
139
|
+
await ctx.answerCallbackQuery();
|
|
140
|
+
try {
|
|
141
|
+
await deps.registry.controller(chatId).addNew(rt.cwd, rt.projectName);
|
|
142
|
+
return refreshMenu(ctx, deps, `\u2728 New session in ${rt.projectName ?? rt.cwd}`);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
return void ctx.reply(`\u274C ${(e as Error).message}`);
|
|
145
|
+
}
|
|
146
|
+
case "stop":
|
|
147
|
+
return void ctx.answerCallbackQuery({ text: (await rt.cancel()) ? "Cancelling\u2026" : "Nothing is running" });
|
|
148
|
+
default:
|
|
149
|
+
return void ctx.answerCallbackQuery();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function confirm(ctx: Context, deps: BotDeps, text: string): Promise<void> {
|
|
154
|
+
await ctx.answerCallbackQuery({ text });
|
|
155
|
+
try {
|
|
156
|
+
await ctx.deleteMessage();
|
|
157
|
+
} catch {
|
|
158
|
+
/* ignore */
|
|
159
|
+
}
|
|
160
|
+
await deps.statusPanel.refresh(ctx.chat!.id);
|
|
161
|
+
await openMainMenu(ctx, deps); // reopen so the new value is visible
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function showAgentMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
165
|
+
const rt = deps.registry.get(ctx.chat!.id);
|
|
166
|
+
await ensureReady(ctx, rt);
|
|
167
|
+
await deps.ephemeral.open(ctx);
|
|
168
|
+
const modes = deps.acp.availableModes.slice(0, 60);
|
|
169
|
+
if (modes.length === 0) {
|
|
170
|
+
// Grok has no headless agent switch; surface the sub-agents it can delegate
|
|
171
|
+
// to (built-ins + any custom ones in ~/.grok/user-settings.json) as info.
|
|
172
|
+
const subs = listAgents(rt.cwd).map((s) => s.name);
|
|
173
|
+
const info = subs.length
|
|
174
|
+
? `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.`
|
|
175
|
+
: "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.";
|
|
176
|
+
await deps.ephemeral.reply(ctx, `Current agent: ${rt.agent || "default"}\n${info}`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const kb = new InlineKeyboard();
|
|
180
|
+
modes.forEach((m, i) => kb.text(`${m.id === rt.agent ? "\u2713 " : ""}${m.name}`, `agent:set:${i}`).row());
|
|
181
|
+
await deps.ephemeral.reply(ctx, `Current agent: ${rt.agent || "default"}\nChoose an agent:`, { reply_markup: kb });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function showReasoningMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
185
|
+
const rt = deps.registry.get(ctx.chat!.id);
|
|
186
|
+
await deps.ephemeral.open(ctx);
|
|
187
|
+
const kb = new InlineKeyboard();
|
|
188
|
+
REASONING_LEVELS.forEach((l) => kb.text(`${l === rt.reasoning ? "\u2713 " : ""}${reasoningLabel(l)}`, `reason:${l}`));
|
|
189
|
+
await deps.ephemeral.reply(ctx, `Current reasoning: ${reasoningLabel(rt.reasoning)}\nChoose effort:`, { reply_markup: kb });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function showModelMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
193
|
+
const rt = deps.registry.get(ctx.chat!.id);
|
|
194
|
+
await ensureReady(ctx, rt);
|
|
195
|
+
await deps.ephemeral.open(ctx);
|
|
196
|
+
const models = deps.acp.availableModels;
|
|
197
|
+
if (models.length === 0) {
|
|
198
|
+
await deps.ephemeral.reply(ctx, "No selectable models reported by Grok yet \u2014 send a message first, then try again.");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const current = rt.model || deps.acp.currentModelId;
|
|
202
|
+
const kb = new InlineKeyboard();
|
|
203
|
+
models.forEach((m, i) => kb.text(`${m.modelId === current ? "\u2713 " : ""}${m.name}`, `model:set:${i}`).row());
|
|
204
|
+
kb.text("Default (agent's model)", "model:clear");
|
|
205
|
+
await deps.ephemeral.reply(ctx, `Current model: ${rt.model || "default"}\nChoose a model:`, { reply_markup: kb });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Ensure a session is live so models/modes are populated; show typing meanwhile. */
|
|
209
|
+
async function ensureReady(ctx: Context, rt: { prepare: () => Promise<void> }): Promise<void> {
|
|
210
|
+
try {
|
|
211
|
+
await ctx.replyWithChatAction("typing");
|
|
212
|
+
} catch {
|
|
213
|
+
/* ignore */
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
await rt.prepare();
|
|
217
|
+
} catch {
|
|
218
|
+
/* menu will show whatever is available */
|
|
219
|
+
}
|
|
220
|
+
}
|