@coinseeker/opencode-telegram-plugin 1.0.3 → 1.0.5

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.5"]
19
19
  }
20
20
  ```
21
21
 
22
+ Current stable version: `@coinseeker/opencode-telegram-plugin@1.0.5`.
23
+
22
24
  Restart OpenCode after editing the config. OpenCode resolves npm package plugins on startup.
23
25
 
24
26
  ## Configure Telegram
@@ -54,7 +56,7 @@ Keep this file private. Never commit or share your Telegram bot token.
54
56
  - OpenCode question prompts via Telegram inline buttons.
55
57
  - Multi-select question prompts with toggle buttons and **Done** submission.
56
58
  - Custom free-text answers from Telegram.
57
- - Permission alerts.
59
+ - Permission approve/reject buttons from Telegram.
58
60
  - Multi-session-safe Telegram polling through a file-lock leader model.
59
61
  - Log file output instead of stdout terminal spam.
60
62
 
@@ -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;
@@ -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
  }
@@ -675,6 +772,10 @@ function shouldSuppressIdle(sessionID) {
675
772
  }
676
773
 
677
774
  // src/events/session-idle.ts
775
+ var ROOT_IDLE_RECHECK_DELAY_MS = 2500;
776
+ function sleep(ms) {
777
+ return new Promise((resolve) => setTimeout(resolve, ms));
778
+ }
678
779
  async function resolveParentID(sessionId, ctx) {
679
780
  const cachedParentID = ctx.sessionTitleService.getParentID(sessionId);
680
781
  if (cachedParentID !== void 0) return cachedParentID;
@@ -691,6 +792,19 @@ async function resolveParentID(sessionId, ctx) {
691
792
  return void 0;
692
793
  }
693
794
  }
795
+ async function hydrateDescendants(sessionId, ctx, seen = /* @__PURE__ */ new Set()) {
796
+ if (seen.has(sessionId)) return;
797
+ seen.add(sessionId);
798
+ try {
799
+ const result = await ctx.client.session.children({ path: { id: sessionId } });
800
+ for (const child of result.data ?? []) {
801
+ ctx.sessionTitleService.setSessionInfo(child);
802
+ await hydrateDescendants(child.id, ctx, seen);
803
+ }
804
+ } catch (err) {
805
+ ctx.logger.warn("session children fetch failed", { sessionId, error: String(err) });
806
+ }
807
+ }
694
808
  async function sendIdleNotification(sessionId, ctx) {
695
809
  if (shouldSuppressIdle(sessionId)) {
696
810
  ctx.logger.info("idle suppressed - session was aborted", { sessionId });
@@ -711,9 +825,21 @@ async function sendIdleNotification(sessionId, ctx) {
711
825
  async function flushDeferredParentIfReady(parentID, ctx) {
712
826
  if (!ctx.sessionTitleService.hasDeferredIdleNotification(parentID)) return;
713
827
  if (ctx.sessionTitleService.hasUnfinishedDescendants(parentID)) return;
828
+ if (ctx.sessionTitleService.getSessionStatus(parentID) !== "idle") {
829
+ ctx.sessionTitleService.clearDeferredIdleNotification(parentID);
830
+ ctx.logger.info("clearing deferred parent idle notification - parent resumed", { sessionId: parentID });
831
+ return;
832
+ }
714
833
  ctx.logger.info("sending deferred parent idle notification", { sessionId: parentID });
715
834
  await sendIdleNotification(parentID, ctx);
716
835
  }
836
+ async function deferParentIdleIfDescendantsRunning(sessionId, ctx) {
837
+ await hydrateDescendants(sessionId, ctx);
838
+ if (!ctx.sessionTitleService.hasUnfinishedDescendants(sessionId)) return false;
839
+ ctx.sessionTitleService.deferIdleNotification(sessionId);
840
+ ctx.logger.info("deferring parent idle notification - child sessions still running", { sessionId });
841
+ return true;
842
+ }
717
843
  async function handleSessionIdle(event, ctx) {
718
844
  const sessionId = event.properties.sessionID;
719
845
  ctx.sessionTitleService.setSessionStatus(sessionId, "idle");
@@ -726,9 +852,15 @@ async function handleSessionIdle(event, ctx) {
726
852
  if (parentID === void 0) {
727
853
  ctx.logger.warn("session parentID unknown; sending idle notification", { sessionId });
728
854
  }
729
- if (ctx.sessionTitleService.hasUnfinishedDescendants(sessionId)) {
730
- ctx.sessionTitleService.deferIdleNotification(sessionId);
731
- ctx.logger.info("deferring parent idle notification - child sessions still running", { sessionId });
855
+ if (await deferParentIdleIfDescendantsRunning(sessionId, ctx)) {
856
+ return;
857
+ }
858
+ await sleep(ctx.idleRecheckDelayMs ?? ROOT_IDLE_RECHECK_DELAY_MS);
859
+ if (ctx.sessionTitleService.getSessionStatus(sessionId) !== "idle") {
860
+ ctx.logger.info("idle notification skipped - session resumed during recheck delay", { sessionId });
861
+ return;
862
+ }
863
+ if (await deferParentIdleIfDescendantsRunning(sessionId, ctx)) {
732
864
  return;
733
865
  }
734
866
  await sendIdleNotification(sessionId, ctx);
@@ -764,28 +896,158 @@ async function handleSessionUpdated(event, ctx) {
764
896
  }
765
897
 
766
898
  // 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);
899
+ var PERMISSION_EXPIRY_MS = 5 * 6e4;
900
+ var CALLBACK_RE = /^p:([^:]+):(o|a|r)$/;
901
+ function isStringArray(value) {
902
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
903
+ }
904
+ function isEventPermissionAsked(event) {
905
+ if (event.type !== "permission.asked") return false;
906
+ const props = event.properties;
907
+ if (!props) return false;
908
+ if (typeof props.id !== "string") return false;
909
+ if (typeof props.sessionID !== "string") return false;
910
+ if (typeof props.permission !== "string") return false;
911
+ if (!isStringArray(props.patterns)) return false;
912
+ if (!isStringArray(props.always)) return false;
913
+ return true;
914
+ }
915
+ function buildCallbackData(shortHash, reply) {
916
+ const data = `p:${shortHash}:${reply}`;
917
+ if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
918
+ return data;
919
+ }
920
+ function normalizeUpdated(permission) {
921
+ const pattern = permission.pattern === void 0 ? [] : Array.isArray(permission.pattern) ? permission.pattern : [permission.pattern];
922
+ return {
923
+ requestID: permission.id,
924
+ sessionID: permission.sessionID,
925
+ title: permission.title,
926
+ permission: permission.type,
927
+ patterns: pattern,
928
+ always: [],
929
+ endpoint: "session",
930
+ claimKey: `permission.updated:${permission.id}`
931
+ };
932
+ }
933
+ function normalizeAsked(permission) {
934
+ return {
935
+ requestID: permission.id,
936
+ sessionID: permission.sessionID,
937
+ title: permission.patterns.join(", ") || permission.permission,
938
+ permission: permission.permission,
939
+ patterns: permission.patterns,
940
+ always: permission.always,
941
+ endpoint: "request",
942
+ claimKey: `permission.asked:${permission.id}`
943
+ };
944
+ }
945
+ function permissionMessage(permission, sessionTitle) {
772
946
  const titleLine = sessionTitle ? `\u{1F4CB} ${sessionTitle}` : `Session: ${permission.sessionID}`;
773
- const message = `\u2753 Permission requested
947
+ const patterns = permission.patterns.length > 0 ? `
948
+ Patterns: ${permission.patterns.join(", ")}` : "";
949
+ const always = permission.always.length > 0 ? `
950
+ Always options: ${permission.always.join(", ")}` : "";
951
+ return `\u2753 Permission requested
774
952
 
775
953
  ${titleLine}
776
954
 
777
- Type: ${permission.type}
778
- Detail: ${permission.title}`;
955
+ Permission: ${permission.permission}
956
+ Detail: ${permission.title}${patterns}${always}`;
957
+ }
958
+ function permissionKeyboard(shortHash) {
959
+ return [
960
+ [{ text: "\u2705 Allow once", callback_data: buildCallbackData(shortHash, "o") }],
961
+ [{ text: "\u267B\uFE0F Always allow", callback_data: buildCallbackData(shortHash, "a") }],
962
+ [{ text: "\u274C Reject", callback_data: buildCallbackData(shortHash, "r") }]
963
+ ];
964
+ }
965
+ function replyFromSelection(selection) {
966
+ if (selection === "o") return "once";
967
+ if (selection === "a") return "always";
968
+ if (selection === "r") return "reject";
969
+ return void 0;
970
+ }
971
+ function replyLabel(reply) {
972
+ if (reply === "once") return "Allowed once";
973
+ if (reply === "always") return "Always allowed";
974
+ return "Rejected";
975
+ }
976
+ async function handleNormalizedPermission(permission, ctx) {
977
+ const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: permission.claimKey });
978
+ if (!claimed) return;
979
+ const shortHash = createPermissionShortHash(permission.requestID);
980
+ const sentAt = Date.now();
981
+ const rawSessionTitle = ctx.sessionTitleService.getSessionTitle(permission.sessionID);
982
+ const sessionTitle = rawSessionTitle === null ? void 0 : rawSessionTitle;
779
983
  try {
780
- await ctx.bot.sendMessage(message);
984
+ const message = await ctx.bot.sendMessage(permissionMessage(permission, sessionTitle), {
985
+ reply_markup: { inline_keyboard: permissionKeyboard(shortHash) }
986
+ });
987
+ const pending = {
988
+ requestID: permission.requestID,
989
+ sessionID: permission.sessionID,
990
+ title: permission.title,
991
+ permission: permission.permission,
992
+ patterns: permission.patterns,
993
+ always: permission.always,
994
+ sentAt,
995
+ expiresAt: sentAt + PERMISSION_EXPIRY_MS,
996
+ telegramMessageId: message.message_id,
997
+ endpoint: permission.endpoint
998
+ };
999
+ await ctx.pendingPermissions.savePending(shortHash, pending);
781
1000
  } catch (err) {
782
1001
  ctx.logger.error("failed to send permission notification", { error: String(err) });
783
1002
  }
784
1003
  }
1004
+ async function expirePending(ctx, shortHash, pending, messageId) {
1005
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Permission request expired");
1006
+ await ctx.pendingPermissions.deletePending(shortHash);
1007
+ ctx.logger.info("pending permission expired", { requestID: pending.requestID });
1008
+ }
1009
+ async function handlePermissionUpdated(event, ctx) {
1010
+ await handleNormalizedPermission(normalizeUpdated(event.properties), ctx);
1011
+ }
1012
+ async function handlePermissionAsked(event, ctx) {
1013
+ await handleNormalizedPermission(normalizeAsked(event.properties), ctx);
1014
+ }
1015
+ function createPermissionDispatcher(ctx) {
1016
+ return {
1017
+ async handleCallbackQuery(data, messageId) {
1018
+ const match = CALLBACK_RE.exec(data);
1019
+ if (!match) return;
1020
+ const shortHash = match[1];
1021
+ const reply = replyFromSelection(match[2]);
1022
+ if (!reply) return;
1023
+ const pending = await ctx.pendingPermissions.loadPending(shortHash);
1024
+ if (!pending) {
1025
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "This permission request has expired.");
1026
+ return;
1027
+ }
1028
+ if (pending.expiresAt < Date.now()) {
1029
+ await expirePending(ctx, shortHash, pending, messageId);
1030
+ return;
1031
+ }
1032
+ try {
1033
+ await ctx.replyToPermission(pending.requestID, pending.sessionID, reply, pending.endpoint);
1034
+ await ctx.bot.editMessageRemoveKeyboard(messageId, `\u2705 Permission ${replyLabel(reply)}
1035
+
1036
+ ${pending.permission}: ${pending.title}`);
1037
+ ctx.logger.info("permission reply sent", { requestID: pending.requestID, sessionID: pending.sessionID, reply });
1038
+ } catch (err) {
1039
+ await ctx.bot.editMessageRemoveKeyboard(messageId, "\u26A0\uFE0F Failed to send permission reply to opencode");
1040
+ ctx.logger.error("failed to send permission reply", { error: String(err), requestID: pending.requestID });
1041
+ } finally {
1042
+ await ctx.pendingPermissions.deletePending(shortHash);
1043
+ }
1044
+ }
1045
+ };
1046
+ }
785
1047
 
786
1048
  // src/events/question-asked.ts
787
1049
  var QUESTION_EXPIRY_MS = 5 * 6e4;
788
- var CALLBACK_RE = /^q:([^:]+):(\d+):(\d+|c|d)$/;
1050
+ var CALLBACK_RE2 = /^q:([^:]+):(\d+):(\d+|c|d)$/;
789
1051
  function isQuestionOption(value) {
790
1052
  return typeof value.label === "string" && typeof value.description === "string";
791
1053
  }
@@ -804,14 +1066,14 @@ function isEventQuestionAsked(event) {
804
1066
  if (!Array.isArray(props.questions)) return false;
805
1067
  return props.questions.every((question) => typeof question === "object" && question !== null && isQuestionInfo(question));
806
1068
  }
807
- function buildCallbackData(shortHash, questionIndex, optionIndex) {
1069
+ function buildCallbackData2(shortHash, questionIndex, optionIndex) {
808
1070
  const data = `q:${shortHash}:${questionIndex}:${optionIndex}`;
809
1071
  if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
810
1072
  return data;
811
1073
  }
812
1074
  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"));
1075
+ const data = question.options.map((_, optionIndex) => buildCallbackData2(shortHash, questionIndex, optionIndex));
1076
+ if (question.custom !== false) data.push(buildCallbackData2(shortHash, questionIndex, "c"));
815
1077
  return data;
816
1078
  }
817
1079
  function useSimpleQuestionKeyboard(question) {
@@ -824,13 +1086,13 @@ function questionInlineKeyboard(shortHash, questionIndex, question, selected) {
824
1086
  const multiple = question.multiple === true;
825
1087
  const inlineKeyboard = question.options.map((option, optionIndex) => [{
826
1088
  text: multiple && selected.includes(option.label) ? `\u2705 ${option.label}` : option.label,
827
- callback_data: buildCallbackData(shortHash, questionIndex, optionIndex)
1089
+ callback_data: buildCallbackData2(shortHash, questionIndex, optionIndex)
828
1090
  }]);
829
1091
  if (question.custom !== false) {
830
- inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData(shortHash, questionIndex, "c") }]);
1092
+ inlineKeyboard.push([{ text: "\u270F\uFE0F Custom answer", callback_data: buildCallbackData2(shortHash, questionIndex, "c") }]);
831
1093
  }
832
1094
  if (multiple) {
833
- inlineKeyboard.push([{ text: "\u2705 Done", callback_data: buildCallbackData(shortHash, questionIndex, "d") }]);
1095
+ inlineKeyboard.push([{ text: "\u2705 Done", callback_data: buildCallbackData2(shortHash, questionIndex, "d") }]);
834
1096
  }
835
1097
  return inlineKeyboard;
836
1098
  }
@@ -878,7 +1140,7 @@ ${answerSummary(pending.questions, answers)}`);
878
1140
  await ctx.pendingQuestions.deletePending(shortHash);
