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.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.startPermissionEventStream({
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
- console.error("Missing project for chat", { chatId, error });
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
- console.error("Failed to abort OpenCode session after timeout", error);
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
- console.error("Failed to send prompt to OpenCode", error);
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 parsed = parsePermissionCallback(data);
534
- if (!parsed) {
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
- const requestId = parsed.requestId;
542
- const permissionReply = parsed.reply;
543
- const pending = pendingPermissions.get(requestId);
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("Permission request not found.");
1023
+ await ctx.answerCbQuery("Question request not found.");
546
1024
  return;
547
1025
  }
548
- try {
549
- await opencode.replyToPermission(requestId, permissionReply, pending.directory);
550
- pendingPermissions.delete(requestId);
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
- catch (error) {
556
- console.error("Failed to reply to permission", error);
557
- await ctx.answerCbQuery("Failed to send response.");
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;