grok-telegram-bot 2.4.0 → 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 +38 -2
- package/CHANGELOG.md +119 -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/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +12 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +96 -15
- package/src/bot/bot.ts +122 -15
- package/src/bot/chat-controller.ts +52 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +159 -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 +207 -0
- package/src/bot/handlers/menu.ts +86 -24
- package/src/bot/handlers/message.ts +101 -21
- package/src/bot/handlers/photo.ts +123 -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 +8 -5
- package/src/bot/menu/ephemeral.ts +13 -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 +300 -0
- package/src/bot/prompt-content.ts +3 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +94 -0
- package/src/bot/session-runtime.ts +647 -158
- package/src/bot/suggestions.ts +91 -31
- 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 +201 -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 +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/session-comment.ts +64 -7
- package/src/render/telegram-bridge.ts +360 -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 +50 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +17 -6
- 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
|
@@ -14,8 +14,16 @@ import { extractReplyContext } from "../reply-context.js";
|
|
|
14
14
|
|
|
15
15
|
const log = createLogger("voice");
|
|
16
16
|
|
|
17
|
+
type VoiceMediaKind = "voice" | "audio" | "video_note";
|
|
18
|
+
|
|
17
19
|
export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
18
|
-
const handle = async (
|
|
20
|
+
const handle = async (
|
|
21
|
+
ctx: Context,
|
|
22
|
+
fileId: string,
|
|
23
|
+
mime: string,
|
|
24
|
+
name: string,
|
|
25
|
+
mediaKind: VoiceMediaKind,
|
|
26
|
+
): Promise<void> => {
|
|
19
27
|
const chatId = ctx.chat!.id;
|
|
20
28
|
if (deps.wizard.isActive(chatId)) {
|
|
21
29
|
await ctx.reply("Finish or /cancel the task wizard before sending voice.");
|
|
@@ -36,11 +44,41 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
|
36
44
|
await ctx.reply("\u{1F399} I couldn't make out any speech.");
|
|
37
45
|
return;
|
|
38
46
|
}
|
|
39
|
-
|
|
40
|
-
const
|
|
47
|
+
const { resolveScope } = await import("../scope.js");
|
|
48
|
+
const { adoptUserPrompt } = await import("../prompt-anchor.js");
|
|
49
|
+
const scope = resolveScope(ctx, deps);
|
|
41
50
|
const quoted = extractReplyContext(ctx);
|
|
42
|
-
const
|
|
43
|
-
|
|
51
|
+
const userMsgId = ctx.message?.message_id;
|
|
52
|
+
const media =
|
|
53
|
+
mediaKind === "video_note"
|
|
54
|
+
? [{ type: "video_note" as const, fileId }]
|
|
55
|
+
: mediaKind === "voice"
|
|
56
|
+
? [{ type: "voice" as const, fileId }]
|
|
57
|
+
: [{ type: "audio" as const, fileId, fileName: name }];
|
|
58
|
+
const anchor = await adoptUserPrompt(deps.api, {
|
|
59
|
+
chatId,
|
|
60
|
+
text: `\u201C${text}\u201D`,
|
|
61
|
+
userMessageIds: userMsgId !== undefined ? [userMsgId] : [],
|
|
62
|
+
messageThreadId: scope.threadExtra.message_thread_id,
|
|
63
|
+
projectName: scope.rt.projectName,
|
|
64
|
+
prefix: "\u{1F399} Voice",
|
|
65
|
+
media,
|
|
66
|
+
});
|
|
67
|
+
const outcome = await scope.rt.submit(
|
|
68
|
+
textPrompt(text, anchor?.replyTo ?? userMsgId, quoted, {
|
|
69
|
+
promptId: anchor?.promptId,
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
if (outcome === "queued") {
|
|
73
|
+
const extra: Record<string, unknown> = { ...scope.threadExtra };
|
|
74
|
+
if (anchor?.replyTo !== undefined) {
|
|
75
|
+
extra.reply_parameters = {
|
|
76
|
+
message_id: anchor.replyTo,
|
|
77
|
+
allow_sending_without_reply: true,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
await ctx.reply("\u{1F4E5} Queued \u2014 will run after the current task.", extra);
|
|
81
|
+
}
|
|
44
82
|
} catch (e) {
|
|
45
83
|
log.warn("voice failed:", (e as Error).message);
|
|
46
84
|
await ctx.reply(`\u274C Voice transcription failed: ${(e as Error).message}`);
|
|
@@ -48,7 +86,13 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
|
48
86
|
};
|
|
49
87
|
|
|
50
88
|
bot.on("message:voice", (ctx) =>
|
|
51
|
-
handle(
|
|
89
|
+
handle(
|
|
90
|
+
ctx,
|
|
91
|
+
ctx.message.voice.file_id,
|
|
92
|
+
ctx.message.voice.mime_type || "audio/ogg",
|
|
93
|
+
"voice.ogg",
|
|
94
|
+
"voice",
|
|
95
|
+
),
|
|
52
96
|
);
|
|
53
97
|
bot.on("message:audio", (ctx) =>
|
|
54
98
|
handle(
|
|
@@ -56,10 +100,11 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
|
56
100
|
ctx.message.audio.file_id,
|
|
57
101
|
ctx.message.audio.mime_type || "audio/mpeg",
|
|
58
102
|
ctx.message.audio.file_name || "audio.mp3",
|
|
103
|
+
"audio",
|
|
59
104
|
),
|
|
60
105
|
);
|
|
61
106
|
bot.on("message:video_note", (ctx) =>
|
|
62
|
-
handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4"),
|
|
107
|
+
handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4", "video_note"),
|
|
63
108
|
);
|
|
64
109
|
}
|
|
65
110
|
|
package/src/bot/image-return.ts
CHANGED
|
@@ -129,6 +129,8 @@ export interface SendImagesOptions {
|
|
|
129
129
|
max: number;
|
|
130
130
|
/** Optional Telegram message id to thread replies under. */
|
|
131
131
|
replyTo?: number;
|
|
132
|
+
/** Forum topic — required so agent images land in the right topic. */
|
|
133
|
+
messageThreadId?: number;
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
/** Send the valid, fresh, not-yet-sent images as documents. Returns how many were sent. */
|
|
@@ -139,10 +141,11 @@ export async function sendImages(
|
|
|
139
141
|
opts: SendImagesOptions,
|
|
140
142
|
): Promise<number> {
|
|
141
143
|
let sent = 0;
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
const extra: Record<string, unknown> = {};
|
|
145
|
+
if (opts.replyTo !== undefined) {
|
|
146
|
+
extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
|
|
147
|
+
}
|
|
148
|
+
if (opts.messageThreadId !== undefined) extra.message_thread_id = opts.messageThreadId;
|
|
146
149
|
for (const path of paths) {
|
|
147
150
|
if (sent >= opts.max) break;
|
|
148
151
|
if (opts.already.has(path)) continue;
|
|
@@ -161,7 +164,7 @@ export async function sendImages(
|
|
|
161
164
|
const file = new InputFile(path, basename(path));
|
|
162
165
|
await api.sendDocument(chatId, file, {
|
|
163
166
|
caption: basename(path),
|
|
164
|
-
...
|
|
167
|
+
...extra,
|
|
165
168
|
});
|
|
166
169
|
sent++;
|
|
167
170
|
log.debug(`sent document ${path}`);
|
|
@@ -95,11 +95,21 @@ export class Ephemeral {
|
|
|
95
95
|
async reply(ctx: Context, text: string, extra: Record<string, unknown> = {}): Promise<number | undefined> {
|
|
96
96
|
const chatId = ctx.chat?.id;
|
|
97
97
|
if (chatId === undefined) return undefined;
|
|
98
|
+
// Stay in the forum topic when the trigger message was in a thread.
|
|
99
|
+
const msg = ctx.message ?? ctx.callbackQuery?.message;
|
|
100
|
+
const threadId =
|
|
101
|
+
msg && "message_thread_id" in msg
|
|
102
|
+
? (msg as { message_thread_id?: number }).message_thread_id
|
|
103
|
+
: undefined;
|
|
104
|
+
const merged =
|
|
105
|
+
threadId !== undefined && extra.message_thread_id === undefined
|
|
106
|
+
? { ...extra, message_thread_id: threadId }
|
|
107
|
+
: extra;
|
|
98
108
|
return this.serialize(chatId, async () => {
|
|
99
109
|
try {
|
|
100
|
-
const
|
|
101
|
-
this.remember(chatId,
|
|
102
|
-
return
|
|
110
|
+
const m = await ctx.reply(text, merged);
|
|
111
|
+
this.remember(chatId, m.message_id);
|
|
112
|
+
return m.message_id;
|
|
103
113
|
} catch {
|
|
104
114
|
return undefined;
|
|
105
115
|
}
|
package/src/bot/menu/keyboard.ts
CHANGED
|
@@ -1,32 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Menu surfaces:
|
|
3
|
-
* -
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* - PERSISTENT bar (reply keyboard): ☰ Menu · 🆕 New session / 🧭 Running · ⏹ Stop
|
|
4
|
+
* — primary place for New session in private chats (not the inline Menu message).
|
|
5
|
+
* - INLINE menu message (opened via ☰ Menu or /menu) — settings & navigation.
|
|
6
|
+
* Forum topics: reply keyboards are unreliable, so New stays on the topic inline menu.
|
|
7
|
+
* Live state lives in the pinned status panel so the bar stays clean.
|
|
8
8
|
*/
|
|
9
9
|
import { InlineKeyboard, Keyboard } from "grammy";
|
|
10
10
|
|
|
11
11
|
export const MENU_BTN = "\u2630 Menu"; // ☰
|
|
12
|
+
/** Persistent bar + forum topic control — brand-new session (same as /new). */
|
|
13
|
+
export const NEW_BTN = "\u{1F195} New session";
|
|
12
14
|
export const RUNNING_BTN = "\u{1F9ED} Running";
|
|
13
15
|
export const STOP_BTN = "\u23F9 Stop";
|
|
14
|
-
export const BAR_LABELS = [MENU_BTN, RUNNING_BTN, STOP_BTN];
|
|
16
|
+
export const BAR_LABELS = [MENU_BTN, NEW_BTN, RUNNING_BTN, STOP_BTN];
|
|
15
17
|
|
|
16
|
-
/** The always-visible compact bar. */
|
|
18
|
+
/** The always-visible compact bar (private chats; best-effort in groups). */
|
|
17
19
|
export function compactKeyboard(): Keyboard {
|
|
18
|
-
return new Keyboard()
|
|
20
|
+
return new Keyboard()
|
|
21
|
+
.text(MENU_BTN)
|
|
22
|
+
.text(NEW_BTN)
|
|
23
|
+
.row()
|
|
24
|
+
.text(RUNNING_BTN)
|
|
25
|
+
.text(STOP_BTN)
|
|
26
|
+
.resized()
|
|
27
|
+
.persistent();
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
/** The full, grouped inline menu (opened via ☰ Menu or /menu). */
|
|
22
|
-
export function mainMenuInline(state: {
|
|
31
|
+
export function mainMenuInline(state: {
|
|
32
|
+
model: string;
|
|
33
|
+
reasoning: string;
|
|
34
|
+
/** Forum topic scope — hide project switch; label topic sessions. */
|
|
35
|
+
forumTopic?: { name: string; account?: string };
|
|
36
|
+
}): InlineKeyboard {
|
|
23
37
|
const t = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + "\u2026" : s);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
38
|
+
const kb = new InlineKeyboard();
|
|
39
|
+
|
|
40
|
+
if (state.forumTopic) {
|
|
41
|
+
// Groups/topics: control first (no reliable reply-keyboard bar). New session here.
|
|
42
|
+
kb.text("\u23F9 Stop", "m:stop")
|
|
43
|
+
.text("\u{1F9ED} Running", "m:running")
|
|
44
|
+
.row()
|
|
45
|
+
.text(NEW_BTN, "m:new")
|
|
46
|
+
.text("\u{1F5C2} Sessions", "m:sessions")
|
|
47
|
+
.row()
|
|
48
|
+
.text(`\u{1F4C1} ${t(state.forumTopic.name, 28)}`, "m:topicinfo")
|
|
49
|
+
.row()
|
|
50
|
+
.text("\u{1F4E5} Import session", "m:import")
|
|
51
|
+
.row()
|
|
52
|
+
.text(`\u{1F9E9} Model \u00B7 ${t(state.model, 24)}`, "m:model")
|
|
53
|
+
.row()
|
|
54
|
+
.text(`\u{1F9E0} Reasoning \u00B7 ${t(state.reasoning, 24)}`, "m:reasoning")
|
|
55
|
+
.row();
|
|
56
|
+
if (state.forumTopic.account) {
|
|
57
|
+
kb.text(`\u{1F465} Account \u00B7 ${t(state.forumTopic.account, 20)}`, "m:accounts").row();
|
|
58
|
+
}
|
|
59
|
+
kb.text("\u{1F4CA} Status", "m:status").text("\u2716 Close", "m:close");
|
|
60
|
+
return kb;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Private chat: New session is on the persistent bar + /new — not on this message.
|
|
64
|
+
kb.text("\u{1F4C1} Project", "m:project")
|
|
65
|
+
.text("\u{1F5C2} Sessions", "m:sessions")
|
|
27
66
|
.row()
|
|
67
|
+
.text("\u23F9 Stop", "m:stop")
|
|
28
68
|
.text("\u{1F9ED} Running", "m:running")
|
|
29
|
-
.text("\u{1F5C2} Sessions", "m:sessions")
|
|
30
69
|
.row()
|
|
31
70
|
.text("\u{1F4E5} Import session", "m:import")
|
|
32
71
|
.row()
|
|
@@ -41,10 +80,10 @@ export function mainMenuInline(state: { model: string; reasoning: string }): Inl
|
|
|
41
80
|
.text("\u{1F465} Accounts", "m:accounts")
|
|
42
81
|
.row()
|
|
43
82
|
.text("\u{1F9E9} MCP", "m:mcp")
|
|
44
|
-
.text("\u23F9 Stop", "m:stop")
|
|
45
83
|
.text("\u{1F6D1} Kill all", "m:killall")
|
|
46
84
|
.row()
|
|
47
85
|
.text("\u2328\uFE0F Show bar", "m:showbar")
|
|
48
86
|
.text("\u{1F648} Hide bar", "m:hidebar")
|
|
49
87
|
.text("\u2716 Close", "m:close");
|
|
88
|
+
return kb;
|
|
50
89
|
}
|
package/src/bot/menu/refresh.ts
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { Context } from "grammy";
|
|
6
6
|
import type { BotDeps } from "../deps.js";
|
|
7
|
+
import { resolveScope } from "../scope.js";
|
|
7
8
|
import { compactKeyboard } from "./keyboard.js";
|
|
8
9
|
|
|
9
10
|
export async function refreshMenu(ctx: Context, deps: BotDeps, text: string): Promise<void> {
|
|
10
11
|
const chatId = ctx.chat!.id;
|
|
11
|
-
|
|
12
|
+
const scope = resolveScope(ctx, deps);
|
|
13
|
+
await ctx.reply(text, { reply_markup: compactKeyboard(), ...scope.threadExtra });
|
|
12
14
|
await deps.statusPanel.refresh(chatId);
|
|
13
15
|
}
|
|
@@ -65,13 +65,19 @@ export class StatusPanel {
|
|
|
65
65
|
if (subagents) activity.push(`\u{1F465} ${subagents}`);
|
|
66
66
|
lines.push(activity.join(SEP));
|
|
67
67
|
|
|
68
|
-
// 3b)
|
|
69
|
-
//
|
|
70
|
-
|
|
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;
|
|
71
72
|
if (comment) {
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
+
});
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
// 4) Where: project | session | context usage.
|
|
@@ -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
|
+
}
|
|
@@ -77,6 +77,9 @@ export function mergeInputs(inputs: PromptInput[]): PromptInput {
|
|
|
77
77
|
images: inputs.flatMap((i) => i.images),
|
|
78
78
|
resourceLinks: inputs.flatMap((i) => i.resourceLinks ?? []),
|
|
79
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,
|
|
80
83
|
quotedText: quotes.length > 0 ? [...new Set(quotes)].join("\n\n---\n\n") : undefined,
|
|
81
84
|
skipSelfRecheck: skipSelfRecheck || undefined,
|
|
82
85
|
};
|