@coinseeker/opencode-telegram-plugin 1.0.4 → 1.0.6

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/README.md CHANGED
@@ -15,10 +15,12 @@ Configure the npm package in `~/.config/opencode/opencode.json`:
15
15
 
16
16
  ```json
17
17
  {
18
- "plugin": ["@coinseeker/opencode-telegram-plugin"]
18
+ "plugin": ["@coinseeker/opencode-telegram-plugin@1.0.6"]
19
19
  }
20
20
  ```
21
21
 
22
+ Current stable version: `@coinseeker/opencode-telegram-plugin@1.0.6`.
23
+
22
24
  Restart OpenCode after editing the config. OpenCode resolves npm package plugins on startup.
23
25
 
24
26
  ## Configure Telegram
@@ -465,6 +465,37 @@ function loadConfig(opts) {
465
465
 
466
466
  // src/bot.ts
467
467
  import { Bot, GrammyError } from "grammy";
468
+
469
+ // src/lib/question-format.ts
470
+ function optionDescriptionText(question) {
471
+ const options = question.options.map((option, index) => {
472
+ const description = option.description.trim();
473
+ return description ? `${index + 1}. ${option.label}
474
+ > ${description}` : `${index + 1}. ${option.label}`;
475
+ });
476
+ return options.length > 0 ? `
477
+
478
+ ${options.join("\n")}` : "";
479
+ }
480
+ function questionText(question) {
481
+ const header = question.header ? `\u2753 ${question.header}` : "\u2753 Question";
482
+ return `${header}
483
+
484
+ ${question.question}${optionDescriptionText(question)}`;
485
+ }
486
+ function pendingQuestionText(questions, questionIndex) {
487
+ const question = questions[questionIndex];
488
+ const prefix = questions.length > 1 ? `Question ${questionIndex + 1}/${questions.length}
489
+
490
+ ` : "";
491
+ const allQuestions = questions.length > 1 ? `All questions:
492
+ ${questions.map((q, i) => `${i + 1}. ${q.header}: ${q.question}`).join("\n")}
493
+
494
+ ` : "";
495
+ return `${allQuestions}${prefix}${questionText(question)}`;
496
+ }
497
+
498
+ // src/bot.ts
468
499
  function createTelegramBot(opts) {
469
500
  const { config, stateStore, logger, polling } = opts;
470
501
  const bot = new Bot(config.botToken);
@@ -485,11 +516,13 @@ function createTelegramBot(opts) {
485
516
  activeChatId = newChatId;
486
517
  await stateStore.write({ chatId: newChatId, discoveredBy: process.pid });
487
518
  logger.info("chat_id discovered", { chatId: newChatId });
488
- await ctx.reply(`\u2705 Chat connected!
519
+ await ctx.reply(
520
+ `\u2705 Chat connected!
489
521
 
490
522
  Your chat_id: ${newChatId}
491
523
 
492
- This chat is now active for OpenCode notifications.`);
524
+ This chat is now active for OpenCode notifications.`
525
+ );
493
526
  }
494
527
  }
495
528
  await next();
@@ -497,7 +530,9 @@ This chat is now active for OpenCode notifications.`);
497
530
  bot.catch((err) => {
498
531
  const e = err.error;
499
532
  if (e instanceof GrammyError && e.error_code === 409) {
500
- logger.info("polling conflict (409) - another process took over", { description: e.description });
533
+ logger.info("polling conflict (409) - another process took over", {
534
+ description: e.description
535
+ });
501
536
  } else {
502
537
  logger.error("bot error", { error: String(e) });
503
538
  }
@@ -508,7 +543,8 @@ This chat is now active for OpenCode notifications.`);
508
543
  const messageId = ctx.callbackQuery.message?.message_id;
509
544
  const chatId = ctx.chat?.id;
510
545
  const userId = ctx.from?.id;
511
- if (!questionDispatcher || messageId === void 0 || chatId === void 0 || userId === void 0) return;
546
+ if (!questionDispatcher || messageId === void 0 || chatId === void 0 || userId === void 0)
547
+ return;
512
548
  await questionDispatcher.handleCallbackQuery(data, messageId, chatId, userId);
513
549
  });
