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
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
* rapid consecutive text messages per chat within a short debounce window
|
|
9
9
|
* (`MESSAGE_BATCH_MS`) into a single prompt — one submission, one confirmation.
|
|
10
10
|
*
|
|
11
|
+
* User messages are replaced by a bot-owned prompt anchor (`#prompt_<id>`) so
|
|
12
|
+
* the chat shows immediate life while CLI/ACP starts, and all AI replies thread
|
|
13
|
+
* to that anchor with the same searchable tag.
|
|
14
|
+
*
|
|
11
15
|
* While a turn is running, the combined message is queued and runs
|
|
12
16
|
* automatically when the current turn finishes.
|
|
13
17
|
* (Wizard input and menu-button text are intercepted by earlier handlers.)
|
|
@@ -15,88 +19,165 @@
|
|
|
15
19
|
import type { Bot } from "grammy";
|
|
16
20
|
import { textPrompt } from "../../app/types.js";
|
|
17
21
|
import { createLogger } from "../../logger.js";
|
|
22
|
+
import { batchKey, forumThreadId } from "../../forum/thread.js";
|
|
18
23
|
import type { BotDeps } from "../deps.js";
|
|
24
|
+
import { adoptUserPrompt } from "../prompt-anchor.js";
|
|
19
25
|
import { extractReplyContext } from "../reply-context.js";
|
|
26
|
+
import { resolveForumRuntime } from "./forum.js";
|
|
20
27
|
|
|
21
28
|
const log = createLogger("message");
|
|
22
29
|
|
|
23
|
-
/** A pending burst of text messages from one chat, awaiting coalescing. */
|
|
30
|
+
/** A pending burst of text messages from one chat/topic, awaiting coalescing. */
|
|
24
31
|
interface TextBatch {
|
|
25
32
|
parts: string[];
|
|
26
33
|
ids: number[];
|
|
34
|
+
/** Forum topic thread id (undefined for private chats / General without id). */
|
|
35
|
+
threadId?: number;
|
|
27
36
|
/** Reference content if the burst began as a reply to another message. */
|
|
28
37
|
quoted?: string;
|
|
29
38
|
timer: NodeJS.Timeout;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
export function registerMessages(bot: Bot, deps: BotDeps): void {
|
|
33
|
-
const batches = new Map<
|
|
42
|
+
const batches = new Map<string, TextBatch>();
|
|
34
43
|
const windowMs = deps.cfg.messageBatchMs;
|
|
35
44
|
|
|
36
|
-
const arm = (
|
|
37
|
-
setTimeout(() => void flush(deps, batches,
|
|
45
|
+
const arm = (key: string): NodeJS.Timeout =>
|
|
46
|
+
setTimeout(() => void flush(deps, batches, key), windowMs);
|
|
38
47
|
|
|
39
48
|
bot.on("message:text", async (ctx) => {
|
|
40
49
|
const text = ctx.message.text;
|
|
41
50
|
if (!text.trim()) return;
|
|
51
|
+
// Slash commands are handled by bot.command / menu — never batch as agent prompts.
|
|
52
|
+
// (Otherwise /forum_setup and /help would also hit the debounce → agent path.)
|
|
53
|
+
if (!text.includes("\n") && text.startsWith("/")) return;
|
|
54
|
+
|
|
42
55
|
const chatId = ctx.chat.id;
|
|
43
56
|
const id = ctx.message.message_id;
|
|
57
|
+
const rawThreadId = ctx.message.message_thread_id;
|
|
58
|
+
const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
|
|
59
|
+
const threadId = isForum ? forumThreadId(rawThreadId) : rawThreadId;
|
|
44
60
|
const quoted = extractReplyContext(ctx);
|
|
61
|
+
const key = batchKey(chatId, rawThreadId, isForum);
|
|
45
62
|
|
|
46
|
-
const batch = batches.get(
|
|
63
|
+
const batch = batches.get(key);
|
|
47
64
|
if (batch) {
|
|
48
65
|
clearTimeout(batch.timer);
|
|
49
66
|
batch.parts.push(text);
|
|
50
67
|
batch.ids.push(id);
|
|
51
68
|
if (quoted && !batch.quoted) batch.quoted = quoted;
|
|
52
|
-
batch.timer = arm(
|
|
69
|
+
batch.timer = arm(key);
|
|
53
70
|
return;
|
|
54
71
|
}
|
|
55
|
-
batches.set(
|
|
72
|
+
batches.set(key, {
|
|
73
|
+
parts: [text],
|
|
74
|
+
ids: [id],
|
|
75
|
+
threadId,
|
|
76
|
+
quoted,
|
|
77
|
+
timer: arm(key),
|
|
78
|
+
});
|
|
56
79
|
});
|
|
57
80
|
}
|
|
58
81
|
|
|
59
82
|
/** Coalesce a chat's buffered parts into one prompt and submit it once. */
|
|
60
|
-
async function flush(deps: BotDeps, batches: Map<
|
|
61
|
-
const batch = batches.get(
|
|
83
|
+
async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string): Promise<void> {
|
|
84
|
+
const batch = batches.get(key);
|
|
62
85
|
if (!batch) return;
|
|
63
|
-
batches.delete(
|
|
86
|
+
batches.delete(key);
|
|
64
87
|
|
|
65
88
|
// Telegram splits at 4096 chars, almost always on a line boundary, so
|
|
66
89
|
// rejoining with a newline reconstructs the original text faithfully.
|
|
67
90
|
const combined = batch.parts.join("\n").trim();
|
|
68
91
|
if (!combined) return;
|
|
69
92
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
93
|
+
const [chatIdStr] = key.split(":");
|
|
94
|
+
const chatId = Number(chatIdStr);
|
|
95
|
+
const threadId = batch.threadId;
|
|
96
|
+
|
|
97
|
+
// Defense-in-depth: never submit slash-only lines as agent prompts.
|
|
73
98
|
if (batch.parts.length === 1 && !combined.includes("\n") && combined.startsWith("/")) {
|
|
74
|
-
await send(deps, chatId, "Unknown command. Type /help to see what I can do.");
|
|
75
99
|
return;
|
|
76
100
|
}
|
|
77
101
|
|
|
78
|
-
|
|
102
|
+
let rt = deps.registry.get(chatId);
|
|
103
|
+
// Forum group: topic-scoped multi-session controller (model/reasoning/running).
|
|
104
|
+
if (deps.forum?.isActiveForumChat(chatId)) {
|
|
105
|
+
const resolved = await resolveForumRuntime(
|
|
106
|
+
deps,
|
|
107
|
+
deps.forum,
|
|
108
|
+
chatId,
|
|
109
|
+
threadId,
|
|
110
|
+
combined,
|
|
111
|
+
batch.ids[0]!,
|
|
112
|
+
);
|
|
113
|
+
if (resolved === "handled" || resolved === "ignore") return;
|
|
114
|
+
rt = resolved.rt;
|
|
115
|
+
// If nothing is selected / no session yet, ensure a new session is created
|
|
116
|
+
// on first message (ensureSession inside submit). If FG has no sessionId
|
|
117
|
+
// after a closed session, start a fresh one for this topic.
|
|
118
|
+
if (!rt.sessionId && !rt.isBusy) {
|
|
119
|
+
try {
|
|
120
|
+
await rt.startNewSession(rt.cwd, rt.projectName);
|
|
121
|
+
} catch {
|
|
122
|
+
/* ensureSession on submit will retry */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
79
127
|
const note = batch.parts.length > 1 ? ` (combined ${batch.parts.length} messages)` : "";
|
|
128
|
+
// Hoisted so a submit failure after a successful adopt can still thread the error.
|
|
129
|
+
let replyTo: number | undefined = batch.ids[0];
|
|
80
130
|
try {
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
const
|
|
131
|
+
// Instant bot anchor: user sees the prompt adopted immediately while CLI warms.
|
|
132
|
+
// All AI output + Done reply to this message and carry #prompt_<id>.
|
|
133
|
+
const anchor = await adoptUserPrompt(deps.api, {
|
|
134
|
+
chatId,
|
|
135
|
+
text: combined,
|
|
136
|
+
userMessageIds: batch.ids,
|
|
137
|
+
messageThreadId: threadId,
|
|
138
|
+
projectName: rt.projectName,
|
|
139
|
+
prefix: "\u{1F4DD} Prompt",
|
|
140
|
+
});
|
|
141
|
+
replyTo = anchor?.replyTo ?? batch.ids[0];
|
|
142
|
+
const outcome = await rt.submit(
|
|
143
|
+
textPrompt(combined, replyTo, batch.quoted, { promptId: anchor?.promptId }),
|
|
144
|
+
);
|
|
84
145
|
if (outcome === "queued") {
|
|
85
146
|
await send(
|
|
86
147
|
deps,
|
|
87
148
|
chatId,
|
|
88
149
|
`\u{1F4E5} Queued (position ${rt.queueLength})${note} \u2014 I'm still working on the previous task. It'll run next.`,
|
|
150
|
+
threadId,
|
|
151
|
+
replyTo,
|
|
89
152
|
);
|
|
90
153
|
}
|
|
154
|
+
// "ran": turn started; complexity is steered silently by the agent.
|
|
91
155
|
} catch (err) {
|
|
92
156
|
log.warn(`submit failed for chat ${chatId}: ${(err as Error).message}`);
|
|
93
|
-
await send(
|
|
157
|
+
await send(
|
|
158
|
+
deps,
|
|
159
|
+
chatId,
|
|
160
|
+
`\u274C Couldn't start your message: ${(err as Error).message}`,
|
|
161
|
+
threadId,
|
|
162
|
+
replyTo,
|
|
163
|
+
);
|
|
94
164
|
}
|
|
95
165
|
}
|
|
96
166
|
|
|
97
|
-
async function send(
|
|
167
|
+
async function send(
|
|
168
|
+
deps: BotDeps,
|
|
169
|
+
chatId: number,
|
|
170
|
+
text: string,
|
|
171
|
+
threadId?: number,
|
|
172
|
+
replyTo?: number,
|
|
173
|
+
): Promise<void> {
|
|
98
174
|
try {
|
|
99
|
-
|
|
175
|
+
const extra: Record<string, unknown> = {};
|
|
176
|
+
if (threadId !== undefined) extra.message_thread_id = threadId;
|
|
177
|
+
if (replyTo !== undefined) {
|
|
178
|
+
extra.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
|
|
179
|
+
}
|
|
180
|
+
await deps.api.sendMessage(chatId, text, extra);
|
|
100
181
|
} catch {
|
|
101
182
|
/* non-fatal */
|
|
102
183
|
}
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
* Photo & image-document handler. Downloads images (including multi-image
|
|
3
3
|
* albums / media groups) and submits them to Grok as ACP image content blocks
|
|
4
4
|
* alongside the caption text.
|
|
5
|
+
*
|
|
6
|
+
* User media messages are replaced by a bot prompt anchor (`#prompt_<id>`);
|
|
7
|
+
* the agent still receives the downloaded image bytes.
|
|
5
8
|
*/
|
|
6
9
|
import type { Bot, Context } from "grammy";
|
|
7
10
|
import type { PromptImage } from "../../app/types.js";
|
|
8
11
|
import { createLogger } from "../../logger.js";
|
|
9
12
|
import type { BotDeps } from "../deps.js";
|
|
13
|
+
import { type AdoptMediaItem, adoptUserPrompt } from "../prompt-anchor.js";
|
|
10
14
|
import { extractReplyContext } from "../reply-context.js";
|
|
11
15
|
|
|
12
16
|
const log = createLogger("photo");
|
|
@@ -15,19 +19,31 @@ const GROUP_DEBOUNCE_MS = 900;
|
|
|
15
19
|
interface GroupBuffer {
|
|
16
20
|
chatId: number;
|
|
17
21
|
caption: string;
|
|
22
|
+
/** Agent-bound image bytes (may be shorter than media if a download failed). */
|
|
18
23
|
images: PromptImage[];
|
|
19
|
-
|
|
24
|
+
/** Chat re-post descriptors (file_id + correct photo vs document kind). */
|
|
25
|
+
media: AdoptMediaItem[];
|
|
26
|
+
/** User Telegram message ids in this album (for delete after anchor). */
|
|
27
|
+
userMessageIds: number[];
|
|
20
28
|
quoted?: string;
|
|
29
|
+
threadId?: number;
|
|
21
30
|
timer: NodeJS.Timeout;
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
export function registerPhotos(bot: Bot, deps: BotDeps): void {
|
|
25
34
|
const groups = new Map<string, GroupBuffer>();
|
|
26
35
|
|
|
27
|
-
const onMedia = async (
|
|
28
|
-
|
|
36
|
+
const onMedia = async (
|
|
37
|
+
ctx: Context,
|
|
38
|
+
image: PromptImage | undefined,
|
|
39
|
+
mediaItem: AdoptMediaItem | undefined,
|
|
40
|
+
caption: string,
|
|
41
|
+
): Promise<void> => {
|
|
42
|
+
// Still re-post to chat when download fails — user must not lose the file.
|
|
43
|
+
if (!mediaItem?.fileId) return;
|
|
29
44
|
const chatId = ctx.chat!.id;
|
|
30
|
-
const
|
|
45
|
+
const msgId = ctx.message?.message_id;
|
|
46
|
+
const threadId = ctx.message?.message_thread_id;
|
|
31
47
|
const quoted = extractReplyContext(ctx);
|
|
32
48
|
|
|
33
49
|
// Don't hijack the task wizard.
|
|
@@ -36,9 +52,21 @@ export function registerPhotos(bot: Bot, deps: BotDeps): void {
|
|
|
36
52
|
return;
|
|
37
53
|
}
|
|
38
54
|
|
|
55
|
+
const images = image ? [image] : [];
|
|
56
|
+
const media = [mediaItem];
|
|
57
|
+
|
|
39
58
|
const groupId = ctx.message?.media_group_id;
|
|
40
59
|
if (!groupId) {
|
|
41
|
-
await submit(
|
|
60
|
+
await submit(
|
|
61
|
+
deps,
|
|
62
|
+
chatId,
|
|
63
|
+
caption,
|
|
64
|
+
images,
|
|
65
|
+
media,
|
|
66
|
+
msgId !== undefined ? [msgId] : [],
|
|
67
|
+
quoted,
|
|
68
|
+
threadId,
|
|
69
|
+
);
|
|
42
70
|
return;
|
|
43
71
|
}
|
|
44
72
|
|
|
@@ -46,17 +74,21 @@ export function registerPhotos(bot: Bot, deps: BotDeps): void {
|
|
|
46
74
|
const existing = groups.get(groupId);
|
|
47
75
|
if (existing) {
|
|
48
76
|
clearTimeout(existing.timer);
|
|
49
|
-
existing.images.push(image);
|
|
77
|
+
if (image) existing.images.push(image);
|
|
78
|
+
existing.media.push(mediaItem);
|
|
50
79
|
if (caption) existing.caption = caption;
|
|
51
80
|
if (quoted && !existing.quoted) existing.quoted = quoted;
|
|
81
|
+
if (msgId !== undefined) existing.userMessageIds.push(msgId);
|
|
52
82
|
existing.timer = setTimeout(() => flush(groups, groupId, deps), GROUP_DEBOUNCE_MS);
|
|
53
83
|
} else {
|
|
54
84
|
groups.set(groupId, {
|
|
55
85
|
chatId,
|
|
56
86
|
caption,
|
|
57
|
-
images
|
|
58
|
-
|
|
87
|
+
images,
|
|
88
|
+
media,
|
|
89
|
+
userMessageIds: msgId !== undefined ? [msgId] : [],
|
|
59
90
|
quoted,
|
|
91
|
+
threadId,
|
|
60
92
|
timer: setTimeout(() => flush(groups, groupId, deps), GROUP_DEBOUNCE_MS),
|
|
61
93
|
});
|
|
62
94
|
}
|
|
@@ -65,15 +97,33 @@ export function registerPhotos(bot: Bot, deps: BotDeps): void {
|
|
|
65
97
|
bot.on("message:photo", async (ctx) => {
|
|
66
98
|
const photos = ctx.message.photo;
|
|
67
99
|
const largest = photos[photos.length - 1];
|
|
68
|
-
const
|
|
69
|
-
|
|
100
|
+
const fileId = largest?.file_id;
|
|
101
|
+
const image = fileId
|
|
102
|
+
? await download(ctx, fileId, "image/jpeg", deps.cfg.token)
|
|
103
|
+
: undefined;
|
|
104
|
+
await onMedia(
|
|
105
|
+
ctx,
|
|
106
|
+
image,
|
|
107
|
+
fileId ? { type: "photo", fileId } : undefined,
|
|
108
|
+
ctx.message.caption ?? "",
|
|
109
|
+
);
|
|
70
110
|
});
|
|
71
111
|
|
|
72
112
|
bot.on("message:document", async (ctx, next) => {
|
|
73
113
|
const doc = ctx.message.document;
|
|
74
114
|
if (!doc.mime_type?.startsWith("image/")) return next(); // let document-handler logic pass
|
|
75
115
|
const image = await download(ctx, doc.file_id, doc.mime_type, deps.cfg.token);
|
|
76
|
-
|
|
116
|
+
// Image-as-document must re-post as document — photo file_ids are a different kind.
|
|
117
|
+
await onMedia(
|
|
118
|
+
ctx,
|
|
119
|
+
image,
|
|
120
|
+
{
|
|
121
|
+
type: "document",
|
|
122
|
+
fileId: doc.file_id,
|
|
123
|
+
fileName: doc.file_name ?? undefined,
|
|
124
|
+
},
|
|
125
|
+
ctx.message.caption ?? "",
|
|
126
|
+
);
|
|
77
127
|
});
|
|
78
128
|
}
|
|
79
129
|
|
|
@@ -81,7 +131,16 @@ async function flush(groups: Map<string, GroupBuffer>, groupId: string, deps: Bo
|
|
|
81
131
|
const buf = groups.get(groupId);
|
|
82
132
|
if (!buf) return;
|
|
83
133
|
groups.delete(groupId);
|
|
84
|
-
await submit(
|
|
134
|
+
await submit(
|
|
135
|
+
deps,
|
|
136
|
+
buf.chatId,
|
|
137
|
+
buf.caption,
|
|
138
|
+
buf.images,
|
|
139
|
+
buf.media,
|
|
140
|
+
buf.userMessageIds,
|
|
141
|
+
buf.quoted,
|
|
142
|
+
buf.threadId,
|
|
143
|
+
);
|
|
85
144
|
}
|
|
86
145
|
|
|
87
146
|
async function submit(
|
|
@@ -89,15 +148,63 @@ async function submit(
|
|
|
89
148
|
chatId: number,
|
|
90
149
|
caption: string,
|
|
91
150
|
images: PromptImage[],
|
|
92
|
-
|
|
151
|
+
media: AdoptMediaItem[],
|
|
152
|
+
userMessageIds: number[],
|
|
93
153
|
quoted?: string,
|
|
154
|
+
threadId?: number,
|
|
94
155
|
): Promise<void> {
|
|
95
|
-
|
|
96
|
-
|
|
156
|
+
let rt = deps.registry.get(chatId);
|
|
157
|
+
if (deps.forum?.isActiveForumChat(chatId) && threadId !== undefined) {
|
|
158
|
+
const { forumThreadId } = await import("../../forum/thread.js");
|
|
159
|
+
const tid = forumThreadId(threadId);
|
|
160
|
+
const resolved = deps.forum.resolveCwd(tid);
|
|
161
|
+
if (resolved) {
|
|
162
|
+
rt = deps.registry.getForumTopic(chatId, tid, resolved.cwd, resolved.projectName);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const n = Math.max(images.length, media.length);
|
|
167
|
+
const label =
|
|
168
|
+
n === 1
|
|
169
|
+
? "\u{1F4F7} Image"
|
|
170
|
+
: `\u{1F4F7} ${n} images`;
|
|
171
|
+
const body = caption.trim()
|
|
172
|
+
? caption
|
|
173
|
+
: n === 1
|
|
174
|
+
? "(image attached)"
|
|
175
|
+
: `(${n} images attached)`;
|
|
176
|
+
|
|
177
|
+
const anchor = await adoptUserPrompt(deps.api, {
|
|
178
|
+
chatId,
|
|
179
|
+
text: body,
|
|
180
|
+
userMessageIds,
|
|
181
|
+
messageThreadId: threadId,
|
|
182
|
+
projectName: rt.projectName,
|
|
183
|
+
prefix: label,
|
|
184
|
+
media,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Agent gets whatever bytes we could download (may be empty if download failed).
|
|
188
|
+
const outcome = await rt.submit({
|
|
189
|
+
text: caption,
|
|
190
|
+
images,
|
|
191
|
+
replyTo: anchor?.replyTo ?? userMessageIds[0],
|
|
192
|
+
promptId: anchor?.promptId,
|
|
193
|
+
quotedText: quoted,
|
|
194
|
+
});
|
|
97
195
|
if (outcome === "queued") {
|
|
196
|
+
const extra: Record<string, unknown> =
|
|
197
|
+
threadId !== undefined ? { message_thread_id: threadId } : {};
|
|
198
|
+
if (anchor?.replyTo !== undefined) {
|
|
199
|
+
extra.reply_parameters = {
|
|
200
|
+
message_id: anchor.replyTo,
|
|
201
|
+
allow_sending_without_reply: true,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
98
204
|
await deps.api.sendMessage(
|
|
99
205
|
chatId,
|
|
100
|
-
`\u{1F4E5} Queued ${
|
|
206
|
+
`\u{1F4E5} Queued ${n} image${n > 1 ? "s" : ""} \u2014 will run after the current task.`,
|
|
207
|
+
extra,
|
|
101
208
|
);
|
|
102
209
|
}
|
|
103
210
|
}
|
|
@@ -7,7 +7,7 @@ import { type Bot, type Context, InlineKeyboard } from "grammy";
|
|
|
7
7
|
import type { RunningSession, SwitchResult } from "../chat-controller.js";
|
|
8
8
|
import type { BotDeps } from "../deps.js";
|
|
9
9
|
import type { HistoryEntry } from "../../sessions/types.js";
|
|
10
|
-
import { jsonlMtimeMs, readFirstPrompt } from "../../sessions/history.js";
|
|
10
|
+
import { jsonlMtimeMs, readFirstPrompt, readLastUserPrompt } from "../../sessions/history.js";
|
|
11
11
|
import { progressBar } from "../../render/progress.js";
|
|
12
12
|
import { refreshMenu } from "../menu/refresh.js";
|
|
13
13
|
import { sendMarkdownDoc } from "../telegram-io.js";
|
|
@@ -49,29 +49,51 @@ function cleanPrompt(raw: string): string {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/** Build a rich card (plain text, no MarkdownV2) + buttons for one controlled
|
|
52
|
-
* session: Switch / History / Close.
|
|
52
|
+
* session: Switch / History / Close.
|
|
53
|
+
*
|
|
54
|
+
* Comment:
|
|
55
|
+
* always — last user prompt (≤250)
|
|
56
|
+
* busy — plus last AI agent thinking on the next line (≤250)
|
|
57
|
+
*/
|
|
53
58
|
function buildRunningCard(s: RunningSession, deps: BotDeps, now: number): { text: string; kb: InlineKeyboard } {
|
|
54
59
|
const dot = s.foreground ? "\u25B6\uFE0F" : s.busy ? "\u{1F7E0}" : "\u26AA";
|
|
55
60
|
const state = s.foreground ? "foreground" : s.busy ? "working" : "idle";
|
|
56
61
|
|
|
57
62
|
let when = "new";
|
|
58
|
-
let
|
|
63
|
+
let lastUser = "";
|
|
64
|
+
let firstPrompt = "";
|
|
59
65
|
if (s.sessionId) {
|
|
60
66
|
const path = deps.store.jsonlPath(s.sessionId);
|
|
61
67
|
const mtime = jsonlMtimeMs(path);
|
|
62
68
|
if (mtime) when = timeAgo(now - mtime);
|
|
63
|
-
|
|
69
|
+
lastUser = readLastUserPrompt(path, 40, 250);
|
|
70
|
+
firstPrompt = cleanPrompt(readFirstPrompt(path));
|
|
71
|
+
if (/session import complete/i.test(firstPrompt)) firstPrompt = "";
|
|
64
72
|
}
|
|
65
73
|
|
|
74
|
+
const diskComment = s.sessionId ? deps.store.get(s.sessionId)?.comment?.trim() : undefined;
|
|
75
|
+
// Order: live runtime (user + thinking) → last user from history → disk → first prompt.
|
|
76
|
+
const comment =
|
|
77
|
+
(s.comment && s.comment.trim()) ||
|
|
78
|
+
lastUser ||
|
|
79
|
+
diskComment ||
|
|
80
|
+
firstPrompt;
|
|
81
|
+
|
|
66
82
|
const meta = [when, state];
|
|
67
83
|
if (s.busy) meta.push("\u23F3");
|
|
68
84
|
if (s.unread > 0) meta.push(`${s.unread} \u{1F4EC} unread`);
|
|
69
85
|
|
|
70
|
-
const lines = [
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
const lines = [`${dot} ${s.projectName}`];
|
|
87
|
+
if (comment) {
|
|
88
|
+
const parts = comment.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
89
|
+
parts.forEach((part, i) => {
|
|
90
|
+
const icon = i === 0 ? (s.busy ? "\u23F3" : "\u{1F4AC}") : "\u{1F9E0}";
|
|
91
|
+
lines.push(`${icon} ${trunc(part, 250)}`);
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
lines.push("\u{1F4AC} (no messages yet)");
|
|
95
|
+
}
|
|
96
|
+
lines.push(`\u{1F552} ${meta.join(" \u00B7 ")}`);
|
|
75
97
|
if (s.progress !== undefined) lines.push(`\u{1F4C8} ${progressBar(s.progress)}`);
|
|
76
98
|
if (s.sessionId) lines.push(`\u{1F194} ${s.sessionId.slice(0, 8)}`);
|
|
77
99
|
|
|
@@ -88,14 +110,55 @@ function buildRunningCard(s: RunningSession, deps: BotDeps, now: number): { text
|
|
|
88
110
|
|
|
89
111
|
export async function showRunning(ctx: Context, deps: BotDeps): Promise<void> {
|
|
90
112
|
await deps.ephemeral.open(ctx);
|
|
91
|
-
const
|
|
113
|
+
const { resolveScope } = await import("../scope.js");
|
|
114
|
+
const scope = resolveScope(ctx, deps);
|
|
115
|
+
// Topic: only this topic's controlled sessions. Private: this chat + same-project forum sessions.
|
|
116
|
+
let list = dedupeBySession(scope.controller.list());
|
|
117
|
+
if (!scope.isForum) {
|
|
118
|
+
const path = scope.rt.cwd;
|
|
119
|
+
for (const fc of deps.registry.allForumControllers()) {
|
|
120
|
+
if (fc.fixedCwd && samePath(fc.fixedCwd, path)) {
|
|
121
|
+
list = dedupeBySession([...list, ...fc.list().map((s) => ({ ...s, projectName: `${s.projectName} \u00B7 topic` }))]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
// Also surface private-bot sessions for the same project path.
|
|
126
|
+
for (const chatId of deps.settings.chatIds()) {
|
|
127
|
+
if (chatId === scope.chatId) continue;
|
|
128
|
+
try {
|
|
129
|
+
const priv = deps.registry.controller(chatId);
|
|
130
|
+
for (const s of priv.list()) {
|
|
131
|
+
// Match by session cwd via store or name — use store meta if available.
|
|
132
|
+
if (s.sessionId) {
|
|
133
|
+
const meta = deps.store.get(s.sessionId);
|
|
134
|
+
if (meta?.cwd && samePath(meta.cwd, scope.rt.cwd)) {
|
|
135
|
+
list = dedupeBySession([
|
|
136
|
+
...list,
|
|
137
|
+
{ ...s, projectName: `${s.projectName} \u00B7 DM`, foreground: false },
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
/* skip */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
92
147
|
if (list.length === 0) {
|
|
93
|
-
await deps.ephemeral.reply(
|
|
148
|
+
await deps.ephemeral.reply(
|
|
149
|
+
ctx,
|
|
150
|
+
scope.isForum
|
|
151
|
+
? "No sessions in this topic yet. Send a message or tap \u{1F195} New."
|
|
152
|
+
: "No sessions controlled yet. Use \u{1F4C1} Project or /new to start one.",
|
|
153
|
+
);
|
|
94
154
|
return;
|
|
95
155
|
}
|
|
96
156
|
const now = Date.now();
|
|
97
157
|
const shown = list.slice(0, CARD_LIMIT);
|
|
98
|
-
|
|
158
|
+
const header = scope.isForum
|
|
159
|
+
? `\u{1F9ED} Running in topic **${scope.projectName ?? "topic"}** (${list.length})`
|
|
160
|
+
: `\u{1F9ED} Sessions controlled by this chat (${list.length}) \u2014 tap \u{1F500} Switch on a card:`;
|
|
161
|
+
await deps.ephemeral.reply(ctx, header);
|
|
99
162
|
for (const s of shown) {
|
|
100
163
|
const { text, kb } = buildRunningCard(s, deps, now);
|
|
101
164
|
await deps.ephemeral.reply(ctx, text, { reply_markup: kb });
|
|
@@ -105,6 +168,11 @@ export async function showRunning(ctx: Context, deps: BotDeps): Promise<void> {
|
|
|
105
168
|
}
|
|
106
169
|
}
|
|
107
170
|
|
|
171
|
+
function samePath(a: string, b: string): boolean {
|
|
172
|
+
return a.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() ===
|
|
173
|
+
b.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
174
|
+
}
|
|
175
|
+
|
|
108
176
|
/** Collapse any cards that share a session id (defensive — the controller
|
|
109
177
|
* already prunes duplicate runtimes, but never show the same session twice). */
|
|
110
178
|
function dedupeBySession(list: RunningSession[]): RunningSession[] {
|
|
@@ -119,14 +187,76 @@ function dedupeBySession(list: RunningSession[]): RunningSession[] {
|
|
|
119
187
|
|
|
120
188
|
/** Switch the chat to a session and show its summary + unread. */
|
|
121
189
|
export async function switchAndShow(ctx: Context, deps: BotDeps, sessionId: string): Promise<void> {
|
|
122
|
-
const
|
|
190
|
+
const { resolveScope } = await import("../scope.js");
|
|
191
|
+
const scope = resolveScope(ctx, deps);
|
|
192
|
+
// Always bring the session into *this* scope's controller (never switch FG on
|
|
193
|
+
// another surface — that would dual-own one ACP session across controllers).
|
|
194
|
+
let res = await scope.controller.switchTo(sessionId);
|
|
195
|
+
if (!res) {
|
|
196
|
+
const meta = deps.store.get(sessionId);
|
|
197
|
+
let cwd = meta?.cwd;
|
|
198
|
+
let name = meta?.title || (cwd ? basenameSafe(cwd) : undefined);
|
|
199
|
+
if (!cwd) {
|
|
200
|
+
// Discover cwd from whatever controller currently lists it.
|
|
201
|
+
for (const c of [scope.controller, ...deps.registry.allForumControllers()]) {
|
|
202
|
+
const hit = c.list().find((s) => s.sessionId === sessionId);
|
|
203
|
+
if (hit) {
|
|
204
|
+
cwd = (c as { fixedCwd?: string }).fixedCwd || scope.rt.cwd;
|
|
205
|
+
name = hit.projectName || name;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (cwd) {
|
|
211
|
+
// Topic controllers are fixed-path — refuse foreign projects.
|
|
212
|
+
if (scope.controller.fixedCwd && !samePath(scope.controller.fixedCwd, cwd)) {
|
|
213
|
+
await ctx.reply(
|
|
214
|
+
"That session belongs to a different project than this topic.",
|
|
215
|
+
scope.threadExtra,
|
|
216
|
+
);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Drop dual ownership: release from any other controller first.
|
|
220
|
+
await releaseSessionElsewhere(deps, scope.controller, sessionId);
|
|
221
|
+
const hist = (await import("../../sessions/history.js")).readHistory(
|
|
222
|
+
deps.store.jsonlPath(sessionId),
|
|
223
|
+
);
|
|
224
|
+
await scope.controller.addAttach(sessionId, cwd, name || basenameSafe(cwd), hist);
|
|
225
|
+
res = await scope.controller.switchTo(sessionId);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
123
228
|
if (!res) {
|
|
124
|
-
await ctx.reply("Session not found (it may have been closed).");
|
|
229
|
+
await ctx.reply("Session not found (it may have been closed).", scope.threadExtra);
|
|
125
230
|
return;
|
|
126
231
|
}
|
|
127
232
|
await deliverSwitch(ctx, deps, res);
|
|
128
233
|
}
|
|
129
234
|
|
|
235
|
+
/** Stop controlling a session on every controller except `keep`. */
|
|
236
|
+
async function releaseSessionElsewhere(
|
|
237
|
+
deps: BotDeps,
|
|
238
|
+
keep: import("../chat-controller.js").ChatController,
|
|
239
|
+
sessionId: string,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
for (const c of deps.registry.allForumControllers()) {
|
|
242
|
+
if (c !== keep && c.findBySession(sessionId)) await c.close(sessionId);
|
|
243
|
+
}
|
|
244
|
+
for (const chatId of deps.settings.chatIds()) {
|
|
245
|
+
try {
|
|
246
|
+
const c = deps.registry.controller(chatId);
|
|
247
|
+
if (c !== keep && c.findBySession(sessionId)) await c.close(sessionId);
|
|
248
|
+
} catch {
|
|
249
|
+
/* skip */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function basenameSafe(p: string): string {
|
|
255
|
+
const n = p.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
256
|
+
const i = n.lastIndexOf("/");
|
|
257
|
+
return i >= 0 ? n.slice(i + 1) : n;
|
|
258
|
+
}
|
|
259
|
+
|
|
130
260
|
export function registerRunning(bot: Bot, deps: BotDeps): void {
|
|
131
261
|
bot.command("running", (ctx) => showRunning(ctx, deps));
|
|
132
262
|
|
|
@@ -140,8 +270,24 @@ export function registerRunning(bot: Bot, deps: BotDeps): void {
|
|
|
140
270
|
|
|
141
271
|
bot.callbackQuery(new RegExp(`^run:close:${UUID}$`), async (ctx) => {
|
|
142
272
|
const id = ctx.match![1]!;
|
|
143
|
-
await
|
|
144
|
-
|
|
273
|
+
const { resolveScope } = await import("../scope.js");
|
|
274
|
+
const scope = resolveScope(ctx, deps);
|
|
275
|
+
let closed = await scope.controller.close(id);
|
|
276
|
+
// Card may show a session owned by the other surface — close on owner.
|
|
277
|
+
if (!closed) {
|
|
278
|
+
const fc = deps.registry.forumControllerForSession(id);
|
|
279
|
+
if (fc) closed = await fc.close(id);
|
|
280
|
+
}
|
|
281
|
+
if (!closed) {
|
|
282
|
+
for (const chatId of deps.settings.chatIds()) {
|
|
283
|
+
const c = deps.registry.controller(chatId);
|
|
284
|
+
if (c.findBySession(id)) {
|
|
285
|
+
closed = await c.close(id);
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
await ctx.answerCallbackQuery({ text: closed ? "Closed" : "Not found" });
|
|
145
291
|
await ctx.deleteMessage().catch(() => {}); // remove just this card
|
|
146
292
|
});
|
|
147
293
|
}
|
|
@@ -171,6 +317,16 @@ async function deliverSwitch(ctx: Context, deps: BotDeps, res: SwitchResult): Pr
|
|
|
171
317
|
if (!res.busy && res.rt.lastTurnSummary) {
|
|
172
318
|
await ctx.reply(res.rt.lastTurnSummary);
|
|
173
319
|
}
|
|
320
|
+
|
|
321
|
+
// Re-show post-turn suggestions generated while this session was in the
|
|
322
|
+
// background (or if the user missed the Done notify). Buttons stay wired to
|
|
323
|
+
// the same batch ids so taps still submit the follow-up.
|
|
324
|
+
if (!res.busy) {
|
|
325
|
+
const sug = res.rt.peekPendingSuggestions();
|
|
326
|
+
if (sug) {
|
|
327
|
+
await ctx.reply(sug.text, { reply_markup: sug.markup }).catch(() => {});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
174
330
|
}
|
|
175
331
|
|
|
176
332
|
function fmtEntry(e: HistoryEntry): string {
|