grok-telegram-bot 2.4.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/.env.example +38 -2
- package/CHANGELOG.md +190 -1
- package/README.md +60 -15
- package/docs/GROUP.md +260 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +4 -4
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +38 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +100 -15
- package/src/bot/bot.ts +193 -17
- package/src/bot/chat-controller.ts +181 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +339 -0
- package/src/bot/handlers/accounts.ts +7 -0
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +217 -0
- package/src/bot/handlers/menu.ts +86 -24
- package/src/bot/handlers/message.ts +247 -27
- package/src/bot/handlers/photo.ts +126 -16
- package/src/bot/handlers/running.ts +150 -24
- package/src/bot/handlers/session-card.ts +13 -5
- package/src/bot/handlers/sessions.ts +68 -18
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +11 -5
- package/src/bot/manager-context.ts +208 -0
- package/src/bot/manager-jobs.ts +142 -0
- package/src/bot/menu/ephemeral.ts +16 -3
- package/src/bot/menu/keyboard.ts +53 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +12 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +299 -0
- package/src/bot/prompt-content.ts +8 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +95 -0
- package/src/bot/session-runtime.ts +1280 -183
- package/src/bot/suggestions.ts +91 -31
- package/src/bot/telegram-actions.ts +1130 -0
- package/src/bot/telegram-bots.ts +496 -0
- package/src/bot/telegram-io.ts +97 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +201 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +652 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +49 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +130 -28
- package/src/index.ts +205 -75
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/hashtags.ts +5 -1
- package/src/render/manager-directive.ts +137 -0
- package/src/render/session-comment.ts +74 -7
- package/src/render/telegram-bridge.ts +464 -0
- package/src/render/tool-call.ts +56 -37
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +16 -4
- package/src/sessions/history.ts +68 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +62 -15
- package/scripts/analyze-jsonl.ts +0 -33
- package/scripts/delayed-restart.ps1 +0 -29
- package/scripts/probe-exit-response-shape.py +0 -77
- package/scripts/probe-plan-exit.py +0 -60
- package/scripts/probe-plan-exit2.py +0 -48
- package/scripts/probe-plan-fields.py +0 -41
- package/scripts/probe-plan-fields2.py +0 -58
- package/scripts/probe-plan-response-path.py +0 -48
- package/scripts/sample-claude-tooluse.ts +0 -21
- package/scripts/sample-kiro-events.ts +0 -31
- package/scripts/smoke-exit-plan.ts +0 -274
- package/scripts/smoke-exit-shapes.ts +0 -252
- package/scripts/smoke-import.mjs +0 -82
- package/scripts/smoke-import.ts +0 -73
|
@@ -8,96 +8,316 @@
|
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
13
|
+
*
|
|
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.
|
|
14
17
|
*/
|
|
15
18
|
import type { Bot } from "grammy";
|
|
16
19
|
import { textPrompt } from "../../app/types.js";
|
|
17
20
|
import { createLogger } from "../../logger.js";
|
|
21
|
+
import {
|
|
22
|
+
batchKey,
|
|
23
|
+
forumThreadId,
|
|
24
|
+
isGeneralThread,
|
|
25
|
+
outboundThreadExtra,
|
|
26
|
+
} from "../../forum/thread.js";
|
|
18
27
|
import type { BotDeps } from "../deps.js";
|
|
28
|
+
import { adoptUserPrompt, newPromptId } from "../prompt-anchor.js";
|
|
19
29
|
import { extractReplyContext } from "../reply-context.js";
|
|
30
|
+
import { resolveForumRuntime } from "./forum.js";
|
|
31
|
+
import type { SessionRuntime } from "../session-runtime.js";
|
|
20
32
|
|
|
21
33
|
const log = createLogger("message");
|
|
22
34
|
|
|
23
|
-
/** A pending burst of text messages from one chat, awaiting coalescing. */
|
|
35
|
+
/** A pending burst of text messages from one chat/topic, awaiting coalescing. */
|
|
24
36
|
interface TextBatch {
|
|
25
37
|
parts: string[];
|
|
26
38
|
ids: number[];
|
|
39
|
+
/** Forum topic thread id (undefined for private chats / General without id). */
|
|
40
|
+
threadId?: number;
|
|
27
41
|
/** Reference content if the burst began as a reply to another message. */
|
|
28
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;
|
|
29
47
|
timer: NodeJS.Timeout;
|
|
30
48
|
}
|
|
31
49
|
|
|
32
50
|
export function registerMessages(bot: Bot, deps: BotDeps): void {
|
|
33
|
-
const batches = new Map<
|
|
51
|
+
const batches = new Map<string, TextBatch>();
|
|
34
52
|
const windowMs = deps.cfg.messageBatchMs;
|
|
35
53
|
|
|
36
|
-
const arm = (
|
|
37
|
-
setTimeout(() => void flush(deps, batches,
|
|
54
|
+
const arm = (key: string): NodeJS.Timeout =>
|
|
55
|
+
setTimeout(() => void flush(deps, batches, key), windowMs);
|
|
38
56
|
|
|
39
57
|
bot.on("message:text", async (ctx) => {
|
|
40
58
|
const text = ctx.message.text;
|
|
41
59
|
if (!text.trim()) return;
|
|
60
|
+
// Slash commands are handled by bot.command / menu — never batch as agent prompts.
|
|
61
|
+
if (!text.includes("\n") && text.startsWith("/")) return;
|
|
62
|
+
|
|
42
63
|
const chatId = ctx.chat.id;
|
|
43
64
|
const id = ctx.message.message_id;
|
|
65
|
+
const rawThreadId = ctx.message.message_thread_id;
|
|
66
|
+
const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
|
|
67
|
+
const threadId = isForum ? forumThreadId(rawThreadId) : rawThreadId;
|
|
44
68
|
const quoted = extractReplyContext(ctx);
|
|
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);
|
|
45
77
|
|
|
46
|
-
const batch = batches.get(
|
|
78
|
+
const batch = batches.get(key);
|
|
47
79
|
if (batch) {
|
|
48
80
|
clearTimeout(batch.timer);
|
|
49
81
|
batch.parts.push(text);
|
|
50
82
|
batch.ids.push(id);
|
|
51
83
|
if (quoted && !batch.quoted) batch.quoted = quoted;
|
|
52
|
-
batch.
|
|
84
|
+
if (replyToMessageId !== undefined && batch.replyToMessageId === undefined) {
|
|
85
|
+
batch.replyToMessageId = replyToMessageId;
|
|
86
|
+
batch.replyToText = replyToText;
|
|
87
|
+
}
|
|
88
|
+
batch.timer = arm(key);
|
|
53
89
|
return;
|
|
54
90
|
}
|
|
55
|
-
batches.set(
|
|
91
|
+
batches.set(key, {
|
|
92
|
+
parts: [text],
|
|
93
|
+
ids: [id],
|
|
94
|
+
threadId,
|
|
95
|
+
quoted,
|
|
96
|
+
replyToMessageId,
|
|
97
|
+
replyToText,
|
|
98
|
+
timer: arm(key),
|
|
99
|
+
});
|
|
56
100
|
});
|
|
57
101
|
}
|
|
58
102
|
|
|
59
103
|
/** 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(
|
|
104
|
+
async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string): Promise<void> {
|
|
105
|
+
const batch = batches.get(key);
|
|
62
106
|
if (!batch) return;
|
|
63
|
-
batches.delete(
|
|
107
|
+
batches.delete(key);
|
|
64
108
|
|
|
65
|
-
// Telegram splits at 4096 chars, almost always on a line boundary, so
|
|
66
|
-
// rejoining with a newline reconstructs the original text faithfully.
|
|
67
109
|
const combined = batch.parts.join("\n").trim();
|
|
68
110
|
if (!combined) return;
|
|
69
111
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
112
|
+
const [chatIdStr] = key.split(":");
|
|
113
|
+
const chatId = Number(chatIdStr);
|
|
114
|
+
const threadId = batch.threadId;
|
|
115
|
+
|
|
73
116
|
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
117
|
return;
|
|
76
118
|
}
|
|
77
119
|
|
|
78
|
-
const
|
|
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) {
|
|
126
|
+
const resolved = await resolveForumRuntime(
|
|
127
|
+
deps,
|
|
128
|
+
deps.forum,
|
|
129
|
+
chatId,
|
|
130
|
+
threadId,
|
|
131
|
+
combined,
|
|
132
|
+
batch.ids[0]!,
|
|
133
|
+
);
|
|
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) ─────────────────
|
|
226
|
+
rt = resolved.rt;
|
|
227
|
+
if (!rt.sessionId && !rt.isBusy) {
|
|
228
|
+
try {
|
|
229
|
+
await rt.startNewSession(rt.cwd, rt.projectName);
|
|
230
|
+
} catch {
|
|
231
|
+
/* ensureSession on submit will retry */
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
79
236
|
const note = batch.parts.length > 1 ? ` (combined ${batch.parts.length} messages)` : "";
|
|
237
|
+
let replyTo: number | undefined = batch.ids[0];
|
|
80
238
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
239
|
+
const anchor = await adoptUserPrompt(deps.api, {
|
|
240
|
+
chatId,
|
|
241
|
+
text: combined,
|
|
242
|
+
userMessageIds: batch.ids,
|
|
243
|
+
messageThreadId: threadId,
|
|
244
|
+
projectName: rt.projectName,
|
|
245
|
+
prefix: "\u{1F4DD} Prompt",
|
|
246
|
+
});
|
|
247
|
+
replyTo = anchor?.replyTo ?? batch.ids[0];
|
|
248
|
+
const outcome = await rt.submit(
|
|
249
|
+
textPrompt(combined, replyTo, batch.quoted, { promptId: anchor?.promptId }),
|
|
250
|
+
);
|
|
84
251
|
if (outcome === "queued") {
|
|
85
252
|
await send(
|
|
86
253
|
deps,
|
|
87
254
|
chatId,
|
|
88
255
|
`\u{1F4E5} Queued (position ${rt.queueLength})${note} \u2014 I'm still working on the previous task. It'll run next.`,
|
|
256
|
+
threadId,
|
|
257
|
+
replyTo,
|
|
89
258
|
);
|
|
90
259
|
}
|
|
91
|
-
// "ran": turn started; complexity is steered silently by the agent.
|
|
92
260
|
} catch (err) {
|
|
93
261
|
log.warn(`submit failed for chat ${chatId}: ${(err as Error).message}`);
|
|
94
|
-
await send(
|
|
262
|
+
await send(
|
|
263
|
+
deps,
|
|
264
|
+
chatId,
|
|
265
|
+
`\u274C Couldn't start your message: ${(err as Error).message}`,
|
|
266
|
+
threadId,
|
|
267
|
+
replyTo,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function send(
|
|
273
|
+
deps: BotDeps,
|
|
274
|
+
chatId: number,
|
|
275
|
+
text: string,
|
|
276
|
+
threadId?: number,
|
|
277
|
+
replyTo?: number,
|
|
278
|
+
): Promise<void> {
|
|
279
|
+
try {
|
|
280
|
+
const extra: Record<string, unknown> = { ...outboundThreadExtra(threadId) };
|
|
281
|
+
if (replyTo !== undefined) {
|
|
282
|
+
extra.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
|
|
283
|
+
}
|
|
284
|
+
await deps.api.sendMessage(chatId, text, extra);
|
|
285
|
+
} catch {
|
|
286
|
+
/* non-fatal */
|
|
287
|
+
}
|
|
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;
|
|
95
310
|
}
|
|
96
311
|
}
|
|
97
312
|
|
|
98
|
-
async function
|
|
313
|
+
async function editStatus(
|
|
314
|
+
deps: BotDeps,
|
|
315
|
+
chatId: number,
|
|
316
|
+
messageId: number,
|
|
317
|
+
text: string,
|
|
318
|
+
): Promise<void> {
|
|
99
319
|
try {
|
|
100
|
-
await deps.api.
|
|
320
|
+
await deps.api.editMessageText(chatId, messageId, text);
|
|
101
321
|
} catch {
|
|
102
322
|
/* non-fatal */
|
|
103
323
|
}
|
|
@@ -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,66 @@ 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
|
+
// Omit General (1) — Bot API rejects message_thread_id=1.
|
|
197
|
+
const extra: Record<string, unknown> =
|
|
198
|
+
threadId !== undefined && threadId !== 1
|
|
199
|
+
? { message_thread_id: threadId }
|
|
200
|
+
: {};
|
|
201
|
+
if (anchor?.replyTo !== undefined) {
|
|
202
|
+
extra.reply_parameters = {
|
|
203
|
+
message_id: anchor.replyTo,
|
|
204
|
+
allow_sending_without_reply: true,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
98
207
|
await deps.api.sendMessage(
|
|
99
208
|
chatId,
|
|
100
|
-
`\u{1F4E5} Queued ${
|
|
209
|
+
`\u{1F4E5} Queued ${n} image${n > 1 ? "s" : ""} \u2014 will run after the current task.`,
|
|
210
|
+
extra,
|
|
101
211
|
);
|
|
102
212
|
}
|
|
103
213
|
}
|