@grinev/opencode-telegram-bot 0.20.6 → 0.21.1
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 +12 -3
- package/README.md +13 -3
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/messages.js +537 -0
- package/dist/bot/handlers/model.js +205 -42
- package/dist/bot/index.js +109 -47
- package/dist/bot/streaming/response-streamer.js +26 -2
- package/dist/bot/utils/assistant-run-footer.js +15 -4
- package/dist/bot/utils/busy-reconciliation.js +8 -0
- package/dist/bot/utils/telegram-text.js +60 -0
- package/dist/config.js +14 -1
- package/dist/i18n/ar.js +491 -0
- package/dist/i18n/de.js +27 -0
- package/dist/i18n/en.js +27 -0
- package/dist/i18n/es.js +27 -0
- package/dist/i18n/fr.js +27 -0
- package/dist/i18n/index.js +7 -0
- package/dist/i18n/ru.js +27 -0
- package/dist/i18n/zh.js +27 -0
- package/dist/model/manager.js +38 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { InlineKeyboard } from "grammy";
|
|
2
|
-
import { selectModel, fetchCurrentModel, getModelSelectionLists } from "../../model/manager.js";
|
|
2
|
+
import { selectModel, fetchCurrentModel, getModelSelectionLists, searchModels, } from "../../model/manager.js";
|
|
3
3
|
import { formatModelForDisplay } from "../../model/types.js";
|
|
4
4
|
import { formatVariantForButton } from "../../variant/manager.js";
|
|
5
5
|
import { logger } from "../../utils/logger.js";
|
|
@@ -8,7 +8,11 @@ import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
|
|
|
8
8
|
import { pinnedMessageManager } from "../../pinned/manager.js";
|
|
9
9
|
import { keyboardManager } from "../../keyboard/manager.js";
|
|
10
10
|
import { clearActiveInlineMenu, ensureActiveInlineMenu, replyWithInlineMenu, } from "./inline-menu.js";
|
|
11
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
11
12
|
import { t } from "../../i18n/index.js";
|
|
13
|
+
const MODEL_SEARCH_CALLBACK = "model:search";
|
|
14
|
+
const MODEL_SEARCH_AGAIN_CALLBACK = "model:search:again";
|
|
15
|
+
const MODEL_SEARCH_CANCEL_CALLBACK = "model:search:cancel";
|
|
12
16
|
function buildModelSelectionMenuText(modelLists) {
|
|
13
17
|
const lines = [t("model.menu.select"), t("model.menu.favorites_title")];
|
|
14
18
|
if (modelLists.favorites.length === 0) {
|
|
@@ -20,9 +24,51 @@ function buildModelSelectionMenuText(modelLists) {
|
|
|
20
24
|
}
|
|
21
25
|
return lines.join("\n");
|
|
22
26
|
}
|
|
27
|
+
function parseModelSearchMetadata() {
|
|
28
|
+
const state = interactionManager.getSnapshot();
|
|
29
|
+
if (!state || state.kind !== "custom") {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const flow = state.metadata.flow;
|
|
33
|
+
const stage = state.metadata.stage;
|
|
34
|
+
if (flow !== "model-search" || typeof stage !== "string") {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const messageId = typeof state.metadata.messageId === "number" ? state.metadata.messageId : undefined;
|
|
38
|
+
return { flow, stage, messageId };
|
|
39
|
+
}
|
|
23
40
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
41
|
+
* Shared logic for applying a model selection and updating UI.
|
|
42
|
+
* Used by both the regular inline menu flow and the search results flow.
|
|
43
|
+
*/
|
|
44
|
+
async function applyModelSelectionAndNotify(ctx, modelInfo) {
|
|
45
|
+
if (ctx.chat) {
|
|
46
|
+
keyboardManager.initialize(ctx.api, ctx.chat.id);
|
|
47
|
+
}
|
|
48
|
+
selectModel(modelInfo);
|
|
49
|
+
keyboardManager.updateModel(modelInfo);
|
|
50
|
+
await pinnedMessageManager.refreshContextLimit();
|
|
51
|
+
const currentAgent = await resolveProjectAgent(getStoredAgent());
|
|
52
|
+
const contextInfo = pinnedMessageManager.getContextInfo() ??
|
|
53
|
+
(pinnedMessageManager.getContextLimit() > 0
|
|
54
|
+
? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
|
|
55
|
+
: null);
|
|
56
|
+
keyboardManager.updateAgent(currentAgent);
|
|
57
|
+
if (contextInfo) {
|
|
58
|
+
keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
|
|
59
|
+
}
|
|
60
|
+
const variantName = formatVariantForButton(modelInfo.variant || "default");
|
|
61
|
+
const keyboard = createMainKeyboard(currentAgent, modelInfo, contextInfo ?? undefined, variantName);
|
|
62
|
+
const displayName = formatModelForDisplay(modelInfo.providerID, modelInfo.modelID);
|
|
63
|
+
await ctx.answerCallbackQuery({ text: t("model.changed_callback", { name: displayName }) });
|
|
64
|
+
await ctx.reply(t("model.changed_message", { name: displayName }), {
|
|
65
|
+
reply_markup: keyboard,
|
|
66
|
+
});
|
|
67
|
+
await ctx.deleteMessage().catch(() => { });
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Handle model selection callback from the inline menu.
|
|
71
|
+
* Skips search-related callbacks (handled separately).
|
|
26
72
|
* @returns true if handled, false otherwise
|
|
27
73
|
*/
|
|
28
74
|
export async function handleModelSelect(ctx) {
|
|
@@ -30,15 +76,18 @@ export async function handleModelSelect(ctx) {
|
|
|
30
76
|
if (!callbackQuery?.data || !callbackQuery.data.startsWith("model:")) {
|
|
31
77
|
return false;
|
|
32
78
|
}
|
|
79
|
+
// Skip search callbacks — handled by handleModelSearchCallback / handleModelSearchResults
|
|
80
|
+
if (callbackQuery.data === MODEL_SEARCH_CALLBACK ||
|
|
81
|
+
callbackQuery.data === MODEL_SEARCH_AGAIN_CALLBACK ||
|
|
82
|
+
callbackQuery.data === MODEL_SEARCH_CANCEL_CALLBACK) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
33
85
|
const isActiveMenu = await ensureActiveInlineMenu(ctx, "model");
|
|
34
86
|
if (!isActiveMenu) {
|
|
35
87
|
return true;
|
|
36
88
|
}
|
|
37
89
|
logger.debug(`[ModelHandler] Received callback: ${callbackQuery.data}`);
|
|
38
90
|
try {
|
|
39
|
-
if (ctx.chat) {
|
|
40
|
-
keyboardManager.initialize(ctx.api, ctx.chat.id);
|
|
41
|
-
}
|
|
42
91
|
// Parse callback data: "model:providerID:modelID"
|
|
43
92
|
const parts = callbackQuery.data.split(":");
|
|
44
93
|
if (parts.length < 3) {
|
|
@@ -52,35 +101,10 @@ export async function handleModelSelect(ctx) {
|
|
|
52
101
|
const modelInfo = {
|
|
53
102
|
providerID,
|
|
54
103
|
modelID,
|
|
55
|
-
variant: "default",
|
|
104
|
+
variant: "default",
|
|
56
105
|
};
|
|
57
|
-
// Select model and persist
|
|
58
|
-
selectModel(modelInfo);
|
|
59
|
-
// Update keyboard manager state (may not be initialized if no session selected)
|
|
60
|
-
keyboardManager.updateModel(modelInfo);
|
|
61
|
-
// Refresh context limit for new model
|
|
62
|
-
await pinnedMessageManager.refreshContextLimit();
|
|
63
|
-
// Update Reply Keyboard with new model and context
|
|
64
|
-
const currentAgent = await resolveProjectAgent(getStoredAgent());
|
|
65
|
-
const contextInfo = pinnedMessageManager.getContextInfo() ??
|
|
66
|
-
(pinnedMessageManager.getContextLimit() > 0
|
|
67
|
-
? { tokensUsed: 0, tokensLimit: pinnedMessageManager.getContextLimit() }
|
|
68
|
-
: null);
|
|
69
|
-
keyboardManager.updateAgent(currentAgent);
|
|
70
|
-
if (contextInfo) {
|
|
71
|
-
keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
|
|
72
|
-
}
|
|
73
|
-
const variantName = formatVariantForButton(modelInfo.variant || "default");
|
|
74
|
-
const keyboard = createMainKeyboard(currentAgent, modelInfo, contextInfo ?? undefined, variantName);
|
|
75
|
-
const displayName = formatModelForDisplay(modelInfo.providerID, modelInfo.modelID);
|
|
76
106
|
clearActiveInlineMenu("model_selected");
|
|
77
|
-
|
|
78
|
-
await ctx.answerCallbackQuery({ text: t("model.changed_callback", { name: displayName }) });
|
|
79
|
-
await ctx.reply(t("model.changed_message", { name: displayName }), {
|
|
80
|
-
reply_markup: keyboard,
|
|
81
|
-
});
|
|
82
|
-
// Delete the inline menu message
|
|
83
|
-
await ctx.deleteMessage().catch(() => { });
|
|
107
|
+
await applyModelSelectionAndNotify(ctx, modelInfo);
|
|
84
108
|
return true;
|
|
85
109
|
}
|
|
86
110
|
catch (err) {
|
|
@@ -91,15 +115,15 @@ export async function handleModelSelect(ctx) {
|
|
|
91
115
|
}
|
|
92
116
|
}
|
|
93
117
|
/**
|
|
94
|
-
* Build inline keyboard with favorite and recent models
|
|
95
|
-
* @param currentModel Current model for highlighting
|
|
96
|
-
* @returns InlineKeyboard with model selection buttons
|
|
118
|
+
* Build inline keyboard with favorite and recent models, plus a search button at the top.
|
|
97
119
|
*/
|
|
98
120
|
export async function buildModelSelectionMenu(currentModel, modelLists) {
|
|
99
121
|
const keyboard = new InlineKeyboard();
|
|
100
122
|
const lists = modelLists ?? (await getModelSelectionLists());
|
|
101
123
|
const favorites = lists.favorites;
|
|
102
124
|
const recent = lists.recent;
|
|
125
|
+
// Search button — always present as first row
|
|
126
|
+
keyboard.text(t("model.search.button"), MODEL_SEARCH_CALLBACK).row();
|
|
103
127
|
if (favorites.length === 0 && recent.length === 0) {
|
|
104
128
|
logger.warn("[ModelHandler] No model choices found in favorites/recent");
|
|
105
129
|
return keyboard;
|
|
@@ -108,7 +132,6 @@ export async function buildModelSelectionMenu(currentModel, modelLists) {
|
|
|
108
132
|
const isActive = currentModel &&
|
|
109
133
|
model.providerID === currentModel.providerID &&
|
|
110
134
|
model.modelID === currentModel.modelID;
|
|
111
|
-
// Inline buttons use full model ID without truncation
|
|
112
135
|
const label = `${prefix} ${model.providerID}/${model.modelID}`;
|
|
113
136
|
const labelWithCheck = isActive ? `✅ ${label}` : label;
|
|
114
137
|
keyboard.text(labelWithCheck, `model:${model.providerID}:${model.modelID}`).row();
|
|
@@ -119,17 +142,13 @@ export async function buildModelSelectionMenu(currentModel, modelLists) {
|
|
|
119
142
|
}
|
|
120
143
|
/**
|
|
121
144
|
* Show model selection menu
|
|
122
|
-
* @param ctx grammY context
|
|
123
145
|
*/
|
|
124
146
|
export async function showModelSelectionMenu(ctx) {
|
|
125
147
|
try {
|
|
126
148
|
const currentModel = fetchCurrentModel();
|
|
127
149
|
const modelLists = await getModelSelectionLists();
|
|
128
150
|
const keyboard = await buildModelSelectionMenu(currentModel, modelLists);
|
|
129
|
-
|
|
130
|
-
await ctx.reply(t("model.menu.empty"));
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
151
|
+
// keyboard always has at least the search button, so length > 0
|
|
133
152
|
const text = buildModelSelectionMenuText(modelLists);
|
|
134
153
|
await replyWithInlineMenu(ctx, {
|
|
135
154
|
menuKind: "model",
|
|
@@ -142,3 +161,147 @@ export async function showModelSelectionMenu(ctx) {
|
|
|
142
161
|
await ctx.reply(t("model.menu.error"));
|
|
143
162
|
}
|
|
144
163
|
}
|
|
164
|
+
// ─── Model search handlers ────────────────────────────────────────────────
|
|
165
|
+
/**
|
|
166
|
+
* Handle the search button callback (model:search) from the inline menu.
|
|
167
|
+
* Transitions the interaction to text-input mode and prompts the user.
|
|
168
|
+
*/
|
|
169
|
+
export async function handleModelSearchCallback(ctx) {
|
|
170
|
+
const data = ctx.callbackQuery?.data;
|
|
171
|
+
if (!data) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
if (data !== MODEL_SEARCH_CALLBACK) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
const isActive = await ensureActiveInlineMenu(ctx, "model");
|
|
178
|
+
if (!isActive) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
await ctx.answerCallbackQuery().catch(() => { });
|
|
182
|
+
await ctx.deleteMessage().catch(() => { });
|
|
183
|
+
// Start a new interaction for search text input
|
|
184
|
+
// interactionManager.start() clears any existing interaction automatically
|
|
185
|
+
interactionManager.start({
|
|
186
|
+
kind: "custom",
|
|
187
|
+
expectedInput: "text",
|
|
188
|
+
metadata: {
|
|
189
|
+
flow: "model-search",
|
|
190
|
+
stage: "input",
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
await ctx.reply(t("model.search.prompt"));
|
|
194
|
+
logger.debug("[ModelHandler] Model search prompt shown");
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Handle text input for model search.
|
|
199
|
+
* Searches the full provider catalog and shows results (or "not found").
|
|
200
|
+
*/
|
|
201
|
+
export async function handleModelSearchTextInput(ctx) {
|
|
202
|
+
const meta = parseModelSearchMetadata();
|
|
203
|
+
if (!meta || meta.stage !== "input") {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
const text = ctx.message?.text;
|
|
207
|
+
if (!text) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
logger.debug(`[ModelHandler] Model search query: "${text}"`);
|
|
211
|
+
try {
|
|
212
|
+
const results = await searchModels(text);
|
|
213
|
+
const keyboard = new InlineKeyboard();
|
|
214
|
+
for (const model of results) {
|
|
215
|
+
const label = `${model.providerID}/${model.modelID}`;
|
|
216
|
+
keyboard.text(label, `model:${model.providerID}:${model.modelID}`).row();
|
|
217
|
+
}
|
|
218
|
+
keyboard.row();
|
|
219
|
+
keyboard.text(t("model.search.search_again"), MODEL_SEARCH_AGAIN_CALLBACK);
|
|
220
|
+
keyboard.text(t("inline.button.cancel"), MODEL_SEARCH_CANCEL_CALLBACK);
|
|
221
|
+
const replyText = results.length === 0
|
|
222
|
+
? t("model.search.no_results", { query: text })
|
|
223
|
+
: t("model.search.results_title", { query: text });
|
|
224
|
+
const sent = await ctx.reply(replyText, { reply_markup: keyboard });
|
|
225
|
+
// Transition to results stage (callback-only)
|
|
226
|
+
interactionManager.transition({
|
|
227
|
+
expectedInput: "callback",
|
|
228
|
+
metadata: {
|
|
229
|
+
flow: "model-search",
|
|
230
|
+
stage: "results",
|
|
231
|
+
messageId: sent.message_id,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
logger.error("[ModelHandler] Model search error:", err);
|
|
238
|
+
await ctx.reply(t("model.search.error"));
|
|
239
|
+
interactionManager.clear("model_search_error");
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Handle callbacks from the search results menu:
|
|
245
|
+
* - model:search:cancel — clears interaction, deletes message
|
|
246
|
+
* - model:search:again — delegates to handleModelSearchCallback
|
|
247
|
+
* - model:provider:model — selects the model from search results
|
|
248
|
+
*/
|
|
249
|
+
export async function handleModelSearchResults(ctx) {
|
|
250
|
+
const data = ctx.callbackQuery?.data;
|
|
251
|
+
if (!data) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
const meta = parseModelSearchMetadata();
|
|
255
|
+
if (!meta || meta.stage !== "results") {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
// Verify message ID matches to reject stale callbacks
|
|
259
|
+
const callbackMessageId = ctx.callbackQuery?.message?.message_id;
|
|
260
|
+
if (meta.messageId !== undefined && callbackMessageId !== meta.messageId) {
|
|
261
|
+
await ctx
|
|
262
|
+
.answerCallbackQuery({ text: t("inline.inactive_callback"), show_alert: true })
|
|
263
|
+
.catch(() => { });
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
// Cancel
|
|
267
|
+
if (data === MODEL_SEARCH_CANCEL_CALLBACK) {
|
|
268
|
+
interactionManager.clear("model_search_cancelled");
|
|
269
|
+
await ctx.answerCallbackQuery({ text: t("inline.cancelled_callback") }).catch(() => { });
|
|
270
|
+
await ctx.deleteMessage().catch(() => { });
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
// Search again — inline implementation
|
|
274
|
+
if (data === MODEL_SEARCH_AGAIN_CALLBACK) {
|
|
275
|
+
await ctx.answerCallbackQuery().catch(() => { });
|
|
276
|
+
await ctx.deleteMessage().catch(() => { });
|
|
277
|
+
interactionManager.start({
|
|
278
|
+
kind: "custom",
|
|
279
|
+
expectedInput: "text",
|
|
280
|
+
metadata: {
|
|
281
|
+
flow: "model-search",
|
|
282
|
+
stage: "input",
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
await ctx.reply(t("model.search.prompt"));
|
|
286
|
+
logger.debug("[ModelHandler] Model search prompt shown (search again)");
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
// Model selection from search results
|
|
290
|
+
if (data.startsWith("model:")) {
|
|
291
|
+
const parts = data.split(":");
|
|
292
|
+
if (parts.length < 3) {
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
const providerID = parts[1];
|
|
296
|
+
const modelID = parts.slice(2).join(":");
|
|
297
|
+
const modelInfo = {
|
|
298
|
+
providerID,
|
|
299
|
+
modelID,
|
|
300
|
+
variant: "default",
|
|
301
|
+
};
|
|
302
|
+
interactionManager.clear("model_search_selected");
|
|
303
|
+
await applyModelSelectionAndNotify(ctx, modelInfo);
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
package/dist/bot/index.js
CHANGED
|
@@ -25,13 +25,14 @@ import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./com
|
|
|
25
25
|
import { handleTaskCallback, handleTaskTextInput, taskCommand } from "./commands/task.js";
|
|
26
26
|
import { handleTaskListCallback, taskListCommand } from "./commands/tasklist.js";
|
|
27
27
|
import { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
|
|
28
|
+
import { handleMessagesCallback, messagesCommand } from "./commands/messages.js";
|
|
28
29
|
import { skillsCommand, handleSkillsCallback, handleSkillTextArguments, } from "./commands/skills.js";
|
|
29
30
|
import { mcpsCommand, handleMcpsCallback } from "./commands/mcps.js";
|
|
30
31
|
import { ttsCommand } from "./commands/tts.js";
|
|
31
32
|
import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
|
|
32
33
|
import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
|
|
33
34
|
import { handleAgentSelect, showAgentSelectionMenu } from "./handlers/agent.js";
|
|
34
|
-
import { handleModelSelect, showModelSelectionMenu } from "./handlers/model.js";
|
|
35
|
+
import { handleModelSelect, handleModelSearchCallback, handleModelSearchResults, handleModelSearchTextInput, showModelSelectionMenu, } from "./handlers/model.js";
|
|
35
36
|
import { handleVariantSelect, showVariantSelectionMenu } from "./handlers/variant.js";
|
|
36
37
|
import { handleContextButtonPress, handleCompactConfirm } from "./handlers/context.js";
|
|
37
38
|
import { handleInlineMenuCancel } from "./handlers/inline-menu.js";
|
|
@@ -59,12 +60,12 @@ import { handleVoiceMessage } from "./handlers/voice.js";
|
|
|
59
60
|
import { handleDocumentMessage } from "./handlers/document.js";
|
|
60
61
|
import { createMediaGroupAttachmentMiddleware } from "./handlers/media-group.js";
|
|
61
62
|
import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
|
|
62
|
-
import { reconcileBusyState } from "./utils/busy-reconciliation.js";
|
|
63
|
+
import { reconcileBusyState, setResponseStreamerForReconciliation } from "./utils/busy-reconciliation.js";
|
|
63
64
|
import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
|
|
64
65
|
import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
|
|
65
66
|
import { deliverThinkingMessage } from "./utils/thinking-message.js";
|
|
66
67
|
import { shouldSuppressUserAbortSessionError } from "./utils/abort-error-suppression.js";
|
|
67
|
-
import { editRenderedBotPart, getTelegramRenderedPartSignature, sendRenderedBotPart, } from "./utils/telegram-text.js";
|
|
68
|
+
import { completeDraftPart, editRenderedBotPart, getTelegramRenderedPartSignature, sendDraftBotPart, sendRenderedBotPart, } from "./utils/telegram-text.js";
|
|
68
69
|
import { formatAssistantRunFooter } from "./utils/assistant-run-footer.js";
|
|
69
70
|
import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
|
|
70
71
|
import { getStoredModel } from "../model/manager.js";
|
|
@@ -86,6 +87,7 @@ let heartbeatTimer = null;
|
|
|
86
87
|
let unsubscribeReadyRestore = null;
|
|
87
88
|
const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
|
|
88
89
|
const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
|
|
90
|
+
const RESPONSE_STREAMING_MODE = config.bot.responseStreamingMode;
|
|
89
91
|
const RESPONSE_STREAM_TEXT_LIMIT = 3800;
|
|
90
92
|
const SESSION_RETRY_PREFIX = "🔁";
|
|
91
93
|
const SUBAGENT_STREAM_PREFIX = "🧩";
|
|
@@ -168,56 +170,105 @@ const toolMessageBatcher = new ToolMessageBatcher({
|
|
|
168
170
|
}
|
|
169
171
|
},
|
|
170
172
|
});
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
173
|
+
let nextDraftId = 1;
|
|
174
|
+
function getNextDraftId() {
|
|
175
|
+
const id = nextDraftId;
|
|
176
|
+
nextDraftId += 1;
|
|
177
|
+
return id;
|
|
178
|
+
}
|
|
179
|
+
const responseStreamer = RESPONSE_STREAMING_MODE === "draft"
|
|
180
|
+
? new ResponseStreamer({
|
|
181
|
+
throttleMs: RESPONSE_STREAM_THROTTLE_MS,
|
|
182
|
+
sendPart: async (part) => {
|
|
183
|
+
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
|
|
184
|
+
throw new Error("Bot context missing for draft send");
|
|
185
|
+
}
|
|
186
|
+
const draftId = getNextDraftId();
|
|
187
|
+
const result = await sendDraftBotPart({
|
|
188
|
+
api: botInstance.api,
|
|
189
|
+
chatId: chatIdInstance,
|
|
190
|
+
draftId,
|
|
191
|
+
part,
|
|
192
|
+
});
|
|
193
|
+
return { messageId: draftId, deliveredSignature: result.deliveredSignature };
|
|
194
|
+
},
|
|
195
|
+
editPart: async (messageId, part) => {
|
|
196
|
+
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
|
|
197
|
+
throw new Error("Bot context missing for draft edit");
|
|
198
|
+
}
|
|
199
|
+
return sendDraftBotPart({
|
|
200
|
+
api: botInstance.api,
|
|
201
|
+
chatId: chatIdInstance,
|
|
202
|
+
draftId: messageId,
|
|
203
|
+
part,
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
deleteText: async () => {
|
|
207
|
+
// Drafts are ephemeral — no need to delete
|
|
208
|
+
},
|
|
209
|
+
completePart: async (part, options) => {
|
|
210
|
+
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
|
|
211
|
+
throw new Error("Bot context missing for draft complete");
|
|
212
|
+
}
|
|
213
|
+
return completeDraftPart({
|
|
190
214
|
api: botInstance.api,
|
|
191
215
|
chatId: chatIdInstance,
|
|
192
|
-
messageId,
|
|
193
216
|
part,
|
|
194
217
|
options,
|
|
195
218
|
});
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
: new ResponseStreamer({
|
|
222
|
+
throttleMs: RESPONSE_STREAM_THROTTLE_MS,
|
|
223
|
+
sendPart: async (part, options) => {
|
|
224
|
+
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
|
|
225
|
+
throw new Error("Bot context missing for streamed send");
|
|
203
226
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
errorMessage.includes("message identifier is not specified")) {
|
|
215
|
-
return;
|
|
227
|
+
return sendRenderedBotPart({
|
|
228
|
+
api: botInstance.api,
|
|
229
|
+
chatId: chatIdInstance,
|
|
230
|
+
part,
|
|
231
|
+
options,
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
editPart: async (messageId, part, options) => {
|
|
235
|
+
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
|
|
236
|
+
throw new Error("Bot context missing for streamed edit");
|
|
216
237
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
238
|
+
try {
|
|
239
|
+
return await editRenderedBotPart({
|
|
240
|
+
api: botInstance.api,
|
|
241
|
+
chatId: chatIdInstance,
|
|
242
|
+
messageId,
|
|
243
|
+
part,
|
|
244
|
+
options,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
249
|
+
if (errorMessage.includes("message is not modified")) {
|
|
250
|
+
return {
|
|
251
|
+
deliveredSignature: getTelegramRenderedPartSignature(part),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
deleteText: async (messageId) => {
|
|
258
|
+
if (!botInstance || !chatIdInstance || chatIdInstance <= 0) {
|
|
259
|
+
throw new Error("Bot context missing for streamed delete");
|
|
260
|
+
}
|
|
261
|
+
await botInstance.api.deleteMessage(chatIdInstance, messageId).catch((error) => {
|
|
262
|
+
const errorMessage = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
263
|
+
if (errorMessage.includes("message to delete not found") ||
|
|
264
|
+
errorMessage.includes("message identifier is not specified")) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
throw error;
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
setResponseStreamerForReconciliation(responseStreamer);
|
|
221
272
|
const toolCallStreamer = new ToolCallStreamer({
|
|
222
273
|
throttleMs: RESPONSE_STREAM_THROTTLE_MS,
|
|
223
274
|
sendText: async (sessionId, text) => {
|
|
@@ -928,6 +979,7 @@ export function createBot() {
|
|
|
928
979
|
bot.command("open", openCommand);
|
|
929
980
|
bot.command("ls", lsCommand);
|
|
930
981
|
bot.command("sessions", sessionsCommand);
|
|
982
|
+
bot.command("messages", messagesCommand);
|
|
931
983
|
bot.command("new", (ctx) => newCommand(ctx, { bot, ensureEventSubscription }));
|
|
932
984
|
bot.command("abort", abortCommand);
|
|
933
985
|
bot.command("detach", detachCommand);
|
|
@@ -964,6 +1016,8 @@ export function createBot() {
|
|
|
964
1016
|
const handledQuestion = await handleQuestionCallback(ctx);
|
|
965
1017
|
const handledPermission = await handlePermissionCallback(ctx);
|
|
966
1018
|
const handledAgent = await handleAgentSelect(ctx);
|
|
1019
|
+
const handledModelSearch = await handleModelSearchCallback(ctx);
|
|
1020
|
+
const handledModelSearchResults = await handleModelSearchResults(ctx);
|
|
967
1021
|
const handledModel = await handleModelSelect(ctx);
|
|
968
1022
|
const handledVariant = await handleVariantSelect(ctx);
|
|
969
1023
|
const handledCompactConfirm = await handleCompactConfirm(ctx);
|
|
@@ -971,9 +1025,10 @@ export function createBot() {
|
|
|
971
1025
|
const handledTaskList = await handleTaskListCallback(ctx);
|
|
972
1026
|
const handledRenameCancel = await handleRenameCancel(ctx);
|
|
973
1027
|
const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
|
|
1028
|
+
const handledMessages = await handleMessagesCallback(ctx, { bot, ensureEventSubscription });
|
|
974
1029
|
const handledSkills = await handleSkillsCallback(ctx, { bot, ensureEventSubscription });
|
|
975
1030
|
const handledMcps = await handleMcpsCallback(ctx);
|
|
976
|
-
logger.debug(`[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, skills=${handledSkills}, mcps=${handledMcps}`);
|
|
1031
|
+
logger.debug(`[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, modelSearch=${handledModelSearch}, modelSearchResults=${handledModelSearchResults}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, messages=${handledMessages}, skills=${handledSkills}, mcps=${handledMcps}`);
|
|
977
1032
|
if (!handledBackgroundSession &&
|
|
978
1033
|
!handledInlineCancel &&
|
|
979
1034
|
!handledSession &&
|
|
@@ -984,6 +1039,8 @@ export function createBot() {
|
|
|
984
1039
|
!handledQuestion &&
|
|
985
1040
|
!handledPermission &&
|
|
986
1041
|
!handledAgent &&
|
|
1042
|
+
!handledModelSearch &&
|
|
1043
|
+
!handledModelSearchResults &&
|
|
987
1044
|
!handledModel &&
|
|
988
1045
|
!handledVariant &&
|
|
989
1046
|
!handledCompactConfirm &&
|
|
@@ -991,6 +1048,7 @@ export function createBot() {
|
|
|
991
1048
|
!handledTaskList &&
|
|
992
1049
|
!handledRenameCancel &&
|
|
993
1050
|
!handledCommands &&
|
|
1051
|
+
!handledMessages &&
|
|
994
1052
|
!handledSkills &&
|
|
995
1053
|
!handledMcps) {
|
|
996
1054
|
logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
|
|
@@ -1183,6 +1241,10 @@ export function createBot() {
|
|
|
1183
1241
|
if (handledTask) {
|
|
1184
1242
|
return;
|
|
1185
1243
|
}
|
|
1244
|
+
const handledModelSearchText = await handleModelSearchTextInput(ctx);
|
|
1245
|
+
if (handledModelSearchText) {
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1186
1248
|
const handledRename = await handleRenameTextAnswer(ctx);
|
|
1187
1249
|
if (handledRename) {
|
|
1188
1250
|
return;
|
|
@@ -56,12 +56,14 @@ export class ResponseStreamer {
|
|
|
56
56
|
sendPart;
|
|
57
57
|
editPart;
|
|
58
58
|
deleteText;
|
|
59
|
+
completePart;
|
|
59
60
|
states = new Map();
|
|
60
61
|
constructor(options) {
|
|
61
62
|
this.throttleMs = Math.max(0, Math.floor(options.throttleMs));
|
|
62
63
|
this.sendPart = options.sendPart;
|
|
63
64
|
this.editPart = options.editPart;
|
|
64
65
|
this.deleteText = options.deleteText;
|
|
66
|
+
this.completePart = options.completePart;
|
|
65
67
|
}
|
|
66
68
|
enqueue(sessionId, messageId, payload) {
|
|
67
69
|
const normalizedPayload = normalizePayload(payload);
|
|
@@ -105,8 +107,22 @@ export class ResponseStreamer {
|
|
|
105
107
|
let synced = true;
|
|
106
108
|
if (options?.flushFinal !== false) {
|
|
107
109
|
synced = await this.enqueueTask(state, () => this.flushState(state, "complete"));
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
}
|
|
111
|
+
if (synced && this.completePart && state.latestPayload) {
|
|
112
|
+
try {
|
|
113
|
+
const realMessageIds = [];
|
|
114
|
+
for (const part of state.latestPayload.parts) {
|
|
115
|
+
if (!part.text) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const result = await this.completePart(part, state.latestPayload.sendOptions);
|
|
119
|
+
realMessageIds.push(result.messageId);
|
|
120
|
+
}
|
|
121
|
+
state.telegramMessageIds = realMessageIds;
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
logger.error(`[ResponseStreamer] Failed to persist draft message: session=${sessionId}, message=${messageId}`, error);
|
|
125
|
+
synced = false;
|
|
110
126
|
}
|
|
111
127
|
}
|
|
112
128
|
const messageIds = [...state.telegramMessageIds];
|
|
@@ -144,6 +160,14 @@ export class ResponseStreamer {
|
|
|
144
160
|
logger.debug(`[ResponseStreamer] Cleared all streams: count=${count}, reason=${reason}`);
|
|
145
161
|
}
|
|
146
162
|
}
|
|
163
|
+
hasActiveStream(sessionId) {
|
|
164
|
+
for (const state of this.states.values()) {
|
|
165
|
+
if (state.sessionId === sessionId && !state.cancelled) {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
147
171
|
getOrCreateState(sessionId, messageId) {
|
|
148
172
|
const key = buildStateKey(sessionId, messageId);
|
|
149
173
|
const existing = this.states.get(key);
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
import { getAgentDisplayName } from "../../agent/types.js";
|
|
2
|
-
function
|
|
3
|
-
const safeElapsedMs = Math.max(0, elapsedMs);
|
|
4
|
-
|
|
2
|
+
function formatDuration(elapsedMs) {
|
|
3
|
+
const safeElapsedMs = Math.max(0, Math.round(elapsedMs));
|
|
4
|
+
const totalSeconds = Math.floor(safeElapsedMs / 1000);
|
|
5
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
6
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
7
|
+
const seconds = totalSeconds % 60;
|
|
8
|
+
const parts = [];
|
|
9
|
+
if (hours > 0)
|
|
10
|
+
parts.push(`${hours}h`);
|
|
11
|
+
if (minutes > 0)
|
|
12
|
+
parts.push(`${minutes}m`);
|
|
13
|
+
if (seconds > 0 || parts.length === 0)
|
|
14
|
+
parts.push(`${seconds}s`);
|
|
15
|
+
return parts.join(" ");
|
|
5
16
|
}
|
|
6
17
|
export function formatAssistantRunFooter({ agent, providerID, modelID, elapsedMs, }) {
|
|
7
18
|
const agentDisplay = getAgentDisplayName(agent);
|
|
8
|
-
return `${agentDisplay} · 🤖 ${providerID}/${modelID} · 🕒 ${
|
|
19
|
+
return `${agentDisplay} · 🤖 ${providerID}/${modelID} · 🕒 ${formatDuration(elapsedMs)}`;
|
|
9
20
|
}
|
|
@@ -10,6 +10,10 @@ const RECONCILE_MIN_INTERVAL_MS = 10_000;
|
|
|
10
10
|
const FOREGROUND_BUSY_RECONCILE_GRACE_MS = 2_000;
|
|
11
11
|
const inFlightDirectories = new Set();
|
|
12
12
|
const lastReconcileAtByDirectory = new Map();
|
|
13
|
+
let responseStreamerInstance = null;
|
|
14
|
+
export function setResponseStreamerForReconciliation(streamer) {
|
|
15
|
+
responseStreamerInstance = streamer;
|
|
16
|
+
}
|
|
13
17
|
function getReconciliationTargets(directory) {
|
|
14
18
|
const foregroundBusySessions = foregroundSessionState
|
|
15
19
|
.getBusySessions()
|
|
@@ -72,6 +76,10 @@ export async function reconcileBusyStateNow(directory, now = Date.now()) {
|
|
|
72
76
|
logger.debug(`[BusyReconciliation] Skipping fresh foreground busy state: session=${session.sessionId}, directory=${session.directory}, status=${status?.type ?? "not-found"}`);
|
|
73
77
|
continue;
|
|
74
78
|
}
|
|
79
|
+
if (responseStreamerInstance?.hasActiveStream(session.sessionId)) {
|
|
80
|
+
logger.debug(`[BusyReconciliation] Skipping clear, responseStreamer still active: session=${session.sessionId}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
75
83
|
logger.info(`[BusyReconciliation] Clearing stale foreground busy state: session=${session.sessionId}, directory=${session.directory}, status=${status?.type ?? "not-found"}`);
|
|
76
84
|
if (attachedSessionForDirectory?.sessionId !== session.sessionId) {
|
|
77
85
|
await markAttachedSessionIdle(session.sessionId);
|