879
1141
  }
880
1142
  }
881
- async function expirePending(ctx, shortHash, pending, messageId) {
1143
+ async function expirePending2(ctx, shortHash, pending, messageId) {
882
1144
  await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Question expired");
883
1145
  await ctx.pendingQuestions.deletePending(shortHash);
884
1146
  ctx.logger.info("pending question expired", { requestID: pending.requestID });
@@ -917,7 +1179,7 @@ async function handleQuestionAsked(event, ctx) {
917
1179
  function createQuestionDispatcher(ctx) {
918
1180
  return {
919
1181
  async handleCallbackQuery(data, messageId, chatId, userId) {
920
- const match = CALLBACK_RE.exec(data);
1182
+ const match = CALLBACK_RE2.exec(data);
921
1183
  if (!match) return;
922
1184
  const shortHash = match[1];
923
1185
  const questionIndex = Number(match[2]);
@@ -928,7 +1190,7 @@ function createQuestionDispatcher(ctx) {
928
1190
  return;
929
1191
  }
930
1192
  if (pending.expiresAt < Date.now()) {
931
- await expirePending(ctx, shortHash, pending, messageId);
1193
+ await expirePending2(ctx, shortHash, pending, messageId);
932
1194
  return;
933
1195
  }
934
1196
  const question = pending.questions[questionIndex];
@@ -971,7 +1233,7 @@ function createQuestionDispatcher(ctx) {
971
1233
  const awaiting = match.data.awaitingCustomFor;
972
1234
  if (!awaiting || awaiting.promptMessageId !== replyToMessageId) return;
973
1235
  if (match.data.expiresAt < Date.now()) {
974
- await expirePending(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
1236
+ await expirePending2(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
975
1237
  return;
976
1238
  }
977
1239
  const question = match.data.questions[awaiting.questionIndex];
@@ -1012,7 +1274,7 @@ async function handleQuestionReplied(event, ctx) {
1012
1274
  }
1013
1275
 
1014
1276
  // src/telegram-remote.ts
1015
- var pluginDir = dirname3(fileURLToPath(import.meta.url));
1277
+ var pluginDir = dirname4(fileURLToPath(import.meta.url));
1016
1278
  var TelegramRemote = async (input) => {
1017
1279
  const logger = createLogger({ namespace: "telegram" });
1018
1280
  try {
@@ -1021,10 +1283,11 @@ var TelegramRemote = async (input) => {
1021
1283
  const config = loadConfig({ logger, env: process.env });
1022
1284
  const stateStore = createStateStore();
1023
1285
  const initialState = await stateStore.read();
1024
- const tokenHash = createHash3("sha256").update(config.botToken).digest("hex").slice(0, 16);
1025
- const lockPath = join5(tmpdir3(), `opencoder-telegram-${tokenHash}.lock`);
1026
- const claimsDir = join5(tmpdir3(), `opencoder-telegram-claims-${tokenHash}`);
1286
+ const tokenHash = createHash4("sha256").update(config.botToken).digest("hex").slice(0, 16);
1287
+ const lockPath = join6(tmpdir4(), `opencoder-telegram-${tokenHash}.lock`);
1288
+ const claimsDir = join6(tmpdir4(), `opencoder-telegram-claims-${tokenHash}`);
1027
1289
  const pendingQuestions = createPendingQuestionStore({ tokenHash });
1290
+ const pendingPermissions = createPendingPermissionStore({ tokenHash });
1028
1291
  const lockResult = await acquireLock({ lockPath });
1029
1292
  const isLeader = lockResult.acquired;
1030
1293
  logger.info(
@@ -1042,6 +1305,23 @@ var TelegramRemote = async (input) => {
1042
1305
  throwOnError: true
1043
1306
  });
1044
1307
  };
1308
+ const replyToPermission = async (requestID, sessionID, reply, endpoint) => {
1309
+ if (endpoint === "request") {
1310
+ await client._client.post({
1311
+ url: `/permission/${encodeURIComponent(requestID)}/reply`,
1312
+ headers: { "Content-Type": "application/json" },
1313
+ body: { reply },
1314
+ throwOnError: true
1315
+ });
1316
+ return;
1317
+ }
1318
+ await client._client.post({
1319
+ url: `/session/${encodeURIComponent(sessionID)}/permissions/${encodeURIComponent(requestID)}`,
1320
+ headers: { "Content-Type": "application/json" },
1321
+ body: { response: reply },
1322
+ throwOnError: true
1323
+ });
1324
+ };
1045
1325
  const bot = createTelegramBot({
1046
1326
  config,
1047
1327
  stateStore,
@@ -1085,10 +1365,13 @@ var TelegramRemote = async (input) => {
1085
1365
  serverUrl: input.serverUrl,
1086
1366
  tokenHash,
1087
1367
  pendingQuestions,
1088
- replyToQuestion
1368
+ pendingPermissions,
1369
+ replyToQuestion,
1370
+ replyToPermission
1089
1371
  };
1090
1372
  if (isLeader) {
1091
1373
  bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
1374
+ bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
1092
1375
  }
1093
1376
  return {
1094
1377
  event: async ({ event }) => {
@@ -1106,6 +1389,10 @@ var TelegramRemote = async (input) => {
1106
1389
  case "permission.updated":
1107
1390
  return handlePermissionUpdated(event, ctx);
1108
1391
  default: {
1392
+ if (isEventPermissionAsked(extEvent)) {
1393
+ if (!isLeader) return;
1394
+ return handlePermissionAsked(extEvent, ctx);
1395
+ }
1109
1396
  if (isEventSessionError(extEvent)) {
1110
1397
  return handleSessionError(extEvent, ctx);
1111
1398
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinseeker/opencode-telegram-plugin",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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",