@coinseeker/opencode-telegram-plugin 1.0.2 → 1.0.4

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
@@ -52,8 +52,9 @@ Keep this file private. Never commit or share your Telegram bot token.
52
52
  - Root session completion notifications.
53
53
  - Background subagent-aware completion: child session messages are suppressed and parent completion waits until children finish.
54
54
  - OpenCode question prompts via Telegram inline buttons.
55
+ - Multi-select question prompts with toggle buttons and **Done** submission.
55
56
  - Custom free-text answers from Telegram.
56
- - Permission alerts.
57
+ - Permission approve/reject buttons from Telegram.
57
58
  - Multi-session-safe Telegram polling through a file-lock leader model.
58
59
  - Log file output instead of stdout terminal spam.
59
60
 
@@ -5,9 +5,9 @@
5
5
 
6
6
  // src/telegram-remote.ts
7
7
  import { fileURLToPath } from "url";
8
- import { dirname as dirname3, join as join5 } from "path";
9
- import { tmpdir as tmpdir3 } from "os";
10
- import { createHash as createHash3 } from "crypto";
8
+ import { dirname as dirname4, join as join6 } from "path";
9
+ import { tmpdir as tmpdir4 } from "os";
10
+ import { createHash as createHash4 } from "crypto";
11
11
 
12
12
  // src/lib/logger.ts
13
13
  import { appendFile } from "fs/promises";
@@ -314,17 +314,103 @@ function createQuestionShortHash(requestID) {
314
314
  return createHash("sha256").update(requestID).digest("base64url").slice(0, 10);
315
315
  }
316
316
 
317
+ // src/lib/pending-permissions.ts
318
+ import { createHash as createHash2 } from "crypto";
319
+ import { mkdir as mkdir3, readFile as readFile4, readdir as readdir2, rename as rename3, unlink as unlink3, writeFile as writeFile3 } from "fs/promises";
320
+ import { tmpdir as tmpdir3 } from "os";
321
+ import { dirname as dirname3, join as join3 } from "path";
322
+ function hasCode4(err, code) {
323
+ return "code" in err && err.code === code;
324
+ }
325
+ function pendingFilePath2(dir, shortHash) {
326
+ return join3(dir, `${shortHash}.json`);
327
+ }
328
+ function parsePending2(text) {
329
+ const parsed = JSON.parse(text);
330
+ if (typeof parsed.requestID !== "string") throw new Error("Invalid pending permission: requestID");
331
+ if (typeof parsed.sessionID !== "string") throw new Error("Invalid pending permission: sessionID");
332
+ if (typeof parsed.title !== "string") throw new Error("Invalid pending permission: title");
333
+ if (typeof parsed.permission !== "string") throw new Error("Invalid pending permission: permission");
334
+ if (!Array.isArray(parsed.patterns)) throw new Error("Invalid pending permission: patterns");
335
+ if (!Array.isArray(parsed.always)) throw new Error("Invalid pending permission: always");
336
+ if (parsed.endpoint !== "request" && parsed.endpoint !== "session") throw new Error("Invalid pending permission: endpoint");
337
+ return parsed;
338
+ }
339
+ async function listPendingFiles2(dir) {
340
+ try {
341
+ const entries = await readdir2(dir, { withFileTypes: true });
342
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name);
343
+ } catch (err) {
344
+ if (err instanceof Error && hasCode4(err, "ENOENT")) return [];
345
+ throw err;
346
+ }
347
+ }
348
+ function shortHashFromFileName2(fileName) {
349
+ return fileName.slice(0, -".json".length);
350
+ }
351
+ function createPendingPermissionStore(opts) {
352
+ const dir = opts.baseDir ?? join3(tmpdir3(), `opencoder-telegram-pending-permissions-${opts.tokenHash}`);
353
+ return {
354
+ dir,
355
+ async savePending(shortHash, data) {
356
+ const filePath = pendingFilePath2(dir, shortHash);
357
+ await mkdir3(dirname3(filePath), { recursive: true });
358
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
359
+ await writeFile3(tmpPath, JSON.stringify(data, null, 2), "utf8");
360
+ await rename3(tmpPath, filePath);
361
+ },
362
+ async loadPending(shortHash) {
363
+ try {
364
+ return parsePending2(await readFile4(pendingFilePath2(dir, shortHash), "utf8"));
365
+ } catch (err) {
366
+ if (err instanceof Error && hasCode4(err, "ENOENT")) return void 0;
367
+ throw err;
368
+ }
369
+ },
370
+ async deletePending(shortHash) {
371
+ try {
372
+ await unlink3(pendingFilePath2(dir, shortHash));
373
+ } catch (err) {
374
+ if (!(err instanceof Error) || !hasCode4(err, "ENOENT")) throw err;
375
+ }
376
+ },
377
+ async findByRequestID(requestID) {
378
+ for (const fileName of await listPendingFiles2(dir)) {
379
+ const shortHash = shortHashFromFileName2(fileName);
380
+ const data = await this.loadPending(shortHash);
381
+ if (data?.requestID === requestID) return { shortHash, data };
382
+ }
383
+ return void 0;
384
+ },
385
+ async sweepExpired() {
386
+ const expired = [];
387
+ for (const fileName of await listPendingFiles2(dir)) {
388
+ const shortHash = shortHashFromFileName2(fileName);
389
+ const data = await this.loadPending(shortHash);
390
+ if (data && data.expiresAt < Date.now()) {
391
+ expired.push(data);
392
+ await this.deletePending(shortHash);
393
+ }
394
+ }
395
+ return expired;
396
+ }
397
+ };
398
+ }
399
+ function createPermissionShortHash(requestID) {
400
+ return createHash2("sha256").update(requestID).digest("base64url").slice(0, 10);
401
+ }
402
+
317
403
  // src/lib/env-loader.ts
