grok-telegram-bot 2.4.0 → 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 +38 -2
- package/CHANGELOG.md +119 -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/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +12 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +96 -15
- package/src/bot/bot.ts +122 -15
- package/src/bot/chat-controller.ts +52 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +159 -0
- package/src/bot/handlers/accounts.ts +7 -0
- 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/menu.ts +86 -24
- package/src/bot/handlers/message.ts +101 -21
- package/src/bot/handlers/photo.ts +123 -16
- package/src/bot/handlers/running.ts +150 -24
- package/src/bot/handlers/session-card.ts +13 -5
- package/src/bot/handlers/sessions.ts +68 -18
- 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 +53 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +12 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +300 -0
- package/src/bot/prompt-content.ts +3 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +94 -0
- package/src/bot/session-runtime.ts +647 -158
- package/src/bot/suggestions.ts +91 -31
- 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 +201 -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 +130 -28
- package/src/index.ts +205 -75
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/hashtags.ts +5 -1
- package/src/render/session-comment.ts +64 -7
- package/src/render/telegram-bridge.ts +360 -0
- package/src/render/tool-call.ts +56 -37
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +16 -4
- package/src/sessions/history.ts +50 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +17 -6
- package/scripts/analyze-jsonl.ts +0 -33
- package/scripts/delayed-restart.ps1 +0 -29
- package/scripts/probe-exit-response-shape.py +0 -77
- package/scripts/probe-plan-exit.py +0 -60
- package/scripts/probe-plan-exit2.py +0 -48
- package/scripts/probe-plan-fields.py +0 -41
- package/scripts/probe-plan-fields2.py +0 -58
- package/scripts/probe-plan-response-path.py +0 -48
- package/scripts/sample-claude-tooluse.ts +0 -21
- package/scripts/sample-kiro-events.ts +0 -31
- package/scripts/smoke-exit-plan.ts +0 -274
- package/scripts/smoke-exit-shapes.ts +0 -252
- package/scripts/smoke-import.mjs +0 -82
- package/scripts/smoke-import.ts +0 -73
|
@@ -57,7 +57,7 @@ export function registerDocuments(bot: Bot, deps: BotDeps): void {
|
|
|
57
57
|
|
|
58
58
|
const caption = ctx.message.caption ?? "";
|
|
59
59
|
const quoted = extractReplyContext(ctx);
|
|
60
|
-
const
|
|
60
|
+
const userMsgId = ctx.message.message_id;
|
|
61
61
|
|
|
62
62
|
let promptText: string;
|
|
63
63
|
if (looksLikeText(buf, doc.mime_type, name)) {
|
|
@@ -75,10 +75,37 @@ export function registerDocuments(bot: Bot, deps: BotDeps): void {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
try {
|
|
78
|
-
const
|
|
79
|
-
const
|
|
78
|
+
const { resolveScope } = await import("../scope.js");
|
|
79
|
+
const { adoptUserPrompt } = await import("../prompt-anchor.js");
|
|
80
|
+
const scope = resolveScope(ctx, deps);
|
|
81
|
+
const anchorPreview = caption.trim() || `File: ${name}`;
|
|
82
|
+
const anchor = await adoptUserPrompt(deps.api, {
|
|
83
|
+
chatId,
|
|
84
|
+
text: anchorPreview,
|
|
85
|
+
userMessageIds: [userMsgId],
|
|
86
|
+
messageThreadId: scope.threadExtra.message_thread_id,
|
|
87
|
+
projectName: scope.rt.projectName,
|
|
88
|
+
prefix: `\u{1F4CE} ${name}`,
|
|
89
|
+
// Re-post the file so it stays in chat after the user message is deleted.
|
|
90
|
+
media: [{ type: "document", fileId: doc.file_id, fileName: name }],
|
|
91
|
+
});
|
|
92
|
+
const outcome = await scope.rt.submit(
|
|
93
|
+
textPrompt(promptText, anchor?.replyTo ?? userMsgId, quoted, {
|
|
94
|
+
promptId: anchor?.promptId,
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
80
97
|
if (outcome === "queued") {
|
|
81
|
-
|
|
98
|
+
const extra: Record<string, unknown> = { ...scope.threadExtra };
|
|
99
|
+
if (anchor?.replyTo !== undefined) {
|
|
100
|
+
extra.reply_parameters = {
|
|
101
|
+
message_id: anchor.replyTo,
|
|
102
|
+
allow_sending_without_reply: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
await ctx.reply(
|
|
106
|
+
`\u{1F4E5} Queued "${name}" \u2014 will run after the current task.`,
|
|
107
|
+
extra,
|
|
108
|
+
);
|
|
82
109
|
}
|
|
83
110
|
} catch (e) {
|
|
84
111
|
log.warn(`submit failed for "${name}":`, (e as Error).message);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forum topic group: auto-setup, user-created topic binding, and path prompts.
|
|
3
|
+
*/
|
|
4
|
+
import type { Bot } from "grammy";
|
|
5
|
+
import { createLogger } from "../../logger.js";
|
|
6
|
+
import type { ForumManager } from "../../forum/manager.js";
|
|
7
|
+
import type { BotDeps } from "../deps.js";
|
|
8
|
+
import { FORUM_GENERAL_THREAD_ID, forumThreadId } from "../../forum/thread.js";
|
|
9
|
+
|
|
10
|
+
const log = createLogger("forum-handler");
|
|
11
|
+
|
|
12
|
+
const BIND_HINT =
|
|
13
|
+
`Send the **absolute project path** or an **exact** catalog project name to bind this topic.\n` +
|
|
14
|
+
`Example: \`H:\\\\Lucru\\\\Domains\\\\MyApp\``;
|
|
15
|
+
|
|
16
|
+
export function registerForum(bot: Bot, deps: BotDeps, forum: ForumManager): void {
|
|
17
|
+
const groupId = forum.groupId;
|
|
18
|
+
|
|
19
|
+
// Service message: user (or another admin) created a topic.
|
|
20
|
+
bot.on("message:forum_topic_created", async (ctx) => {
|
|
21
|
+
if (ctx.chat?.id !== groupId) return;
|
|
22
|
+
// Group ignored (not admin / no Topics / probe failed).
|
|
23
|
+
if (!forum.isReady) {
|
|
24
|
+
log.debug(`ignore forum_topic_created — forum not ready (${forum.getStatusText()})`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const created = ctx.message.forum_topic_created;
|
|
28
|
+
const threadId = ctx.message.message_thread_id;
|
|
29
|
+
if (!created || threadId === undefined) return;
|
|
30
|
+
// Skip General — always workspace-bound.
|
|
31
|
+
if (threadId === FORUM_GENERAL_THREAD_ID) return;
|
|
32
|
+
|
|
33
|
+
forum.noteUserTopic(threadId, created.name);
|
|
34
|
+
log.info(`user topic created: ${created.name} (#${threadId})`);
|
|
35
|
+
|
|
36
|
+
// Exact catalog name → bind immediately (no message required).
|
|
37
|
+
const auto = await forum.tryAutoBindByTopicName(threadId, created.name);
|
|
38
|
+
if (auto.status === "bound") {
|
|
39
|
+
await ctx
|
|
40
|
+
.reply(
|
|
41
|
+
`\u2705 Topic **${created.name}** auto-bound to project:\n\`${auto.binding.projectPath}\`\n\nYou can chat here now.`,
|
|
42
|
+
{ parse_mode: "Markdown", message_thread_id: threadId },
|
|
43
|
+
)
|
|
44
|
+
.catch(() => {});
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (auto.status === "already_bound") {
|
|
49
|
+
await ctx
|
|
50
|
+
.reply(
|
|
51
|
+
`\u{1F4CC} New topic **${created.name}**.\n\n` +
|
|
52
|
+
`Exact catalog match \`${auto.projectPath}\` is already bound to topic #${auto.otherThreadId}.\n` +
|
|
53
|
+
BIND_HINT,
|
|
54
|
+
{ parse_mode: "Markdown", message_thread_id: threadId },
|
|
55
|
+
)
|
|
56
|
+
.catch(() => {});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await ctx
|
|
61
|
+
.reply(
|
|
62
|
+
`\u{1F4CC} New topic **${created.name}**.\n\n` +
|
|
63
|
+
`No exact catalog project matched this name.\n` +
|
|
64
|
+
BIND_HINT,
|
|
65
|
+
{ parse_mode: "Markdown", message_thread_id: threadId },
|
|
66
|
+
)
|
|
67
|
+
.catch(() => {});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Re-probe when the bot is promoted/demoted in the configured group.
|
|
71
|
+
bot.on("my_chat_member", async (ctx) => {
|
|
72
|
+
if (ctx.chat?.id !== groupId) return;
|
|
73
|
+
const status = ctx.myChatMember.new_chat_member.status;
|
|
74
|
+
if (status === "administrator" || status === "creator") {
|
|
75
|
+
log.info(`bot became ${status} in topic group — re-running forum setup`);
|
|
76
|
+
void forum.ensureSetup().catch((e) => {
|
|
77
|
+
log.warn(`forum re-setup after promotion failed: ${(e as Error).message}`);
|
|
78
|
+
});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (status === "member" || status === "restricted" || status === "left" || status === "kicked") {
|
|
82
|
+
forum.markDisabled(
|
|
83
|
+
"not_admin",
|
|
84
|
+
`bot status is now "${status}" — forum topic management ignored until the bot is admin again`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Optional: re-run setup command for admins in the group.
|
|
90
|
+
bot.command("forum_setup", async (ctx) => {
|
|
91
|
+
const threadId = ctx.message?.message_thread_id;
|
|
92
|
+
const replyOpts =
|
|
93
|
+
threadId !== undefined ? { message_thread_id: threadId } : {};
|
|
94
|
+
if (ctx.chat?.id !== groupId) {
|
|
95
|
+
await ctx.reply("Use this command inside the configured forum group.", replyOpts).catch(() => {});
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
await ctx
|
|
99
|
+
.reply(
|
|
100
|
+
"Setting up forum topics (this can take a while for large catalogs)…",
|
|
101
|
+
replyOpts,
|
|
102
|
+
)
|
|
103
|
+
.catch(() => {});
|
|
104
|
+
try {
|
|
105
|
+
await forum.ensureSetup();
|
|
106
|
+
if (!forum.isReady) {
|
|
107
|
+
await ctx
|
|
108
|
+
.reply(
|
|
109
|
+
`\u26A0\uFE0F Forum setup skipped / group ignored.\n${forum.getStatusText()}`,
|
|
110
|
+
replyOpts,
|
|
111
|
+
)
|
|
112
|
+
.catch(() => {});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const n = forum.store.all().length;
|
|
116
|
+
await ctx
|
|
117
|
+
.reply(`\u2705 Forum setup done. ${n} topic(s) mapped.\n${forum.getStatusText()}`, replyOpts)
|
|
118
|
+
.catch(() => {});
|
|
119
|
+
} catch (e) {
|
|
120
|
+
await ctx.reply(`Setup failed: ${(e as Error).message}`, replyOpts).catch(() => {});
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
void deps;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve a forum-group text message to a SessionRuntime, or handle bind flow.
|
|
129
|
+
* Returns "handled" when the message was consumed (bind ask / bind result).
|
|
130
|
+
*/
|
|
131
|
+
export async function resolveForumRuntime(
|
|
132
|
+
deps: BotDeps,
|
|
133
|
+
forum: ForumManager,
|
|
134
|
+
chatId: number,
|
|
135
|
+
threadId: number | undefined,
|
|
136
|
+
text: string,
|
|
137
|
+
messageId: number,
|
|
138
|
+
): Promise<{ rt: import("../session-runtime.js").SessionRuntime } | "handled" | "ignore"> {
|
|
139
|
+
if (chatId !== forum.groupId) return "ignore";
|
|
140
|
+
// Not admin / Topics off / probe failed — do not steal group messages.
|
|
141
|
+
if (!forum.isReady) return "ignore";
|
|
142
|
+
const tid = forumThreadId(threadId);
|
|
143
|
+
|
|
144
|
+
// General topic → always workspace (no path prompt).
|
|
145
|
+
if (tid === FORUM_GENERAL_THREAD_ID) {
|
|
146
|
+
const cwd = deps.cfg.workspace;
|
|
147
|
+
if (!forum.store.get(tid)?.projectPath) {
|
|
148
|
+
forum.store.bindProject(tid, cwd, "General", "general");
|
|
149
|
+
}
|
|
150
|
+
const rt = deps.registry.getForumTopic(chatId, tid, cwd, "General");
|
|
151
|
+
return { rt };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Ensure we know about this thread.
|
|
155
|
+
let binding = forum.store.get(tid);
|
|
156
|
+
if (!binding) {
|
|
157
|
+
binding = forum.noteUserTopic(tid, `Topic ${tid}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Unbound only: try to interpret text as path / exact catalog name (do not
|
|
161
|
+
// steal normal prompts when already bound — even if pending flag is stale).
|
|
162
|
+
if (!binding.projectPath) {
|
|
163
|
+
const result = forum.tryBindPath(tid, text);
|
|
164
|
+
if (result.ok) {
|
|
165
|
+
const iconNote = result.binding.iconPath ? `\nIcon: ${result.binding.iconPath}` : "";
|
|
166
|
+
await deps.api
|
|
167
|
+
.sendMessage(
|
|
168
|
+
chatId,
|
|
169
|
+
`\u2705 Bound to project:\n\`${result.binding.projectPath}\`${iconNote}\n\nYou can chat here now.`,
|
|
170
|
+
{ message_thread_id: tid, parse_mode: "Markdown", reply_parameters: { message_id: messageId } },
|
|
171
|
+
)
|
|
172
|
+
.catch(() => {});
|
|
173
|
+
// Warm runtime; path-only message is not submitted as an agent prompt.
|
|
174
|
+
const resolved = forum.resolveCwd(tid);
|
|
175
|
+
if (resolved) {
|
|
176
|
+
deps.registry.getForumTopic(chatId, tid, resolved.cwd, resolved.projectName);
|
|
177
|
+
}
|
|
178
|
+
return "handled";
|
|
179
|
+
}
|
|
180
|
+
await deps.api
|
|
181
|
+
.sendMessage(
|
|
182
|
+
chatId,
|
|
183
|
+
`\u2753 ${result.error}\n\n${BIND_HINT.replace(/\*\*/g, "")}`,
|
|
184
|
+
{ message_thread_id: tid, reply_parameters: { message_id: messageId } },
|
|
185
|
+
)
|
|
186
|
+
.catch(() => {});
|
|
187
|
+
return "handled";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const resolved = forum.resolveCwd(tid);
|
|
191
|
+
if (!resolved) {
|
|
192
|
+
forum.store.markPending(tid);
|
|
193
|
+
await deps.api
|
|
194
|
+
.sendMessage(
|
|
195
|
+
chatId,
|
|
196
|
+
`\u2753 This topic is not linked to a project yet.\n${BIND_HINT.replace(/\*\*/g, "")}`,
|
|
197
|
+
{ message_thread_id: tid },
|
|
198
|
+
)
|
|
199
|
+
.catch(() => {});
|
|
200
|
+
return "handled";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Multi-session controller for this topic (own model/reasoning/running list).
|
|
204
|
+
// First message with no sessionId creates one via ensureSession on submit.
|
|
205
|
+
const controller = deps.registry.forumController(chatId, tid, resolved.cwd, resolved.projectName);
|
|
206
|
+
return { rt: controller.foreground() };
|
|
207
|
+
}
|
package/src/bot/handlers/menu.ts
CHANGED
|
@@ -11,8 +11,17 @@ import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
|
11
11
|
import { reasoningLabel } from "../../app/reasoning.js";
|
|
12
12
|
import { REASONING_LEVELS, type ReasoningEffort } from "../../app/types.js";
|
|
13
13
|
import type { BotDeps } from "../deps.js";
|
|
14
|
-
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";
|
|
15
23
|
import { refreshMenu } from "../menu/refresh.js";
|
|
24
|
+
import { resolveScope } from "../scope.js";
|
|
16
25
|
import { showImportSources } from "./import-session.js";
|
|
17
26
|
import { showKillConfirm } from "./kill.js";
|
|
18
27
|
import { showMcp } from "./mcp.js";
|
|
@@ -23,14 +32,25 @@ import { showSessions } from "./sessions.js";
|
|
|
23
32
|
import { showTasks } from "./tasks.js";
|
|
24
33
|
import { showUsage } from "./usage.js";
|
|
25
34
|
|
|
26
|
-
/** Open the full inline menu, showing the current model/reasoning. */
|
|
35
|
+
/** Open the full inline menu, showing the current model/reasoning (topic-aware). */
|
|
27
36
|
export async function openMainMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
28
37
|
await deps.ephemeral.open(ctx);
|
|
29
|
-
const
|
|
30
|
-
|
|
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, {
|
|
31
48
|
reply_markup: mainMenuInline({
|
|
32
|
-
model: rt.model || "default",
|
|
33
|
-
reasoning: reasoningLabel(rt.reasoning),
|
|
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,
|
|
34
54
|
}),
|
|
35
55
|
});
|
|
36
56
|
}
|
|
@@ -39,14 +59,27 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
|
|
|
39
59
|
// Compact persistent bar.
|
|
40
60
|
bot.hears(BAR_LABELS, async (ctx) => {
|
|
41
61
|
deps.wizard.abort(ctx.chat.id);
|
|
62
|
+
const scope = resolveScope(ctx, deps);
|
|
42
63
|
switch (ctx.message?.text) {
|
|
43
64
|
case MENU_BTN:
|
|
44
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
|
+
}
|
|
45
75
|
case RUNNING_BTN:
|
|
46
76
|
return showRunning(ctx, deps);
|
|
47
77
|
case STOP_BTN: {
|
|
48
|
-
const
|
|
49
|
-
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
|
+
);
|
|
50
83
|
}
|
|
51
84
|
}
|
|
52
85
|
});
|
|
@@ -57,7 +90,7 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
|
|
|
57
90
|
// ── Reasoning ──────────────────────────────────────────────────────────────
|
|
58
91
|
bot.callbackQuery(/^reason:(minimal|low|medium|high|max)$/, async (ctx) => {
|
|
59
92
|
const level = ctx.match![1] as ReasoningEffort;
|
|
60
|
-
|
|
93
|
+
resolveScope(ctx, deps).rt.setReasoningPref(level);
|
|
61
94
|
await confirm(ctx, deps, `\u{1F9E0} Reasoning: ${reasoningLabel(level)}`);
|
|
62
95
|
});
|
|
63
96
|
|
|
@@ -66,37 +99,52 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
|
|
|
66
99
|
const entry = deps.acp.availableModels[Number(ctx.match![1])];
|
|
67
100
|
if (!entry) return void ctx.answerCallbackQuery({ text: "Expired, tap Model again." });
|
|
68
101
|
await ctx.answerCallbackQuery({ text: `\u{1F9E9} Model: ${entry.name}` });
|
|
69
|
-
const res = await
|
|
102
|
+
const res = await resolveScope(ctx, deps).rt.setModelPref(entry.modelId);
|
|
70
103
|
if (!res.ok) {
|
|
71
|
-
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(() => {});
|
|
72
105
|
}
|
|
73
106
|
await confirmUi(ctx, deps);
|
|
74
107
|
});
|
|
75
108
|
bot.callbackQuery("model:clear", async (ctx) => {
|
|
76
109
|
await ctx.answerCallbackQuery({ text: "\u{1F9E9} Model: default" });
|
|
77
|
-
await
|
|
110
|
+
await resolveScope(ctx, deps).rt.setModelPref("");
|
|
78
111
|
await confirmUi(ctx, deps);
|
|
79
112
|
});
|
|
80
113
|
}
|
|
81
114
|
|
|
82
115
|
/** Dispatch an inline-menu action (`m:<action>`). */
|
|
83
116
|
async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promise<void> {
|
|
84
|
-
const
|
|
85
|
-
const rt =
|
|
117
|
+
const scope = resolveScope(ctx, deps);
|
|
118
|
+
const { chatId, rt, controller, threadExtra, isForum, projectName, projectPath } = scope;
|
|
86
119
|
switch (action) {
|
|
87
120
|
case "close":
|
|
88
121
|
await ctx.answerCallbackQuery();
|
|
89
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
|
+
);
|
|
90
130
|
case "hidebar":
|
|
91
131
|
await ctx.answerCallbackQuery();
|
|
92
132
|
await ctx.deleteMessage().catch(() => {});
|
|
93
133
|
return void ctx.reply("\u{1F648} Bar hidden \u2014 send /menu to bring it back.", {
|
|
94
134
|
reply_markup: { remove_keyboard: true },
|
|
135
|
+
...threadExtra,
|
|
95
136
|
});
|
|
96
137
|
case "showbar":
|
|
97
138
|
await ctx.answerCallbackQuery();
|
|
98
|
-
return void ctx.reply("\u2328\uFE0F Bar restored.", {
|
|
139
|
+
return void ctx.reply("\u2328\uFE0F Bar restored.", {
|
|
140
|
+
reply_markup: compactKeyboard(),
|
|
141
|
+
...threadExtra,
|
|
142
|
+
});
|
|
99
143
|
case "project":
|
|
144
|
+
if (isForum) {
|
|
145
|
+
await ctx.answerCallbackQuery({ text: "Project is fixed to this topic", show_alert: true });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
100
148
|
await ctx.answerCallbackQuery();
|
|
101
149
|
return showProjects(ctx, deps);
|
|
102
150
|
case "running":
|
|
@@ -142,17 +190,26 @@ async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promis
|
|
|
142
190
|
await ctx.answerCallbackQuery();
|
|
143
191
|
return showKillConfirm(ctx, deps);
|
|
144
192
|
case "new":
|
|
145
|
-
await ctx.answerCallbackQuery();
|
|
193
|
+
await ctx.answerCallbackQuery({ text: "Creating session\u2026" });
|
|
194
|
+
await ctx.reply("\u2728 Creating new session\u2026", threadExtra).catch(() => {});
|
|
146
195
|
try {
|
|
147
|
-
await
|
|
196
|
+
await controller.addNew(rt.cwd, rt.projectName);
|
|
148
197
|
return refreshMenu(ctx, deps, `\u2728 New session in ${rt.projectName ?? rt.cwd}`);
|
|
149
198
|
} catch (e) {
|
|
150
|
-
return void ctx.reply(`\u274C ${(e as Error).message}
|
|
199
|
+
return void ctx.reply(`\u274C ${(e as Error).message}`, threadExtra);
|
|
151
200
|
}
|
|
152
201
|
case "stop": {
|
|
153
202
|
// Answer first so a slow cancel never times out the callback query.
|
|
154
|
-
|
|
155
|
-
|
|
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(() => {});
|
|
156
213
|
return;
|
|
157
214
|
}
|
|
158
215
|
default:
|
|
@@ -178,20 +235,25 @@ async function confirmUi(ctx: Context, deps: BotDeps): Promise<void> {
|
|
|
178
235
|
}
|
|
179
236
|
|
|
180
237
|
async function showReasoningMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
181
|
-
const rt =
|
|
238
|
+
const rt = resolveScope(ctx, deps).rt;
|
|
182
239
|
await deps.ephemeral.open(ctx);
|
|
183
240
|
const kb = new InlineKeyboard();
|
|
184
241
|
REASONING_LEVELS.forEach((l) => kb.text(`${l === rt.reasoning ? "\u2713 " : ""}${reasoningLabel(l)}`, `reason:${l}`));
|
|
185
|
-
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
|
+
});
|
|
186
245
|
}
|
|
187
246
|
|
|
188
247
|
async function showModelMenu(ctx: Context, deps: BotDeps): Promise<void> {
|
|
189
|
-
const rt =
|
|
248
|
+
const rt = resolveScope(ctx, deps).rt;
|
|
190
249
|
await ensureReady(ctx, rt);
|
|
191
250
|
await deps.ephemeral.open(ctx);
|
|
192
251
|
const models = deps.acp.availableModels;
|
|
193
252
|
if (models.length === 0) {
|
|
194
|
-
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
|
+
);
|
|
195
257
|
return;
|
|
196
258
|
}
|
|
197
259
|
const current = rt.model || deps.acp.currentModelId;
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* rapid consecutive text messages per chat within a short debounce window
|
|
9
9
|
* (`MESSAGE_BATCH_MS`) into a single prompt — one submission, one confirmation.
|
|
10
10
|
*
|
|
11
|
+
* User messages are replaced by a bot-owned prompt anchor (`#prompt_<id>`) so
|
|
12
|
+
* the chat shows immediate life while CLI/ACP starts, and all AI replies thread
|
|
13
|
+
* to that anchor with the same searchable tag.
|
|
14
|
+
*
|
|
11
15
|
* While a turn is running, the combined message is queued and runs
|
|
12
16
|
* automatically when the current turn finishes.
|
|
13
17
|
* (Wizard input and menu-button text are intercepted by earlier handlers.)
|
|
@@ -15,89 +19,165 @@
|
|
|
15
19
|
import type { Bot } from "grammy";
|
|
16
20
|
import { textPrompt } from "../../app/types.js";
|
|
17
21
|
import { createLogger } from "../../logger.js";
|
|
22
|
+
import { batchKey, forumThreadId } from "../../forum/thread.js";
|
|
18
23
|
import type { BotDeps } from "../deps.js";
|
|
24
|
+
import { adoptUserPrompt } from "../prompt-anchor.js";
|
|
19
25
|
import { extractReplyContext } from "../reply-context.js";
|
|
26
|
+
import { resolveForumRuntime } from "./forum.js";
|
|
20
27
|
|
|
21
28
|
const log = createLogger("message");
|
|
22
29
|
|
|
23
|
-
/** A pending burst of text messages from one chat, awaiting coalescing. */
|
|
30
|
+
/** A pending burst of text messages from one chat/topic, awaiting coalescing. */
|
|
24
31
|
interface TextBatch {
|
|
25
32
|
parts: string[];
|
|
26
33
|
ids: number[];
|
|
34
|
+
/** Forum topic thread id (undefined for private chats / General without id). */
|
|
35
|
+
threadId?: number;
|
|
27
36
|
/** Reference content if the burst began as a reply to another message. */
|
|
28
37
|
quoted?: string;
|
|
29
38
|
timer: NodeJS.Timeout;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
export function registerMessages(bot: Bot, deps: BotDeps): void {
|
|
33
|
-
const batches = new Map<
|
|
42
|
+
const batches = new Map<string, TextBatch>();
|
|
34
43
|
const windowMs = deps.cfg.messageBatchMs;
|
|
35
44
|
|
|
36
|
-
const arm = (
|
|
37
|
-
setTimeout(() => void flush(deps, batches,
|
|
45
|
+
const arm = (key: string): NodeJS.Timeout =>
|
|
46
|
+
setTimeout(() => void flush(deps, batches, key), windowMs);
|
|
38
47
|
|
|
39
48
|
bot.on("message:text", async (ctx) => {
|
|
40
49
|
const text = ctx.message.text;
|
|
41
50
|
if (!text.trim()) return;
|
|
51
|
+
// Slash commands are handled by bot.command / menu — never batch as agent prompts.
|
|
52
|
+
// (Otherwise /forum_setup and /help would also hit the debounce → agent path.)
|
|
53
|
+
if (!text.includes("\n") && text.startsWith("/")) return;
|
|
54
|
+
|
|
42
55
|
const chatId = ctx.chat.id;
|
|
43
56
|
const id = ctx.message.message_id;
|
|
57
|
+
const rawThreadId = ctx.message.message_thread_id;
|
|
58
|
+
const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
|
|
59
|
+
const threadId = isForum ? forumThreadId(rawThreadId) : rawThreadId;
|
|
44
60
|
const quoted = extractReplyContext(ctx);
|
|
61
|
+
const key = batchKey(chatId, rawThreadId, isForum);
|
|
45
62
|
|
|
46
|
-
const batch = batches.get(
|
|
63
|
+
const batch = batches.get(key);
|
|
47
64
|
if (batch) {
|
|
48
65
|
clearTimeout(batch.timer);
|
|
49
66
|
batch.parts.push(text);
|
|
50
67
|
batch.ids.push(id);
|
|
51
68
|
if (quoted && !batch.quoted) batch.quoted = quoted;
|
|
52
|
-
batch.timer = arm(
|
|
69
|
+
batch.timer = arm(key);
|
|
53
70
|
return;
|
|
54
71
|
}
|
|
55
|
-
batches.set(
|
|
72
|
+
batches.set(key, {
|
|
73
|
+
parts: [text],
|
|
74
|
+
ids: [id],
|
|
75
|
+
threadId,
|
|
76
|
+
quoted,
|
|
77
|
+
timer: arm(key),
|
|
78
|
+
});
|
|
56
79
|
});
|
|
57
80
|
}
|
|
58
81
|
|
|
59
82
|
/** Coalesce a chat's buffered parts into one prompt and submit it once. */
|
|
60
|
-
async function flush(deps: BotDeps, batches: Map<
|
|
61
|
-
const batch = batches.get(
|
|
83
|
+
async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string): Promise<void> {
|
|
84
|
+
const batch = batches.get(key);
|
|
62
85
|
if (!batch) return;
|
|
63
|
-
batches.delete(
|
|
86
|
+
batches.delete(key);
|
|
64
87
|
|
|
65
88
|
// Telegram splits at 4096 chars, almost always on a line boundary, so
|
|
66
89
|
// rejoining with a newline reconstructs the original text faithfully.
|
|
67
90
|
const combined = batch.parts.join("\n").trim();
|
|
68
91
|
if (!combined) return;
|
|
69
92
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
const [chatIdStr] = key.split(":");
|
|
94
|
+
const chatId = Number(chatIdStr);
|
|
95
|
+
const threadId = batch.threadId;
|
|
96
|
+
|
|
97
|
+
// Defense-in-depth: never submit slash-only lines as agent prompts.
|
|
73
98
|
if (batch.parts.length === 1 && !combined.includes("\n") && combined.startsWith("/")) {
|
|
74
|
-
await send(deps, chatId, "Unknown command. Type /help to see what I can do.");
|
|
75
99
|
return;
|
|
76
100
|
}
|
|
77
101
|
|
|
78
|
-
|
|
102
|
+
let rt = deps.registry.get(chatId);
|
|
103
|
+
// Forum group: topic-scoped multi-session controller (model/reasoning/running).
|
|
104
|
+
if (deps.forum?.isActiveForumChat(chatId)) {
|
|
105
|
+
const resolved = await resolveForumRuntime(
|
|
106
|
+
deps,
|
|
107
|
+
deps.forum,
|
|
108
|
+
chatId,
|
|
109
|
+
threadId,
|
|
110
|
+
combined,
|
|
111
|
+
batch.ids[0]!,
|
|
112
|
+
);
|
|
113
|
+
if (resolved === "handled" || resolved === "ignore") return;
|
|
114
|
+
rt = resolved.rt;
|
|
115
|
+
// If nothing is selected / no session yet, ensure a new session is created
|
|
116
|
+
// on first message (ensureSession inside submit). If FG has no sessionId
|
|
117
|
+
// after a closed session, start a fresh one for this topic.
|
|
118
|
+
if (!rt.sessionId && !rt.isBusy) {
|
|
119
|
+
try {
|
|
120
|
+
await rt.startNewSession(rt.cwd, rt.projectName);
|
|
121
|
+
} catch {
|
|
122
|
+
/* ensureSession on submit will retry */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
79
127
|
const note = batch.parts.length > 1 ? ` (combined ${batch.parts.length} messages)` : "";
|
|
128
|
+
// Hoisted so a submit failure after a successful adopt can still thread the error.
|
|
129
|
+
let replyTo: number | undefined = batch.ids[0];
|
|
80
130
|
try {
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
const
|
|
131
|
+
// Instant bot anchor: user sees the prompt adopted immediately while CLI warms.
|
|
132
|
+
// All AI output + Done reply to this message and carry #prompt_<id>.
|
|
133
|
+
const anchor = await adoptUserPrompt(deps.api, {
|
|
134
|
+
chatId,
|
|
135
|
+
text: combined,
|
|
136
|
+
userMessageIds: batch.ids,
|
|
137
|
+
messageThreadId: threadId,
|
|
138
|
+
projectName: rt.projectName,
|
|
139
|
+
prefix: "\u{1F4DD} Prompt",
|
|
140
|
+
});
|
|
141
|
+
replyTo = anchor?.replyTo ?? batch.ids[0];
|
|
142
|
+
const outcome = await rt.submit(
|
|
143
|
+
textPrompt(combined, replyTo, batch.quoted, { promptId: anchor?.promptId }),
|
|
144
|
+
);
|
|
84
145
|
if (outcome === "queued") {
|
|
85
146
|
await send(
|
|
86
147
|
deps,
|
|
87
148
|
chatId,
|
|
88
149
|
`\u{1F4E5} Queued (position ${rt.queueLength})${note} \u2014 I'm still working on the previous task. It'll run next.`,
|
|
150
|
+
threadId,
|
|
151
|
+
replyTo,
|
|
89
152
|
);
|
|
90
153
|
}
|
|
91
154
|
// "ran": turn started; complexity is steered silently by the agent.
|
|
92
155
|
} catch (err) {
|
|
93
156
|
log.warn(`submit failed for chat ${chatId}: ${(err as Error).message}`);
|
|
94
|
-
await send(
|
|
157
|
+
await send(
|
|
158
|
+
deps,
|
|
159
|
+
chatId,
|
|
160
|
+
`\u274C Couldn't start your message: ${(err as Error).message}`,
|
|
161
|
+
threadId,
|
|
162
|
+
replyTo,
|
|
163
|
+
);
|
|
95
164
|
}
|
|
96
165
|
}
|
|
97
166
|
|
|
98
|
-
async function send(
|
|
167
|
+
async function send(
|
|
168
|
+
deps: BotDeps,
|
|
169
|
+
chatId: number,
|
|
170
|
+
text: string,
|
|
171
|
+
threadId?: number,
|
|
172
|
+
replyTo?: number,
|
|
173
|
+
): Promise<void> {
|
|
99
174
|
try {
|
|
100
|
-
|
|
175
|
+
const extra: Record<string, unknown> = {};
|
|
176
|
+
if (threadId !== undefined) extra.message_thread_id = threadId;
|
|
177
|
+
if (replyTo !== undefined) {
|
|
178
|
+
extra.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
|
|
179
|
+
}
|
|
180
|
+
await deps.api.sendMessage(chatId, text, extra);
|
|
101
181
|
} catch {
|
|
102
182
|
/* non-fatal */
|
|
103
183
|
}
|