514
550
  bot.callbackQuery(/^p:([^:]+):(o|a|r)$/, async (ctx) => {
@@ -563,17 +599,20 @@ This chat is now active for OpenCode notifications.`);
563
599
  return { message_id: result.message_id };
564
600
  },
565
601
  async sendQuestionWithKeyboard(question, callbackData) {
566
- const inlineKeyboard = question.options.map((option, index) => [{
567
- text: option.label,
568
- callback_data: callbackData[index] ?? ""
569
- }]);
602
+ const inlineKeyboard = question.options.map((option, index) => [
603
+ {
604
+ text: option.label,
605
+ callback_data: callbackData[index] ?? ""
606
+ }
607
+ ]);
570
608
  if (callbackData[question.options.length]) {
571
- inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data: callbackData[question.options.length] }]);
609
+ inlineKeyboard.push([
610
+ { text: "\u270F\uFE0F Custom answer", callback_data: callbackData[question.options.length] }
611
+ ]);
572
612
  }
573
- const header = question.header ? `\u2753 ${question.header}` : "\u2753 Question";
574
- return this.sendMessage(`${header}
575
-
576
- ${question.question}`, { reply_markup: { inline_keyboard: inlineKeyboard } });
613
+ return this.sendMessage(questionText(question), {
614
+ reply_markup: { inline_keyboard: inlineKeyboard }
615
+ });
577
616
  },
578
617
  async editMessage(messageId, text) {
579
618
  const chatId = await requireChatId("editMessage");
@@ -772,6 +811,10 @@ function shouldSuppressIdle(sessionID) {
772
811
  }
773
812
 
774
813
  // src/events/session-idle.ts
814
+ var ROOT_IDLE_RECHECK_DELAY_MS = 2500;
815
+ function sleep(ms) {
816
+ return new Promise((resolve) => setTimeout(resolve, ms));
817
+ }
775
818
  async function resolveParentID(sessionId, ctx) {
776
819
  const cachedParentID = ctx.sessionTitleService.getParentID(sessionId);
777
820
  if (cachedParentID !== void 0) return cachedParentID;
@@ -788,6 +831,19 @@ async function resolveParentID(sessionId, ctx) {
788
831
  return void 0;
789
832
  }
790
833
  }
834
+ async function hydrateDescendants(sessionId, ctx, seen = /* @__PURE__ */ new Set()) {
835
+ if (seen.has(sessionId)) return;
836
+ seen.add(sessionId);
837
+ try {
838
+ const result = await ctx.client.session.children({ path: { id: sessionId } });
839
+ for (const child of result.data ?? []) {
840
+ ctx.sessionTitleService.setSessionInfo(child);
841
+ await hydrateDescendants(child.id, ctx, seen);
842
+ }
843
+ } catch (err) {
844
+ ctx.logger.warn("session children fetch failed", { sessionId, error: String(err) });
845
+ }
846
+ }
791
847
  async function sendIdleNotification(sessionId, ctx) {
792
848
  if (shouldSuppressIdle(sessionId)) {
793
849
  ctx.logger.info("idle suppressed - session was aborted", { sessionId });
@@ -808,9 +864,21 @@ async function sendIdleNotification(sessionId, ctx) {
808
864
  async function flushDeferredParentIfReady(parentID, ctx) {
809
865
  if (!ctx.sessionTitleService.hasDeferredIdleNotification(parentID)) return;
810
866
  if (ctx.sessionTitleService.hasUnfinishedDescendants(parentID)) return;
867
+ if (ctx.sessionTitleService.getSessionStatus(parentID) !== "idle") {
868
+ ctx.sessionTitleService.clearDeferredIdleNotification(parentID);
869
+ ctx.logger.info("clearing deferred parent idle notification - parent resumed", { sessionId: parentID });
870
+ return;
871
+ }
811
872
  ctx.logger.info("sending deferred parent idle notification", { sessionId: parentID });
812
873
  await sendIdleNotification(parentID, ctx);
813
874
  }
875
+ async function deferParentIdleIfDescendantsRunning(sessionId, ctx) {
876
+ await hydrateDescendants(sessionId, ctx);
877
+ if (!ctx.sessionTitleService.hasUnfinishedDescendants(sessionId)) return false;
878
+ ctx.sessionTitleService.deferIdleNotification(sessionId);
879
+ ctx.logger.info("deferring parent idle notification - child sessions still running", { sessionId });
880
+ return true;
881
+ }
814
882
  async function handleSessionIdle(event, ctx) {
815
883
  const sessionId = event.properties.sessionID;
816
884
  ctx.sessionTitleService.setSessionStatus(sessionId, "idle");
@@ -823,9 +891,15 @@ async function handleSessionIdle(event, ctx) {
823
891
  if (parentID === void 0) {
824
892
  ctx.logger.warn("session parentID unknown; sending idle notification", { sessionId });
825
893
  }
826
- if (ctx.sessionTitleService.hasUnfinishedDescendants(sessionId)) {
827
- ctx.sessionTitleService.deferIdleNotification(sessionId);
828
- ctx.logger.info("deferring parent idle notification - child sessions still running", { sessionId });
894
+ if (await deferParentIdleIfDescendantsRunning(sessionId, ctx)) {
895
+ return;
896
+ }
897
+ await sleep(ctx.idleRecheckDelayMs ?? ROOT_IDLE_RECHECK_DELAY_MS);
898
+ if (ctx.sessionTitleService.getSessionStatus(sessionId) !== "idle") {
899
+ ctx.logger.info("idle notification skipped - session resumed during recheck delay", { sessionId });
900
+ return;
901
+ }
902
+ if (await deferParentIdleIfDescendantsRunning(sessionId, ctx)) {
829
903
  return;
830
904
  }
831
905
  await sendIdleNotification(sessionId, ctx);
@@ -1020,7 +1094,9 @@ function isQuestionInfo(value) {
1020
1094
  if (typeof value.question !== "string") return false;
1021
1095
  if (typeof value.header !== "string") return false;
1022
1096
  if (!Array.isArray(value.options)) return false;
1023
- return value.options.every((option) => typeof option === "object" && option !== null && isQuestionOption(option));
1097
+ return value.options.every(
1098
+ (option) => typeof option === "object" && option !== null && isQuestionOption(option)
1099
+ );
1024
1100
  }
1025
1101
  function isEventQuestionAsked(event) {
1026
1102
  if (event.type !== "question.asked") return false;
@@ -1029,15 +1105,20 @@ function isEventQuestionAsked(event) {
1029
1105
  if (typeof props.id !== "string") return false;
1030
1106
  if (typeof props.sessionID !== "string") return false;
1031
1107
  if (!Array.isArray(props.questions)) return false;
1032
- return props.questions.every((question) => typeof question === "object" && question !== null && isQuestionInfo(question));
1108
+ return props.questions.every(
1109
+ (question) => typeof question === "object" && question !== null && isQuestionInfo(question)
1110
+ );
1033
1111
  }
1034
1112
  function buildCallbackData2(shortHash, questionIndex, optionIndex) {
1035
1113
  const data = `q:${shortHash}:${questionIndex}:${optionIndex}`;
1036
- if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
1114
+ if (Buffer.byteLength(data, "utf8") > 64)
1115
+ throw new Error("Telegram callback_data exceeds 64 bytes");
1037
1116
  return data;
1038
1117
  }
1039
1118
  function callbackDataForQuestion(shortHash, questionIndex, question) {
1040
- const data = question.options.map((_, optionIndex) => buildCallbackData2(shortHash, questionIndex, optionIndex));
1119
+ const data = question.options.map(
1120
+ (_, optionIndex) => buildCallbackData2(shortHash, questionIndex, optionIndex)
1121
+ );
1041
1122
  if (question.custom !== false) data.push(buildCallbackData2(shortHash, questionIndex, "c"));
1042
1123
  return data;
1043
1124
  }
@@ -1049,39 +1130,44 @@ function selectedAnswers(pending, questionIndex) {
1049
1130
  }
1050
1131
  function questionInlineKeyboard(shortHash, questionIndex, question, selected) {
1051
1132
  const multiple = question.multiple === true;
1052
- const inlineKeyboard = question.options.map((option, optionIndex) => [{
1053
- text: multiple && selected.includes(option.label) ? `\u2705 ${option.label}` : option.label,
1054
- callback_data: buildCallbackData2(shortHash, questionIndex, optionIndex)
1055
- }]);
1133
+ const inlineKeyboard = question.options.map((option, optionIndex) => [
1134
+ {
1135
+ text: multiple && selected.includes(option.label) ? `\u2705 ${option.label}` : option.label,
1136
+ callback_data: buildCallbackData2(shortHash, questionIndex, optionIndex)
1137
+ }
1138
+ ]);
1056
1139
  if (question.custom !== false) {
1057
- inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData2(shortHash, questionIndex, "c") }]);
1140
+ inlineKeyboard.push([
1141
+ { text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData2(shortHash, questionIndex, "c") }
1142
+ ]);
1058
1143
  }
1059
1144
  if (multiple) {
1060
- inlineKeyboard.push([{ text: "\u2705 Done", callback_data: buildCallbackData2(shortHash, questionIndex, "d") }]);
1145
+ inlineKeyboard.push([
1146
+ { text: "\u2705 Done", callback_data: buildCallbackData2(shortHash, questionIndex, "d") }
1147
+ ]);
1061
1148
  }
1062
1149
  return inlineKeyboard;
1063
1150
  }
1064
1151
  function questionPromptText(pending, questionIndex) {
1065
- const question = pending.questions[questionIndex];
1066
- const prefix = pending.questions.length > 1 ? `Question ${questionIndex + 1}/${pending.questions.length}
1067
-
1068
- ` : "";
1069
- const allQuestions = pending.questions.length > 1 ? `All questions:
1070
- ${pending.questions.map((q, i) => `${i + 1}. ${q.header}: ${q.question}`).join("\n")}
1071
-
1072
- ` : "";
1073
- return `${allQuestions}${prefix}\u2753 ${question.header}
1074
-
1075
- ${question.question}`;
1152
+ return pendingQuestionText(pending.questions, questionIndex);
1076
1153
  }
1077
1154
  function answerSummary(questions, answers) {
1078
- return answers.map((answer, index) => `${index + 1}. ${questions[index]?.header ?? "Question"}: ${answer.join(", ") || "(empty)"}`).join("\n");
1155
+ return answers.map(
1156
+ (answer, index) => `${index + 1}. ${questions[index]?.header ?? "Question"}: ${answer.join(", ") || "(empty)"}`
1157
+ ).join("\n");
1079
1158
  }
1080
1159
  async function editPromptForQuestion(ctx, pending, shortHash, questionIndex) {
1081
1160
  const messageId = pending.telegramMessageIds[0];
1082
1161
  const question = pending.questions[questionIndex];
1083
- const inlineKeyboard = questionInlineKeyboard(shortHash, questionIndex, question, selectedAnswers(pending, questionIndex));
1084
- await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), { reply_markup: { inline_keyboard: inlineKeyboard } });
1162
+ const inlineKeyboard = questionInlineKeyboard(
1163
+ shortHash,
1164
+ questionIndex,
1165
+ question,
1166
+ selectedAnswers(pending, questionIndex)
1167
+ );
1168
+ await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), {
1169
+ reply_markup: { inline_keyboard: inlineKeyboard }
1170
+ });
1085
1171
  }
1086
1172
  async function completeIfReady(ctx, pending, shortHash) {
1087
1173
  const nextIndex = pending.answersInProgress.findIndex((answer) => answer === null);
@@ -1095,12 +1181,21 @@ async function completeIfReady(ctx, pending, shortHash) {
1095
1181
  const messageId = pending.telegramMessageIds[0];
1096
1182
  try {
1097
1183
  await ctx.replyToQuestion(pending.requestID, answers);
1098
- await ctx.bot.editMessageRemoveKeyboard(messageId, `\u2705 Answered:
1099
- ${answerSummary(pending.questions, answers)}`);
1100
- ctx.logger.info("question reply sent", { requestID: pending.requestID, sessionID: pending.sessionID });
1184
+ await ctx.bot.editMessageRemoveKeyboard(
1185
+ messageId,
1186
+ `\u2705 Answered:
1187
+ ${answerSummary(pending.questions, answers)}`
1188
+ );
1189
+ ctx.logger.info("question reply sent", {
1190
+ requestID: pending.requestID,
1191
+ sessionID: pending.sessionID
1192
+ });
1101
1193
  } catch (err) {
1102
1194
  await ctx.bot.editMessageRemoveKeyboard(messageId, "\u26A0\uFE0F Failed to send answer to opencode");
1103
- ctx.logger.error("failed to send question reply", { error: String(err), requestID: pending.requestID });
1195
+ ctx.logger.error("failed to send question reply", {
1196
+ error: String(err),
1197
+ requestID: pending.requestID
1198
+ });
1104
1199
  } finally {
1105
1200
  await ctx.pendingQuestions.deletePending(shortHash);
1106
1201
  }
@@ -1113,7 +1208,11 @@ async function expirePending2(ctx, shortHash, pending, messageId) {
1113
1208
  async function handleQuestionAsked(event, ctx) {
1114
1209
  const request = event.properties;
1115
1210
  if (request.questions.length === 0) return;
1116
- const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: `question.asked:${request.id}`, ttlMs: 5e3 });
1211
+ const claimed = await claimOnce({
1212
+ claimsDir: ctx.claimsDir,
1213
+ key: `question.asked:${request.id}`,
1214
+ ttlMs: 5e3
1215
+ });
1117
1216
  if (!claimed) return;
1118
1217
  const shortHash = createQuestionShortHash(request.id);
1119
1218
  const firstQuestion = request.questions[0];
@@ -1129,16 +1228,26 @@ async function handleQuestionAsked(event, ctx) {
1129
1228
  answersInProgress: request.questions.map(() => null)
1130
1229
  };
1131
1230
  try {
1132
- const message = request.questions.length === 1 && useSimpleQuestionKeyboard(firstQuestion) ? await ctx.bot.sendQuestionWithKeyboard(firstQuestion, callbackDataForQuestion(shortHash, 0, firstQuestion)) : await ctx.bot.sendMessage(questionPromptText(pending, 0), {
1231
+ const message = request.questions.length === 1 && useSimpleQuestionKeyboard(firstQuestion) ? await ctx.bot.sendQuestionWithKeyboard(
1232
+ firstQuestion,
1233
+ callbackDataForQuestion(shortHash, 0, firstQuestion)
1234
+ ) : await ctx.bot.sendMessage(questionPromptText(pending, 0), {
1133
1235
  reply_markup: {
1134
1236
  inline_keyboard: questionInlineKeyboard(shortHash, 0, firstQuestion, [])
1135
1237
  }
1136
1238
  });
1137
1239
  pending.telegramMessageIds = [message.message_id];
1138
1240
  await ctx.pendingQuestions.savePending(shortHash, pending);
1139
- ctx.logger.info("question prompt sent", { requestID: request.id, sessionID: request.sessionID, count: request.questions.length });
1241
+ ctx.logger.info("question prompt sent", {
1242
+ requestID: request.id,
1243
+ sessionID: request.sessionID,
1244
+ count: request.questions.length
1245
+ });
1140
1246
  } catch (err) {
1141
- ctx.logger.error("failed to send question prompt", { error: String(err), requestID: request.id });
1247
+ ctx.logger.error("failed to send question prompt", {
1248
+ error: String(err),
1249
+ requestID: request.id
1250
+ });
1142
1251
  }
1143
1252
  }
1144
1253
  function createQuestionDispatcher(ctx) {
@@ -1162,12 +1271,26 @@ function createQuestionDispatcher(ctx) {
1162
1271
  if (!question) return;
1163
1272
  if (selection === "c") {
1164
1273
  if (question.multiple === true) {
1165
- await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), { reply_markup: { inline_keyboard: [] } });
1274
+ await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), {
1275
+ reply_markup: { inline_keyboard: [] }
1276
+ });
1166
1277
  } else {
1167
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u270F\uFE0F Reply to the next message with your custom answer.");
1278
+ await ctx.bot.editMessageRemoveKeyboard(
1279
+ messageId,
1280
+ "\u270F\uFE0F Reply to the next message with your custom answer."
1281
+ );
1168
1282
  }
1169
- const prompt = await ctx.bot.replyWithForceReply("Type your custom answer", "Type your answer");
1170
- pending.awaitingCustomFor = { shortHash, questionIndex, chatId, userId, promptMessageId: prompt.message_id };
1283
+ const prompt = await ctx.bot.replyWithForceReply(
1284
+ "Type your custom answer",
1285
+ "Type your answer"
1286
+ );
1287
+ pending.awaitingCustomFor = {
1288
+ shortHash,
1289
+ questionIndex,
1290
+ chatId,
1291
+ userId,
1292
+ promptMessageId: prompt.message_id
1293
+ };
1171
1294
  await ctx.pendingQuestions.savePending(shortHash, pending);
1172
1295
  return;
1173
1296
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinseeker/opencode-telegram-plugin",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Control and monitor OpenCode from Telegram with notifications, question replies, and subagent-aware completion.",
5
5
  "type": "module",
6
6
  "main": "dist/telegram-remote.js",