318
404
  import { existsSync } from "fs";
319
405
  import { homedir as homedir2 } from "os";
320
- import { join as join3 } from "path";
406
+ import { join as join4 } from "path";
321
407
  import dotenv from "dotenv";
322
408
  function loadPluginEnv(opts) {
323
409
  const paths = [
324
- join3(opts.pluginDir, "../../.env"),
325
- join3(opts.pluginDir, "..", ".env"),
326
- join3(opts.pluginDir, ".env"),
327
- join3(opts.homeDir ?? homedir2(), ".config/opencode/telegram-remote/.env")
410
+ join4(opts.pluginDir, "../../.env"),
411
+ join4(opts.pluginDir, "..", ".env"),
412
+ join4(opts.pluginDir, ".env"),
413
+ join4(opts.homeDir ?? homedir2(), ".config/opencode/telegram-remote/.env")
328
414
  ];
329
415
  const loadedFrom = [];
330
416
  const values = {};
@@ -384,6 +470,7 @@ function createTelegramBot(opts) {
384
470
  const bot = new Bot(config.botToken);
385
471
  let activeChatId = opts.initialChatId;
386
472
  let questionDispatcher;
473
+ let permissionDispatcher;
387
474
  if (polling) {
388
475
  bot.use(async (ctx, next) => {
389
476
  const userId = ctx.from?.id;
@@ -415,7 +502,7 @@ This chat is now active for OpenCode notifications.`);
415
502
  logger.error("bot error", { error: String(e) });
416
503
  }
417
504
  });
418
- bot.callbackQuery(/^q:([^:]+):(\d+):(\d+|c)$/, async (ctx) => {
505
+ bot.callbackQuery(/^q:([^:]+):(\d+):(\d+|c|d)$/, async (ctx) => {
419
506
  await ctx.answerCallbackQuery();
420
507
  const data = ctx.callbackQuery.data;
421
508
  const messageId = ctx.callbackQuery.message?.message_id;
@@ -424,6 +511,13 @@ This chat is now active for OpenCode notifications.`);
424
511
  if (!questionDispatcher || messageId === void 0 || chatId === void 0 || userId === void 0) return;
425
512
  await questionDispatcher.handleCallbackQuery(data, messageId, chatId, userId);
426
513
  });
514
+ bot.callbackQuery(/^p:([^:]+):(o|a|r)$/, async (ctx) => {
515
+ await ctx.answerCallbackQuery();
516
+ const data = ctx.callbackQuery.data;
517
+ const messageId = ctx.callbackQuery.message?.message_id;
518
+ if (!permissionDispatcher || messageId === void 0) return;
519
+ await permissionDispatcher.handleCallbackQuery(data, messageId);
520
+ });
427
521
  bot.on("message:text", async (ctx) => {
428
522
  const replyToMessageId = ctx.message.reply_to_message?.message_id;
429
523
  const chatId = ctx.chat.id;
@@ -511,6 +605,9 @@ ${question.question}`, { reply_markup: { inline_keyboard: inlineKeyboard } });
511
605
  },
512
606
  setQuestionDispatcher(dispatcher) {
513
607
  questionDispatcher = dispatcher;
608
+ },
609
+ setPermissionDispatcher(dispatcher) {
610
+ permissionDispatcher = dispatcher;
514
611
  }
515
612
  };
516
613
  }
@@ -585,29 +682,29 @@ var SessionTitleService = class {
585
682
  };
586
683
 
587
684
  // src/lib/claim.ts
588
- import { mkdir as mkdir3, open as open2, readdir as readdir2, stat as stat2, unlink as unlink3 } from "fs/promises";
589
- import { join as join4 } from "path";
590
- import { createHash as createHash2 } from "crypto";
685
+ import { mkdir as mkdir4, open as open2, readdir as readdir3, stat as stat2, unlink as unlink4 } from "fs/promises";
686
+ import { join as join5 } from "path";
687
+ import { createHash as createHash3 } from "crypto";
591
688
  var DEFAULT_TTL_MS2 = 6e4;
592
689
  var sweptDirs = /* @__PURE__ */ new Set();
593
- function hasCode4(err, code) {
690
+ function hasCode5(err, code) {
594
691
  return "code" in err && err.code === code;
595
692
  }
596
693
  function claimPath(claimsDir, key) {
597
- const hash = createHash2("sha256").update(key).digest("hex").slice(0, 16);
598
- return join4(claimsDir, `${hash}.claim`);
694
+ const hash = createHash3("sha256").update(key).digest("hex").slice(0, 16);
695
+ return join5(claimsDir, `${hash}.claim`);
599
696
  }
600
697
  async function sweep(claimsDir, ttlMs) {
601
698
  if (sweptDirs.has(claimsDir)) return;
602
699
  sweptDirs.add(claimsDir);
603
700
  try {
604
- const entries = await readdir2(claimsDir, { withFileTypes: true });
701
+ const entries = await readdir3(claimsDir, { withFileTypes: true });
605
702
  await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith(".claim")).map(async (entry) => {
606
- const filePath = join4(claimsDir, entry.name);
703
+ const filePath = join5(claimsDir, entry.name);
607
704
  try {
608
705
  const fileStat = await stat2(filePath);
609
706
  if (Date.now() - fileStat.mtimeMs > ttlMs * 2) {
610
- await unlink3(filePath);
707
+ await unlink4(filePath);
611
708
  }
612
709
  } catch {
613
710
  }
@@ -626,20 +723,20 @@ async function createClaim(filePath) {
626
723
  }
627
724
  async function claimOnce(opts) {
628
725
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS2;
629
- await mkdir3(opts.claimsDir, { recursive: true });
726
+ await mkdir4(opts.claimsDir, { recursive: true });
630
727
  await sweep(opts.claimsDir, ttlMs);
631
728
  const filePath = claimPath(opts.claimsDir, opts.key);
632
729
  for (let attempt = 0; attempt < 2; attempt += 1) {
633
730
  try {
634
731
  return await createClaim(filePath);
635
732
  } catch (err) {
636
- if (!(err instanceof Error) || !hasCode4(err, "EEXIST")) throw err;
733
+ if (!(err instanceof Error) || !hasCode5(err, "EEXIST")) throw err;
637
734
  try {
638
735
  const fileStat = await stat2(filePath);
639
736
  if (Date.now() - fileStat.mtimeMs <= ttlMs || attempt === 1) return false;
640
- await unlink3(filePath);
737
+ await unlink4(filePath);
641
738
  } catch (statErr) {
642
- if (statErr instanceof Error && hasCode4(statErr, "ENOENT")) continue;
739
+ if (statErr instanceof Error && hasCode5(statErr, "ENOENT")) continue;
643
740
  return false;
644
741
  }
645
742
  }
@@ -764,28 +861,158 @@ async function handleSessionUpdated(event, ctx) {
764
861
  }
765
862
 
766
863
  // src/events/permission-updated.ts
767
- async function handlePermissionUpdated(event, ctx) {
768
- const permission = event.properties;
769
- const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: `permission.updated:${permission.id}` });
770
- if (!claimed) return;
771
- const sessionTitle = ctx.sessionTitleService.getSessionTitle(permission.sessionID);
864
+ var PERMISSION_EXPIRY_MS = 5 * 6e4;
865
+ var CALLBACK_RE = /^p:([^:]+):(o|a|r)$/;
866
+ function isStringArray(value) {
867
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
868
+ }
869
+ function isEventPermissionAsked(event) {
870
+ if (event.type !== "permission.asked") return false;
871
+ const props = event.properties;
872
+ if (!props) return false;
873
+ if (typeof props.id !== "string") return false;
874
+ if (typeof props.sessionID !== "string") return false;
875
+ if (typeof props.permission !== "string") return false;
876
+ if (!isStringArray(props.patterns)) return false;
877
+ if (!isStringArray(props.always)) return false;
878
+ return true;
879
+ }
880
+ function buildCallbackData(shortHash, reply) {
881
+ const data = `p:${shortHash}:${reply}`;
882
+ if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
883
+ return data;
884
+ }
885
+ function normalizeUpdated(permission) {
886
+ const pattern = permission.pattern === void 0 ? [] : Array.isArray(permission.pattern) ? permission.pattern : [permission.pattern];
887
+ return {
888
+ requestID: permission.id,
889
+ sessionID: permission.sessionID,
890
+ title: permission.title,
891
+ permission: permission.type,
892
+ patterns: pattern,
893
+ always: [],
894
+ endpoint: "session",
895
+ claimKey: `permission.updated:${permission.id}`
896
+ };
897
+ }
898
+ function normalizeAsked(permission) {
899
+ return {
900
+ requestID: permission.id,
901
+ sessionID: permission.sessionID,
902
+ title: permission.patterns.join(", ") || permission.permission,
903
+ permission: permission.permission,
904
+ patterns: permission.patterns,
905
+ always: permission.always,
906
+ endpoint: "request",
907
+ claimKey: `permission.asked:${permission.id}`
908
+ };
909
+ }
910
+ function permissionMessage(permission, sessionTitle) {
772
911
  const titleLine = sessionTitle ? `\u{1F4CB} ${sessionTitle}` : `Session: ${permission.sessionID}`;
773
- const message = `\u2753 Permission requested
912
+ const patterns = permission.patterns.length > 0 ? `
913
+ Patterns: ${permission.patterns.join(", ")}` : "";
914
+ const always = permission.always.length > 0 ? `
915
+ Always options: ${permission.always.join(", ")}` : "";
916
+ return `\u2753 Permission requested
774
917
 
775
918
  ${titleLine}
776
919
 
777
- Type: ${permission.type}
778
- Detail: ${permission.title}`;
920
+ Permission: ${permission.permission}
921
+ Detail: ${permission.title}${patterns}${always}`;
922
+ }
923
+ function permissionKeyboard(shortHash) {
924
+ return [
925
+ [{ text: "\u2705 Allow once", callback_data: buildCallbackData(shortHash, "o") }],
926
+ [{ text: "\u267B\uFE0F Always allow", callback_data: buildCallbackData(shortHash, "a") }],
927
+ [{ text: "\u274C Reject", callback_data: buildCallbackData(shortHash, "r") }]
928
+ ];
929
+ }
930
+ function replyFromSelection(selection) {
931
+ if (selection === "o") return "once";
932
+ if (selection === "a") return "always";
933
+ if (selection === "r") return "reject";
934
+ return void 0;
935
+ }
936
+ function replyLabel(reply) {
937
+ if (reply === "once") return "Allowed once";
938
+ if (reply === "always") return "Always allowed";
939
+ return "Rejected";
940
+ }
941
+ async function handleNormalizedPermission(permission, ctx) {
942
+ const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: permission.claimKey });
943
+ if (!claimed) return;
944
+ const shortHash = createPermissionShortHash(permission.requestID);
945
+ const sentAt = Date.now();
946
+ const rawSessionTitle = ctx.sessionTitleService.getSessionTitle(permission.sessionID);
947
+ const sessionTitle = rawSessionTitle === null ? void 0 : rawSessionTitle;
779
948
  try {
780
- await ctx.bot.sendMessage(message);
949
+ const message = await ctx.bot.sendMessage(permissionMessage(permission, sessionTitle), {
950
+ reply_markup: { inline_keyboard: permissionKeyboard(shortHash) }
951
+ });
952
+ const pending = {
953
+ requestID: permission.requestID,
954
+ sessionID: permission.sessionID,
955
+ title: permission.title,
956
+ permission: permission.permission,
957
+ patterns: permission.patterns,
958
+ always: permission.always,
959
+ sentAt,
960
+ expiresAt: sentAt + PERMISSION_EXPIRY_MS,
961
+ telegramMessageId: message.message_id,
962
+ endpoint: permission.endpoint
963
+ };
964
+ await ctx.pendingPermissions.savePending(shortHash, pending);
781
965
  } catch (err) {
782
966
  ctx.logger.error("failed to send permission notification", { error: String(err) });
783
967
  }
784
968
  }
969
+ async function expirePending(ctx, shortHash, pending, messageId) {
970
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Permission request expired");
971
+ await ctx.pendingPermissions.deletePending(shortHash);
972
+ ctx.logger.info("pending permission expired", { requestID: pending.requestID });
973
+ }
974
+ async function handlePermissionUpdated(event, ctx) {
975
+ await handleNormalizedPermission(normalizeUpdated(event.properties), ctx);
976
+ }
977
+ async function handlePermissionAsked(event, ctx) {
978
+ await handleNormalizedPermission(normalizeAsked(event.properties), ctx);
979
+ }
980
+ function createPermissionDispatcher(ctx) {
981
+ return {
982
+ async handleCallbackQuery(data, messageId) {
983
+ const match = CALLBACK_RE.exec(data);
984
+ if (!match) return;
985
+ const shortHash = match[1];
986
+ const reply = replyFromSelection(match[2]);
987
+ if (!reply) return;
988
+ const pending = await ctx.pendingPermissions.loadPending(shortHash);
989
+ if (!pending) {
990
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "This permission request has expired.");
991
+ return;
992
+ }
993
+ if (pending.expiresAt < Date.now()) {
994
+ await expirePending(ctx, shortHash, pending, messageId);
995
+ return;
996
+ }
997
+ try {
998
+ await ctx.replyToPermission(pending.requestID, pending.sessionID, reply, pending.endpoint);
999
+ await ctx.bot.editMessageRemoveKeyboard(messageId, `\u2705 Permission ${replyLabel(reply)}
1000
+
1001
+ ${pending.permission}: ${pending.title}`);
1002
+ ctx.logger.info("permission reply sent", { requestID: pending.requestID, sessionID: pending.sessionID, reply });
1003
+ } catch (err) {
1004
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "\u26A0\uFE0F Failed to send permission reply to opencode");
1005
+ ctx.logger.error("failed to send permission reply", { error: String(err), requestID: pending.requestID });
1006
+ } finally {
1007
+ await ctx.pendingPermissions.deletePending(shortHash);
1008
+ }
1009
+ }
1010
+ };
1011
+ }
785
1012
 
786
1013
  // src/events/question-asked.ts
787
1014
  var QUESTION_EXPIRY_MS = 5 * 6e4;
788
- var CALLBACK_RE = /^q:([^:]+):(\d+):(\d+|c)$/;
1015
+ var CALLBACK_RE2 = /^q:([^:]+):(\d+):(\d+|c|d)$/;
789
1016
  function isQuestionOption(value) {
790
1017
  return typeof value.label === "string" && typeof value.description === "string";
791
1018
  }
@@ -804,16 +1031,36 @@ function isEventQuestionAsked(event) {
804
1031
  if (!Array.isArray(props.questions)) return false;
805
1032
  return props.questions.every((question) => typeof question === "object" && question !== null && isQuestionInfo(question));
806
1033
  }
807
- function buildCallbackData(shortHash, questionIndex, optionIndex) {
1034
+ function buildCallbackData2(shortHash, questionIndex, optionIndex) {
808
1035
  const data = `q:${shortHash}:${questionIndex}:${optionIndex}`;
809
1036
  if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
810
1037
  return data;
811
1038
  }
812
1039
  function callbackDataForQuestion(shortHash, questionIndex, question) {
813
- const data = question.options.map((_, optionIndex) => buildCallbackData(shortHash, questionIndex, optionIndex));
814
- if (question.custom !== false) data.push(buildCallbackData(shortHash, questionIndex, "c"));
1040
+ const data = question.options.map((_, optionIndex) => buildCallbackData2(shortHash, questionIndex, optionIndex));
1041
+ if (question.custom !== false) data.push(buildCallbackData2(shortHash, questionIndex, "c"));
815
1042
  return data;
816
1043
  }
1044
+ function useSimpleQuestionKeyboard(question) {
1045
+ return question.multiple !== true;
1046
+ }
1047
+ function selectedAnswers(pending, questionIndex) {
1048
+ return pending.answersInProgress[questionIndex] ?? [];
1049
+ }
1050
+ function questionInlineKeyboard(shortHash, questionIndex, question, selected) {
1051
+ 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
+ }]);
1056
+ if (question.custom !== false) {
1057
+ inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData2(shortHash, questionIndex, "c") }]);
1058
+ }
1059
+ if (multiple) {
1060
+ inlineKeyboard.push([{ text: "\u2705 Done", callback_data: buildCallbackData2(shortHash, questionIndex, "d") }]);
1061
+ }
1062
+ return inlineKeyboard;
1063
+ }
817
1064
  function questionPromptText(pending, questionIndex) {
818
1065
  const question = pending.questions[questionIndex];
819
1066
  const prefix = pending.questions.length > 1 ? `Question ${questionIndex + 1}/${pending.questions.length}
@@ -833,13 +1080,7 @@ function answerSummary(questions, answers) {
833
1080
  async function editPromptForQuestion(ctx, pending, shortHash, questionIndex) {
834
1081
  const messageId = pending.telegramMessageIds[0];
835
1082
  const question = pending.questions[questionIndex];
836
- const inlineKeyboard = question.options.map((option, optionIndex) => [{
837
- text: option.label,
838
- callback_data: buildCallbackData(shortHash, questionIndex, optionIndex)
839
- }]);
840
- if (question.custom !== false) {
841
- inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData(shortHash, questionIndex, "c") }]);
842
- }
1083
+ const inlineKeyboard = questionInlineKeyboard(shortHash, questionIndex, question, selectedAnswers(pending, questionIndex));
843
1084
  await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), { reply_markup: { inline_keyboard: inlineKeyboard } });
844
1085
  }
