grok-telegram-bot 2.5.0 → 2.7.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 +13 -0
- package/CHANGELOG.md +106 -0
- package/README.md +20 -5
- package/docs/GROUP.md +39 -4
- package/docs/INSTALL.md +2 -0
- package/package.json +4 -4
- package/scripts/setup.mjs +20 -3
- package/src/app/instance.ts +223 -0
- package/src/app/types.ts +34 -1
- package/src/bot/ask-user-service.ts +226 -0
- package/src/bot/auth.ts +5 -1
- package/src/bot/bot.ts +105 -4
- package/src/bot/chat-controller.ts +129 -0
- package/src/bot/commands.ts +22 -2
- package/src/bot/group-memory.ts +192 -12
- package/src/bot/handlers/forum.ts +16 -6
- package/src/bot/handlers/grok-slash.ts +336 -0
- package/src/bot/handlers/message.ts +165 -25
- package/src/bot/handlers/photo.ts +4 -1
- package/src/bot/handlers/system.ts +63 -1
- package/src/bot/image-return.ts +4 -1
- package/src/bot/manager-context.ts +208 -0
- package/src/bot/manager-jobs.ts +142 -0
- package/src/bot/menu/ephemeral.ts +4 -1
- package/src/bot/plan-exit-service.ts +169 -0
- package/src/bot/prompt-anchor.ts +2 -3
- package/src/bot/prompt-content.ts +5 -0
- package/src/bot/registry.ts +11 -2
- package/src/bot/scope.ts +9 -8
- package/src/bot/session-runtime.ts +665 -55
- package/src/bot/telegram-actions.ts +728 -38
- package/src/bot/telegram-bots.ts +2 -1
- package/src/bot/telegram-io.ts +4 -1
- package/src/cli.ts +43 -7
- package/src/config.ts +35 -25
- package/src/forum/manager.ts +2 -1
- package/src/forum/thread.ts +33 -0
- package/src/grok/client.ts +29 -5
- package/src/grok/plan-approval.ts +8 -0
- package/src/index.ts +4 -0
- package/src/render/manager-directive.ts +137 -0
- package/src/render/session-comment.ts +10 -0
- package/src/render/telegram-bridge.ts +118 -14
- package/src/service/linux.ts +21 -15
- package/src/service/macos.ts +20 -15
- package/src/service/platform.ts +12 -3
- package/src/service/types.ts +6 -0
- package/src/service/windows.ts +31 -22
- package/src/sessions/history.ts +18 -0
- package/src/stream/streamer.ts +46 -10
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Grok `ask_user_question` reverse requests.
|
|
3
|
+
* Headless default used to skip; we now present options as Telegram buttons.
|
|
4
|
+
*/
|
|
5
|
+
import type { Api } from "grammy";
|
|
6
|
+
import { InlineKeyboard } from "grammy";
|
|
7
|
+
import { outboundThreadExtra } from "../forum/thread.js";
|
|
8
|
+
import { createLogger } from "../logger.js";
|
|
9
|
+
import type { RuntimeRegistry } from "./registry.js";
|
|
10
|
+
|
|
11
|
+
const log = createLogger("ask-user");
|
|
12
|
+
const TIMEOUT_MS = 30 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
export interface InterviewQuestion {
|
|
15
|
+
id: string;
|
|
16
|
+
prompt: string;
|
|
17
|
+
options: Array<{ id: string; label: string }>;
|
|
18
|
+
multi: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** ACP reverse-request result (externally tagged enum, smoke-verified skip). */
|
|
22
|
+
export type AskUserResult =
|
|
23
|
+
| { SkipInterview: null }
|
|
24
|
+
| { SubmitAnswers: { answers: Array<{ questionId: string; selected: string[] }> } };
|
|
25
|
+
|
|
26
|
+
interface Pending {
|
|
27
|
+
resolve: (r: AskUserResult) => void;
|
|
28
|
+
chatId: number;
|
|
29
|
+
messageId?: number;
|
|
30
|
+
questions: InterviewQuestion[];
|
|
31
|
+
picked: Map<string, Set<string>>;
|
|
32
|
+
index: number;
|
|
33
|
+
timer: NodeJS.Timeout;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class AskUserService {
|
|
37
|
+
private readonly pending = new Map<string, Pending>();
|
|
38
|
+
private seq = 0;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly api: Api,
|
|
42
|
+
private readonly registry: RuntimeRegistry,
|
|
43
|
+
public autoSkip = false,
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
async handle(params: Record<string, unknown>): Promise<AskUserResult> {
|
|
47
|
+
const questions = parseQuestions(params);
|
|
48
|
+
const sessionId = str(params.sessionId) || str(params.session_id) || "";
|
|
49
|
+
if (this.autoSkip || questions.length === 0) {
|
|
50
|
+
log.info(`skip ask_user_question (${questions.length} q)`);
|
|
51
|
+
return { SkipInterview: null };
|
|
52
|
+
}
|
|
53
|
+
const desc = sessionId ? this.registry.describeSession(sessionId) : { chatId: undefined };
|
|
54
|
+
const chatId = desc.chatId;
|
|
55
|
+
if (chatId === undefined) return { SkipInterview: null };
|
|
56
|
+
const threadExtra = outboundThreadExtra(desc.threadId);
|
|
57
|
+
|
|
58
|
+
const reqId = String(++this.seq);
|
|
59
|
+
const picked = new Map<string, Set<string>>();
|
|
60
|
+
for (const q of questions) picked.set(q.id, new Set());
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const msg = await this.api.sendMessage(chatId, renderQuestion(questions, 0, picked), {
|
|
64
|
+
reply_markup: questionKeyboard(reqId, questions[0]!, picked.get(questions[0]!.id)!),
|
|
65
|
+
disable_notification: false,
|
|
66
|
+
...threadExtra,
|
|
67
|
+
});
|
|
68
|
+
return new Promise<AskUserResult>((resolve) => {
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
const p = this.pending.get(reqId);
|
|
71
|
+
if (!p) return;
|
|
72
|
+
this.pending.delete(reqId);
|
|
73
|
+
void this.api.editMessageText(p.chatId, p.messageId ?? 0, "\u231B Question timed out \u2014 skipped.").catch(
|
|
74
|
+
() => {},
|
|
75
|
+
);
|
|
76
|
+
resolve({ SkipInterview: null });
|
|
77
|
+
}, TIMEOUT_MS);
|
|
78
|
+
this.pending.set(reqId, {
|
|
79
|
+
resolve,
|
|
80
|
+
chatId,
|
|
81
|
+
messageId: msg.message_id,
|
|
82
|
+
questions,
|
|
83
|
+
picked,
|
|
84
|
+
index: 0,
|
|
85
|
+
timer,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
} catch (e) {
|
|
89
|
+
log.warn("send ask_user failed:", (e as Error).message);
|
|
90
|
+
return { SkipInterview: null };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
tap(reqId: string, kind: string, value?: string): string | undefined {
|
|
95
|
+
const p = this.pending.get(reqId);
|
|
96
|
+
if (!p) return undefined;
|
|
97
|
+
const q = p.questions[p.index];
|
|
98
|
+
if (!q) return undefined;
|
|
99
|
+
|
|
100
|
+
if (kind === "skip") {
|
|
101
|
+
this.settle(p, reqId, { SkipInterview: null }, "\u23ED Skipped questions.");
|
|
102
|
+
return "Skipped";
|
|
103
|
+
}
|
|
104
|
+
if (kind === "opt" && value !== undefined) {
|
|
105
|
+
const opt = q.options[Number(value)];
|
|
106
|
+
if (!opt) return "Expired";
|
|
107
|
+
const set = p.picked.get(q.id)!;
|
|
108
|
+
if (q.multi) {
|
|
109
|
+
if (set.has(opt.id)) set.delete(opt.id);
|
|
110
|
+
else set.add(opt.id);
|
|
111
|
+
} else {
|
|
112
|
+
set.clear();
|
|
113
|
+
set.add(opt.id);
|
|
114
|
+
}
|
|
115
|
+
void this.redraw(reqId, p);
|
|
116
|
+
return q.multi ? "Toggled" : "Selected";
|
|
117
|
+
}
|
|
118
|
+
if (kind === "next") {
|
|
119
|
+
if (p.index < p.questions.length - 1) {
|
|
120
|
+
p.index += 1;
|
|
121
|
+
void this.redraw(reqId, p);
|
|
122
|
+
return "Next";
|
|
123
|
+
}
|
|
124
|
+
const answers = p.questions.map((qq) => ({
|
|
125
|
+
questionId: qq.id,
|
|
126
|
+
selected: [...(p.picked.get(qq.id) ?? [])],
|
|
127
|
+
}));
|
|
128
|
+
this.settle(p, reqId, { SubmitAnswers: { answers } }, "\u2705 Answers sent.");
|
|
129
|
+
return "Submitted";
|
|
130
|
+
}
|
|
131
|
+
if (kind === "prev" && p.index > 0) {
|
|
132
|
+
p.index -= 1;
|
|
133
|
+
void this.redraw(reqId, p);
|
|
134
|
+
return "Back";
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async redraw(reqId: string, p: Pending): Promise<void> {
|
|
140
|
+
const q = p.questions[p.index]!;
|
|
141
|
+
const set = p.picked.get(q.id)!;
|
|
142
|
+
if (p.messageId === undefined) return;
|
|
143
|
+
await this.api
|
|
144
|
+
.editMessageText(p.chatId, p.messageId, renderQuestion(p.questions, p.index, p.picked), {
|
|
145
|
+
reply_markup: questionKeyboard(reqId, q, set, p.index, p.questions.length),
|
|
146
|
+
})
|
|
147
|
+
.catch(() => {});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private settle(p: Pending, reqId: string, result: AskUserResult, text: string): void {
|
|
151
|
+
clearTimeout(p.timer);
|
|
152
|
+
this.pending.delete(reqId);
|
|
153
|
+
if (p.messageId !== undefined) {
|
|
154
|
+
void this.api.editMessageText(p.chatId, p.messageId, text, { reply_markup: { inline_keyboard: [] } }).catch(
|
|
155
|
+
() => {},
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
p.resolve(result);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function parseQuestions(params: Record<string, unknown>): InterviewQuestion[] {
|
|
163
|
+
const raw = params.questions ?? params.questionnaire ?? params.items;
|
|
164
|
+
if (!Array.isArray(raw)) return [];
|
|
165
|
+
const out: InterviewQuestion[] = [];
|
|
166
|
+
raw.forEach((item, i) => {
|
|
167
|
+
if (!item || typeof item !== "object") return;
|
|
168
|
+
const o = item as Record<string, unknown>;
|
|
169
|
+
const prompt = str(o.question) || str(o.prompt) || str(o.header) || str(o.text) || `Question ${i + 1}`;
|
|
170
|
+
const id = str(o.id) || str(o.questionId) || `q${i}`;
|
|
171
|
+
const multi = o.multiSelect === true || o.multi === true || o.allow_multiple === true;
|
|
172
|
+
const optsRaw = Array.isArray(o.options) ? o.options : [];
|
|
173
|
+
const options = optsRaw
|
|
174
|
+
.map((opt, j) => {
|
|
175
|
+
if (typeof opt === "string") return { id: opt, label: opt };
|
|
176
|
+
if (!opt || typeof opt !== "object") return undefined;
|
|
177
|
+
const oo = opt as Record<string, unknown>;
|
|
178
|
+
const label = str(oo.label) || str(oo.name) || str(oo.text) || `Option ${j + 1}`;
|
|
179
|
+
const oid = str(oo.id) || str(oo.value) || label;
|
|
180
|
+
return { id: oid, label };
|
|
181
|
+
})
|
|
182
|
+
.filter((x): x is { id: string; label: string } => Boolean(x));
|
|
183
|
+
if (options.length === 0) options.push({ id: "yes", label: "Yes" }, { id: "no", label: "No" });
|
|
184
|
+
out.push({ id, prompt, options, multi });
|
|
185
|
+
});
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderQuestion(
|
|
190
|
+
questions: InterviewQuestion[],
|
|
191
|
+
index: number,
|
|
192
|
+
picked: Map<string, Set<string>>,
|
|
193
|
+
): string {
|
|
194
|
+
const q = questions[index]!;
|
|
195
|
+
const set = picked.get(q.id) ?? new Set();
|
|
196
|
+
const lines = [
|
|
197
|
+
`\u2753 Grok has a question (${index + 1}/${questions.length})`,
|
|
198
|
+
"",
|
|
199
|
+
q.prompt,
|
|
200
|
+
q.multi ? "\n(multi-select \u2014 tap to toggle, then Next)" : "",
|
|
201
|
+
set.size ? `\nSelected: ${[...set].join(", ")}` : "",
|
|
202
|
+
];
|
|
203
|
+
return lines.filter(Boolean).join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function questionKeyboard(
|
|
207
|
+
reqId: string,
|
|
208
|
+
q: InterviewQuestion,
|
|
209
|
+
selected: Set<string>,
|
|
210
|
+
index = 0,
|
|
211
|
+
total = 1,
|
|
212
|
+
): InlineKeyboard {
|
|
213
|
+
const kb = new InlineKeyboard();
|
|
214
|
+
q.options.slice(0, 12).forEach((opt, j) => {
|
|
215
|
+
const mark = selected.has(opt.id) ? "\u2705 " : "";
|
|
216
|
+
kb.text(`${mark}${opt.label.slice(0, 40)}`, `asku:${reqId}:opt:${j}`).row();
|
|
217
|
+
});
|
|
218
|
+
if (index > 0) kb.text("\u25C0 Back", `asku:${reqId}:prev`);
|
|
219
|
+
kb.text(index < total - 1 ? "Next \u25B6" : "\u2705 Submit", `asku:${reqId}:next`);
|
|
220
|
+
kb.row().text("Skip", `asku:${reqId}:skip`);
|
|
221
|
+
return kb;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function str(v: unknown): string {
|
|
225
|
+
return typeof v === "string" ? v : "";
|
|
226
|
+
}
|
package/src/bot/auth.ts
CHANGED
|
@@ -112,7 +112,11 @@ async function denyUnauthorized(
|
|
|
112
112
|
}
|
|
113
113
|
if (!ctx.chat || isGroupChat(ctx)) return; // groups: ignore quietly
|
|
114
114
|
const threadId = m && "message_thread_id" in m ? m.message_thread_id : undefined;
|
|
115
|
-
|
|
115
|
+
// Omit General (1) — Bot API rejects message_thread_id=1.
|
|
116
|
+
const extra =
|
|
117
|
+
threadId !== undefined && threadId !== 1
|
|
118
|
+
? { message_thread_id: threadId as number }
|
|
119
|
+
: {};
|
|
116
120
|
await ctx
|
|
117
121
|
.reply("\u26D4 Not authorized. Ask the bot owner to add your Telegram ID to ALLOWED_USERS.", extra)
|
|
118
122
|
.catch(() => {});
|
package/src/bot/bot.ts
CHANGED
|
@@ -44,6 +44,9 @@ import { registerTasks, registerWizardInput } from "./handlers/tasks.js";
|
|
|
44
44
|
import { registerUsage } from "./handlers/usage.js";
|
|
45
45
|
import { registerVoice } from "./handlers/voice.js";
|
|
46
46
|
import { registerForum } from "./handlers/forum.js";
|
|
47
|
+
import { registerGrokSlash } from "./handlers/grok-slash.js";
|
|
48
|
+
import { AskUserService } from "./ask-user-service.js";
|
|
49
|
+
import { PlanExitService } from "./plan-exit-service.js";
|
|
47
50
|
import { StatusPanel } from "./menu/status-panel.js";
|
|
48
51
|
import { sendMarkdownDoc } from "./telegram-io.js";
|
|
49
52
|
import { Ephemeral } from "./menu/ephemeral.js";
|
|
@@ -116,21 +119,58 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
116
119
|
forum,
|
|
117
120
|
bots: telegramBots,
|
|
118
121
|
// Cross-topic orchestration: General can create a topic and send_prompt there.
|
|
119
|
-
|
|
122
|
+
// sessionId resumes a specific Grok session (memory follow-up) instead of
|
|
123
|
+
// dumping into whatever is currently open in that topic.
|
|
124
|
+
submitTopicPrompt: async ({
|
|
125
|
+
threadId,
|
|
126
|
+
cwd,
|
|
127
|
+
projectName,
|
|
128
|
+
prompt,
|
|
129
|
+
newSession,
|
|
130
|
+
sessionId,
|
|
131
|
+
reportBack,
|
|
132
|
+
}) => {
|
|
120
133
|
if (cfg.topicGroupId === undefined) {
|
|
121
134
|
throw new Error("TOPIC_GROUP_ID unset");
|
|
122
135
|
}
|
|
123
136
|
const groupId = cfg.topicGroupId;
|
|
124
137
|
const controller = registry.forumController(groupId, threadId, cwd, projectName);
|
|
138
|
+
// Explicit resume wins over newSession / foreground.
|
|
139
|
+
if (sessionId) {
|
|
140
|
+
const sw = await controller.addResume(sessionId, cwd, projectName);
|
|
141
|
+
if (reportBack) sw.rt.setReportBack(reportBack);
|
|
142
|
+
const outcome = await sw.rt.submit(textPrompt(prompt));
|
|
143
|
+
return { outcome, sessionId: sw.rt.sessionId ?? sessionId };
|
|
144
|
+
}
|
|
125
145
|
if (newSession) {
|
|
126
146
|
const rt = await controller.addNew(cwd, projectName);
|
|
147
|
+
if (reportBack) rt.setReportBack(reportBack);
|
|
127
148
|
const outcome = await rt.submit(textPrompt(prompt));
|
|
128
149
|
return { outcome, sessionId: rt.sessionId };
|
|
129
150
|
}
|
|
130
151
|
const rt = controller.foreground();
|
|
152
|
+
if (reportBack) rt.setReportBack(reportBack);
|
|
131
153
|
const outcome = await rt.submit(textPrompt(prompt));
|
|
132
154
|
return { outcome, sessionId: rt.sessionId };
|
|
133
155
|
},
|
|
156
|
+
// Child topic Done → wake General manager with a WORK REPORT.
|
|
157
|
+
wakeManager: async ({ originChatId, originThreadId, prompt }) => {
|
|
158
|
+
if (cfg.topicGroupId === undefined) {
|
|
159
|
+
throw new Error("TOPIC_GROUP_ID unset");
|
|
160
|
+
}
|
|
161
|
+
const groupId = cfg.topicGroupId;
|
|
162
|
+
// origin is always General in manager mode; bind workspace.
|
|
163
|
+
const controller = registry.forumController(
|
|
164
|
+
originChatId || groupId,
|
|
165
|
+
originThreadId,
|
|
166
|
+
cfg.workspace,
|
|
167
|
+
"General",
|
|
168
|
+
);
|
|
169
|
+
const rt = controller.foreground();
|
|
170
|
+
await rt.submit(
|
|
171
|
+
textPrompt(prompt, undefined, undefined, { skipSelfRecheck: true }),
|
|
172
|
+
);
|
|
173
|
+
},
|
|
134
174
|
});
|
|
135
175
|
|
|
136
176
|
const deps: BotDeps = {
|
|
@@ -170,6 +210,18 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
170
210
|
onUnpinned: (chatId) => statusPanel.ensurePinned(chatId),
|
|
171
211
|
});
|
|
172
212
|
acp.permissionHandler = (p) => permissions.handle(p);
|
|
213
|
+
|
|
214
|
+
const planExit = new PlanExitService(bot.api, registry, cfg.autoApprovePlan, (chatId) =>
|
|
215
|
+
statusPanel.ensurePinned(chatId),
|
|
216
|
+
);
|
|
217
|
+
acp.planExitHandler = (params) => planExit.handle(params);
|
|
218
|
+
|
|
219
|
+
const askUser = new AskUserService(
|
|
220
|
+
bot.api,
|
|
221
|
+
registry,
|
|
222
|
+
cfg.autoApprovePlan && cfg.autoApprovePermissions,
|
|
223
|
+
);
|
|
224
|
+
acp.askUserHandler = (params) => askUser.handle(params);
|
|
173
225
|
// /stop and /cancel must cancel pending interactive permissions for that
|
|
174
226
|
// session only (ACP requires cancelled outcomes) — never kill the agent.
|
|
175
227
|
acp.onSessionCancel = (sessionId) => {
|
|
@@ -215,6 +267,16 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
215
267
|
if (sid) await switchAndShow(ctx, deps, sid);
|
|
216
268
|
});
|
|
217
269
|
|
|
270
|
+
bot.callbackQuery(/^planx:(\d+):(ok|chg|no)$/, async (ctx) => {
|
|
271
|
+
const toast = planExit.resolveChoice(ctx.match![1]!, ctx.match![2]!);
|
|
272
|
+
await ctx.answerCallbackQuery({ text: toast ?? "Expired" });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
bot.callbackQuery(/^asku:(\d+):(opt|next|prev|skip)(?::(.*))?$/, async (ctx) => {
|
|
276
|
+
const toast = askUser.tap(ctx.match![1]!, ctx.match![2]!, ctx.match![3]);
|
|
277
|
+
await ctx.answerCallbackQuery({ text: toast ?? "Expired" });
|
|
278
|
+
});
|
|
279
|
+
|
|
218
280
|
// Legacy complexity buttons (removed — agent decides; auto-plan if complex).
|
|
219
281
|
bot.callbackQuery(/^cplx:(simple|complex)$/, async (ctx) => {
|
|
220
282
|
await ctx.answerCallbackQuery({ text: "Complexity is automatic now" });
|
|
@@ -231,18 +293,50 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
231
293
|
const index = Number(ctx.match![2]);
|
|
232
294
|
const { resolveScope } = await import("./scope.js");
|
|
233
295
|
const { adoptUserPrompt } = await import("./prompt-anchor.js");
|
|
296
|
+
const { isGeneralThread } = await import("../forum/thread.js");
|
|
234
297
|
const scope = resolveScope(ctx, deps);
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
298
|
+
// General may have parallel sessions — find the runtime that owns this batch.
|
|
299
|
+
const hit =
|
|
300
|
+
scope.controller.takeSuggestionAnywhere(batchId, index) ??
|
|
301
|
+
(() => {
|
|
302
|
+
const t = scope.rt.takeSuggestion(batchId, index);
|
|
303
|
+
return t ? { rt: scope.rt, text: t } : undefined;
|
|
304
|
+
})();
|
|
305
|
+
if (!hit) {
|
|
238
306
|
await ctx.answerCallbackQuery({ text: "Suggestion expired", show_alert: true });
|
|
239
307
|
return;
|
|
240
308
|
}
|
|
309
|
+
const { rt, text } = hit;
|
|
241
310
|
await ctx.answerCallbackQuery({ text: "Sending\u2026" });
|
|
242
311
|
// Dim the keyboard so double-taps don't re-fire.
|
|
243
312
|
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {});
|
|
244
313
|
try {
|
|
245
314
|
const chatId = ctx.chat?.id;
|
|
315
|
+
const isGeneral = isGeneralThread(scope.threadId);
|
|
316
|
+
const sugMsgId = ctx.callbackQuery.message?.message_id;
|
|
317
|
+
// General: keep chat clean — no anchor overwrite; continue same session
|
|
318
|
+
// and reply to the message that carried the buttons.
|
|
319
|
+
if (isGeneral) {
|
|
320
|
+
if (rt.sessionId && sugMsgId !== undefined) {
|
|
321
|
+
scope.controller.bindTelegramMessage(sugMsgId, rt.sessionId);
|
|
322
|
+
}
|
|
323
|
+
const outcome = await rt.submit(
|
|
324
|
+
textPrompt(text, sugMsgId, undefined, { promptId: undefined }),
|
|
325
|
+
);
|
|
326
|
+
if (outcome === "queued") {
|
|
327
|
+
const extra: Record<string, unknown> = { ...scope.threadExtra };
|
|
328
|
+
if (sugMsgId !== undefined) {
|
|
329
|
+
extra.reply_parameters = {
|
|
330
|
+
message_id: sugMsgId,
|
|
331
|
+
allow_sending_without_reply: true,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
await deps.api
|
|
335
|
+
.sendMessage(chatId!, "\u{1F4E5} Queued on that thread.", extra)
|
|
336
|
+
.catch(() => {});
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
246
340
|
const anchor =
|
|
247
341
|
chatId !== undefined
|
|
248
342
|
? await adoptUserPrompt(deps.api, {
|
|
@@ -280,6 +374,12 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
280
374
|
|
|
281
375
|
registerMenu(bot, deps); // persistent-keyboard buttons (hears)
|
|
282
376
|
registerWizardInput(bot, deps); // wizard text input (before commands)
|
|
377
|
+
bot.on("message:text", async (ctx, next) => {
|
|
378
|
+
const text = ctx.message?.text ?? "";
|
|
379
|
+
if (!text || text.startsWith("/")) return next();
|
|
380
|
+
if (planExit.takeFeedback(ctx.chat.id, text)) return;
|
|
381
|
+
await next();
|
|
382
|
+
});
|
|
283
383
|
if (forum) registerForum(bot, deps, forum);
|
|
284
384
|
registerControl(bot, deps);
|
|
285
385
|
registerProjects(bot, deps);
|
|
@@ -298,6 +398,7 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
|
|
|
298
398
|
registerPhotos(bot, deps); // photos & image documents
|
|
299
399
|
registerDocuments(bot, deps); // non-image files (text inlined, binaries saved)
|
|
300
400
|
registerVoice(bot, deps); // voice / audio -> transcription -> prompt
|
|
401
|
+
registerGrokSlash(bot, deps); // Grok Build /goal /plan /compact … + catch-all
|
|
301
402
|
registerMessages(bot, deps); // catch-all text prompt — keep last
|
|
302
403
|
|
|
303
404
|
bot.catch((err) => {
|
|
@@ -63,6 +63,12 @@ export class ChatController {
|
|
|
63
63
|
/** Telegram bridge (forum / memory / sibling bots); set by the registry. */
|
|
64
64
|
bridge?: ChatBridgeServices;
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* General manager: map Telegram message ids (user + bot) → session id so a
|
|
68
|
+
* reply-to continues the same ACP session instead of spawning a new one.
|
|
69
|
+
*/
|
|
70
|
+
private readonly telegramMsgSessions = new Map<number, string>();
|
|
71
|
+
|
|
66
72
|
constructor(
|
|
67
73
|
private readonly api: Api,
|
|
68
74
|
private readonly chatId: number,
|
|
@@ -128,6 +134,105 @@ export class ChatController {
|
|
|
128
134
|
return rt;
|
|
129
135
|
}
|
|
130
136
|
|
|
137
|
+
/**
|
|
138
|
+
* General manager: spawn a fresh session for one user message without
|
|
139
|
+
* killing an in-flight sibling's Telegram stream (parallel prompts).
|
|
140
|
+
*/
|
|
141
|
+
async addParallel(cwd: string, projectName?: string): Promise<SessionRuntime> {
|
|
142
|
+
return this.addNew(cwd, projectName);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Remember that a Telegram message belongs to a Grok session (reply routing). */
|
|
146
|
+
bindTelegramMessage(messageId: number, sessionId: string): void {
|
|
147
|
+
if (!messageId || !sessionId) return;
|
|
148
|
+
this.telegramMsgSessions.set(messageId, sessionId);
|
|
149
|
+
// Cap map growth (keep newest ~500).
|
|
150
|
+
if (this.telegramMsgSessions.size > 500) {
|
|
151
|
+
const drop = this.telegramMsgSessions.size - 500;
|
|
152
|
+
let i = 0;
|
|
153
|
+
for (const k of this.telegramMsgSessions.keys()) {
|
|
154
|
+
this.telegramMsgSessions.delete(k);
|
|
155
|
+
if (++i >= drop) break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Resolve a controlled runtime from a Telegram message id (reply-to). */
|
|
161
|
+
runtimeForTelegramMessage(messageId: number | undefined): SessionRuntime | undefined {
|
|
162
|
+
if (messageId === undefined) return undefined;
|
|
163
|
+
this.ensureRestored();
|
|
164
|
+
const sid = this.telegramMsgSessions.get(messageId);
|
|
165
|
+
if (sid) {
|
|
166
|
+
const byId = this.runtimes.find((r) => r.sessionId === sid);
|
|
167
|
+
if (byId) return byId;
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolve session from `#sess_xxxxxxxx` in a replied-to bot message body
|
|
174
|
+
* (works across process restarts when the in-memory map is cold).
|
|
175
|
+
*/
|
|
176
|
+
runtimeForSessionTag(text: string | undefined): SessionRuntime | undefined {
|
|
177
|
+
if (!text) return undefined;
|
|
178
|
+
this.ensureRestored();
|
|
179
|
+
const tag = parseSessTag(text);
|
|
180
|
+
if (!tag) return undefined;
|
|
181
|
+
return this.runtimes.find((r) => r.sessionId && sessionMatchesTag(r.sessionId, tag));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Cold-start / map-miss: resolve continue target from reply message id, #sess_
|
|
186
|
+
* tag on controlled runtimes, or disk session list (attach into this controller).
|
|
187
|
+
*/
|
|
188
|
+
async resolveContinueFromReply(opts: {
|
|
189
|
+
replyToMessageId?: number;
|
|
190
|
+
replyToText?: string;
|
|
191
|
+
cwd: string;
|
|
192
|
+
projectName?: string;
|
|
193
|
+
}): Promise<SessionRuntime | undefined> {
|
|
194
|
+
this.ensureRestored();
|
|
195
|
+
const byMsg = this.runtimeForTelegramMessage(opts.replyToMessageId);
|
|
196
|
+
if (byMsg) return byMsg;
|
|
197
|
+
const byTag = this.runtimeForSessionTag(opts.replyToText);
|
|
198
|
+
if (byTag) return byTag;
|
|
199
|
+
|
|
200
|
+
const tag = parseSessTag(opts.replyToText);
|
|
201
|
+
if (!tag) return undefined;
|
|
202
|
+
// Disk fallback: find full session id, then resume into this controller.
|
|
203
|
+
let metas;
|
|
204
|
+
try {
|
|
205
|
+
metas = this.store.list(80);
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
const meta = metas.find((m) => sessionMatchesTag(m.sessionId, tag));
|
|
210
|
+
if (!meta) return undefined;
|
|
211
|
+
try {
|
|
212
|
+
const sw = await this.addResume(
|
|
213
|
+
meta.sessionId,
|
|
214
|
+
meta.cwd || opts.cwd,
|
|
215
|
+
opts.projectName,
|
|
216
|
+
);
|
|
217
|
+
return sw.rt;
|
|
218
|
+
} catch {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Find any runtime that still holds this suggestion batch (General parallel). */
|
|
224
|
+
takeSuggestionAnywhere(
|
|
225
|
+
batchId: number,
|
|
226
|
+
index: number,
|
|
227
|
+
): { rt: SessionRuntime; text: string } | undefined {
|
|
228
|
+
this.ensureRestored();
|
|
229
|
+
for (const r of this.runtimes) {
|
|
230
|
+
const text = r.takeSuggestion(batchId, index);
|
|
231
|
+
if (text) return { rt: r, text };
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
131
236
|
/**
|
|
132
237
|
* Switch the chat to a project directory **without** waiting on ACP.
|
|
133
238
|
* - Reuses an existing controlled runtime for the same path when possible.
|
|
@@ -410,6 +515,9 @@ export class ChatController {
|
|
|
410
515
|
this.markSeen(rt);
|
|
411
516
|
this.persist();
|
|
412
517
|
};
|
|
518
|
+
rt.onTelegramMessageBound = (messageId, sessionId) => {
|
|
519
|
+
this.bindTelegramMessage(messageId, sessionId);
|
|
520
|
+
};
|
|
413
521
|
return rt;
|
|
414
522
|
}
|
|
415
523
|
|
|
@@ -466,3 +574,24 @@ export class ChatController {
|
|
|
466
574
|
function normPath(p: string): string {
|
|
467
575
|
return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
468
576
|
}
|
|
577
|
+
|
|
578
|
+
/** Extract `#sess_xxxxxxxx` from bot message text/caption. */
|
|
579
|
+
function parseSessTag(text: string | undefined): string | undefined {
|
|
580
|
+
if (!text) return undefined;
|
|
581
|
+
const m = text.match(/#sess_([a-z0-9]{6,12})/i);
|
|
582
|
+
return m?.[1]?.toLowerCase();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Match full session UUID (or short form) to a #sess_ tag body. */
|
|
586
|
+
function sessionMatchesTag(sessionId: string, tag: string): boolean {
|
|
587
|
+
const compact = sessionId.replace(/-/g, "").toLowerCase();
|
|
588
|
+
const short = sessionId.slice(0, 8).replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
589
|
+
const t = tag.toLowerCase();
|
|
590
|
+
return (
|
|
591
|
+
short === t ||
|
|
592
|
+
short.startsWith(t) ||
|
|
593
|
+
t.startsWith(short) ||
|
|
594
|
+
compact.startsWith(t) ||
|
|
595
|
+
sessionId.toLowerCase().startsWith(t)
|
|
596
|
+
);
|
|
597
|
+
}
|
package/src/bot/commands.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* a shorter list with cancel/menu first — reply keyboards are unreliable there.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
import { GROK_FORWARDED_COMMANDS } from "./handlers/grok-slash.js";
|
|
9
|
+
|
|
10
|
+
/** Bot-local commands (not forwarded to Grok). Order = Telegram "/" menu order. */
|
|
11
|
+
export const BOT_COMMANDS: { command: string; description: string }[] = [
|
|
10
12
|
// Core control
|
|
11
13
|
{ command: "start", description: "Welcome, menu & status panel" },
|
|
12
14
|
{ command: "menu", description: "Open the menu" },
|
|
@@ -37,10 +39,17 @@ export const COMMANDS: { command: string; description: string }[] = [
|
|
|
37
39
|
{ command: "killall", description: "Kill all active sessions on the PC" },
|
|
38
40
|
{ command: "model", description: "Switch model: /model <id>" },
|
|
39
41
|
{ command: "restart", description: "Restart the Grok agent" },
|
|
42
|
+
{ command: "sandbox", description: "Show / set Grok sandbox profile" },
|
|
40
43
|
{ command: "unwatch", description: "Stop following a live session" },
|
|
41
44
|
{ command: "help", description: "Show help" },
|
|
42
45
|
];
|
|
43
46
|
|
|
47
|
+
/** Full Telegram menu: bot-local + Grok Build shell forwards (≤100). */
|
|
48
|
+
export const COMMANDS: { command: string; description: string }[] = [
|
|
49
|
+
...BOT_COMMANDS,
|
|
50
|
+
...GROK_FORWARDED_COMMANDS.map(({ command, description }) => ({ command, description })),
|
|
51
|
+
];
|
|
52
|
+
|
|
44
53
|
/**
|
|
45
54
|
* Group / forum command menu — keep short; cancel & menu first so topics can
|
|
46
55
|
* stop a turn without the private reply-keyboard bar.
|
|
@@ -56,6 +65,9 @@ export const GROUP_COMMANDS: { command: string; description: string }[] = [
|
|
|
56
65
|
{ command: "btw", description: "Queue or run: /btw <text>" },
|
|
57
66
|
{ command: "flush", description: "Run queued follow-ups now" },
|
|
58
67
|
{ command: "model", description: "Switch model: /model <id>" },
|
|
68
|
+
{ command: "goal", description: "Grok /goal — run until done" },
|
|
69
|
+
{ command: "plan", description: "Grok /plan — enter plan mode" },
|
|
70
|
+
{ command: "compact", description: "Grok /compact — compress context" },
|
|
59
71
|
{ command: "help", description: "Show help" },
|
|
60
72
|
];
|
|
61
73
|
|
|
@@ -91,4 +103,12 @@ export const HELP_TEXT = [
|
|
|
91
103
|
"/flush \u2014 run queued follow-ups immediately",
|
|
92
104
|
"/reauth \u2014 sign in to Grok",
|
|
93
105
|
"/accounts \u2014 switch saved Grok accounts",
|
|
106
|
+
"/sandbox \u2014 show or set GROK_SANDBOX (needs /restart)",
|
|
107
|
+
"",
|
|
108
|
+
"GROK BUILD SLASH (forwarded into the active session)",
|
|
109
|
+
"/goal /plan /view_plan /compact /context /session_info",
|
|
110
|
+
"/deep_research /workflow /workflows /loop",
|
|
111
|
+
"/remember /memory /memory_flush /dream",
|
|
112
|
+
"Underscores map to hyphens (e.g. /view_plan \u2192 /view-plan).",
|
|
113
|
+
"Other non-bot Grok /commands and skills are also forwarded.",
|
|
94
114
|
].join("\n");
|