grok-telegram-bot 2.3.1 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +64 -2
- package/CHANGELOG.md +156 -1
- package/README.md +58 -15
- package/docs/GROUP.md +225 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +1 -1
- package/src/app/accounts.ts +84 -0
- package/src/app/instance-lock.ts +6 -0
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +30 -2
- package/src/app/updater.ts +38 -6
- package/src/app/usage.ts +204 -7
- package/src/bot/account-rotator.ts +10 -0
- package/src/bot/auth.ts +96 -15
- package/src/bot/bot.ts +154 -11
- package/src/bot/chat-controller.ts +82 -13
- package/src/bot/commands.ts +69 -27
- package/src/bot/complexity-gate.ts +69 -0
- package/src/bot/deps.ts +22 -0
- package/src/bot/group-memory.ts +159 -0
- package/src/bot/handlers/accounts.ts +58 -1
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +207 -0
- package/src/bot/handlers/import-session.ts +290 -0
- package/src/bot/handlers/menu.ts +102 -61
- package/src/bot/handlers/message.ts +102 -21
- package/src/bot/handlers/photo.ts +123 -16
- package/src/bot/handlers/running.ts +172 -16
- package/src/bot/handlers/session-card.ts +20 -0
- package/src/bot/handlers/sessions.ts +76 -15
- package/src/bot/handlers/usage.ts +118 -16
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +8 -5
- package/src/bot/menu/ephemeral.ts +13 -3
- package/src/bot/menu/keyboard.ts +54 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +25 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +300 -0
- package/src/bot/prompt-content.ts +7 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +94 -0
- package/src/bot/session-fork.ts +11 -0
- package/src/bot/session-runtime.ts +1254 -83
- package/src/bot/suggestions.ts +489 -0
- package/src/bot/telegram-actions.ts +440 -0
- package/src/bot/telegram-bots.ts +495 -0
- package/src/bot/telegram-io.ts +94 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +242 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +651 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +16 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +214 -37
- package/src/grok/plan-approval.ts +72 -0
- package/src/grok/session-log.ts +16 -0
- package/src/grok/types.ts +21 -2
- package/src/import/build-import.ts +132 -0
- package/src/import/history-readers.ts +681 -0
- package/src/import/list-running.ts +100 -0
- package/src/import/sources.ts +78 -0
- package/src/index.ts +315 -30
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/diff.ts +11 -2
- package/src/render/file-summary.ts +31 -1
- package/src/render/hashtags.ts +5 -1
- package/src/render/markdown.ts +293 -35
- package/src/render/plan.ts +127 -0
- package/src/render/session-comment.ts +318 -0
- package/src/render/telegram-bridge.ts +360 -0
- package/src/render/tool-call-detail.ts +400 -19
- package/src/render/tool-call-merge.ts +115 -0
- package/src/render/tool-call.ts +444 -162
- package/src/render/truncate.ts +85 -0
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +30 -6
- package/src/sessions/history.ts +98 -0
- package/src/sessions/process.ts +7 -0
- package/src/sessions/store.ts +3 -0
- package/src/sessions/types.ts +5 -0
- package/src/stream/streamer.ts +90 -15
- package/src/tasks/runner.ts +4 -3
|
@@ -49,11 +49,15 @@ export class StatusPanel {
|
|
|
49
49
|
const SEP = " | "; // pipe delimiter between inline fields
|
|
50
50
|
const lines: string[] = [];
|
|
51
51
|
|
|
52
|
-
// 1)
|
|
53
|
-
//
|
|
52
|
+
// 1) Active plan board first (always above the progress bar) so the user
|
|
53
|
+
// always sees done / in-progress / pending steps while a plan is live.
|
|
54
|
+
const plan = rt.planBoard;
|
|
55
|
+
if (plan) lines.push(plan);
|
|
56
|
+
|
|
57
|
+
// 2) Progress — only while a turn is live (cleared when it ends).
|
|
54
58
|
if (progress !== undefined) lines.push(`\u{1F4C8} ${progressBar(progress)}`);
|
|
55
59
|
|
|
56
|
-
//
|
|
60
|
+
// 3) Activity: state + only the counters that currently apply.
|
|
57
61
|
const activity: string[] = [rt.isBusy ? "\u23F3 Working" : "\u2705 Idle"];
|
|
58
62
|
if (rt.queueLength > 0) activity.push(`\u{1F4E5} ${rt.queueLength} queued`);
|
|
59
63
|
if (running > 1) activity.push(`\u{1F9ED} ${running} sessions`);
|
|
@@ -61,13 +65,28 @@ export class StatusPanel {
|
|
|
61
65
|
if (subagents) activity.push(`\u{1F465} ${subagents}`);
|
|
62
66
|
lines.push(activity.join(SEP));
|
|
63
67
|
|
|
64
|
-
//
|
|
68
|
+
// 3b) Last user prompt (+ thinking while busy) — same source as Running cards.
|
|
69
|
+
// When a plan board is already shown above, still surface the user prompt;
|
|
70
|
+
// skip only a pure thinking second line if plan is active (less noise).
|
|
71
|
+
const comment = rt.cardComment;
|
|
72
|
+
if (comment) {
|
|
73
|
+
const parts = comment.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
74
|
+
const hideThinking = !!(rt.planSummary && rt.isBusy);
|
|
75
|
+
parts.forEach((part, i) => {
|
|
76
|
+
if (hideThinking && i > 0) return;
|
|
77
|
+
const icon = i === 0 ? (rt.isBusy ? "\u23F3" : "\u{1F4AC}") : "\u{1F9E0}";
|
|
78
|
+
const shown = part.length > 250 ? `${part.slice(0, 249)}\u2026` : part;
|
|
79
|
+
lines.push(`${icon} ${shown}`);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 4) Where: project | session | context usage.
|
|
65
84
|
const loc = [`\u{1F4C1} ${project}`, `\u{1F9F5} ${session}`];
|
|
66
85
|
if (ctxPct !== undefined) loc.push(`\u{1F4CA} ${ctxPct.toFixed(0)}% context`);
|
|
67
86
|
lines.push(loc.join(SEP));
|
|
68
87
|
|
|
69
|
-
//
|
|
70
|
-
lines.push([`\u{
|
|
88
|
+
// 5) How: reasoning | model (agent picker removed — plan mode is automatic).
|
|
89
|
+
lines.push([`\u{1F9E0} ${reasoningLabel(s.reasoning)}`, `\u{1F9E9} ${s.model || "default"}`].join(SEP));
|
|
71
90
|
|
|
72
91
|
return lines.join("\n");
|
|
73
92
|
}
|
|
@@ -161,6 +161,25 @@ export class PermissionService {
|
|
|
161
161
|
return this.pending.get(reqId)?.sessionId;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* ACP: when a session is cancelled, all pending permission requests for that
|
|
166
|
+
* session must complete with `cancelled`. Returns how many were cancelled.
|
|
167
|
+
* Does not touch other sessions.
|
|
168
|
+
*/
|
|
169
|
+
cancelForSession(sessionId: string): number {
|
|
170
|
+
let n = 0;
|
|
171
|
+
for (const [reqId, p] of [...this.pending.entries()]) {
|
|
172
|
+
if (p.sessionId !== sessionId) continue;
|
|
173
|
+
clearTimeout(p.timer);
|
|
174
|
+
this.pending.delete(reqId);
|
|
175
|
+
void this.finishPrompt(p, "\u{1F510} (cancelled — turn stopped)");
|
|
176
|
+
p.resolve({ outcome: { outcome: "cancelled" } });
|
|
177
|
+
n++;
|
|
178
|
+
}
|
|
179
|
+
if (n > 0) log.info(`cancelled ${n} pending permission(s) for session ${sessionId.slice(0, 8)}`);
|
|
180
|
+
return n;
|
|
181
|
+
}
|
|
182
|
+
|
|
164
183
|
/** Unpin (if pinned) and optionally rewrite the prompt message. */
|
|
165
184
|
private async finishPrompt(p: Pending, text?: string): Promise<void> {
|
|
166
185
|
if (p.messageId !== undefined && text) {
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt anchors & command acks — instant bot feedback while the CLI/ACP warms up.
|
|
3
|
+
*
|
|
4
|
+
* When the user sends a prompt we:
|
|
5
|
+
* 1. post a bot-owned message with the prompt text + `#prompt_<id>`
|
|
6
|
+
* (and re-attach any photos/files/voice so media is not lost when the
|
|
7
|
+
* user's original is deleted)
|
|
8
|
+
* 2. best-effort delete the user's original message(s)
|
|
9
|
+
* 3. thread every AI reply (and Done/error) to that bot message
|
|
10
|
+
*
|
|
11
|
+
* Commands use {@link ackCommand}: delete the slash message immediately and
|
|
12
|
+
* post a short status so the chat never looks dead during slow handlers.
|
|
13
|
+
*/
|
|
14
|
+
import type { Api, Context } from "grammy";
|
|
15
|
+
import { InputMediaBuilder } from "grammy";
|
|
16
|
+
import { createLogger } from "../logger.js";
|
|
17
|
+
import { tagSafe } from "../render/hashtags.js";
|
|
18
|
+
|
|
19
|
+
const log = createLogger("prompt-anchor");
|
|
20
|
+
|
|
21
|
+
/** Telegram hard cap for text messages; leave room for prefix + tags. */
|
|
22
|
+
const BODY_BUDGET = 3800;
|
|
23
|
+
/** Telegram caption hard cap on photo/document/audio/video. */
|
|
24
|
+
const CAPTION_BUDGET = 1024;
|
|
25
|
+
|
|
26
|
+
export type AdoptMediaItem =
|
|
27
|
+
| { type: "photo"; fileId: string }
|
|
28
|
+
| { type: "document"; fileId: string; fileName?: string }
|
|
29
|
+
| { type: "voice"; fileId: string }
|
|
30
|
+
| { type: "audio"; fileId: string; fileName?: string }
|
|
31
|
+
| { type: "video"; fileId: string }
|
|
32
|
+
| { type: "video_note"; fileId: string };
|
|
33
|
+
|
|
34
|
+
export interface AdoptPromptOpts {
|
|
35
|
+
chatId: number;
|
|
36
|
+
/** User prompt body to echo on the anchor (not sent to the agent twice — agent gets original text). */
|
|
37
|
+
text: string;
|
|
38
|
+
/** User Telegram message ids to delete after the anchor is posted. */
|
|
39
|
+
userMessageIds: number[];
|
|
40
|
+
messageThreadId?: number;
|
|
41
|
+
projectName?: string;
|
|
42
|
+
/** Leading emoji/label, e.g. "📝", "📷", "🎤". */
|
|
43
|
+
prefix?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Media from the user's message(s), re-posted via Telegram file_id so the
|
|
46
|
+
* chat keeps images/files after the original is deleted. Same bot may reuse
|
|
47
|
+
* file_ids it received.
|
|
48
|
+
*/
|
|
49
|
+
media?: AdoptMediaItem[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PromptAnchor {
|
|
53
|
+
/** Bot message id — use as PromptInput.replyTo. */
|
|
54
|
+
replyTo: number;
|
|
55
|
+
/** Short id for `#prompt_<id>` footers. */
|
|
56
|
+
promptId: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Short unique id safe for Telegram hashtag bodies. */
|
|
60
|
+
export function newPromptId(): string {
|
|
61
|
+
const t = Date.now().toString(36);
|
|
62
|
+
const r = Math.random().toString(36).slice(2, 6);
|
|
63
|
+
return tagSafe(`${t}${r}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatPromptTag(promptId: string): string {
|
|
67
|
+
return `#prompt_${tagSafe(promptId)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build the plain-text body of a prompt-anchor message (hashtags stay tappable). */
|
|
71
|
+
export function formatPromptAnchorBody(
|
|
72
|
+
text: string,
|
|
73
|
+
promptId: string,
|
|
74
|
+
opts?: { prefix?: string; projectName?: string },
|
|
75
|
+
): string {
|
|
76
|
+
const prefix = (opts?.prefix ?? "\u{1F4DD}").trim();
|
|
77
|
+
const body = truncateBody(text.trim() || "(empty)", BODY_BUDGET);
|
|
78
|
+
const tags = [formatPromptTag(promptId)];
|
|
79
|
+
if (opts?.projectName?.trim()) {
|
|
80
|
+
tags.push(`#proj_${tagSafe(opts.projectName)}`);
|
|
81
|
+
}
|
|
82
|
+
return `${prefix}\n${body}\n\n${tags.join(" ")}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fit anchor body into a media caption (1024). Prefer keeping the trailing
|
|
87
|
+
* hashtag line so `#prompt_` stays searchable — never slice tags off the end.
|
|
88
|
+
*/
|
|
89
|
+
export function fitCaption(body: string, max = CAPTION_BUDGET): string {
|
|
90
|
+
if (body.length <= max) return body;
|
|
91
|
+
const lines = body.split("\n");
|
|
92
|
+
const last = (lines[lines.length - 1] ?? "").trim();
|
|
93
|
+
const tags = last.includes("#prompt_") ? last : "";
|
|
94
|
+
if (tags) {
|
|
95
|
+
// Reserve room for "\n…\n\n" + tags; never clip the tag line.
|
|
96
|
+
const sep = "\n\u2026\n\n";
|
|
97
|
+
const budget = max - tags.length - sep.length;
|
|
98
|
+
if (budget > 40) {
|
|
99
|
+
const cutAt = body.lastIndexOf(tags);
|
|
100
|
+
const rawHead = (cutAt > 0 ? body.slice(0, cutAt) : body).replace(/\s+$/, "");
|
|
101
|
+
const head = rawHead.length > budget ? rawHead.slice(0, budget) : rawHead;
|
|
102
|
+
return `${head}${sep}${tags}`;
|
|
103
|
+
}
|
|
104
|
+
// Tags alone almost fill the caption — keep tags only.
|
|
105
|
+
return tags.length <= max ? tags : tags.slice(0, max - 1) + "\u2026";
|
|
106
|
+
}
|
|
107
|
+
return body.slice(0, max - 1) + "\u2026";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Post a bot-owned prompt message (with media when provided), delete the user's
|
|
112
|
+
* original(s), return ids for threading + tagging. On send failure returns
|
|
113
|
+
* undefined (caller falls back).
|
|
114
|
+
*/
|
|
115
|
+
export async function adoptUserPrompt(
|
|
116
|
+
api: Api,
|
|
117
|
+
opts: AdoptPromptOpts,
|
|
118
|
+
): Promise<PromptAnchor | undefined> {
|
|
119
|
+
const promptId = newPromptId();
|
|
120
|
+
const body = formatPromptAnchorBody(opts.text, promptId, {
|
|
121
|
+
prefix: opts.prefix,
|
|
122
|
+
projectName: opts.projectName,
|
|
123
|
+
});
|
|
124
|
+
const threadExtra: Record<string, unknown> = {
|
|
125
|
+
disable_notification: true,
|
|
126
|
+
};
|
|
127
|
+
if (opts.messageThreadId !== undefined) {
|
|
128
|
+
threadExtra.message_thread_id = opts.messageThreadId;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let replyTo: number;
|
|
132
|
+
try {
|
|
133
|
+
const media = (opts.media ?? []).filter((m) => !!m.fileId);
|
|
134
|
+
if (media.length > 0) {
|
|
135
|
+
replyTo = await sendAnchorWithMedia(api, opts.chatId, media, body, threadExtra);
|
|
136
|
+
} else {
|
|
137
|
+
const msg = await api.sendMessage(opts.chatId, body, threadExtra);
|
|
138
|
+
replyTo = msg.message_id;
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
log.warn(`anchor send failed chat=${opts.chatId}: ${(err as Error).message}`);
|
|
142
|
+
// Fallback: text-only anchor so threading still works if media re-post fails.
|
|
143
|
+
try {
|
|
144
|
+
const msg = await api.sendMessage(opts.chatId, body, threadExtra);
|
|
145
|
+
replyTo = msg.message_id;
|
|
146
|
+
} catch (err2) {
|
|
147
|
+
log.warn(`anchor text fallback failed chat=${opts.chatId}: ${(err2 as Error).message}`);
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await deleteUserMessages(api, opts.chatId, opts.userMessageIds);
|
|
153
|
+
return { replyTo, promptId };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Re-post user media with caption/tags so files remain in chat history. */
|
|
157
|
+
async function sendAnchorWithMedia(
|
|
158
|
+
api: Api,
|
|
159
|
+
chatId: number,
|
|
160
|
+
media: AdoptMediaItem[],
|
|
161
|
+
body: string,
|
|
162
|
+
threadExtra: Record<string, unknown>,
|
|
163
|
+
): Promise<number> {
|
|
164
|
+
const caption = fitCaption(body);
|
|
165
|
+
const needsFollowUp = body.length > CAPTION_BUDGET;
|
|
166
|
+
|
|
167
|
+
// Photo album: one media group (caption on first only).
|
|
168
|
+
if (media.length > 1 && media.every((m) => m.type === "photo")) {
|
|
169
|
+
const group = media.map((m, i) =>
|
|
170
|
+
i === 0
|
|
171
|
+
? InputMediaBuilder.photo(m.fileId, { caption })
|
|
172
|
+
: InputMediaBuilder.photo(m.fileId),
|
|
173
|
+
);
|
|
174
|
+
const msgs = await api.sendMediaGroup(chatId, group, threadExtra);
|
|
175
|
+
const firstId = msgs[0]?.message_id;
|
|
176
|
+
if (firstId === undefined) throw new Error("sendMediaGroup returned no messages");
|
|
177
|
+
if (needsFollowUp) {
|
|
178
|
+
await api
|
|
179
|
+
.sendMessage(chatId, body, {
|
|
180
|
+
...threadExtra,
|
|
181
|
+
reply_parameters: { message_id: firstId, allow_sending_without_reply: true },
|
|
182
|
+
})
|
|
183
|
+
.catch(() => {});
|
|
184
|
+
}
|
|
185
|
+
return firstId;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Single (or primary) media item; extra photos sent as a follow-up group.
|
|
189
|
+
const primary = media[0]!;
|
|
190
|
+
const restPhotos = media.slice(1).filter((m): m is Extract<AdoptMediaItem, { type: "photo" }> => m.type === "photo");
|
|
191
|
+
|
|
192
|
+
let replyTo: number;
|
|
193
|
+
const capOpts = { caption, ...threadExtra };
|
|
194
|
+
|
|
195
|
+
switch (primary.type) {
|
|
196
|
+
case "photo": {
|
|
197
|
+
const msg = await api.sendPhoto(chatId, primary.fileId, capOpts);
|
|
198
|
+
replyTo = msg.message_id;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case "document": {
|
|
202
|
+
const msg = await api.sendDocument(chatId, primary.fileId, capOpts);
|
|
203
|
+
replyTo = msg.message_id;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case "voice": {
|
|
207
|
+
const msg = await api.sendVoice(chatId, primary.fileId, capOpts);
|
|
208
|
+
replyTo = msg.message_id;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case "audio": {
|
|
212
|
+
const msg = await api.sendAudio(chatId, primary.fileId, capOpts);
|
|
213
|
+
replyTo = msg.message_id;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case "video": {
|
|
217
|
+
const msg = await api.sendVideo(chatId, primary.fileId, capOpts);
|
|
218
|
+
replyTo = msg.message_id;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case "video_note": {
|
|
222
|
+
// video_note has no caption — post note then text anchor as reply.
|
|
223
|
+
const msg = await api.sendVideoNote(chatId, primary.fileId, threadExtra);
|
|
224
|
+
replyTo = msg.message_id;
|
|
225
|
+
await api
|
|
226
|
+
.sendMessage(chatId, body, {
|
|
227
|
+
...threadExtra,
|
|
228
|
+
reply_parameters: { message_id: replyTo, allow_sending_without_reply: true },
|
|
229
|
+
})
|
|
230
|
+
.catch(() => {});
|
|
231
|
+
return replyTo;
|
|
232
|
+
}
|
|
233
|
+
default: {
|
|
234
|
+
const msg = await api.sendMessage(chatId, body, threadExtra);
|
|
235
|
+
return msg.message_id;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (restPhotos.length > 0) {
|
|
240
|
+
const group = restPhotos.map((m) => InputMediaBuilder.photo(m.fileId));
|
|
241
|
+
await api.sendMediaGroup(chatId, group, threadExtra).catch((e) => {
|
|
242
|
+
log.debug(`extra photo group failed: ${(e as Error).message}`);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (needsFollowUp) {
|
|
247
|
+
await api
|
|
248
|
+
.sendMessage(chatId, body, {
|
|
249
|
+
...threadExtra,
|
|
250
|
+
reply_parameters: { message_id: replyTo, allow_sending_without_reply: true },
|
|
251
|
+
})
|
|
252
|
+
.catch(() => {});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return replyTo;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Best-effort delete of user messages (private chats may refuse). */
|
|
259
|
+
export async function deleteUserMessages(
|
|
260
|
+
api: Api,
|
|
261
|
+
chatId: number,
|
|
262
|
+
messageIds: number[],
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
const unique = [...new Set(messageIds.filter((id) => Number.isFinite(id) && id > 0))];
|
|
265
|
+
await Promise.all(
|
|
266
|
+
unique.map(async (id) => {
|
|
267
|
+
try {
|
|
268
|
+
await api.deleteMessage(chatId, id);
|
|
269
|
+
} catch {
|
|
270
|
+
/* no rights / already gone / too old — non-fatal */
|
|
271
|
+
}
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Instant command feedback: delete the user's command and post a bot status.
|
|
278
|
+
* Fire-and-forget delete so slow work never waits on Telegram cleanup.
|
|
279
|
+
*/
|
|
280
|
+
export async function ackCommand(
|
|
281
|
+
ctx: Context,
|
|
282
|
+
text: string,
|
|
283
|
+
extra: Record<string, unknown> = {},
|
|
284
|
+
): Promise<number | undefined> {
|
|
285
|
+
void ctx.deleteMessage().catch(() => {});
|
|
286
|
+
try {
|
|
287
|
+
const msg = await ctx.reply(text, extra);
|
|
288
|
+
return msg.message_id;
|
|
289
|
+
} catch (err) {
|
|
290
|
+
log.debug(`ackCommand reply failed: ${(err as Error).message}`);
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function truncateBody(text: string, max: number): string {
|
|
296
|
+
if (text.length <= max) return text;
|
|
297
|
+
const head = Math.floor(max * 0.7);
|
|
298
|
+
const tail = max - head - 5;
|
|
299
|
+
return `${text.slice(0, head)}\n\u2026\n${text.slice(-tail)}`;
|
|
300
|
+
}
|
|
@@ -66,6 +66,9 @@ export function mergeInputs(inputs: PromptInput[]): PromptInput {
|
|
|
66
66
|
const quotes = inputs
|
|
67
67
|
.map((i) => i.quotedText?.trim())
|
|
68
68
|
.filter((q): q is string => !!q);
|
|
69
|
+
// Preserve meta flags: auto-suggestion batches / self-recheck must not re-arm
|
|
70
|
+
// another recheck after merge (dropping this caused infinite recheck loops).
|
|
71
|
+
const skipSelfRecheck = inputs.some((i) => i.skipSelfRecheck);
|
|
69
72
|
return {
|
|
70
73
|
text: inputs
|
|
71
74
|
.map((i) => i.text)
|
|
@@ -74,7 +77,11 @@ export function mergeInputs(inputs: PromptInput[]): PromptInput {
|
|
|
74
77
|
images: inputs.flatMap((i) => i.images),
|
|
75
78
|
resourceLinks: inputs.flatMap((i) => i.resourceLinks ?? []),
|
|
76
79
|
replyTo: inputs.find((i) => i.replyTo !== undefined)?.replyTo,
|
|
80
|
+
// First prompt's id wins (same rule as replyTo) so merged queue turns keep
|
|
81
|
+
// one searchable #prompt_ tag threaded to the first anchor.
|
|
82
|
+
promptId: inputs.find((i) => i.promptId)?.promptId,
|
|
77
83
|
quotedText: quotes.length > 0 ? [...new Set(quotes)].join("\n\n---\n\n") : undefined,
|
|
84
|
+
skipSelfRecheck: skipSelfRecheck || undefined,
|
|
78
85
|
};
|
|
79
86
|
}
|
|
80
87
|
|
package/src/bot/registry.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type { AppConfig } from "../config.js";
|
|
|
18
18
|
import { subagentSummary } from "../render/subagent.js";
|
|
19
19
|
import type { SessionStore } from "../sessions/store.js";
|
|
20
20
|
import type { AccountRotator } from "./account-rotator.js";
|
|
21
|
-
import { ChatController } from "./chat-controller.js";
|
|
21
|
+
import { ChatController, type ChatBridgeServices } from "./chat-controller.js";
|
|
22
22
|
import type { SessionRuntime } from "./session-runtime.js";
|
|
23
23
|
|
|
24
24
|
export interface SessionDescription {
|
|
@@ -34,8 +34,14 @@ export interface SessionDescription {
|
|
|
34
34
|
|
|
35
35
|
export class RuntimeRegistry {
|
|
36
36
|
private readonly controllers = new Map<number, ChatController>();
|
|
37
|
+
/**
|
|
38
|
+
* Forum topic controllers keyed by `chatId:threadId`.
|
|
39
|
+
* Each topic has its own multi-session controller + settings (model/reasoning).
|
|
40
|
+
*/
|
|
41
|
+
private readonly forumControllers = new Map<string, ChatController>();
|
|
37
42
|
private refresher: ((chatId: number) => void) | undefined;
|
|
38
43
|
private rotator: AccountRotator | undefined;
|
|
44
|
+
private bridge: ChatBridgeServices | undefined;
|
|
39
45
|
/** Chat ids with a running turn, most-recently-started last. */
|
|
40
46
|
private readonly activeChats: number[] = [];
|
|
41
47
|
/** Subagent sessionId -> owner chat id. */
|
|
@@ -60,6 +66,13 @@ export class RuntimeRegistry {
|
|
|
60
66
|
this.rotator = rotator;
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
/** Telegram bridge services (forum, session store, sibling bots). */
|
|
70
|
+
setBridge(bridge: ChatBridgeServices): void {
|
|
71
|
+
this.bridge = bridge;
|
|
72
|
+
for (const c of this.controllers.values()) c.bridge = bridge;
|
|
73
|
+
for (const c of this.forumControllers.values()) c.bridge = bridge;
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
controller(chatId: number): ChatController {
|
|
64
77
|
let c = this.controllers.get(chatId);
|
|
65
78
|
if (!c) {
|
|
@@ -74,6 +87,7 @@ export class RuntimeRegistry {
|
|
|
74
87
|
(busy) => this.noteActivity(chatId, busy),
|
|
75
88
|
() => this.rotator,
|
|
76
89
|
);
|
|
90
|
+
c.bridge = this.bridge;
|
|
77
91
|
this.controllers.set(chatId, c);
|
|
78
92
|
}
|
|
79
93
|
return c;
|
|
@@ -84,9 +98,78 @@ export class RuntimeRegistry {
|
|
|
84
98
|
return this.controller(chatId).foreground();
|
|
85
99
|
}
|
|
86
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Multi-session controller for a forum topic (fixed project path).
|
|
103
|
+
* Settings key: `{chatId}:t{threadId}` — own model / reasoning / sessions.
|
|
104
|
+
*/
|
|
105
|
+
forumController(
|
|
106
|
+
chatId: number,
|
|
107
|
+
threadId: number,
|
|
108
|
+
cwd: string,
|
|
109
|
+
projectName?: string,
|
|
110
|
+
): ChatController {
|
|
111
|
+
const key = `${chatId}:${threadId}`;
|
|
112
|
+
let c = this.forumControllers.get(key);
|
|
113
|
+
if (c && c.fixedCwd && normPath(c.fixedCwd) !== normPath(cwd)) {
|
|
114
|
+
// Path re-bind — dispose and recreate.
|
|
115
|
+
c.dispose();
|
|
116
|
+
this.forumControllers.delete(key);
|
|
117
|
+
c = undefined;
|
|
118
|
+
}
|
|
119
|
+
if (!c) {
|
|
120
|
+
c = new ChatController(
|
|
121
|
+
this.api,
|
|
122
|
+
chatId,
|
|
123
|
+
this.acp,
|
|
124
|
+
this.cfg,
|
|
125
|
+
this.settings,
|
|
126
|
+
this.store,
|
|
127
|
+
(id) => this.refresher?.(id),
|
|
128
|
+
(busy) => this.noteActivity(chatId, busy),
|
|
129
|
+
() => this.rotator,
|
|
130
|
+
{
|
|
131
|
+
messageThreadId: threadId,
|
|
132
|
+
settingsKey: `${chatId}:t${threadId}`,
|
|
133
|
+
fixedCwd: cwd,
|
|
134
|
+
fixedProjectName: projectName,
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
c.bridge = this.bridge;
|
|
138
|
+
this.forumControllers.set(key, c);
|
|
139
|
+
}
|
|
140
|
+
return c;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Foreground runtime for a forum topic. Creates the topic controller lazily.
|
|
145
|
+
*/
|
|
146
|
+
getForumTopic(
|
|
147
|
+
chatId: number,
|
|
148
|
+
threadId: number,
|
|
149
|
+
cwd: string,
|
|
150
|
+
projectName?: string,
|
|
151
|
+
): SessionRuntime {
|
|
152
|
+
return this.forumController(chatId, threadId, cwd, projectName).foreground();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** All forum topic controllers (for bidirectional session listing). */
|
|
156
|
+
allForumControllers(): ChatController[] {
|
|
157
|
+
return [...this.forumControllers.values()];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Forum controller that currently owns a session id, if any. */
|
|
161
|
+
forumControllerForSession(sessionId: string): ChatController | undefined {
|
|
162
|
+
for (const c of this.forumControllers.values()) {
|
|
163
|
+
if (c.findBySession(sessionId)) return c;
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
87
168
|
disposeAll(): void {
|
|
88
169
|
for (const c of this.controllers.values()) c.dispose();
|
|
89
170
|
this.controllers.clear();
|
|
171
|
+
for (const c of this.forumControllers.values()) c.dispose();
|
|
172
|
+
this.forumControllers.clear();
|
|
90
173
|
}
|
|
91
174
|
|
|
92
175
|
/** Find the chat that currently controls a given session id. */
|
|
@@ -94,6 +177,12 @@ export class RuntimeRegistry {
|
|
|
94
177
|
for (const [chatId, c] of this.controllers) {
|
|
95
178
|
if (c.findBySession(sessionId)) return chatId;
|
|
96
179
|
}
|
|
180
|
+
for (const [key, c] of this.forumControllers) {
|
|
181
|
+
if (c.findBySession(sessionId)) {
|
|
182
|
+
const chatId = Number(key.split(":")[0]);
|
|
183
|
+
return Number.isFinite(chatId) ? chatId : undefined;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
97
186
|
return undefined;
|
|
98
187
|
}
|
|
99
188
|
|
|
@@ -184,3 +273,7 @@ export class RuntimeRegistry {
|
|
|
184
273
|
}
|
|
185
274
|
}
|
|
186
275
|
}
|
|
276
|
+
|
|
277
|
+
function normPath(p: string): string {
|
|
278
|
+
return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
279
|
+
}
|
package/src/bot/scope.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve private-chat vs forum-topic scope for handlers (menu, running, sessions).
|
|
3
|
+
* Forum topics use a dedicated ChatController with per-topic settings + sessions.
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from "grammy";
|
|
6
|
+
import type { BotDeps } from "./deps.js";
|
|
7
|
+
import type { ChatController } from "./chat-controller.js";
|
|
8
|
+
import type { SessionRuntime } from "./session-runtime.js";
|
|
9
|
+
import { forumThreadId } from "../forum/thread.js";
|
|
10
|
+
import { FORUM_GENERAL_THREAD_ID } from "../forum/thread.js";
|
|
11
|
+
|
|
12
|
+
export interface HandlerScope {
|
|
13
|
+
chatId: number;
|
|
14
|
+
/** Forum message_thread_id when in the configured topic group. */
|
|
15
|
+
threadId?: number;
|
|
16
|
+
isForum: boolean;
|
|
17
|
+
/** Settings key for this scope (chat or chat:t{thread}). */
|
|
18
|
+
settingsKey: string;
|
|
19
|
+
controller: ChatController;
|
|
20
|
+
/** Foreground runtime for this scope. */
|
|
21
|
+
rt: SessionRuntime;
|
|
22
|
+
/** Extra fields so replies land in the same topic. */
|
|
23
|
+
threadExtra: { message_thread_id?: number };
|
|
24
|
+
/** Project path when forum topic is bound. */
|
|
25
|
+
projectPath?: string;
|
|
26
|
+
projectName?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build settings storage key. */
|
|
30
|
+
export function settingsKeyFor(chatId: number, threadId?: number): string {
|
|
31
|
+
if (threadId === undefined) return String(chatId);
|
|
32
|
+
return `${chatId}:t${threadId}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Extract thread id from message or callback message. */
|
|
36
|
+
export function threadIdFromContext(ctx: Context): number | undefined {
|
|
37
|
+
const msg = ctx.message ?? ctx.callbackQuery?.message;
|
|
38
|
+
if (!msg || !("message_thread_id" in msg)) return undefined;
|
|
39
|
+
const tid = (msg as { message_thread_id?: number }).message_thread_id;
|
|
40
|
+
return typeof tid === "number" ? tid : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve handler scope. For the forum group, ensures topic binding (General →
|
|
45
|
+
* workspace) and returns the topic's ChatController + foreground runtime.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveScope(ctx: Context, deps: BotDeps): HandlerScope {
|
|
48
|
+
const chatId = ctx.chat!.id;
|
|
49
|
+
const rawThread = threadIdFromContext(ctx);
|
|
50
|
+
const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
|
|
51
|
+
const threadExtra =
|
|
52
|
+
isForum || rawThread !== undefined
|
|
53
|
+
? { message_thread_id: isForum ? forumThreadId(rawThread) : rawThread }
|
|
54
|
+
: {};
|
|
55
|
+
|
|
56
|
+
if (!isForum || !deps.forum) {
|
|
57
|
+
const controller = deps.registry.controller(chatId);
|
|
58
|
+
return {
|
|
59
|
+
chatId,
|
|
60
|
+
isForum: false,
|
|
61
|
+
settingsKey: settingsKeyFor(chatId),
|
|
62
|
+
controller,
|
|
63
|
+
rt: controller.foreground(),
|
|
64
|
+
threadExtra: rawThread !== undefined ? { message_thread_id: rawThread } : {},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const tid = forumThreadId(rawThread);
|
|
69
|
+
// Ensure General / AI paths are bound before opening menus.
|
|
70
|
+
if (tid === FORUM_GENERAL_THREAD_ID) {
|
|
71
|
+
if (!deps.forum.store.get(tid)?.projectPath) {
|
|
72
|
+
deps.forum.store.bindProject(tid, deps.cfg.workspace, "General", "general");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const resolved = deps.forum.resolveCwd(tid);
|
|
76
|
+
const cwd = resolved?.cwd ?? deps.cfg.workspace;
|
|
77
|
+
const projectName = resolved?.projectName ?? (tid === FORUM_GENERAL_THREAD_ID ? "General" : `Topic ${tid}`);
|
|
78
|
+
if (!resolved && tid !== FORUM_GENERAL_THREAD_ID) {
|
|
79
|
+
// Unbound topic — still allow menu with workspace fallback for prefs; path
|
|
80
|
+
// bind happens on next text message via resolveForumRuntime.
|
|
81
|
+
}
|
|
82
|
+
const controller = deps.registry.forumController(chatId, tid, cwd, projectName);
|
|
83
|
+
return {
|
|
84
|
+
chatId,
|
|
85
|
+
threadId: tid,
|
|
86
|
+
isForum: true,
|
|
87
|
+
settingsKey: settingsKeyFor(chatId, tid),
|
|
88
|
+
controller,
|
|
89
|
+
rt: controller.foreground(),
|
|
90
|
+
threadExtra: { message_thread_id: tid },
|
|
91
|
+
projectPath: cwd,
|
|
92
|
+
projectName,
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/bot/session-fork.ts
CHANGED
|
@@ -33,3 +33,14 @@ export function buildPriming(transcript: string): string {
|
|
|
33
33
|
"=== END TRANSCRIPT ===",
|
|
34
34
|
].join("\n");
|
|
35
35
|
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* First-turn prompt body used after a foreign-session import. The heavy
|
|
39
|
+
* transcript lives in {@link SessionRuntime}'s primingContext; this is the
|
|
40
|
+
* short user message that flushes priming into the live Grok session.
|
|
41
|
+
*/
|
|
42
|
+
export const IMPORT_CONFIRM_PROMPT =
|
|
43
|
+
"Session import complete. Confirm you have the full imported context " +
|
|
44
|
+
"(read the transcript file if anything was truncated inline). " +
|
|
45
|
+
"Reply with a one-line ready confirmation: project path + one-sentence " +
|
|
46
|
+
"summary of the task so far. Do not continue work until I send the next message.";
|