opencode-telegram-bridge 1.5.2 → 1.7.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/dist/bot-logic.d.ts +34 -0
- package/dist/bot-logic.d.ts.map +1 -1
- package/dist/bot-logic.js +62 -0
- package/dist/bot-logic.js.map +1 -1
- package/dist/bot.d.ts.map +1 -1
- package/dist/bot.js +597 -21
- package/dist/bot.js.map +1 -1
- package/dist/opencode.d.ts +31 -2
- package/dist/opencode.d.ts.map +1 -1
- package/dist/opencode.js +108 -7
- package/dist/opencode.js.map +1 -1
- package/docs/usage.md +2 -0
- package/package.json +1 -1
package/dist/bot.js
CHANGED
|
@@ -6,7 +6,7 @@ import { HOME_PROJECT_ALIAS } from "./projects.js";
|
|
|
6
6
|
import { splitTelegramMessage } from "./telegram.js";
|
|
7
7
|
import { DEFAULT_MAX_IMAGE_BYTES, TelegramImageTooLargeError, downloadTelegramFileAsAttachment, downloadTelegramImageAsAttachment, isImageDocument, isPdfDocument, pickLargestPhoto, } from "./telegram-image.js";
|
|
8
8
|
import { OpencodeModelCapabilityError, OpencodeModelModalitiesError, ProjectAliasNotFoundError, ProjectConfigurationError, TelegramFileDownloadError, TelegramFileDownloadTimeoutError, } from "./errors.js";
|
|
9
|
-
import { buildPermissionKeyboardSpec, buildPermissionSummary, formatCommandOutput, formatModelList, formatPermissionDecision, formatProjectList, formatUserLabel, isAuthorized, isCommandMessage, parseModelCommand, parsePermissionCallback, parseProjectCommand, } from "./bot-logic.js";
|
|
9
|
+
import { buildPermissionKeyboardSpec, buildPermissionSummary, formatCommandOutput, formatModelList, formatPermissionDecision, formatProjectList, formatStatusReply, formatUserLabel, isAuthorized, isCommandMessage, parseModelCommand, parsePermissionCallback, parseQuestionCallback, parseProjectCommand, } from "./bot-logic.js";
|
|
10
10
|
const execAsync = promisify(exec);
|
|
11
11
|
export const toTelegrafInlineKeyboard = (spec) => {
|
|
12
12
|
const buttons = spec.buttons.map((button) => Markup.button.callback(button.text, button.data));
|
|
@@ -16,6 +16,25 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
16
16
|
const bot = new Telegraf(config.botToken, {
|
|
17
17
|
handlerTimeout: config.handlerTimeoutMs,
|
|
18
18
|
});
|
|
19
|
+
const serializeError = (error) => {
|
|
20
|
+
if (error instanceof Error) {
|
|
21
|
+
return {
|
|
22
|
+
name: error.name,
|
|
23
|
+
message: error.message,
|
|
24
|
+
stack: error.stack,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return { message: String(error) };
|
|
28
|
+
};
|
|
29
|
+
const logEvent = (level, event, context = {}) => {
|
|
30
|
+
const payload = {
|
|
31
|
+
ts: new Date().toISOString(),
|
|
32
|
+
event,
|
|
33
|
+
...context,
|
|
34
|
+
};
|
|
35
|
+
// One JSON object per line makes post-mortem analysis easier.
|
|
36
|
+
console[level](JSON.stringify(payload));
|
|
37
|
+
};
|
|
19
38
|
/*
|
|
20
39
|
* Telegraf wraps each update handler in a timeout. When that timeout fires,
|
|
21
40
|
* it logs an error but does not cancel the async handler. To avoid dangling
|
|
@@ -24,6 +43,8 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
24
43
|
*/
|
|
25
44
|
const promptGuard = createPromptGuard(config.promptTimeoutMs);
|
|
26
45
|
const pendingPermissions = new Map();
|
|
46
|
+
const pendingQuestions = new Map();
|
|
47
|
+
const pendingQuestionsByChat = new Map();
|
|
27
48
|
const sendReply = async (chatId, replyToMessageId, text) => {
|
|
28
49
|
try {
|
|
29
50
|
const chunks = splitTelegramMessage(text);
|
|
@@ -38,6 +59,193 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
38
59
|
console.error("Failed to send Telegram reply", error);
|
|
39
60
|
}
|
|
40
61
|
};
|
|
62
|
+
const getPendingQuestionForChat = (chatId) => {
|
|
63
|
+
const requestId = pendingQuestionsByChat.get(chatId);
|
|
64
|
+
if (!requestId) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return pendingQuestions.get(requestId) ?? null;
|
|
68
|
+
};
|
|
69
|
+
const truncate = (value, maxLength) => {
|
|
70
|
+
if (value.length <= maxLength) {
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
return `${value.slice(0, maxLength)}...`;
|
|
74
|
+
};
|
|
75
|
+
const buildQuestionPromptText = (pending) => {
|
|
76
|
+
const { request, currentIndex, answers } = pending;
|
|
77
|
+
const questions = request.questions;
|
|
78
|
+
const question = questions[currentIndex];
|
|
79
|
+
if (!question) {
|
|
80
|
+
return "OpenCode asked a question, but the question payload was missing.";
|
|
81
|
+
}
|
|
82
|
+
const header = (question.header ?? "").trim();
|
|
83
|
+
const prompt = (question.question ?? "").trim();
|
|
84
|
+
const isMultiple = Boolean(question.multiple);
|
|
85
|
+
const customDisabled = question.custom === false;
|
|
86
|
+
const lines = [
|
|
87
|
+
`OpenCode question (${currentIndex + 1}/${questions.length})`,
|
|
88
|
+
...(header ? [header] : []),
|
|
89
|
+
...(prompt ? [prompt] : []),
|
|
90
|
+
];
|
|
91
|
+
const selected = new Set(Array.isArray(answers[currentIndex]) ? answers[currentIndex] : []);
|
|
92
|
+
const options = question.options ?? [];
|
|
93
|
+
if (options.length > 0) {
|
|
94
|
+
lines.push("", "Options:");
|
|
95
|
+
options.forEach((option, index) => {
|
|
96
|
+
const label = truncate(String(option.label ?? "").trim(), 120);
|
|
97
|
+
const description = truncate(String(option.description ?? "").trim(), 240);
|
|
98
|
+
const prefix = isMultiple ? (selected.has(option.label) ? "[x]" : "[ ]") : "";
|
|
99
|
+
const prefixWithSpace = prefix ? `${prefix} ` : "";
|
|
100
|
+
const suffix = description ? ` - ${description}` : "";
|
|
101
|
+
lines.push(`${prefixWithSpace}${index + 1}) ${label}${suffix}`);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (isMultiple) {
|
|
105
|
+
lines.push("", "This question allows selecting multiple options. Tap options to toggle, then press Next.");
|
|
106
|
+
}
|
|
107
|
+
if (customDisabled) {
|
|
108
|
+
lines.push("", "Custom answers are disabled for this question.");
|
|
109
|
+
}
|
|
110
|
+
lines.push("", "If you don't choose any of the options, your next message will be treated as the answer to the question.");
|
|
111
|
+
// Keep some headroom under Telegram's 4096 character limit.
|
|
112
|
+
return truncate(lines.join("\n"), 3800);
|
|
113
|
+
};
|
|
114
|
+
const chunk = (items, size) => {
|
|
115
|
+
if (size <= 0) {
|
|
116
|
+
return [items];
|
|
117
|
+
}
|
|
118
|
+
const rows = [];
|
|
119
|
+
for (let index = 0; index < items.length; index += size) {
|
|
120
|
+
rows.push(items.slice(index, index + size));
|
|
121
|
+
}
|
|
122
|
+
return rows;
|
|
123
|
+
};
|
|
124
|
+
const buildQuestionKeyboardRows = (pending) => {
|
|
125
|
+
const requestId = pending.request.id;
|
|
126
|
+
const question = pending.request.questions[pending.currentIndex];
|
|
127
|
+
if (!question) {
|
|
128
|
+
return [[{ text: "Cancel", data: `q:${requestId}:cancel` }]];
|
|
129
|
+
}
|
|
130
|
+
const optionButtons = (question.options ?? []).map((_, index) => ({
|
|
131
|
+
text: String(index + 1),
|
|
132
|
+
data: `q:${requestId}:opt:${index}`,
|
|
133
|
+
}));
|
|
134
|
+
const optionRows = chunk(optionButtons, 5);
|
|
135
|
+
const isMultiple = Boolean(question.multiple);
|
|
136
|
+
const isLastQuestion = pending.currentIndex >= pending.request.questions.length - 1;
|
|
137
|
+
const navigationRow = [];
|
|
138
|
+
if (isMultiple) {
|
|
139
|
+
navigationRow.push({
|
|
140
|
+
text: isLastQuestion ? "Submit" : "Next",
|
|
141
|
+
data: `q:${requestId}:next`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
navigationRow.push({ text: "Cancel", data: `q:${requestId}:cancel` });
|
|
145
|
+
return optionRows.length > 0 ? [...optionRows, navigationRow] : [navigationRow];
|
|
146
|
+
};
|
|
147
|
+
const toTelegrafInlineKeyboardRows = (rows) => {
|
|
148
|
+
const telegrafRows = rows.map((row) => row.map((button) => Markup.button.callback(button.text, button.data)));
|
|
149
|
+
return Markup.inlineKeyboard(telegrafRows);
|
|
150
|
+
};
|
|
151
|
+
const updateQuestionMessage = async (pending) => {
|
|
152
|
+
const text = buildQuestionPromptText(pending);
|
|
153
|
+
const replyMarkup = toTelegrafInlineKeyboardRows(buildQuestionKeyboardRows(pending));
|
|
154
|
+
await bot.telegram.editMessageText(pending.chatId, pending.messageId, undefined, text, {
|
|
155
|
+
reply_markup: replyMarkup.reply_markup,
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
const clearPendingQuestion = async (chatId, options) => {
|
|
159
|
+
const pending = getPendingQuestionForChat(chatId);
|
|
160
|
+
if (!pending) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
pendingQuestions.delete(pending.request.id);
|
|
164
|
+
pendingQuestionsByChat.delete(chatId);
|
|
165
|
+
if (options?.reject) {
|
|
166
|
+
try {
|
|
167
|
+
await opencode.rejectQuestion(pending.request.id, pending.directory);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
console.error("Failed to reject question", error);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (options?.reason) {
|
|
174
|
+
try {
|
|
175
|
+
await bot.telegram.editMessageText(pending.chatId, pending.messageId, undefined, `${buildQuestionPromptText(pending)}\n\nStatus: ${options.reason}`);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
console.error("Failed to update question status message", error);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const deletePendingQuestion = (pending) => {
|
|
183
|
+
pendingQuestions.delete(pending.request.id);
|
|
184
|
+
const current = pendingQuestionsByChat.get(pending.chatId);
|
|
185
|
+
if (current === pending.request.id) {
|
|
186
|
+
pendingQuestionsByChat.delete(pending.chatId);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const collectQuestionAnswers = (pending) => {
|
|
190
|
+
const collected = [];
|
|
191
|
+
for (const answer of pending.answers) {
|
|
192
|
+
if (!answer || answer.length === 0) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
collected.push(answer);
|
|
196
|
+
}
|
|
197
|
+
return collected;
|
|
198
|
+
};
|
|
199
|
+
const advanceQuestionOrSubmit = async (pending) => {
|
|
200
|
+
const isLast = pending.currentIndex >= pending.request.questions.length - 1;
|
|
201
|
+
if (!isLast) {
|
|
202
|
+
pending.currentIndex += 1;
|
|
203
|
+
await updateQuestionMessage(pending);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const answers = collectQuestionAnswers(pending);
|
|
207
|
+
if (!answers) {
|
|
208
|
+
throw new Error("Missing answers for one or more questions");
|
|
209
|
+
}
|
|
210
|
+
await opencode.replyToQuestion(pending.request.id, answers, pending.directory);
|
|
211
|
+
deletePendingQuestion(pending);
|
|
212
|
+
try {
|
|
213
|
+
await bot.telegram.editMessageText(pending.chatId, pending.messageId, undefined, `${buildQuestionPromptText(pending)}\n\nStatus: Answer sent to OpenCode. Waiting for response...`);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
console.error("Failed to update question completion message", error);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const handleQuestionTextAnswer = async (chatId, replyToMessageId, text) => {
|
|
220
|
+
const pending = getPendingQuestionForChat(chatId);
|
|
221
|
+
if (!pending) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
const question = pending.request.questions[pending.currentIndex];
|
|
225
|
+
if (!question) {
|
|
226
|
+
await sendReply(chatId, replyToMessageId, "Question not available.");
|
|
227
|
+
deletePendingQuestion(pending);
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
if (question.custom === false) {
|
|
231
|
+
await sendReply(chatId, replyToMessageId, "Please choose one of the options for this question.");
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
const trimmed = text.trim();
|
|
235
|
+
if (!trimmed) {
|
|
236
|
+
await sendReply(chatId, replyToMessageId, "Answer cannot be empty.");
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
pending.answers[pending.currentIndex] = [trimmed];
|
|
240
|
+
try {
|
|
241
|
+
await advanceQuestionOrSubmit(pending);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
console.error("Failed to reply to question", error);
|
|
245
|
+
await sendReply(chatId, replyToMessageId, "Failed to send answer to OpenCode.");
|
|
246
|
+
}
|
|
247
|
+
return true;
|
|
248
|
+
};
|
|
41
249
|
const buildBotCommands = () => {
|
|
42
250
|
const commands = [
|
|
43
251
|
{ command: "start", description: "Confirm the bot is online" },
|
|
@@ -46,6 +254,7 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
46
254
|
description: "Manage project aliases (list/current/add/remove/set)",
|
|
47
255
|
},
|
|
48
256
|
{ command: "model", description: "Show, list, or set the active model" },
|
|
257
|
+
{ command: "status", description: "Show project/model/session status" },
|
|
49
258
|
{ command: "reset", description: "Reset the active project session" },
|
|
50
259
|
{ command: "abort", description: "Abort the in-flight prompt" },
|
|
51
260
|
];
|
|
@@ -94,7 +303,7 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
94
303
|
};
|
|
95
304
|
}
|
|
96
305
|
};
|
|
97
|
-
opencode.
|
|
306
|
+
opencode.startEventStream({
|
|
98
307
|
onPermissionAsked: async ({ request, directory }) => {
|
|
99
308
|
const owner = opencode.getSessionOwner(request.sessionID);
|
|
100
309
|
if (!owner) {
|
|
@@ -121,6 +330,51 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
121
330
|
console.error("Failed to send permission request", error);
|
|
122
331
|
}
|
|
123
332
|
},
|
|
333
|
+
onQuestionAsked: async ({ request, directory }) => {
|
|
334
|
+
const owner = opencode.getSessionOwner(request.sessionID);
|
|
335
|
+
if (!owner) {
|
|
336
|
+
console.warn("Question request for unknown session", {
|
|
337
|
+
sessionId: request.sessionID,
|
|
338
|
+
requestId: request.id,
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const existing = getPendingQuestionForChat(owner.chatId);
|
|
343
|
+
if (existing) {
|
|
344
|
+
console.warn("Question request while another question is pending", {
|
|
345
|
+
chatId: owner.chatId,
|
|
346
|
+
requestId: request.id,
|
|
347
|
+
existingRequestId: existing.request.id,
|
|
348
|
+
});
|
|
349
|
+
try {
|
|
350
|
+
await opencode.rejectQuestion(request.id, directory);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
console.error("Failed to reject unexpected question", error);
|
|
354
|
+
}
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const pending = {
|
|
358
|
+
chatId: owner.chatId,
|
|
359
|
+
messageId: 0,
|
|
360
|
+
directory,
|
|
361
|
+
request,
|
|
362
|
+
currentIndex: 0,
|
|
363
|
+
answers: Array.from({ length: request.questions.length }, () => null),
|
|
364
|
+
};
|
|
365
|
+
try {
|
|
366
|
+
const replyMarkup = toTelegrafInlineKeyboardRows(buildQuestionKeyboardRows(pending));
|
|
367
|
+
const message = await bot.telegram.sendMessage(owner.chatId, buildQuestionPromptText(pending), {
|
|
368
|
+
reply_markup: replyMarkup.reply_markup,
|
|
369
|
+
});
|
|
370
|
+
pending.messageId = message.message_id;
|
|
371
|
+
pendingQuestions.set(request.id, pending);
|
|
372
|
+
pendingQuestionsByChat.set(owner.chatId, request.id);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
console.error("Failed to send question request", error);
|
|
376
|
+
}
|
|
377
|
+
},
|
|
124
378
|
onError: (error) => {
|
|
125
379
|
console.error("OpenCode event stream error", error);
|
|
126
380
|
},
|
|
@@ -145,25 +399,70 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
145
399
|
void ctx.reply("Missing chat context.");
|
|
146
400
|
return;
|
|
147
401
|
}
|
|
402
|
+
const startedAt = Date.now();
|
|
148
403
|
let project;
|
|
149
404
|
try {
|
|
150
405
|
project = getProjectForChat(chatId);
|
|
151
406
|
}
|
|
152
407
|
catch (error) {
|
|
153
|
-
|
|
408
|
+
logEvent("error", "prompt.project_missing", {
|
|
409
|
+
chatId,
|
|
410
|
+
replyToMessageId,
|
|
411
|
+
userLabel,
|
|
412
|
+
error: serializeError(error),
|
|
413
|
+
});
|
|
154
414
|
void ctx.reply("Missing project configuration.");
|
|
155
415
|
return;
|
|
156
416
|
}
|
|
417
|
+
logEvent("log", "prompt.start", {
|
|
418
|
+
chatId,
|
|
419
|
+
replyToMessageId,
|
|
420
|
+
userLabel,
|
|
421
|
+
projectAlias: project.alias,
|
|
422
|
+
projectDir: project.path,
|
|
423
|
+
promptTimeoutMs: config.promptTimeoutMs,
|
|
424
|
+
hasPendingQuestion: Boolean(getPendingQuestionForChat(chatId)),
|
|
425
|
+
});
|
|
157
426
|
let timedOut = false;
|
|
158
427
|
const abortController = promptGuard.tryStart(chatId, replyToMessageId, ({ replyToMessageId: timeoutReplyToMessageId, sessionId }) => {
|
|
159
428
|
timedOut = true;
|
|
160
429
|
void (async () => {
|
|
430
|
+
const elapsedMs = Date.now() - startedAt;
|
|
431
|
+
logEvent("warn", "prompt.timeout", {
|
|
432
|
+
chatId,
|
|
433
|
+
replyToMessageId: timeoutReplyToMessageId,
|
|
434
|
+
sessionId,
|
|
435
|
+
projectAlias: project.alias,
|
|
436
|
+
projectDir: project.path,
|
|
437
|
+
elapsedMs,
|
|
438
|
+
promptTimeoutMs: config.promptTimeoutMs,
|
|
439
|
+
});
|
|
440
|
+
await clearPendingQuestion(chatId, { reason: "Timed out", reject: true });
|
|
161
441
|
if (!sessionId) {
|
|
442
|
+
logEvent("warn", "prompt.timeout.session_not_ready", {
|
|
443
|
+
chatId,
|
|
444
|
+
replyToMessageId: timeoutReplyToMessageId,
|
|
445
|
+
projectDir: project.path,
|
|
446
|
+
elapsedMs,
|
|
447
|
+
});
|
|
162
448
|
await sendReply(chatId, timeoutReplyToMessageId, "OpenCode request timed out. Nothing to abort yet (session not ready). You can send a new message.");
|
|
163
449
|
return;
|
|
164
450
|
}
|
|
165
451
|
try {
|
|
452
|
+
logEvent("log", "prompt.timeout.abort_attempt", {
|
|
453
|
+
chatId,
|
|
454
|
+
replyToMessageId: timeoutReplyToMessageId,
|
|
455
|
+
sessionId,
|
|
456
|
+
projectDir: project.path,
|
|
457
|
+
});
|
|
166
458
|
const aborted = await opencode.abortSession(sessionId, project.path);
|
|
459
|
+
logEvent("log", "prompt.timeout.abort_result", {
|
|
460
|
+
chatId,
|
|
461
|
+
replyToMessageId: timeoutReplyToMessageId,
|
|
462
|
+
sessionId,
|
|
463
|
+
projectDir: project.path,
|
|
464
|
+
aborted,
|
|
465
|
+
});
|
|
167
466
|
if (aborted) {
|
|
168
467
|
await sendReply(chatId, timeoutReplyToMessageId, "OpenCode request timed out. Server-side prompt aborted. You can send a new message.");
|
|
169
468
|
return;
|
|
@@ -171,31 +470,85 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
171
470
|
await sendReply(chatId, timeoutReplyToMessageId, "OpenCode request timed out. Tried to abort the server-side prompt, but it was not aborted. You can send a new message.");
|
|
172
471
|
}
|
|
173
472
|
catch (error) {
|
|
174
|
-
|
|
473
|
+
logEvent("error", "prompt.timeout.abort_error", {
|
|
474
|
+
chatId,
|
|
475
|
+
replyToMessageId: timeoutReplyToMessageId,
|
|
476
|
+
sessionId,
|
|
477
|
+
projectDir: project.path,
|
|
478
|
+
error: serializeError(error),
|
|
479
|
+
});
|
|
175
480
|
await sendReply(chatId, timeoutReplyToMessageId, "OpenCode request timed out. Failed to abort the server-side prompt. You can send a new message.");
|
|
176
481
|
}
|
|
177
482
|
})();
|
|
178
483
|
});
|
|
179
484
|
if (!abortController) {
|
|
485
|
+
logEvent("warn", "prompt.blocked_in_flight", {
|
|
486
|
+
chatId,
|
|
487
|
+
replyToMessageId,
|
|
488
|
+
userLabel,
|
|
489
|
+
projectAlias: project.alias,
|
|
490
|
+
projectDir: project.path,
|
|
491
|
+
});
|
|
180
492
|
void sendReply(chatId, replyToMessageId, "Your previous message has not been replied to yet. This message will be ignored.");
|
|
181
493
|
return;
|
|
182
494
|
}
|
|
183
495
|
void (async () => {
|
|
184
496
|
try {
|
|
185
497
|
const resolvedInput = typeof input === "function" ? await input() : input;
|
|
498
|
+
logEvent("log", "prompt.input", {
|
|
499
|
+
chatId,
|
|
500
|
+
replyToMessageId,
|
|
501
|
+
projectDir: project.path,
|
|
502
|
+
textLength: resolvedInput.text.length,
|
|
503
|
+
fileCount: resolvedInput.files?.length ?? 0,
|
|
504
|
+
fileMimes: resolvedInput.files?.map((file) => file.mime) ?? [],
|
|
505
|
+
});
|
|
186
506
|
if (abortController.signal.aborted) {
|
|
507
|
+
logEvent("warn", "prompt.aborted_before_session", {
|
|
508
|
+
chatId,
|
|
509
|
+
replyToMessageId,
|
|
510
|
+
projectDir: project.path,
|
|
511
|
+
});
|
|
187
512
|
return;
|
|
188
513
|
}
|
|
189
514
|
const sessionId = await opencode.ensureSessionId(chatId, project.path);
|
|
190
515
|
promptGuard.setSessionId(chatId, abortController, sessionId);
|
|
516
|
+
logEvent("log", "prompt.session_ready", {
|
|
517
|
+
chatId,
|
|
518
|
+
replyToMessageId,
|
|
519
|
+
sessionId,
|
|
520
|
+
projectDir: project.path,
|
|
521
|
+
});
|
|
191
522
|
if (abortController.signal.aborted) {
|
|
523
|
+
logEvent("warn", "prompt.aborted_after_session", {
|
|
524
|
+
chatId,
|
|
525
|
+
replyToMessageId,
|
|
526
|
+
sessionId,
|
|
527
|
+
projectDir: project.path,
|
|
528
|
+
});
|
|
192
529
|
return;
|
|
193
530
|
}
|
|
194
531
|
const storedModel = chatModels.getModel(chatId, project.path);
|
|
195
532
|
const promptOptions = storedModel
|
|
196
533
|
? { signal: abortController.signal, model: storedModel, sessionId }
|
|
197
534
|
: { signal: abortController.signal, sessionId };
|
|
535
|
+
logEvent("log", "prompt.send", {
|
|
536
|
+
chatId,
|
|
537
|
+
replyToMessageId,
|
|
538
|
+
sessionId,
|
|
539
|
+
projectDir: project.path,
|
|
540
|
+
model: storedModel ?? null,
|
|
541
|
+
});
|
|
198
542
|
const result = await opencode.promptFromChat(chatId, resolvedInput, project.path, promptOptions);
|
|
543
|
+
logEvent("log", "prompt.success", {
|
|
544
|
+
chatId,
|
|
545
|
+
replyToMessageId,
|
|
546
|
+
sessionId,
|
|
547
|
+
projectDir: project.path,
|
|
548
|
+
elapsedMs: Date.now() - startedAt,
|
|
549
|
+
replyLength: result.reply.length,
|
|
550
|
+
returnedModel: result.model,
|
|
551
|
+
});
|
|
199
552
|
if (!storedModel && result.model) {
|
|
200
553
|
chatModels.setModel(chatId, project.path, result.model);
|
|
201
554
|
}
|
|
@@ -205,9 +558,22 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
205
558
|
}
|
|
206
559
|
catch (error) {
|
|
207
560
|
if (abortController.signal.aborted) {
|
|
561
|
+
logEvent("warn", "prompt.aborted", {
|
|
562
|
+
chatId,
|
|
563
|
+
replyToMessageId,
|
|
564
|
+
projectDir: project.path,
|
|
565
|
+
elapsedMs: Date.now() - startedAt,
|
|
566
|
+
error: serializeError(error),
|
|
567
|
+
});
|
|
208
568
|
return;
|
|
209
569
|
}
|
|
210
|
-
|
|
570
|
+
logEvent("error", "prompt.error", {
|
|
571
|
+
chatId,
|
|
572
|
+
replyToMessageId,
|
|
573
|
+
projectDir: project.path,
|
|
574
|
+
elapsedMs: Date.now() - startedAt,
|
|
575
|
+
error: serializeError(error),
|
|
576
|
+
});
|
|
211
577
|
if (!timedOut) {
|
|
212
578
|
if (error instanceof TelegramImageTooLargeError) {
|
|
213
579
|
await sendReply(chatId, replyToMessageId, error.message);
|
|
@@ -235,6 +601,14 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
235
601
|
}
|
|
236
602
|
finally {
|
|
237
603
|
promptGuard.finish(chatId);
|
|
604
|
+
logEvent("log", "prompt.finish", {
|
|
605
|
+
chatId,
|
|
606
|
+
replyToMessageId,
|
|
607
|
+
projectDir: project.path,
|
|
608
|
+
elapsedMs: Date.now() - startedAt,
|
|
609
|
+
timedOut,
|
|
610
|
+
aborted: abortController.signal.aborted,
|
|
611
|
+
});
|
|
238
612
|
}
|
|
239
613
|
})();
|
|
240
614
|
};
|
|
@@ -405,6 +779,85 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
405
779
|
await ctx.reply(message);
|
|
406
780
|
}
|
|
407
781
|
});
|
|
782
|
+
const getModelContextLimit = (providers, model) => {
|
|
783
|
+
const provider = providers.find((entry) => entry.id === model.providerID);
|
|
784
|
+
if (!provider) {
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
const info = provider.models[model.modelID];
|
|
788
|
+
if (!info || typeof info !== "object") {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
const limit = info.limit;
|
|
792
|
+
if (!limit || typeof limit !== "object") {
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
const context = limit.context;
|
|
796
|
+
if (typeof context !== "number" || !Number.isFinite(context)) {
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
return context;
|
|
800
|
+
};
|
|
801
|
+
bot.command("status", async (ctx) => {
|
|
802
|
+
if (!isAuthorized(ctx.from, config.allowedUserId)) {
|
|
803
|
+
await ctx.reply("Not authorized.");
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const chatId = ctx.chat?.id;
|
|
807
|
+
if (!chatId) {
|
|
808
|
+
console.warn("Missing chat id for incoming status command");
|
|
809
|
+
await ctx.reply("Missing chat context.");
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
let project;
|
|
813
|
+
try {
|
|
814
|
+
project = getProjectForChat(chatId);
|
|
815
|
+
}
|
|
816
|
+
catch (error) {
|
|
817
|
+
console.error("Missing project for chat", { chatId, error });
|
|
818
|
+
await ctx.reply("Missing project configuration.");
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const storedModel = chatModels.getModel(chatId, project.path);
|
|
822
|
+
const sessionId = opencode.getSessionId(chatId, project.path);
|
|
823
|
+
if (!sessionId) {
|
|
824
|
+
const base = formatStatusReply({
|
|
825
|
+
project,
|
|
826
|
+
model: storedModel,
|
|
827
|
+
sessionId: null,
|
|
828
|
+
tokens: null,
|
|
829
|
+
contextLimit: null,
|
|
830
|
+
});
|
|
831
|
+
await ctx.reply(`${base}\n\nNo OpenCode session yet. Send a message to start one.`);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
let stats = null;
|
|
835
|
+
try {
|
|
836
|
+
stats = await opencode.getLatestAssistantStats(sessionId, project.path);
|
|
837
|
+
}
|
|
838
|
+
catch (error) {
|
|
839
|
+
console.error("Failed to fetch session messages for /status", error);
|
|
840
|
+
}
|
|
841
|
+
const model = storedModel ?? stats?.model ?? null;
|
|
842
|
+
let contextLimit = null;
|
|
843
|
+
if (model) {
|
|
844
|
+
try {
|
|
845
|
+
const providers = await opencode.listModels(project.path);
|
|
846
|
+
contextLimit = getModelContextLimit(providers, model);
|
|
847
|
+
}
|
|
848
|
+
catch (error) {
|
|
849
|
+
console.error("Failed to fetch providers for /status", error);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const reply = formatStatusReply({
|
|
853
|
+
project,
|
|
854
|
+
model,
|
|
855
|
+
sessionId,
|
|
856
|
+
tokens: stats?.tokens ?? null,
|
|
857
|
+
contextLimit,
|
|
858
|
+
});
|
|
859
|
+
await ctx.reply(reply);
|
|
860
|
+
});
|
|
408
861
|
bot.command("reset", async (ctx) => {
|
|
409
862
|
if (!isAuthorized(ctx.from, config.allowedUserId)) {
|
|
410
863
|
await ctx.reply("Not authorized.");
|
|
@@ -449,6 +902,7 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
449
902
|
await ctx.reply("Missing project configuration.");
|
|
450
903
|
return;
|
|
451
904
|
}
|
|
905
|
+
await clearPendingQuestion(chatId, { reason: "Aborted", reject: true });
|
|
452
906
|
const aborted = promptGuard.abort(chatId);
|
|
453
907
|
if (!aborted) {
|
|
454
908
|
await ctx.reply("No in-flight prompt to abort.");
|
|
@@ -530,34 +984,138 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
530
984
|
return;
|
|
531
985
|
}
|
|
532
986
|
const data = callbackQuery.data;
|
|
533
|
-
const
|
|
534
|
-
|
|
987
|
+
const permissionParsed = parsePermissionCallback(data);
|
|
988
|
+
const questionParsed = permissionParsed ? null : parseQuestionCallback(data);
|
|
989
|
+
if (!permissionParsed && !questionParsed) {
|
|
535
990
|
return;
|
|
536
991
|
}
|
|
537
992
|
if (!isAuthorized(ctx.from, config.allowedUserId)) {
|
|
538
993
|
await ctx.answerCbQuery("Not authorized.");
|
|
539
994
|
return;
|
|
540
995
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
996
|
+
if (permissionParsed) {
|
|
997
|
+
const requestId = permissionParsed.requestId;
|
|
998
|
+
const permissionReply = permissionParsed.reply;
|
|
999
|
+
const pending = pendingPermissions.get(requestId);
|
|
1000
|
+
if (!pending) {
|
|
1001
|
+
await ctx.answerCbQuery("Permission request not found.");
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
await opencode.replyToPermission(requestId, permissionReply, pending.directory);
|
|
1006
|
+
pendingPermissions.delete(requestId);
|
|
1007
|
+
const decisionLabel = formatPermissionDecision(permissionReply);
|
|
1008
|
+
await bot.telegram.editMessageText(pending.chatId, pending.messageId, undefined, `${pending.summary}\nDecision: ${decisionLabel}`);
|
|
1009
|
+
await ctx.answerCbQuery("Response sent.");
|
|
1010
|
+
}
|
|
1011
|
+
catch (error) {
|
|
1012
|
+
console.error("Failed to reply to permission", error);
|
|
1013
|
+
await ctx.answerCbQuery("Failed to send response.");
|
|
1014
|
+
}
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const parsed = questionParsed;
|
|
1018
|
+
if (!parsed) {
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const pending = pendingQuestions.get(parsed.requestId);
|
|
544
1022
|
if (!pending) {
|
|
545
|
-
await ctx.answerCbQuery("
|
|
1023
|
+
await ctx.answerCbQuery("Question request not found.");
|
|
546
1024
|
return;
|
|
547
1025
|
}
|
|
548
|
-
|
|
549
|
-
await
|
|
550
|
-
|
|
551
|
-
const decisionLabel = formatPermissionDecision(permissionReply);
|
|
552
|
-
await bot.telegram.editMessageText(pending.chatId, pending.messageId, undefined, `${pending.summary}\nDecision: ${decisionLabel}`);
|
|
553
|
-
await ctx.answerCbQuery("Response sent.");
|
|
1026
|
+
if (ctx.chat?.id !== pending.chatId) {
|
|
1027
|
+
await ctx.answerCbQuery("Not authorized.");
|
|
1028
|
+
return;
|
|
554
1029
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
1030
|
+
if (parsed.action === "cancel") {
|
|
1031
|
+
try {
|
|
1032
|
+
await opencode.rejectQuestion(pending.request.id, pending.directory);
|
|
1033
|
+
}
|
|
1034
|
+
catch (error) {
|
|
1035
|
+
console.error("Failed to reject question", error);
|
|
1036
|
+
}
|
|
1037
|
+
deletePendingQuestion(pending);
|
|
1038
|
+
try {
|
|
1039
|
+
await bot.telegram.editMessageText(pending.chatId, pending.messageId, undefined, `${buildQuestionPromptText(pending)}\n\nStatus: Cancelled`);
|
|
1040
|
+
}
|
|
1041
|
+
catch (error) {
|
|
1042
|
+
console.error("Failed to update cancelled question message", error);
|
|
1043
|
+
}
|
|
1044
|
+
await ctx.answerCbQuery("Cancelled.");
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
const question = pending.request.questions[pending.currentIndex];
|
|
1048
|
+
if (!question) {
|
|
1049
|
+
await ctx.answerCbQuery("Question not available.");
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
const options = question.options ?? [];
|
|
1053
|
+
const optionLabels = new Set(options.map((option) => option.label));
|
|
1054
|
+
if (parsed.action === "option") {
|
|
1055
|
+
if (parsed.optionIndex >= options.length) {
|
|
1056
|
+
await ctx.answerCbQuery("Invalid option.");
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
const selectedOption = options[parsed.optionIndex];
|
|
1060
|
+
if (!selectedOption) {
|
|
1061
|
+
await ctx.answerCbQuery("Invalid option.");
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const selectedLabel = selectedOption.label;
|
|
1065
|
+
if (question.multiple) {
|
|
1066
|
+
const current = pending.answers[pending.currentIndex];
|
|
1067
|
+
const base = Array.isArray(current)
|
|
1068
|
+
? current.filter((value) => optionLabels.has(value))
|
|
1069
|
+
: [];
|
|
1070
|
+
const next = base.includes(selectedLabel)
|
|
1071
|
+
? base.filter((value) => value !== selectedLabel)
|
|
1072
|
+
: [...base, selectedLabel];
|
|
1073
|
+
pending.answers[pending.currentIndex] = next;
|
|
1074
|
+
try {
|
|
1075
|
+
await updateQuestionMessage(pending);
|
|
1076
|
+
await ctx.answerCbQuery("Updated.");
|
|
1077
|
+
}
|
|
1078
|
+
catch (error) {
|
|
1079
|
+
console.error("Failed to update question message", error);
|
|
1080
|
+
await ctx.answerCbQuery("Failed to update.");
|
|
1081
|
+
}
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
pending.answers[pending.currentIndex] = [selectedLabel];
|
|
1085
|
+
try {
|
|
1086
|
+
await advanceQuestionOrSubmit(pending);
|
|
1087
|
+
await ctx.answerCbQuery("Selected.");
|
|
1088
|
+
}
|
|
1089
|
+
catch (error) {
|
|
1090
|
+
console.error("Failed to submit question answer", error);
|
|
1091
|
+
await ctx.answerCbQuery("Failed to send answer.");
|
|
1092
|
+
}
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
if (parsed.action === "next") {
|
|
1096
|
+
if (!question.multiple) {
|
|
1097
|
+
await ctx.answerCbQuery("Select an option.");
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
const current = pending.answers[pending.currentIndex];
|
|
1101
|
+
const hasAnswer = Array.isArray(current) && current.length > 0;
|
|
1102
|
+
if (!hasAnswer) {
|
|
1103
|
+
await ctx.answerCbQuery(question.custom === false
|
|
1104
|
+
? "Select at least one option."
|
|
1105
|
+
: "Select at least one option or type an answer.");
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
try {
|
|
1109
|
+
await advanceQuestionOrSubmit(pending);
|
|
1110
|
+
await ctx.answerCbQuery("Sent.");
|
|
1111
|
+
}
|
|
1112
|
+
catch (error) {
|
|
1113
|
+
console.error("Failed to submit question answer", error);
|
|
1114
|
+
await ctx.answerCbQuery("Failed to send answer.");
|
|
1115
|
+
}
|
|
558
1116
|
}
|
|
559
1117
|
});
|
|
560
|
-
bot.on("text", (ctx) => {
|
|
1118
|
+
bot.on("text", async (ctx) => {
|
|
561
1119
|
if (!isAuthorized(ctx.from, config.allowedUserId)) {
|
|
562
1120
|
void ctx.reply("Not authorized.");
|
|
563
1121
|
return;
|
|
@@ -568,6 +1126,14 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
568
1126
|
const text = ctx.message.text;
|
|
569
1127
|
const userLabel = formatUserLabel(ctx.from);
|
|
570
1128
|
console.log(`[telegram] ${userLabel}: ${text}`);
|
|
1129
|
+
const chatId = ctx.chat?.id;
|
|
1130
|
+
const replyToMessageId = ctx.message?.message_id;
|
|
1131
|
+
if (chatId) {
|
|
1132
|
+
const handled = await handleQuestionTextAnswer(chatId, replyToMessageId, text);
|
|
1133
|
+
if (handled) {
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
571
1137
|
runPrompt(ctx, userLabel, { text });
|
|
572
1138
|
});
|
|
573
1139
|
bot.on("photo", (ctx) => {
|
|
@@ -581,6 +1147,11 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
581
1147
|
if (isCommandMessage(ctx.message)) {
|
|
582
1148
|
return;
|
|
583
1149
|
}
|
|
1150
|
+
const chatId = ctx.chat?.id;
|
|
1151
|
+
if (chatId && getPendingQuestionForChat(chatId)) {
|
|
1152
|
+
void ctx.reply("OpenCode is waiting for you to answer a question. Please reply with text or use the buttons.");
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
584
1155
|
const userLabel = formatUserLabel(ctx.from);
|
|
585
1156
|
const telegram = ctx.telegram;
|
|
586
1157
|
const photos = ctx.message.photo;
|
|
@@ -620,6 +1191,11 @@ export const startBot = (config, opencode, projects, chatProjects, chatModels) =
|
|
|
620
1191
|
if (isCommandMessage(ctx.message)) {
|
|
621
1192
|
return;
|
|
622
1193
|
}
|
|
1194
|
+
const chatId = ctx.chat?.id;
|
|
1195
|
+
if (chatId && getPendingQuestionForChat(chatId)) {
|
|
1196
|
+
void ctx.reply("OpenCode is waiting for you to answer a question. Please reply with text or use the buttons.");
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
623
1199
|
const userLabel = formatUserLabel(ctx.from);
|
|
624
1200
|
const telegram = ctx.telegram;
|
|
625
1201
|
const document = ctx.message.document;
|