845
1086
  async function completeIfReady(ctx, pending, shortHash) {
@@ -864,7 +1105,7 @@ ${answerSummary(pending.questions, answers)}`);
864
1105
  await ctx.pendingQuestions.deletePending(shortHash);
865
1106
  }
866
1107
  }
867
- async function expirePending(ctx, shortHash, pending, messageId) {
1108
+ async function expirePending2(ctx, shortHash, pending, messageId) {
868
1109
  await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Question expired");
869
1110
  await ctx.pendingQuestions.deletePending(shortHash);
870
1111
  ctx.logger.info("pending question expired", { requestID: pending.requestID });
@@ -888,12 +1129,9 @@ async function handleQuestionAsked(event, ctx) {
888
1129
  answersInProgress: request.questions.map(() => null)
889
1130
  };
890
1131
  try {
891
- const message = request.questions.length === 1 ? await ctx.bot.sendQuestionWithKeyboard(firstQuestion, callbackDataForQuestion(shortHash, 0, firstQuestion)) : await ctx.bot.sendMessage(questionPromptText(pending, 0), {
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), {
892
1133
  reply_markup: {
893
- inline_keyboard: firstQuestion.options.map((option, optionIndex) => [{
894
- text: option.label,
895
- callback_data: buildCallbackData(shortHash, 0, optionIndex)
896
- }]).concat(firstQuestion.custom !== false ? [[{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData(shortHash, 0, "c") }]] : [])
1134
+ inline_keyboard: questionInlineKeyboard(shortHash, 0, firstQuestion, [])
897
1135
  }
898
1136
  });
899
1137
  pending.telegramMessageIds = [message.message_id];
@@ -906,7 +1144,7 @@ async function handleQuestionAsked(event, ctx) {
906
1144
  function createQuestionDispatcher(ctx) {
907
1145
  return {
908
1146
  async handleCallbackQuery(data, messageId, chatId, userId) {
909
- const match = CALLBACK_RE.exec(data);
1147
+ const match = CALLBACK_RE2.exec(data);
910
1148
  if (!match) return;
911
1149
  const shortHash = match[1];
912
1150
  const questionIndex = Number(match[2]);
@@ -917,22 +1155,38 @@ function createQuestionDispatcher(ctx) {
917
1155
  return;
918
1156
  }
919
1157
  if (pending.expiresAt < Date.now()) {
920
- await expirePending(ctx, shortHash, pending, messageId);
1158
+ await expirePending2(ctx, shortHash, pending, messageId);
921
1159
  return;
922
1160
  }
923
1161
  const question = pending.questions[questionIndex];
924
1162
  if (!question) return;
925
1163
  if (selection === "c") {
926
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u270F\uFE0F Reply to the next message with your custom answer.");
1164
+ if (question.multiple === true) {
1165
+ await ctx.bot.editMessageText(messageId, questionPromptText(pending, questionIndex), { reply_markup: { inline_keyboard: [] } });
1166
+ } else {
1167
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "\u270F\uFE0F Reply to the next message with your custom answer.");
1168
+ }
927
1169
  const prompt = await ctx.bot.replyWithForceReply("Type your custom answer", "Type your answer");
928
1170
  pending.awaitingCustomFor = { shortHash, questionIndex, chatId, userId, promptMessageId: prompt.message_id };
929
1171
  await ctx.pendingQuestions.savePending(shortHash, pending);
930
1172
  return;
931
1173
  }
1174
+ if (selection === "d") {
1175
+ if (question.multiple !== true) return;
1176
+ pending.answersInProgress[questionIndex] = selectedAnswers(pending, questionIndex);
1177
+ pending.awaitingCustomFor = void 0;
1178
+ await completeIfReady(ctx, pending, shortHash);
1179
+ return;
1180
+ }
932
1181
  const option = question.options[Number(selection)];
933
1182
  if (!option) return;
934
1183
  if (question.multiple === true) {
935
- ctx.logger.info("multiple-choice question handled as single-select", { requestID: pending.requestID, questionIndex });
1184
+ const current = selectedAnswers(pending, questionIndex);
1185
+ pending.answersInProgress[questionIndex] = current.includes(option.label) ? current.filter((answer) => answer !== option.label) : [...current, option.label];
1186
+ pending.awaitingCustomFor = void 0;
1187
+ await ctx.pendingQuestions.savePending(shortHash, pending);
1188
+ await editPromptForQuestion(ctx, pending, shortHash, questionIndex);
1189
+ return;
936
1190
  }
937
1191
  pending.answersInProgress[questionIndex] = [option.label];
938
1192
  pending.awaitingCustomFor = void 0;
@@ -944,7 +1198,17 @@ function createQuestionDispatcher(ctx) {
944
1198
  const awaiting = match.data.awaitingCustomFor;
945
1199
  if (!awaiting || awaiting.promptMessageId !== replyToMessageId) return;
946
1200
  if (match.data.expiresAt < Date.now()) {
947
- await expirePending(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
1201
+ await expirePending2(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
1202
+ return;
1203
+ }
1204
+ const question = match.data.questions[awaiting.questionIndex];
1205
+ if (question?.multiple === true) {
1206
+ const current = selectedAnswers(match.data, awaiting.questionIndex);
1207
+ match.data.answersInProgress[awaiting.questionIndex] = current.includes(text) ? current : [...current, text];
1208
+ match.data.awaitingCustomFor = void 0;
1209
+ await ctx.bot.sendMessage("\u2705 Custom answer added. Tap Done when finished.");
1210
+ await ctx.pendingQuestions.savePending(match.shortHash, match.data);
1211
+ await editPromptForQuestion(ctx, match.data, match.shortHash, awaiting.questionIndex);
948
1212
  return;
949
1213
  }
950
1214
  match.data.answersInProgress[awaiting.questionIndex] = [text];
@@ -975,7 +1239,7 @@ async function handleQuestionReplied(event, ctx) {
975
1239
  }
976
1240
 
977
1241
  // src/telegram-remote.ts
978
- var pluginDir = dirname3(fileURLToPath(import.meta.url));
1242
+ var pluginDir = dirname4(fileURLToPath(import.meta.url));
979
1243
  var TelegramRemote = async (input) => {
980
1244
  const logger = createLogger({ namespace: "telegram" });
981
1245
  try {
@@ -984,10 +1248,11 @@ var TelegramRemote = async (input) => {
984
1248
  const config = loadConfig({ logger, env: process.env });
985
1249
  const stateStore = createStateStore();
986
1250
  const initialState = await stateStore.read();
987
- const tokenHash = createHash3("sha256").update(config.botToken).digest("hex").slice(0, 16);
988
- const lockPath = join5(tmpdir3(), `opencoder-telegram-${tokenHash}.lock`);
989
- const claimsDir = join5(tmpdir3(), `opencoder-telegram-claims-${tokenHash}`);
1251
+ const tokenHash = createHash4("sha256").update(config.botToken).digest("hex").slice(0, 16);
1252
+ const lockPath = join6(tmpdir4(), `opencoder-telegram-${tokenHash}.lock`);
1253
+ const claimsDir = join6(tmpdir4(), `opencoder-telegram-claims-${tokenHash}`);
990
1254
  const pendingQuestions = createPendingQuestionStore({ tokenHash });
1255
+ const pendingPermissions = createPendingPermissionStore({ tokenHash });
991
1256
  const lockResult = await acquireLock({ lockPath });
992
1257
  const isLeader = lockResult.acquired;
993
1258
  logger.info(
@@ -1005,6 +1270,23 @@ var TelegramRemote = async (input) => {
1005
1270
  throwOnError: true
1006
1271
  });
1007
1272
  };
1273
+ const replyToPermission = async (requestID, sessionID, reply, endpoint) => {
1274
+ if (endpoint === "request") {
1275
+ await client._client.post({
1276
+ url: `/permission/${encodeURIComponent(requestID)}/reply`,
1277
+ headers: { "Content-Type": "application/json" },
1278
+ body: { reply },
1279
+ throwOnError: true
1280
+ });
1281
+ return;
1282
+ }
1283
+ await client._client.post({
1284
+ url: `/session/${encodeURIComponent(sessionID)}/permissions/${encodeURIComponent(requestID)}`,
1285
+ headers: { "Content-Type": "application/json" },
1286
+ body: { response: reply },
1287
+ throwOnError: true
1288
+ });
1289
+ };
1008
1290
  const bot = createTelegramBot({
1009
1291
  config,
1010
1292
  stateStore,
@@ -1048,10 +1330,13 @@ var TelegramRemote = async (input) => {
1048
1330
  serverUrl: input.serverUrl,
1049
1331
  tokenHash,
1050
1332
  pendingQuestions,
1051
- replyToQuestion
1333
+ pendingPermissions,
1334
+ replyToQuestion,
1335
+ replyToPermission
1052
1336
  };
1053
1337
  if (isLeader) {
1054
1338
  bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
1339
+ bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
1055
1340
  }
1056
1341
  return {
1057
1342
  event: async ({ event }) => {
@@ -1069,6 +1354,10 @@ var TelegramRemote = async (input) => {
1069
1354
  case "permission.updated":
1070
1355
  return handlePermissionUpdated(event, ctx);
1071
1356
  default: {
1357
+ if (isEventPermissionAsked(extEvent)) {
1358
+ if (!isLeader) return;
1359
+ return handlePermissionAsked(extEvent, ctx);
1360
+ }
1072
1361
  if (isEventSessionError(extEvent)) {
1073
1362
  return handleSessionError(extEvent, ctx);
1074
1363
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinseeker/opencode-telegram-plugin",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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",