grok-telegram-bot 2.5.0 → 2.6.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/CHANGELOG.md +71 -0
- package/README.md +5 -3
- package/docs/GROUP.md +39 -4
- package/package.json +4 -4
- package/src/app/types.ts +27 -1
- package/src/bot/auth.ts +5 -1
- package/src/bot/bot.ts +73 -4
- package/src/bot/chat-controller.ts +129 -0
- package/src/bot/group-memory.ts +192 -12
- package/src/bot/handlers/forum.ts +16 -6
- package/src/bot/handlers/message.ts +165 -25
- package/src/bot/handlers/photo.ts +4 -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/prompt-anchor.ts +2 -3
- package/src/bot/prompt-content.ts +5 -0
- package/src/bot/scope.ts +9 -8
- package/src/bot/session-runtime.ts +663 -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/forum/manager.ts +2 -1
- package/src/forum/thread.ts +33 -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/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";
|
|
@@ -8,22 +8,27 @@
|
|
|
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
|
-
*
|
|
12
|
-
* the chat shows immediate life while CLI/ACP starts
|
|
13
|
-
* to that anchor with the same searchable tag.
|
|
11
|
+
* Project topics: user messages are replaced by a bot-owned prompt anchor
|
|
12
|
+
* (`#prompt_<id>`) so the chat shows immediate life while CLI/ACP starts.
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* General (manager): user messages are KEPT; AI replies thread to the user
|
|
15
|
+
* message. Each message is a NEW session (parallel), unless the user replies
|
|
16
|
+
* to a bot message — then that session continues.
|
|
18
17
|
*/
|
|
19
18
|
import type { Bot } from "grammy";
|
|
20
19
|
import { textPrompt } from "../../app/types.js";
|
|
21
20
|
import { createLogger } from "../../logger.js";
|
|
22
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
batchKey,
|
|
23
|
+
forumThreadId,
|
|
24
|
+
isGeneralThread,
|
|
25
|
+
outboundThreadExtra,
|
|
26
|
+
} from "../../forum/thread.js";
|
|
23
27
|
import type { BotDeps } from "../deps.js";
|
|
24
|
-
import { adoptUserPrompt } from "../prompt-anchor.js";
|
|
28
|
+
import { adoptUserPrompt, newPromptId } from "../prompt-anchor.js";
|
|
25
29
|
import { extractReplyContext } from "../reply-context.js";
|
|
26
30
|
import { resolveForumRuntime } from "./forum.js";
|
|
31
|
+
import type { SessionRuntime } from "../session-runtime.js";
|
|
27
32
|
|
|
28
33
|
const log = createLogger("message");
|
|
29
34
|
|
|
@@ -35,6 +40,10 @@ interface TextBatch {
|
|
|
35
40
|
threadId?: number;
|
|
36
41
|
/** Reference content if the burst began as a reply to another message. */
|
|
37
42
|
quoted?: string;
|
|
43
|
+
/** Telegram message_id the user is replying to (for session follow-up). */
|
|
44
|
+
replyToMessageId?: number;
|
|
45
|
+
/** Text of the replied-to message (for #sess_ recovery). */
|
|
46
|
+
replyToText?: string;
|
|
38
47
|
timer: NodeJS.Timeout;
|
|
39
48
|
}
|
|
40
49
|
|
|
@@ -49,7 +58,6 @@ export function registerMessages(bot: Bot, deps: BotDeps): void {
|
|
|
49
58
|
const text = ctx.message.text;
|
|
50
59
|
if (!text.trim()) return;
|
|
51
60
|
// 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
61
|
if (!text.includes("\n") && text.startsWith("/")) return;
|
|
54
62
|
|
|
55
63
|
const chatId = ctx.chat.id;
|
|
@@ -58,7 +66,14 @@ export function registerMessages(bot: Bot, deps: BotDeps): void {
|
|
|
58
66
|
const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
|
|
59
67
|
const threadId = isForum ? forumThreadId(rawThreadId) : rawThreadId;
|
|
60
68
|
const quoted = extractReplyContext(ctx);
|
|
61
|
-
const
|
|
69
|
+
const replyToMessageId = ctx.message.reply_to_message?.message_id;
|
|
70
|
+
const replyToText =
|
|
71
|
+
ctx.message.reply_to_message?.text ?? ctx.message.reply_to_message?.caption;
|
|
72
|
+
// General: do not coalesce independent messages (parallel sessions).
|
|
73
|
+
// Still coalesce multi-part 4096 splits when reply chain is empty and rapid.
|
|
74
|
+
const key = isGeneralThread(threadId) && isForum
|
|
75
|
+
? `${chatId}:${threadId ?? 1}:m${id}`
|
|
76
|
+
: batchKey(chatId, rawThreadId, isForum);
|
|
62
77
|
|
|
63
78
|
const batch = batches.get(key);
|
|
64
79
|
if (batch) {
|
|
@@ -66,6 +81,10 @@ export function registerMessages(bot: Bot, deps: BotDeps): void {
|
|
|
66
81
|
batch.parts.push(text);
|
|
67
82
|
batch.ids.push(id);
|
|
68
83
|
if (quoted && !batch.quoted) batch.quoted = quoted;
|
|
84
|
+
if (replyToMessageId !== undefined && batch.replyToMessageId === undefined) {
|
|
85
|
+
batch.replyToMessageId = replyToMessageId;
|
|
86
|
+
batch.replyToText = replyToText;
|
|
87
|
+
}
|
|
69
88
|
batch.timer = arm(key);
|
|
70
89
|
return;
|
|
71
90
|
}
|
|
@@ -74,6 +93,8 @@ export function registerMessages(bot: Bot, deps: BotDeps): void {
|
|
|
74
93
|
ids: [id],
|
|
75
94
|
threadId,
|
|
76
95
|
quoted,
|
|
96
|
+
replyToMessageId,
|
|
97
|
+
replyToText,
|
|
77
98
|
timer: arm(key),
|
|
78
99
|
});
|
|
79
100
|
});
|
|
@@ -85,8 +106,6 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
|
|
|
85
106
|
if (!batch) return;
|
|
86
107
|
batches.delete(key);
|
|
87
108
|
|
|
88
|
-
// Telegram splits at 4096 chars, almost always on a line boundary, so
|
|
89
|
-
// rejoining with a newline reconstructs the original text faithfully.
|
|
90
109
|
const combined = batch.parts.join("\n").trim();
|
|
91
110
|
if (!combined) return;
|
|
92
111
|
|
|
@@ -94,14 +113,16 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
|
|
|
94
113
|
const chatId = Number(chatIdStr);
|
|
95
114
|
const threadId = batch.threadId;
|
|
96
115
|
|
|
97
|
-
// Defense-in-depth: never submit slash-only lines as agent prompts.
|
|
98
116
|
if (batch.parts.length === 1 && !combined.includes("\n") && combined.startsWith("/")) {
|
|
99
117
|
return;
|
|
100
118
|
}
|
|
101
119
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
120
|
+
const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
|
|
121
|
+
const isGeneral = isForum && isGeneralThread(threadId);
|
|
122
|
+
|
|
123
|
+
let rt: SessionRuntime = deps.registry.get(chatId);
|
|
124
|
+
|
|
125
|
+
if (isForum && deps.forum) {
|
|
105
126
|
const resolved = await resolveForumRuntime(
|
|
106
127
|
deps,
|
|
107
128
|
deps.forum,
|
|
@@ -111,10 +132,98 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
|
|
|
111
132
|
batch.ids[0]!,
|
|
112
133
|
);
|
|
113
134
|
if (resolved === "handled" || resolved === "ignore") return;
|
|
135
|
+
|
|
136
|
+
if (isGeneral) {
|
|
137
|
+
// ── General manager path ─────────────────────────────────────────
|
|
138
|
+
const controller = deps.registry.forumController(
|
|
139
|
+
chatId,
|
|
140
|
+
forumThreadId(threadId),
|
|
141
|
+
resolved.rt.cwd,
|
|
142
|
+
resolved.rt.projectName ?? "General",
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Reply → continue same session (map, #sess_ on controlled runtimes, or disk).
|
|
146
|
+
// Fresh message → new parallel session (does not queue behind other General work).
|
|
147
|
+
const continueRt = await controller.resolveContinueFromReply({
|
|
148
|
+
replyToMessageId: batch.replyToMessageId,
|
|
149
|
+
replyToText: batch.replyToText,
|
|
150
|
+
cwd: resolved.rt.cwd,
|
|
151
|
+
projectName: resolved.rt.projectName ?? "General",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const userMsgId = batch.ids[0]!;
|
|
155
|
+
const replyTo = userMsgId;
|
|
156
|
+
// Keep user message; reply to it. No overwrite / adopt delete.
|
|
157
|
+
|
|
158
|
+
// New session: post "Starting…" FIRST (before ACP session/new) so the user
|
|
159
|
+
// sees life immediately. runTurn later edits it to Thinking… then streams.
|
|
160
|
+
// Follow-up on existing session: skip Starting (runTurn posts Thinking…).
|
|
161
|
+
let seedMessageId: number | undefined;
|
|
162
|
+
if (!continueRt) {
|
|
163
|
+
seedMessageId = await sendStatus(
|
|
164
|
+
deps,
|
|
165
|
+
chatId,
|
|
166
|
+
"Starting\u2026",
|
|
167
|
+
threadId,
|
|
168
|
+
replyTo,
|
|
169
|
+
);
|
|
170
|
+
rt = await controller.addParallel(
|
|
171
|
+
resolved.rt.cwd,
|
|
172
|
+
resolved.rt.projectName ?? "General",
|
|
173
|
+
);
|
|
174
|
+
if (seedMessageId !== undefined && rt.sessionId) {
|
|
175
|
+
controller.bindTelegramMessage(seedMessageId, rt.sessionId);
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
rt = continueRt;
|
|
179
|
+
// FG for status panel; manager setForeground keeps busy siblings streaming.
|
|
180
|
+
if (rt.sessionId) await controller.switchTo(rt.sessionId).catch(() => {});
|
|
181
|
+
}
|
|
182
|
+
if (rt.sessionId) controller.bindTelegramMessage(userMsgId, rt.sessionId);
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const outcome = await rt.submit(
|
|
186
|
+
textPrompt(combined, replyTo, batch.quoted, {
|
|
187
|
+
promptId: newPromptId(),
|
|
188
|
+
seedMessageId,
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
if (rt.sessionId) {
|
|
192
|
+
controller.bindTelegramMessage(userMsgId, rt.sessionId);
|
|
193
|
+
if (seedMessageId !== undefined) {
|
|
194
|
+
controller.bindTelegramMessage(seedMessageId, rt.sessionId);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Never show "queued" spam for new parallel sessions; only if continuing
|
|
198
|
+
// the same session that is already busy.
|
|
199
|
+
if (outcome === "queued" && continueRt) {
|
|
200
|
+
await send(
|
|
201
|
+
deps,
|
|
202
|
+
chatId,
|
|
203
|
+
`\u{1F4E5} Got it — queued as a follow-up on that thread.`,
|
|
204
|
+
threadId,
|
|
205
|
+
replyTo,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
} catch (err) {
|
|
209
|
+
log.warn(`general submit failed chat ${chatId}: ${(err as Error).message}`);
|
|
210
|
+
if (seedMessageId !== undefined) {
|
|
211
|
+
await editStatus(deps, chatId, seedMessageId, `\u274C Couldn't start: ${(err as Error).message}`);
|
|
212
|
+
} else {
|
|
213
|
+
await send(
|
|
214
|
+
deps,
|
|
215
|
+
chatId,
|
|
216
|
+
`\u274C Couldn't start: ${(err as Error).message}`,
|
|
217
|
+
threadId,
|
|
218
|
+
replyTo,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Project / AI Chat topics (existing behavior) ─────────────────
|
|
114
226
|
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
227
|
if (!rt.sessionId && !rt.isBusy) {
|
|
119
228
|
try {
|
|
120
229
|
await rt.startNewSession(rt.cwd, rt.projectName);
|
|
@@ -125,11 +234,8 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
|
|
|
125
234
|
}
|
|
126
235
|
|
|
127
236
|
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
237
|
let replyTo: number | undefined = batch.ids[0];
|
|
130
238
|
try {
|
|
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
239
|
const anchor = await adoptUserPrompt(deps.api, {
|
|
134
240
|
chatId,
|
|
135
241
|
text: combined,
|
|
@@ -151,7 +257,6 @@ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string
|
|
|
151
257
|
replyTo,
|
|
152
258
|
);
|
|
153
259
|
}
|
|
154
|
-
// "ran": turn started; complexity is steered silently by the agent.
|
|
155
260
|
} catch (err) {
|
|
156
261
|
log.warn(`submit failed for chat ${chatId}: ${(err as Error).message}`);
|
|
157
262
|
await send(
|
|
@@ -172,8 +277,7 @@ async function send(
|
|
|
172
277
|
replyTo?: number,
|
|
173
278
|
): Promise<void> {
|
|
174
279
|
try {
|
|
175
|
-
const extra: Record<string, unknown> = {};
|
|
176
|
-
if (threadId !== undefined) extra.message_thread_id = threadId;
|
|
280
|
+
const extra: Record<string, unknown> = { ...outboundThreadExtra(threadId) };
|
|
177
281
|
if (replyTo !== undefined) {
|
|
178
282
|
extra.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
|
|
179
283
|
}
|
|
@@ -182,3 +286,39 @@ async function send(
|
|
|
182
286
|
/* non-fatal */
|
|
183
287
|
}
|
|
184
288
|
}
|
|
289
|
+
|
|
290
|
+
/** Post status bubble; returns message_id for later edit (Starting… → Thinking…). */
|
|
291
|
+
async function sendStatus(
|
|
292
|
+
deps: BotDeps,
|
|
293
|
+
chatId: number,
|
|
294
|
+
text: string,
|
|
295
|
+
threadId?: number,
|
|
296
|
+
replyTo?: number,
|
|
297
|
+
): Promise<number | undefined> {
|
|
298
|
+
try {
|
|
299
|
+
const extra: Record<string, unknown> = {
|
|
300
|
+
disable_notification: true,
|
|
301
|
+
...outboundThreadExtra(threadId),
|
|
302
|
+
};
|
|
303
|
+
if (replyTo !== undefined) {
|
|
304
|
+
extra.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
|
|
305
|
+
}
|
|
306
|
+
const msg = await deps.api.sendMessage(chatId, text, extra);
|
|
307
|
+
return msg.message_id;
|
|
308
|
+
} catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function editStatus(
|
|
314
|
+
deps: BotDeps,
|
|
315
|
+
chatId: number,
|
|
316
|
+
messageId: number,
|
|
317
|
+
text: string,
|
|
318
|
+
): Promise<void> {
|
|
319
|
+
try {
|
|
320
|
+
await deps.api.editMessageText(chatId, messageId, text);
|
|
321
|
+
} catch {
|
|
322
|
+
/* non-fatal */
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -193,8 +193,11 @@ async function submit(
|
|
|
193
193
|
quotedText: quoted,
|
|
194
194
|
});
|
|
195
195
|
if (outcome === "queued") {
|
|
196
|
+
// Omit General (1) — Bot API rejects message_thread_id=1.
|
|
196
197
|
const extra: Record<string, unknown> =
|
|
197
|
-
threadId !== undefined
|
|
198
|
+
threadId !== undefined && threadId !== 1
|
|
199
|
+
? { message_thread_id: threadId }
|
|
200
|
+
: {};
|
|
198
201
|
if (anchor?.replyTo !== undefined) {
|
|
199
202
|
extra.reply_parameters = {
|
|
200
203
|
message_id: anchor.replyTo,
|
package/src/bot/image-return.ts
CHANGED
|
@@ -145,7 +145,10 @@ export async function sendImages(
|
|
|
145
145
|
if (opts.replyTo !== undefined) {
|
|
146
146
|
extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
|
|
147
147
|
}
|
|
148
|
-
|
|
148
|
+
// Omit General (1) — Bot API rejects message_thread_id=1.
|
|
149
|
+
if (opts.messageThreadId !== undefined && opts.messageThreadId !== 1) {
|
|
150
|
+
extra.message_thread_id = opts.messageThreadId;
|
|
151
|
+
}
|
|
149
152
|
for (const path of paths) {
|
|
150
153
|
if (sent >= opts.max) break;
|
|
151
154
|
if (opts.already.has(path)) continue;
|