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
package/src/bot/group-memory.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* Local "group memory" search across forum topics, session metadata, and
|
|
3
3
|
* recent session history. Telegram bots have no general message-search API,
|
|
4
4
|
* so this indexes what the bridge already stores on disk.
|
|
5
|
+
*
|
|
6
|
+
* Ranking is **relevance + recency**: newer sessions / history win over old
|
|
7
|
+
* matching text so "what was last worked on" does not surface stale Done notes.
|
|
5
8
|
*/
|
|
6
9
|
import { join } from "node:path";
|
|
7
10
|
import { readHistory } from "../sessions/history.js";
|
|
@@ -19,6 +22,8 @@ export interface MemoryHit {
|
|
|
19
22
|
path?: string;
|
|
20
23
|
sessionId?: string;
|
|
21
24
|
threadId?: number;
|
|
25
|
+
/** Epoch ms for secondary sort / display (session or entry time). */
|
|
26
|
+
at?: number;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export interface GroupMemorySearchOpts {
|
|
@@ -29,6 +34,13 @@ export interface GroupMemorySearchOpts {
|
|
|
29
34
|
topics?: ForumTopicBinding[];
|
|
30
35
|
/** Max sessions whose JSONL tails are scanned. */
|
|
31
36
|
maxSessions?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Prefer sessions under these paths (e.g. workspace for General, then a
|
|
39
|
+
* project path). Earlier paths get a higher score boost.
|
|
40
|
+
*/
|
|
41
|
+
preferPaths?: string[];
|
|
42
|
+
/** Boost history from sessions whose title/comment looks like General manager. */
|
|
43
|
+
preferGeneral?: boolean;
|
|
32
44
|
}
|
|
33
45
|
|
|
34
46
|
/** Tokenize a query into lowercase alphanumeric tokens (min length 2). */
|
|
@@ -62,72 +74,208 @@ export function scoreTokens(haystack: string, tokens: string[]): number {
|
|
|
62
74
|
return score;
|
|
63
75
|
}
|
|
64
76
|
|
|
77
|
+
/**
|
|
78
|
+
* True when the user is asking what happened *recently* (last mods, last work).
|
|
79
|
+
* These queries weight recency much higher than pure text match.
|
|
80
|
+
* Avoid bare "work"/"done" — those appear in many normal asks and would
|
|
81
|
+
* over-prioritize recency over relevance.
|
|
82
|
+
*/
|
|
83
|
+
export function queryWantsRecency(query: string): boolean {
|
|
84
|
+
return /\b(last|latest|recent|newest|today|yesterday|modif(?:y|ied|ication|ications)?|changes?|changed|updated|what\s+was|what\s+did|last\s+work|recent\s+work|last\s+done)\b/i.test(
|
|
85
|
+
query,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Normalize epoch seconds vs milliseconds (or ISO string) to ms.
|
|
91
|
+
* Returns undefined when missing/invalid (never NaN).
|
|
92
|
+
*/
|
|
93
|
+
export function normalizeEpochMs(
|
|
94
|
+
value: string | number | undefined,
|
|
95
|
+
): number | undefined {
|
|
96
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
97
|
+
let t: number;
|
|
98
|
+
if (typeof value === "number") {
|
|
99
|
+
t = value;
|
|
100
|
+
} else {
|
|
101
|
+
t = Date.parse(value);
|
|
102
|
+
}
|
|
103
|
+
if (!Number.isFinite(t) || t <= 0) return undefined;
|
|
104
|
+
// Seconds since epoch (~1e9) → ms. ms since epoch is ~1e12+.
|
|
105
|
+
if (t > 0 && t < 1e11) t *= 1000;
|
|
106
|
+
if (!Number.isFinite(t) || t <= 0) return undefined;
|
|
107
|
+
return t;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Recency score 0–20 from an updatedAt ISO string or epoch ms/seconds.
|
|
112
|
+
* <1h:20, <6h:16, <24h:12, <3d:8, <7d:4, <30d:2, older:0.
|
|
113
|
+
*/
|
|
114
|
+
export function recencyBoost(updatedAt: string | number | undefined, now = Date.now()): number {
|
|
115
|
+
const t = normalizeEpochMs(updatedAt);
|
|
116
|
+
if (t === undefined) return 0;
|
|
117
|
+
const ageH = Math.max(0, (now - t) / 3_600_000);
|
|
118
|
+
if (ageH < 1) return 20;
|
|
119
|
+
if (ageH < 6) return 16;
|
|
120
|
+
if (ageH < 24) return 12;
|
|
121
|
+
if (ageH < 72) return 8;
|
|
122
|
+
if (ageH < 168) return 4;
|
|
123
|
+
if (ageH < 720) return 2;
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Human-ish age for snippets. */
|
|
128
|
+
export function formatAge(at: number | undefined, now = Date.now()): string {
|
|
129
|
+
if (!at || !Number.isFinite(at)) return "";
|
|
130
|
+
const mins = Math.max(0, Math.round((now - at) / 60_000));
|
|
131
|
+
if (mins < 60) return `${mins}m ago`;
|
|
132
|
+
const hours = Math.round(mins / 60);
|
|
133
|
+
if (hours < 48) return `${hours}h ago`;
|
|
134
|
+
const days = Math.round(hours / 24);
|
|
135
|
+
return `${days}d ago`;
|
|
136
|
+
}
|
|
137
|
+
|
|
65
138
|
/**
|
|
66
139
|
* Search topics + session store + recent history. Pure ranking over provided
|
|
67
140
|
* store; safe to call on the main bot thread (capped I/O).
|
|
141
|
+
*
|
|
142
|
+
* Sort key: score (relevance + recency) DESC, then `at` DESC (newest first).
|
|
68
143
|
*/
|
|
69
144
|
export function searchGroupMemory(opts: GroupMemorySearchOpts): MemoryHit[] {
|
|
70
145
|
const tokens = tokenizeQuery(opts.query);
|
|
71
146
|
if (tokens.length === 0) return [];
|
|
72
147
|
const limit = Math.max(1, Math.min(20, opts.limit ?? 8));
|
|
73
148
|
const maxSessions = opts.maxSessions ?? 40;
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
const wantsRecent = queryWantsRecency(opts.query);
|
|
151
|
+
/** Multiply recency when user asks for last/recent work. */
|
|
152
|
+
const recencyMul = wantsRecent ? 2.5 : 1.2;
|
|
74
153
|
const hits: MemoryHit[] = [];
|
|
75
154
|
|
|
76
155
|
for (const t of opts.topics ?? []) {
|
|
77
156
|
const hay = [t.name, t.projectPath ?? "", t.kind, t.sessionId ?? ""].join("\n");
|
|
78
157
|
const score = scoreTokens(hay, tokens);
|
|
79
158
|
if (score <= 0) continue;
|
|
159
|
+
// Topic map updatedAt is already epoch ms.
|
|
160
|
+
const at = normalizeEpochMs(t.updatedAt);
|
|
161
|
+
// Mild recency on topics (routing), not full "last work" weight.
|
|
162
|
+
const r = recencyBoost(at, now) * Math.min(1.2, recencyMul);
|
|
80
163
|
hits.push({
|
|
81
164
|
kind: "topic",
|
|
82
165
|
title: t.name,
|
|
83
166
|
snippet: t.projectPath ? `path: ${t.projectPath}` : `kind: ${t.kind}`,
|
|
84
|
-
score: score + 2
|
|
167
|
+
score: score + 2 + r,
|
|
85
168
|
path: t.projectPath ?? undefined,
|
|
86
169
|
threadId: t.threadId,
|
|
87
170
|
sessionId: t.sessionId,
|
|
171
|
+
at,
|
|
88
172
|
});
|
|
89
173
|
}
|
|
90
174
|
|
|
91
175
|
let metas: SessionMeta[] = [];
|
|
92
176
|
try {
|
|
177
|
+
// Store already returns most-recently-updated first.
|
|
93
178
|
metas = opts.store.list(maxSessions);
|
|
94
179
|
} catch {
|
|
95
180
|
metas = [];
|
|
96
181
|
}
|
|
97
182
|
|
|
183
|
+
const prefer = (opts.preferPaths ?? []).map((p) => p.replace(/\\/g, "/").toLowerCase());
|
|
184
|
+
|
|
185
|
+
// Per project path: which session is the newest (for "last work in X").
|
|
186
|
+
const newestByCwd = new Map<string, number>();
|
|
98
187
|
for (const m of metas) {
|
|
188
|
+
const key = normPath(m.cwd);
|
|
189
|
+
if (!key) continue;
|
|
190
|
+
const t = normalizeEpochMs(m.updatedAt) ?? 0;
|
|
191
|
+
const prev = newestByCwd.get(key) ?? 0;
|
|
192
|
+
if (t > prev) newestByCwd.set(key, t);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const m of metas) {
|
|
196
|
+
const pathBoost = pathPreferenceBoost(m.cwd, prefer);
|
|
197
|
+
const generalBoost =
|
|
198
|
+
opts.preferGeneral &&
|
|
199
|
+
(/^general$/i.test(m.title || "") || /general/i.test(m.comment || ""))
|
|
200
|
+
? 5
|
|
201
|
+
: 0;
|
|
202
|
+
const sessionAt = normalizeEpochMs(m.updatedAt);
|
|
203
|
+
const rBoost = recencyBoost(sessionAt, now) * recencyMul;
|
|
204
|
+
const newestBoost =
|
|
205
|
+
sessionAt !== undefined && newestByCwd.get(normPath(m.cwd)) === sessionAt ? 10 : 0;
|
|
206
|
+
|
|
99
207
|
const hay = [m.title, m.comment ?? "", m.cwd, m.sessionId].join("\n");
|
|
100
|
-
const
|
|
101
|
-
|
|
208
|
+
const base = scoreTokens(hay, tokens);
|
|
209
|
+
// Require text relevance — path/recency alone never invents a hit.
|
|
210
|
+
if (base <= 0) {
|
|
211
|
+
// Still scan history below if the session might contain matching tails.
|
|
212
|
+
}
|
|
213
|
+
const score = base + pathBoost + generalBoost + rBoost + newestBoost;
|
|
214
|
+
const age = formatAge(sessionAt, now);
|
|
215
|
+
|
|
216
|
+
if (base > 0 && Number.isFinite(score) && score > 0) {
|
|
102
217
|
hits.push({
|
|
103
218
|
kind: "session",
|
|
104
219
|
title: m.title || m.sessionId.slice(0, 8),
|
|
105
220
|
snippet: clamp(
|
|
106
|
-
[m.comment, m.cwd].filter(Boolean).join(" · ") || m.sessionId,
|
|
107
|
-
|
|
221
|
+
[age ? `[${age}]` : "", m.comment, m.cwd].filter(Boolean).join(" · ") || m.sessionId,
|
|
222
|
+
240,
|
|
108
223
|
),
|
|
109
224
|
score,
|
|
110
225
|
path: m.cwd || undefined,
|
|
111
226
|
sessionId: m.sessionId,
|
|
227
|
+
at: sessionAt,
|
|
112
228
|
});
|
|
113
229
|
}
|
|
114
230
|
|
|
115
|
-
// History tail
|
|
231
|
+
// History: deeper tail for recent or path-matched sessions.
|
|
232
|
+
const histDepth =
|
|
233
|
+
rBoost >= 12 || pathBoost + generalBoost > 0 || wantsRecent ? 28 : 14;
|
|
116
234
|
if (m.historyBytes <= 0) continue;
|
|
117
235
|
try {
|
|
118
236
|
const path = join(opts.sessionsDir, `${m.sessionId}.jsonl`);
|
|
119
|
-
const entries = readHistory(path,
|
|
120
|
-
|
|
237
|
+
const entries = readHistory(path, histDepth);
|
|
238
|
+
// Prefer newer entries: history is oldest→newest; weight later indices.
|
|
239
|
+
const n = entries.length;
|
|
240
|
+
for (let i = 0; i < n; i++) {
|
|
241
|
+
const e = entries[i]!;
|
|
121
242
|
if (!e.text?.trim()) continue;
|
|
122
|
-
|
|
123
|
-
if (
|
|
243
|
+
// Skip meta dumps that pollute "last work" answers.
|
|
244
|
+
if (/MANAGER CONTEXT \(auto/i.test(e.text)) continue;
|
|
245
|
+
if (/^COMPLEXITY \(decide yourself/i.test(e.text)) continue;
|
|
246
|
+
if (/TELEGRAM BRIDGE \(how to work/i.test(e.text)) continue;
|
|
247
|
+
|
|
248
|
+
const textScore = scoreTokens(e.text, tokens);
|
|
249
|
+
if (textScore <= 0) continue;
|
|
250
|
+
|
|
251
|
+
// Prefer entry timestamp; fall back to session updatedAt (not 0/NaN).
|
|
252
|
+
const entryAt = normalizeEpochMs(e.timestamp) ?? sessionAt;
|
|
253
|
+
const entryRecency = recencyBoost(entryAt, now) * recencyMul;
|
|
254
|
+
// Position boost: last entries in the tail are newest.
|
|
255
|
+
const posBoost = n > 1 ? Math.round((i / (n - 1)) * 6) : 3;
|
|
256
|
+
// Prefer user prompts + assistant Done prose for "what was last done".
|
|
257
|
+
const roleBoost = e.role === "user" ? 3 : e.role === "assistant" ? 3 : 0;
|
|
258
|
+
const s =
|
|
259
|
+
textScore +
|
|
260
|
+
pathBoost +
|
|
261
|
+
generalBoost +
|
|
262
|
+
entryRecency +
|
|
263
|
+
posBoost +
|
|
264
|
+
roleBoost +
|
|
265
|
+
(newestBoost > 0 ? 4 : 0);
|
|
266
|
+
if (!Number.isFinite(s) || s <= 0) continue;
|
|
267
|
+
const eAge = formatAge(entryAt, now);
|
|
124
268
|
hits.push({
|
|
125
269
|
kind: "history",
|
|
126
270
|
title: `${e.role} · ${(m.title || m.sessionId.slice(0, 8)).slice(0, 40)}`,
|
|
127
|
-
snippet: clamp(
|
|
271
|
+
snippet: clamp(
|
|
272
|
+
[eAge ? `[${eAge}]` : "", e.text.replace(/\s+/g, " ").trim()].filter(Boolean).join(" "),
|
|
273
|
+
240,
|
|
274
|
+
),
|
|
128
275
|
score: s,
|
|
129
276
|
path: m.cwd || undefined,
|
|
130
277
|
sessionId: m.sessionId,
|
|
278
|
+
at: entryAt,
|
|
131
279
|
});
|
|
132
280
|
}
|
|
133
281
|
} catch {
|
|
@@ -135,7 +283,13 @@ export function searchGroupMemory(opts: GroupMemorySearchOpts): MemoryHit[] {
|
|
|
135
283
|
}
|
|
136
284
|
}
|
|
137
285
|
|
|
138
|
-
|
|
286
|
+
// Newest high scores first.
|
|
287
|
+
hits.sort(
|
|
288
|
+
(a, b) =>
|
|
289
|
+
b.score - a.score ||
|
|
290
|
+
(b.at ?? 0) - (a.at ?? 0) ||
|
|
291
|
+
a.kind.localeCompare(b.kind),
|
|
292
|
+
);
|
|
139
293
|
// Dedupe near-identical snippets.
|
|
140
294
|
const seen = new Set<string>();
|
|
141
295
|
const out: MemoryHit[] = [];
|
|
@@ -157,3 +311,29 @@ function clamp(s: string, max: number): string {
|
|
|
157
311
|
function escapeReg(s: string): string {
|
|
158
312
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
159
313
|
}
|
|
314
|
+
|
|
315
|
+
function normPath(p: string | undefined): string {
|
|
316
|
+
if (!p) return "";
|
|
317
|
+
return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Higher boost for earlier preferred paths.
|
|
322
|
+
* Index 0 is usually the workspace (General) — use **exact** match only so we
|
|
323
|
+
* do not treat every child project under Domains as "General memory".
|
|
324
|
+
* Later entries (project paths) allow prefix match.
|
|
325
|
+
*/
|
|
326
|
+
function pathPreferenceBoost(cwd: string | undefined, prefer: string[]): number {
|
|
327
|
+
if (!cwd || prefer.length === 0) return 0;
|
|
328
|
+
const n = cwd.replace(/\\/g, "/").toLowerCase();
|
|
329
|
+
for (let i = 0; i < prefer.length; i++) {
|
|
330
|
+
const p = prefer[i]!.replace(/\\/g, "/").toLowerCase();
|
|
331
|
+
if (!p) continue;
|
|
332
|
+
const exact = n === p;
|
|
333
|
+
const child = i > 0 && (n === p || n.startsWith(p + "/"));
|
|
334
|
+
if (exact || child) {
|
|
335
|
+
return 8 - Math.min(6, i * 2);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return 0;
|
|
339
|
+
}
|
|
@@ -5,7 +5,11 @@ import type { Bot } from "grammy";
|
|
|
5
5
|
import { createLogger } from "../../logger.js";
|
|
6
6
|
import type { ForumManager } from "../../forum/manager.js";
|
|
7
7
|
import type { BotDeps } from "../deps.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
FORUM_GENERAL_THREAD_ID,
|
|
10
|
+
forumThreadId,
|
|
11
|
+
outboundThreadExtra,
|
|
12
|
+
} from "../../forum/thread.js";
|
|
9
13
|
|
|
10
14
|
const log = createLogger("forum-handler");
|
|
11
15
|
|
|
@@ -89,8 +93,7 @@ export function registerForum(bot: Bot, deps: BotDeps, forum: ForumManager): voi
|
|
|
89
93
|
// Optional: re-run setup command for admins in the group.
|
|
90
94
|
bot.command("forum_setup", async (ctx) => {
|
|
91
95
|
const threadId = ctx.message?.message_thread_id;
|
|
92
|
-
const replyOpts =
|
|
93
|
-
threadId !== undefined ? { message_thread_id: threadId } : {};
|
|
96
|
+
const replyOpts = outboundThreadExtra(threadId);
|
|
94
97
|
if (ctx.chat?.id !== groupId) {
|
|
95
98
|
await ctx.reply("Use this command inside the configured forum group.", replyOpts).catch(() => {});
|
|
96
99
|
return;
|
|
@@ -167,7 +170,11 @@ export async function resolveForumRuntime(
|
|
|
167
170
|
.sendMessage(
|
|
168
171
|
chatId,
|
|
169
172
|
`\u2705 Bound to project:\n\`${result.binding.projectPath}\`${iconNote}\n\nYou can chat here now.`,
|
|
170
|
-
{
|
|
173
|
+
{
|
|
174
|
+
...outboundThreadExtra(tid),
|
|
175
|
+
parse_mode: "Markdown",
|
|
176
|
+
reply_parameters: { message_id: messageId },
|
|
177
|
+
},
|
|
171
178
|
)
|
|
172
179
|
.catch(() => {});
|
|
173
180
|
// Warm runtime; path-only message is not submitted as an agent prompt.
|
|
@@ -181,7 +188,10 @@ export async function resolveForumRuntime(
|
|
|
181
188
|
.sendMessage(
|
|
182
189
|
chatId,
|
|
183
190
|
`\u2753 ${result.error}\n\n${BIND_HINT.replace(/\*\*/g, "")}`,
|
|
184
|
-
{
|
|
191
|
+
{
|
|
192
|
+
...outboundThreadExtra(tid),
|
|
193
|
+
reply_parameters: { message_id: messageId },
|
|
194
|
+
},
|
|
185
195
|
)
|
|
186
196
|
.catch(() => {});
|
|
187
197
|
return "handled";
|
|
@@ -194,7 +204,7 @@ export async function resolveForumRuntime(
|
|
|
194
204
|
.sendMessage(
|
|
195
205
|
chatId,
|
|
196
206
|
`\u2753 This topic is not linked to a project yet.\n${BIND_HINT.replace(/\*\*/g, "")}`,
|
|
197
|
-
|
|
207
|
+
outboundThreadExtra(tid),
|
|
198
208
|
)
|
|
199
209
|
.catch(() => {});
|
|
200
210
|
return "handled";
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forward Grok Build slash commands (e.g. /goal, /plan, /compact) into the
|
|
3
|
+
* active ACP session. Grok shell builtins are parsed from the prompt text by
|
|
4
|
+
* slash_exec — they never reached the agent before because the Telegram message
|
|
5
|
+
* handler treated unknown "/…" lines as typos.
|
|
6
|
+
*
|
|
7
|
+
* Bot-local commands (projects, sessions, reauth, …) stay reserved and are
|
|
8
|
+
* handled by their own `bot.command` registrations. Name collisions use
|
|
9
|
+
* non-colliding Telegram aliases that still send the correct Grok slash line.
|
|
10
|
+
*/
|
|
11
|
+
import type { Bot, Context } from "grammy";
|
|
12
|
+
import { textPrompt } from "../../app/types.js";
|
|
13
|
+
import { createLogger } from "../../logger.js";
|
|
14
|
+
import { isGeneralThread } from "../../forum/thread.js";
|
|
15
|
+
import type { BotDeps } from "../deps.js";
|
|
16
|
+
import { extractReplyContext } from "../reply-context.js";
|
|
17
|
+
import { resolveScope } from "../scope.js";
|
|
18
|
+
|
|
19
|
+
const log = createLogger("grok-slash");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Telegram command names reserved by this bot (without leading slash).
|
|
23
|
+
* Must stay in sync with bot.command registrations / COMMANDS menu.
|
|
24
|
+
* Bare names in this set are NEVER stolen for Grok — use collision aliases.
|
|
25
|
+
*/
|
|
26
|
+
export const BOT_RESERVED_COMMANDS = new Set(
|
|
27
|
+
[
|
|
28
|
+
"start",
|
|
29
|
+
"menu",
|
|
30
|
+
"help",
|
|
31
|
+
"projects",
|
|
32
|
+
"project",
|
|
33
|
+
"sessions",
|
|
34
|
+
"active",
|
|
35
|
+
"running",
|
|
36
|
+
"killall",
|
|
37
|
+
"mcp",
|
|
38
|
+
"tasks",
|
|
39
|
+
"newtask",
|
|
40
|
+
"history",
|
|
41
|
+
"new",
|
|
42
|
+
"status",
|
|
43
|
+
"usage",
|
|
44
|
+
"btw",
|
|
45
|
+
"flush",
|
|
46
|
+
"queue",
|
|
47
|
+
"clearqueue",
|
|
48
|
+
"cancel",
|
|
49
|
+
"stop",
|
|
50
|
+
"unwatch",
|
|
51
|
+
"model",
|
|
52
|
+
"restart",
|
|
53
|
+
"sandbox",
|
|
54
|
+
"reauth",
|
|
55
|
+
"accounts",
|
|
56
|
+
"import",
|
|
57
|
+
"forum_setup",
|
|
58
|
+
].map((c) => c.toLowerCase()),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Telegram command name (no slash, lowercase) → Grok shell command name (no slash).
|
|
63
|
+
* Covers hyphenated multi-word forms, documented Grok aliases, and collision aliases
|
|
64
|
+
* for bot-reserved bare names that still have distinct Grok builtins.
|
|
65
|
+
*/
|
|
66
|
+
export const GROK_SLASH_ALIASES: Readonly<Record<string, string>> = {
|
|
67
|
+
// Multi-word (Telegram underscore ↔ Grok hyphen)
|
|
68
|
+
view_plan: "view-plan",
|
|
69
|
+
show_plan: "view-plan",
|
|
70
|
+
plan_view: "view-plan",
|
|
71
|
+
deep_research: "deep-research",
|
|
72
|
+
always_approve: "always-approve",
|
|
73
|
+
session_info: "session-info",
|
|
74
|
+
imagine_video: "imagine-video",
|
|
75
|
+
config_agents: "config-agents",
|
|
76
|
+
release_notes: "release-notes",
|
|
77
|
+
import_claude: "import-claude",
|
|
78
|
+
compact_mode: "compact-mode",
|
|
79
|
+
vim_mode: "vim-mode",
|
|
80
|
+
agents_dashboard: "dashboard",
|
|
81
|
+
// No-underscore convenience forms
|
|
82
|
+
viewplan: "view-plan",
|
|
83
|
+
showplan: "view-plan",
|
|
84
|
+
planview: "view-plan",
|
|
85
|
+
deepresearch: "deep-research",
|
|
86
|
+
alwaysapprove: "always-approve",
|
|
87
|
+
sessioninfo: "session-info",
|
|
88
|
+
imaginevideo: "imagine-video",
|
|
89
|
+
configagents: "config-agents",
|
|
90
|
+
releasenotes: "release-notes",
|
|
91
|
+
importclaude: "import-claude",
|
|
92
|
+
// Documented Grok aliases
|
|
93
|
+
clear: "new",
|
|
94
|
+
undo: "rewind",
|
|
95
|
+
title: "rename",
|
|
96
|
+
m: "model",
|
|
97
|
+
mem: "memory",
|
|
98
|
+
cost: "usage",
|
|
99
|
+
agents: "config-agents",
|
|
100
|
+
howto: "docs",
|
|
101
|
+
guides: "docs",
|
|
102
|
+
changelog: "release-notes",
|
|
103
|
+
ml: "multiline",
|
|
104
|
+
t: "theme",
|
|
105
|
+
full: "fullscreen",
|
|
106
|
+
tour: "tutorial",
|
|
107
|
+
onboarding: "tutorial",
|
|
108
|
+
welcome: "home",
|
|
109
|
+
exit: "quit",
|
|
110
|
+
prefs: "settings",
|
|
111
|
+
preferences: "settings",
|
|
112
|
+
config: "settings",
|
|
113
|
+
terminal_setup: "doctor",
|
|
114
|
+
terminal_check: "doctor",
|
|
115
|
+
terminal_info: "doctor",
|
|
116
|
+
terminalsetup: "doctor",
|
|
117
|
+
terminalcheck: "doctor",
|
|
118
|
+
terminalinfo: "doctor",
|
|
119
|
+
// Bot name collisions → still send the Grok builtin
|
|
120
|
+
grok_new: "new",
|
|
121
|
+
session_new: "new",
|
|
122
|
+
grok_clear: "new",
|
|
123
|
+
memory_flush: "flush",
|
|
124
|
+
grok_flush: "flush",
|
|
125
|
+
grok_usage: "usage",
|
|
126
|
+
grok_cost: "usage",
|
|
127
|
+
grok_btw: "btw",
|
|
128
|
+
// session-info aliases when bare status/info are bot-reserved
|
|
129
|
+
grok_status: "session-info",
|
|
130
|
+
grok_info: "session-info",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Official shell builtins that are meaningful over ACP (not pure TUI/pager).
|
|
135
|
+
* Used for tests / inventory; menu may advertise a subset + collision aliases.
|
|
136
|
+
*/
|
|
137
|
+
export const GROK_SHELL_ACP_COMMANDS: readonly string[] = [
|
|
138
|
+
"new",
|
|
139
|
+
"compact",
|
|
140
|
+
"context",
|
|
141
|
+
"session-info",
|
|
142
|
+
"fork",
|
|
143
|
+
"rewind",
|
|
144
|
+
"copy",
|
|
145
|
+
"export",
|
|
146
|
+
"delete",
|
|
147
|
+
"rename",
|
|
148
|
+
"model",
|
|
149
|
+
"effort",
|
|
150
|
+
"always-approve",
|
|
151
|
+
"auto",
|
|
152
|
+
"plan",
|
|
153
|
+
"view-plan",
|
|
154
|
+
"memory",
|
|
155
|
+
"flush",
|
|
156
|
+
"dream",
|
|
157
|
+
"remember",
|
|
158
|
+
"hooks",
|
|
159
|
+
"plugins",
|
|
160
|
+
"marketplace",
|
|
161
|
+
"skills",
|
|
162
|
+
"imagine",
|
|
163
|
+
"imagine-video",
|
|
164
|
+
"loop",
|
|
165
|
+
"goal",
|
|
166
|
+
"deep-research",
|
|
167
|
+
"workflow",
|
|
168
|
+
"workflows",
|
|
169
|
+
"feedback",
|
|
170
|
+
"btw",
|
|
171
|
+
"mcps",
|
|
172
|
+
"doctor",
|
|
173
|
+
"release-notes",
|
|
174
|
+
"docs",
|
|
175
|
+
"import-claude",
|
|
176
|
+
"config-agents",
|
|
177
|
+
"personas",
|
|
178
|
+
"login",
|
|
179
|
+
"logout",
|
|
180
|
+
"usage",
|
|
181
|
+
"privacy",
|
|
182
|
+
"settings",
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
/** Grok Build slash commands we advertise in the Telegram menu (Telegram-safe names). */
|
|
186
|
+
export const GROK_FORWARDED_COMMANDS: { command: string; description: string; grok: string }[] = [
|
|
187
|
+
// Session
|
|
188
|
+
{ command: "compact", description: "Grok /compact — compress context", grok: "compact" },
|
|
189
|
+
{ command: "context", description: "Grok /context — context window usage", grok: "context" },
|
|
190
|
+
{ command: "session_info", description: "Grok /session-info", grok: "session-info" },
|
|
191
|
+
{ command: "fork", description: "Grok /fork — branch session", grok: "fork" },
|
|
192
|
+
{ command: "rewind", description: "Grok /rewind — undo last turns", grok: "rewind" },
|
|
193
|
+
{ command: "copy", description: "Grok /copy — copy last response", grok: "copy" },
|
|
194
|
+
{ command: "export", description: "Grok /export — export conversation", grok: "export" },
|
|
195
|
+
{ command: "delete", description: "Grok /delete — delete session history", grok: "delete" },
|
|
196
|
+
{ command: "rename", description: "Grok /rename <title>", grok: "rename" },
|
|
197
|
+
{ command: "grok_new", description: "Grok /new — fresh session (CLI)", grok: "new" },
|
|
198
|
+
// Model / mode
|
|
199
|
+
{ command: "effort", description: "Grok /effort low|medium|high|xhigh", grok: "effort" },
|
|
200
|
+
{ command: "always_approve", description: "Grok /always-approve toggle", grok: "always-approve" },
|
|
201
|
+
{ command: "auto", description: "Grok /auto — auto permission mode", grok: "auto" },
|
|
202
|
+
{ command: "plan", description: "Grok /plan — enter plan mode", grok: "plan" },
|
|
203
|
+
{ command: "view_plan", description: "Grok /view-plan — show saved plan", grok: "view-plan" },
|
|
204
|
+
// Memory
|
|
205
|
+
{ command: "memory", description: "Grok /memory — browse memories", grok: "memory" },
|
|
206
|
+
{ command: "memory_flush", description: "Grok /flush — save session to memory", grok: "flush" },
|
|
207
|
+
{ command: "dream", description: "Grok /dream — consolidate memory", grok: "dream" },
|
|
208
|
+
{ command: "remember", description: "Grok /remember <note>", grok: "remember" },
|
|
209
|
+
// Extensions
|
|
210
|
+
{ command: "hooks", description: "Grok /hooks — hooks panel", grok: "hooks" },
|
|
211
|
+
{ command: "plugins", description: "Grok /plugins — plugins panel", grok: "plugins" },
|
|
212
|
+
{ command: "marketplace", description: "Grok /marketplace — plugin marketplace", grok: "marketplace" },
|
|
213
|
+
{ command: "skills", description: "Grok /skills — skills panel", grok: "skills" },
|
|
214
|
+
// Media
|
|
215
|
+
{ command: "imagine", description: "Grok /imagine <description>", grok: "imagine" },
|
|
216
|
+
{ command: "imagine_video", description: "Grok /imagine-video <description>", grok: "imagine-video" },
|
|
217
|
+
// Scheduling / workflows / goals
|
|
218
|
+
{ command: "loop", description: "Grok /loop [interval] <prompt>", grok: "loop" },
|
|
219
|
+
{ command: "goal", description: "Grok /goal — set/status/pause/resume/clear", grok: "goal" },
|
|
220
|
+
{ command: "deep_research", description: "Grok /deep-research <query>", grok: "deep-research" },
|
|
221
|
+
{ command: "workflow", description: "Grok /workflow — run/manage workflow", grok: "workflow" },
|
|
222
|
+
{ command: "workflows", description: "Grok /workflows — workflow dashboard", grok: "workflows" },
|
|
223
|
+
// Other
|
|
224
|
+
{ command: "feedback", description: "Grok /feedback [message]", grok: "feedback" },
|
|
225
|
+
{ command: "grok_btw", description: "Grok /btw — aside without interrupting", grok: "btw" },
|
|
226
|
+
{ command: "mcps", description: "Grok /mcps — MCP servers modal", grok: "mcps" },
|
|
227
|
+
{ command: "doctor", description: "Grok /doctor — session diagnostics", grok: "doctor" },
|
|
228
|
+
{ command: "release_notes", description: "Grok /release-notes", grok: "release-notes" },
|
|
229
|
+
{ command: "docs", description: "Grok /docs — how-to guides", grok: "docs" },
|
|
230
|
+
{ command: "import_claude", description: "Grok /import-claude", grok: "import-claude" },
|
|
231
|
+
{ command: "config_agents", description: "Grok /config-agents — agent defs", grok: "config-agents" },
|
|
232
|
+
{ command: "personas", description: "Grok /personas — manage personas", grok: "personas" },
|
|
233
|
+
{ command: "login", description: "Grok /login — re-auth in session", grok: "login" },
|
|
234
|
+
{ command: "logout", description: "Grok /logout", grok: "logout" },
|
|
235
|
+
{ command: "grok_usage", description: "Grok /usage — credit/billing", grok: "usage" },
|
|
236
|
+
{ command: "privacy", description: "Grok /privacy — data/retention", grok: "privacy" },
|
|
237
|
+
{ command: "settings", description: "Grok /settings — config modal", grok: "settings" },
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
/** Resolve a Telegram command token (no slash) to a Grok shell command name. */
|
|
241
|
+
export function resolveGrokCommandName(telegramName: string): string {
|
|
242
|
+
const name = telegramName.toLowerCase();
|
|
243
|
+
const fromAlias = GROK_SLASH_ALIASES[name];
|
|
244
|
+
if (fromAlias) return fromAlias;
|
|
245
|
+
const fromMenu = GROK_FORWARDED_COMMANDS.find((c) => c.command === name);
|
|
246
|
+
if (fromMenu) return fromMenu.grok;
|
|
247
|
+
// underscore → hyphen for multi-word Grok commands (deep_research → deep-research)
|
|
248
|
+
return name.replace(/_/g, "-");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** True when a bare slash line should be forwarded to Grok (not a bot command). */
|
|
252
|
+
export function shouldForwardSlashToGrok(text: string): boolean {
|
|
253
|
+
const t = text.trim();
|
|
254
|
+
if (!t.startsWith("/") || t.includes("\n")) return false;
|
|
255
|
+
// /cmd@botname args
|
|
256
|
+
const m = t.match(/^\/([A-Za-z0-9_]+)(?:@\w+)?(?:\s|$)/);
|
|
257
|
+
if (!m) return false;
|
|
258
|
+
const name = m[1]!.toLowerCase();
|
|
259
|
+
if (BOT_RESERVED_COMMANDS.has(name)) return false;
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Normalize Telegram `/view_plan foo` → Grok `/view-plan foo`. */
|
|
264
|
+
export function toGrokSlashLine(text: string): string {
|
|
265
|
+
const t = text.trim();
|
|
266
|
+
const m = t.match(/^\/([A-Za-z0-9_]+)(@\w+)?([\s\S]*)$/);
|
|
267
|
+
if (!m) return t;
|
|
268
|
+
const rest = m[3] ?? "";
|
|
269
|
+
const name = resolveGrokCommandName(m[1]!);
|
|
270
|
+
return `/${name}${rest}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function submitGrokSlash(ctx: Context, deps: BotDeps, line: string): Promise<void> {
|
|
274
|
+
if (!ctx.chat) return;
|
|
275
|
+
const grokLine = toGrokSlashLine(line);
|
|
276
|
+
const scope = resolveScope(ctx, deps);
|
|
277
|
+
const extra: Record<string, unknown> = { parse_mode: "Markdown", ...scope.threadExtra };
|
|
278
|
+
if (scope.isForum && isGeneralThread(scope.threadId) && /^\/goal(?:\s|$)/i.test(grokLine)) {
|
|
279
|
+
await ctx.reply(
|
|
280
|
+
"Use /goal in a **project topic** or **AI Chat**, not in General \\(manager\\).",
|
|
281
|
+
extra,
|
|
282
|
+
);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const rt = scope.rt;
|
|
286
|
+
try {
|
|
287
|
+
// Prefer dedicated ACP command RPC when the agent supports it; fall back to
|
|
288
|
+
// session/prompt (Grok slash_exec parses leading / in the prompt).
|
|
289
|
+
if (rt.sessionId) {
|
|
290
|
+
try {
|
|
291
|
+
await deps.acp.executeCommand(rt.sessionId, grokLine);
|
|
292
|
+
await ctx.reply(`\u25B6\uFE0F Sent to Grok: \`${grokLine}\``, extra);
|
|
293
|
+
return;
|
|
294
|
+
} catch (err) {
|
|
295
|
+
log.debug(
|
|
296
|
+
`executeCommand failed for ${grokLine}: ${(err as Error).message}; falling back to prompt`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const outcome = await rt.submit(
|
|
301
|
+
textPrompt(grokLine, ctx.message?.message_id, extractReplyContext(ctx), {
|
|
302
|
+
skipSelfRecheck: true,
|
|
303
|
+
rawSlashCommand: true,
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
if (outcome === "queued") {
|
|
307
|
+
await ctx.reply(
|
|
308
|
+
`\u{1F4E5} Queued (position ${rt.queueLength}): \`${grokLine}\` \u2014 runs after the current turn.`,
|
|
309
|
+
extra,
|
|
310
|
+
);
|
|
311
|
+
} else {
|
|
312
|
+
await ctx.reply(`\u25B6\uFE0F Running \`${grokLine}\`\u2026`, extra);
|
|
313
|
+
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
log.warn(`grok slash failed: ${(err as Error).message}`);
|
|
316
|
+
await ctx.reply(`\u274C Could not run \`${grokLine}\`: ${(err as Error).message}`, extra);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function registerGrokSlash(bot: Bot, deps: BotDeps): void {
|
|
321
|
+
// Explicit Telegram commands for advertised Grok builtins (appear in menu).
|
|
322
|
+
for (const def of GROK_FORWARDED_COMMANDS) {
|
|
323
|
+
if (BOT_RESERVED_COMMANDS.has(def.command)) continue;
|
|
324
|
+
bot.command(def.command, async (ctx) => {
|
|
325
|
+
const args = (ctx.match || "").toString();
|
|
326
|
+
const line = args ? `/${def.command} ${args}` : `/${def.command}`;
|
|
327
|
+
await submitGrokSlash(ctx, deps, line);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
// Catch-all for other Grok / skills slashes not in the menu.
|
|
331
|
+
bot.on("message:text", async (ctx, next) => {
|
|
332
|
+
const text = ctx.message?.text ?? "";
|
|
333
|
+
if (!shouldForwardSlashToGrok(text)) return next();
|
|
334
|
+
await submitGrokSlash(ctx, deps, text);
|
|
335
|
+
});
|
|
336
|
+
}
|