@coinseeker/opencode-telegram-plugin 1.0.14 → 1.1.1

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,11 +15,11 @@ Configure the npm package in `~/.config/opencode/opencode.json`:
15
15
 
16
16
  ```json
17
17
  {
18
- "plugin": ["@coinseeker/opencode-telegram-plugin@1.0.14"]
18
+ "plugin": ["@coinseeker/opencode-telegram-plugin@1.1.1"]
19
19
  }
20
20
  ```
21
21
 
22
- Current stable version: `@coinseeker/opencode-telegram-plugin@1.0.14`.
22
+ Current stable version: `@coinseeker/opencode-telegram-plugin@1.1.1`.
23
23
 
24
24
  Restart OpenCode after editing the config. OpenCode resolves npm package plugins on startup.
25
25
 
@@ -40,9 +40,10 @@ function pendingQuestionText(questions, questionIndex) {
40
40
 
41
41
  // src/bot.ts
42
42
  function createTelegramBot(opts) {
43
- const { config, stateStore, logger, polling } = opts;
43
+ const { config, stateStore, logger } = opts;
44
44
  const bot = new Bot(config.botToken);
45
45
  let activeChatId = opts.initialChatId;
46
+ let pollingActive = false;
46
47
  let questionDispatcher;
47
48
  let permissionDispatcher;
48
49
  let startWorkDispatcher;
@@ -51,103 +52,101 @@ function createTelegramBot(opts) {
51
52
  let startWorkCommandDispatcher;
52
53
  let helpDispatcher;
53
54
  let managerObj;
54
- if (polling) {
55
- bot.use(async (ctx, next) => {
56
- const userId = ctx.from?.id;
57
- if (!userId || !config.allowedUserIds.includes(userId)) {
58
- logger.warn("unauthorized access attempt", { userId });
59
- return;
60
- }
61
- if (ctx.chat?.type !== "private") return;
62
- if (ctx.chat?.id) {
63
- const newChatId = ctx.chat.id;
64
- if (activeChatId !== newChatId) {
65
- activeChatId = newChatId;
66
- await stateStore.write({ chatId: newChatId, discoveredBy: process.pid });
67
- logger.info("chat_id discovered", { chatId: newChatId });
68
- await ctx.reply(
69
- `\u2705 Chat connected!
55
+ bot.use(async (ctx, next) => {
56
+ const userId = ctx.from?.id;
57
+ if (!userId || !config.allowedUserIds.includes(userId)) {
58
+ logger.warn("unauthorized access attempt", { userId });
59
+ return;
60
+ }
61
+ if (ctx.chat?.type !== "private") return;
62
+ if (ctx.chat?.id) {
63
+ const newChatId = ctx.chat.id;
64
+ if (activeChatId !== newChatId) {
65
+ activeChatId = newChatId;
66
+ await stateStore.write({ chatId: newChatId, discoveredBy: process.pid });
67
+ logger.info("chat_id discovered", { chatId: newChatId });
68
+ await ctx.reply(
69
+ `\u2705 Chat connected!
70
70
 
71
71
  Your chat_id: ${newChatId}
72
72
 
73
73
  This chat is now active for OpenCode notifications.`
74
- );
75
- }
76
- }
77
- await next();
78
- });
79
- bot.catch((err) => {
80
- const e = err.error;
81
- if (e instanceof GrammyError && e.error_code === 409) {
82
- logger.info("polling conflict (409) - another process took over", {
83
- description: e.description
84
- });
85
- } else {
86
- logger.error("bot error", { error: String(e) });
74
+ );
87
75
  }
88
- });
89
- bot.callbackQuery(/^q:([^:]+):(\d+):(\d+|c|d)$/, async (ctx) => {
90
- await ctx.answerCallbackQuery();
91
- const data = ctx.callbackQuery.data;
92
- const messageId = ctx.callbackQuery.message?.message_id;
93
- const chatId = ctx.chat?.id;
94
- const userId = ctx.from?.id;
95
- if (!questionDispatcher || messageId === void 0 || chatId === void 0 || userId === void 0)
96
- return;
97
- await questionDispatcher.handleCallbackQuery(data, messageId, chatId, userId);
98
- });
99
- bot.callbackQuery(/^p:([^:]+):(o|a|r)$/, async (ctx) => {
100
- await ctx.answerCallbackQuery();
101
- const data = ctx.callbackQuery.data;
102
- const messageId = ctx.callbackQuery.message?.message_id;
103
- if (!permissionDispatcher || messageId === void 0) return;
104
- await permissionDispatcher.handleCallbackQuery(data, messageId);
105
- });
106
- bot.callbackQuery(/^sw:([^:]+)$/, async (ctx) => {
107
- await ctx.answerCallbackQuery();
108
- const data = ctx.callbackQuery.data;
109
- const messageId = ctx.callbackQuery.message?.message_id;
110
- if (!startWorkDispatcher || messageId === void 0) return;
111
- await startWorkDispatcher.handleCallbackQuery(data, messageId);
112
- });
113
- bot.command("sessions", async (ctx) => {
114
- if (!sessionsDispatcher) return;
115
- const chatId = ctx.chat?.id;
116
- const userId = ctx.from?.id;
117
- if (chatId === void 0 || userId === void 0) return;
118
- await sessionsDispatcher({ chatId, userId, bot: managerObj });
119
- });
120
- bot.command("status", async (ctx) => {
121
- if (!statusDispatcher) return;
122
- const chatId = ctx.chat?.id;
123
- const userId = ctx.from?.id;
124
- if (chatId === void 0 || userId === void 0) return;
125
- const args = ctx.match.trim().split(/\s+/).filter(Boolean);
126
- await statusDispatcher({ chatId, userId, bot: managerObj, args });
127
- });
128
- bot.command("start_work", async (ctx) => {
129
- if (!startWorkCommandDispatcher) return;
130
- const chatId = ctx.chat?.id;
131
- const userId = ctx.from?.id;
132
- if (chatId === void 0 || userId === void 0) return;
133
- const args = ctx.match.trim().split(/\s+/).filter(Boolean);
134
- await startWorkCommandDispatcher({ chatId, userId, bot: managerObj, args });
135
- });
136
- bot.command("help", async (ctx) => {
137
- if (!helpDispatcher) return;
138
- const chatId = ctx.chat?.id;
139
- const userId = ctx.from?.id;
140
- if (chatId === void 0 || userId === void 0) return;
141
- await helpDispatcher({ chatId, userId, bot: managerObj });
142
- });
143
- bot.on("message:text", async (ctx) => {
144
- const replyToMessageId = ctx.message.reply_to_message?.message_id;
145
- const chatId = ctx.chat.id;
146
- const userId = ctx.from?.id;
147
- if (!questionDispatcher || replyToMessageId === void 0 || userId === void 0) return;
148
- await questionDispatcher.handleTextReply(ctx.message.text, chatId, userId, replyToMessageId);
149
- });
150
- }
76
+ }
77
+ await next();
78
+ });
79
+ bot.catch((err) => {
80
+ const e = err.error;
81
+ if (e instanceof GrammyError && e.error_code === 409) {
82
+ logger.info("polling conflict (409) - another process took over", {
83
+ description: e.description
84
+ });
85
+ } else {
86
+ logger.error("bot error", { error: String(e) });
87
+ }
88
+ });
89
+ bot.callbackQuery(/^q:([^:]+):(\d+):(\d+|c|d)$/, async (ctx) => {
90
+ await ctx.answerCallbackQuery();
91
+ const data = ctx.callbackQuery.data;
92
+ const messageId = ctx.callbackQuery.message?.message_id;
93
+ const chatId = ctx.chat?.id;
94
+ const userId = ctx.from?.id;
95
+ if (!questionDispatcher || messageId === void 0 || chatId === void 0 || userId === void 0)
96
+ return;
97
+ await questionDispatcher.handleCallbackQuery(data, messageId, chatId, userId);
98
+ });
99
+ bot.callbackQuery(/^p:([^:]+):(o|a|r)$/, async (ctx) => {
100
+ await ctx.answerCallbackQuery();
101
+ const data = ctx.callbackQuery.data;
102
+ const messageId = ctx.callbackQuery.message?.message_id;
103
+ if (!permissionDispatcher || messageId === void 0) return;
104
+ await permissionDispatcher.handleCallbackQuery(data, messageId);
105
+ });
106
+ bot.callbackQuery(/^sw:([^:]+)$/, async (ctx) => {
107
+ await ctx.answerCallbackQuery();
108
+ const data = ctx.callbackQuery.data;
109
+ const messageId = ctx.callbackQuery.message?.message_id;
110
+ if (!startWorkDispatcher || messageId === void 0) return;
111
+ await startWorkDispatcher.handleCallbackQuery(data, messageId);
112
+ });
113
+ bot.command("sessions", async (ctx) => {
114
+ if (!sessionsDispatcher) return;
115
+ const chatId = ctx.chat?.id;
116
+ const userId = ctx.from?.id;
117
+ if (chatId === void 0 || userId === void 0) return;
118
+ await sessionsDispatcher({ chatId, userId, bot: managerObj });
119
+ });
120
+ bot.command("status", async (ctx) => {
121
+ if (!statusDispatcher) return;
122
+ const chatId = ctx.chat?.id;
123
+ const userId = ctx.from?.id;
124
+ if (chatId === void 0 || userId === void 0) return;
125
+ const args = ctx.match.trim().split(/\s+/).filter(Boolean);
126
+ await statusDispatcher({ chatId, userId, bot: managerObj, args });
127
+ });
128
+ bot.command("start_work", async (ctx) => {
129
+ if (!startWorkCommandDispatcher) return;
130
+ const chatId = ctx.chat?.id;
131
+ const userId = ctx.from?.id;
132
+ if (chatId === void 0 || userId === void 0) return;
133
+ const args = ctx.match.trim().split(/\s+/).filter(Boolean);
134
+ await startWorkCommandDispatcher({ chatId, userId, bot: managerObj, args });
135
+ });
136
+ bot.command("help", async (ctx) => {
137
+ if (!helpDispatcher) return;
138
+ const chatId = ctx.chat?.id;
139
+ const userId = ctx.from?.id;
140
+ if (chatId === void 0 || userId === void 0) return;
141
+ await helpDispatcher({ chatId, userId, bot: managerObj });
142
+ });
143
+ bot.on("message:text", async (ctx) => {
144
+ const replyToMessageId = ctx.message.reply_to_message?.message_id;
145
+ const chatId = ctx.chat.id;
146
+ const userId = ctx.from?.id;
147
+ if (!questionDispatcher || replyToMessageId === void 0 || userId === void 0) return;
148
+ await questionDispatcher.handleTextReply(ctx.message.text, chatId, userId, replyToMessageId);
149
+ });
151
150
  const requireChatId = async (action) => {
152
151
  if (activeChatId) return activeChatId;
153
152
  const state = await stateStore.read();
@@ -159,10 +158,8 @@ This chat is now active for OpenCode notifications.`
159
158
  };
160
159
  managerObj = {
161
160
  async start() {
162
- if (!polling) {
163
- logger.info("pass-through mode - skipping bot.start()");
164
- return;
165
- }
161
+ if (pollingActive) return;
162
+ pollingActive = true;
166
163
  try {
167
164
  await bot.api.setMyCommands([
168
165
  { command: "sessions", description: "\uD65C\uC131 \uC138\uC158 \uBAA9\uB85D (top 20)" },
@@ -173,22 +170,30 @@ This chat is now active for OpenCode notifications.`
173
170
  } catch (err) {
174
171
  logger.warn("setMyCommands failed", { error: String(err) });
175
172
  }
176
- await bot.start({
177
- drop_pending_updates: true,
178
- onStart: () => {
179
- logger.info("polling started");
180
- }
181
- });
173
+ try {
174
+ await bot.start({
175
+ drop_pending_updates: true,
176
+ onStart: () => {
177
+ logger.info("polling started");
178
+ }
179
+ });
180
+ } catch (err) {
181
+ pollingActive = false;
182
+ throw err;
183
+ }
182
184
  },
183
185
  async stop() {
184
- if (polling) {
185
- try {
186
- await bot.stop();
187
- } catch (err) {
188
- logger.warn("bot.stop() error", { error: String(err) });
189
- }
186
+ if (!pollingActive) return;
187
+ pollingActive = false;
188
+ try {
189
+ await bot.stop();
190
+ } catch (err) {
191
+ logger.warn("bot.stop() error", { error: String(err) });
190
192
  }
191
193
  },
194
+ isPolling() {
195
+ return pollingActive;
196
+ },
192
197
  async sendMessage(text, options) {
193
198
  const chatId = await requireChatId("sendMessage");
194
199
  const result = await bot.api.sendMessage(chatId, text, options);
@@ -364,7 +369,7 @@ async function claimOnce(opts) {
364
369
 
365
370
  // src/lib/pending-permissions.ts
366
371
  import { createHash as createHash2 } from "crypto";
367
- import { mkdir as mkdir2, readFile, readdir as readdir2, rename, unlink as unlink2, writeFile } from "fs/promises";
372
+ import { mkdir as mkdir2, readdir as readdir2, readFile, rename, unlink as unlink2, writeFile } from "fs/promises";
368
373
  import { tmpdir } from "os";
369
374
  import { dirname, join as join2 } from "path";
370
375
  function hasCode2(err, code) {
@@ -375,13 +380,17 @@ function pendingFilePath(dir, shortHash) {
375
380
  }
376
381
  function parsePending(text) {
377
382
  const parsed = JSON.parse(text);
378
- if (typeof parsed.requestID !== "string") throw new Error("Invalid pending permission: requestID");
379
- if (typeof parsed.sessionID !== "string") throw new Error("Invalid pending permission: sessionID");
383
+ if (typeof parsed.requestID !== "string")
384
+ throw new Error("Invalid pending permission: requestID");
385
+ if (typeof parsed.sessionID !== "string")
386
+ throw new Error("Invalid pending permission: sessionID");
380
387
  if (typeof parsed.title !== "string") throw new Error("Invalid pending permission: title");
381
- if (typeof parsed.permission !== "string") throw new Error("Invalid pending permission: permission");
388
+ if (typeof parsed.permission !== "string")
389
+ throw new Error("Invalid pending permission: permission");
382
390
  if (!Array.isArray(parsed.patterns)) throw new Error("Invalid pending permission: patterns");
383
391
  if (!Array.isArray(parsed.always)) throw new Error("Invalid pending permission: always");
384
- if (parsed.endpoint !== "request" && parsed.endpoint !== "session") throw new Error("Invalid pending permission: endpoint");
392
+ if (parsed.endpoint !== "request" && parsed.endpoint !== "session")
393
+ throw new Error("Invalid pending permission: endpoint");
385
394
  return parsed;
386
395
  }
387
396
  async function listPendingFiles(dir) {
@@ -422,11 +431,13 @@ function createPendingPermissionStore(opts) {
422
431
  if (!(err instanceof Error) || !hasCode2(err, "ENOENT")) throw err;
423
432
  }
424
433
  },
425
- async findByRequestID(requestID) {
434
+ async findByRequestID(requestID, sessionID, serverUrl) {
426
435
  for (const fileName of await listPendingFiles(dir)) {
427
436
  const shortHash = shortHashFromFileName(fileName);
428
437
  const data = await this.loadPending(shortHash);
429
- if (data?.requestID === requestID) return { shortHash, data };
438
+ if (data?.requestID === requestID && (sessionID === void 0 || data.sessionID === sessionID) && (serverUrl === void 0 || data.serverUrl === serverUrl)) {
439
+ return { shortHash, data };
440
+ }
430
441
  }
431
442
  return void 0;
432
443
  },
@@ -444,8 +455,9 @@ function createPendingPermissionStore(opts) {
444
455
  }
445
456
  };
446
457
  }
447
- function createPermissionShortHash(requestID) {
448
- return createHash2("sha256").update(requestID).digest("base64url").slice(0, 10);
458
+ function createPermissionShortHash(requestID, sessionID, endpoint2, serverUrl) {
459
+ const source = sessionID === void 0 ? requestID : `${serverUrl ?? ""}:${endpoint2 ?? ""}:${sessionID}:${requestID}`;
460
+ return createHash2("sha256").update(source).digest("base64url").slice(0, 10);
449
461
  }
450
462
 
451
463
  // src/events/permission-updated.ts
@@ -467,7 +479,8 @@ function isEventPermissionAsked(event) {
467
479
  }
468
480
  function buildCallbackData(shortHash, reply) {
469
481
  const data = `p:${shortHash}:${reply}`;
470
- if (Buffer.byteLength(data, "utf8") > 64) throw new Error("Telegram callback_data exceeds 64 bytes");
482
+ if (Buffer.byteLength(data, "utf8") > 64)
483
+ throw new Error("Telegram callback_data exceeds 64 bytes");
471
484
  return data;
472
485
  }
473
486
  function normalizeUpdated(permission) {
@@ -479,8 +492,7 @@ function normalizeUpdated(permission) {
479
492
  permission: permission.type,
480
493
  patterns: pattern,
481
494
  always: [],
482
- endpoint: "session",
483
- claimKey: `permission.updated:${permission.id}`
495
+ endpoint: "session"
484
496
  };
485
497
  }
486
498
  function normalizeAsked(permission) {
@@ -491,8 +503,7 @@ function normalizeAsked(permission) {
491
503
  permission: permission.permission,
492
504
  patterns: permission.patterns,
493
505
  always: permission.always,
494
- endpoint: "request",
495
- claimKey: `permission.asked:${permission.id}`
506
+ endpoint: "request"
496
507
  };
497
508
  }
498
509
  function permissionMessage(permission, sessionTitle) {
@@ -527,9 +538,15 @@ function replyLabel(reply) {
527
538
  return "Rejected";
528
539
  }
529
540
  async function handleNormalizedPermission(permission, ctx) {
530
- const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: permission.claimKey });
541
+ const permissionKey = `${ctx.serverUrl.href}:${permission.sessionID}:${permission.requestID}`;
542
+ const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: `permission:${permissionKey}` });
531
543
  if (!claimed) return;
532
- const shortHash = createPermissionShortHash(permission.requestID);
544
+ const shortHash = createPermissionShortHash(
545
+ permission.requestID,
546
+ permission.sessionID,
547
+ permission.endpoint,
548
+ ctx.serverUrl.href
549
+ );
533
550
  const sentAt = Date.now();
534
551
  const rawSessionTitle = ctx.sessionTitleService.getSessionTitle(permission.sessionID);
535
552
  const sessionTitle = rawSessionTitle === null ? void 0 : rawSessionTitle;
@@ -555,11 +572,6 @@ async function handleNormalizedPermission(permission, ctx) {
555
572
  ctx.logger.error("failed to send permission notification", { error: String(err) });
556
573
  }
557
574
  }
558
- async function expirePending(ctx, shortHash, pending, messageId) {
559
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Permission request expired");
560
- await ctx.pendingPermissions.deletePending(shortHash);
561
- ctx.logger.info("pending permission expired", { requestID: pending.requestID });
562
- }
563
575
  async function handlePermissionUpdated(event, ctx) {
564
576
  await handleNormalizedPermission(normalizeUpdated(event.properties), ctx);
565
577
  }
@@ -583,7 +595,11 @@ function externalReplyLabel(value) {
583
595
  async function handlePermissionReplied(event, ctx) {
584
596
  const requestID = event.properties.requestID ?? event.properties.permissionID;
585
597
  if (!requestID) return;
586
- const found = await ctx.pendingPermissions.findByRequestID(requestID);
598
+ const found = await ctx.pendingPermissions.findByRequestID(
599
+ requestID,
600
+ event.properties.sessionID,
601
+ ctx.serverUrl.href
602
+ );
587
603
  if (!found) return;
588
604
  const label = externalReplyLabel(event.properties.reply ?? event.properties.response);
589
605
  try {
@@ -619,10 +635,6 @@ function createPermissionDispatcher(ctx) {
619
635
  await ctx.bot.editMessageRemoveKeyboard(messageId, "This permission request has expired.");
620
636
  return;
621
637
  }
622
- if (pending.expiresAt < Date.now()) {
623
- await expirePending(ctx, shortHash, pending, messageId);
624
- return;
625
- }
626
638
  try {
627
639
  await ctx.replyToPermission(
628
640
  pending.requestID,
@@ -631,13 +643,26 @@ function createPermissionDispatcher(ctx) {
631
643
  pending.endpoint,
632
644
  pending.serverUrl
633
645
  );
634
- await ctx.bot.editMessageRemoveKeyboard(messageId, `\u2705 Permission ${replyLabel(reply)}
646
+ await ctx.bot.editMessageRemoveKeyboard(
647
+ messageId,
648
+ `\u2705 Permission ${replyLabel(reply)}
635
649
 
636
- ${pending.permission}: ${pending.title}`);
637
- ctx.logger.info("permission reply sent", { requestID: pending.requestID, sessionID: pending.sessionID, reply });
650
+ ${pending.permission}: ${pending.title}`
651
+ );
652
+ ctx.logger.info("permission reply sent", {
653
+ requestID: pending.requestID,
654
+ sessionID: pending.sessionID,
655
+ reply
656
+ });
638
657
  } catch (err) {
639
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u26A0\uFE0F Failed to send permission reply to opencode");
640
- ctx.logger.error("failed to send permission reply", { error: String(err), requestID: pending.requestID });
658
+ await ctx.bot.editMessageRemoveKeyboard(
659
+ messageId,
660
+ "\u26A0\uFE0F Failed to send permission reply to opencode"
661
+ );
662
+ ctx.logger.error("failed to send permission reply", {
663
+ error: String(err),
664
+ requestID: pending.requestID
665
+ });
641
666
  } finally {
642
667
  await ctx.pendingPermissions.deletePending(shortHash);
643
668
  }
@@ -647,7 +672,7 @@ ${pending.permission}: ${pending.title}`);
647
672
 
648
673
  // src/lib/pending-questions.ts
649
674
  import { createHash as createHash3 } from "crypto";
650
- import { mkdir as mkdir3, readFile as readFile2, readdir as readdir3, rename as rename2, unlink as unlink3, writeFile as writeFile2 } from "fs/promises";
675
+ import { mkdir as mkdir3, readdir as readdir3, readFile as readFile2, rename as rename2, unlink as unlink3, writeFile as writeFile2 } from "fs/promises";
651
676
  import { tmpdir as tmpdir2 } from "os";
652
677
  import { dirname as dirname2, join as join3 } from "path";
653
678
  function hasCode3(err, code) {
@@ -661,8 +686,10 @@ function parsePending2(text) {
661
686
  if (typeof parsed.requestID !== "string") throw new Error("Invalid pending question: requestID");
662
687
  if (typeof parsed.sessionID !== "string") throw new Error("Invalid pending question: sessionID");
663
688
  if (!Array.isArray(parsed.questions)) throw new Error("Invalid pending question: questions");
664
- if (!Array.isArray(parsed.telegramMessageIds)) throw new Error("Invalid pending question: telegramMessageIds");
665
- if (!Array.isArray(parsed.answersInProgress)) throw new Error("Invalid pending question: answersInProgress");
689
+ if (!Array.isArray(parsed.telegramMessageIds))
690
+ throw new Error("Invalid pending question: telegramMessageIds");
691
+ if (!Array.isArray(parsed.answersInProgress))
692
+ throw new Error("Invalid pending question: answersInProgress");
666
693
  parsed.answersInProgress = parsed.answersInProgress.map((answer) => answer ?? null);
667
694
  return parsed;
668
695
  }
@@ -716,11 +743,13 @@ function createPendingQuestionStore(opts) {
716
743
  }
717
744
  return expired;
718
745
  },
719
- async findByRequestID(requestID) {
746
+ async findByRequestID(requestID, sessionID, serverUrl) {
720
747
  for (const fileName of await listPendingFiles2(dir)) {
721
748
  const shortHash = shortHashFromFileName2(fileName);
722
749
  const data = await this.loadPending(shortHash);
723
- if (data?.requestID === requestID) return { shortHash, data };
750
+ if (data?.requestID === requestID && (sessionID === void 0 || data.sessionID === sessionID) && (serverUrl === void 0 || data.serverUrl === serverUrl)) {
751
+ return { shortHash, data };
752
+ }
724
753
  }
725
754
  return void 0;
726
755
  },
@@ -729,14 +758,16 @@ function createPendingQuestionStore(opts) {
729
758
  const shortHash = shortHashFromFileName2(fileName);
730
759
  const data = await this.loadPending(shortHash);
731
760
  const awaiting = data?.awaitingCustomFor;
732
- if (awaiting && awaiting.chatId === chatId && awaiting.userId === userId) return { shortHash, data };
761
+ if (awaiting && awaiting.chatId === chatId && awaiting.userId === userId)
762
+ return { shortHash, data };
733
763
  }
734
764
  return void 0;
735
765
  }
736
766
  };
737
767
  }
738
- function createQuestionShortHash(requestID) {
739
- return createHash3("sha256").update(requestID).digest("base64url").slice(0, 10);
768
+ function createQuestionShortHash(requestID, sessionID, serverUrl) {
769
+ const source = sessionID === void 0 ? requestID : `${serverUrl ?? ""}:${sessionID}:${requestID}`;
770
+ return createHash3("sha256").update(source).digest("base64url").slice(0, 10);
740
771
  }
741
772
 
742
773
  // src/events/question-asked.ts
@@ -855,21 +886,16 @@ ${answerSummary(pending.questions, answers)}`
855
886
  await ctx.pendingQuestions.deletePending(shortHash);
856
887
  }
857
888
  }
858
- async function expirePending2(ctx, shortHash, pending, messageId) {
859
- await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Question expired");
860
- await ctx.pendingQuestions.deletePending(shortHash);
861
- ctx.logger.info("pending question expired", { requestID: pending.requestID });
862
- }
863
889
  async function handleQuestionAsked(event, ctx) {
864
890
  const request = event.properties;
865
891
  if (request.questions.length === 0) return;
866
892
  const claimed = await claimOnce({
867
893
  claimsDir: ctx.claimsDir,
868
- key: `question.asked:${request.id}`,
894
+ key: `question:${ctx.serverUrl.href}:${request.sessionID}:${request.id}`,
869
895
  ttlMs: 5e3
870
896
  });
871
897
  if (!claimed) return;
872
- const shortHash = createQuestionShortHash(request.id);
898
+ const shortHash = createQuestionShortHash(request.id, request.sessionID, ctx.serverUrl.href);
873
899
  const firstQuestion = request.questions[0];
874
900
  const sentAt = Date.now();
875
901
  const pending = {
@@ -919,10 +945,7 @@ function createQuestionDispatcher(ctx) {
919
945
  await ctx.bot.editMessageRemoveKeyboard(messageId, "This question has expired.");
920
946
  return;
921
947
  }
922
- if (pending.expiresAt < Date.now()) {
923
- await expirePending2(ctx, shortHash, pending, messageId);
924
- return;
925
- }
948
+ pending.expiresAt = Date.now() + QUESTION_EXPIRY_MS;
926
949
  const question = pending.questions[questionIndex];
927
950
  if (!question) return;
928
951
  if (selection === "c") {
@@ -976,10 +999,7 @@ function createQuestionDispatcher(ctx) {
976
999
  if (!match) return;
977
1000
  const awaiting = match.data.awaitingCustomFor;
978
1001
  if (!awaiting || awaiting.promptMessageId !== replyToMessageId) return;
979
- if (match.data.expiresAt < Date.now()) {
980
- await expirePending2(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
981
- return;
982
- }
1002
+ match.data.expiresAt = Date.now() + QUESTION_EXPIRY_MS;
983
1003
  const question = match.data.questions[awaiting.questionIndex];
984
1004
  if (question?.multiple === true) {
985
1005
  const current = selectedAnswers(match.data, awaiting.questionIndex);
@@ -1002,16 +1022,31 @@ function createQuestionDispatcher(ctx) {
1002
1022
  function isEventQuestionReplied(event) {
1003
1023
  if (event.type !== "question.replied") return false;
1004
1024
  const props = event.properties;
1005
- return Boolean(props && typeof props.requestID === "string" && typeof props.sessionID === "string");
1025
+ return Boolean(
1026
+ props && typeof props.requestID === "string" && typeof props.sessionID === "string"
1027
+ );
1006
1028
  }
1007
1029
  async function handleQuestionReplied(event, ctx) {
1008
- const found = await ctx.pendingQuestions.findByRequestID(event.properties.requestID);
1009
- if (!found) return;
1030
+ const found = await ctx.pendingQuestions.findByRequestID(
1031
+ event.properties.requestID,
1032
+ event.properties.sessionID,
1033
+ ctx.serverUrl.href
1034
+ );
1035
+ if (!found) {
1036
+ ctx.logger.info("question.replied no pending match", {
1037
+ requestID: event.properties.requestID,
1038
+ sessionID: event.properties.sessionID
1039
+ });
1040
+ return;
1041
+ }
1010
1042
  const messageId = found.data.telegramMessageIds[0];
1011
1043
  try {
1012
1044
  await ctx.bot.editMessageRemoveKeyboard(messageId, "\u2705 Already answered in opencode.");
1013
1045
  } catch (err) {
1014
- ctx.logger.error("failed to edit externally answered question", { error: String(err), requestID: event.properties.requestID });
1046
+ ctx.logger.error("failed to edit externally answered question", {
1047
+ error: String(err),
1048
+ requestID: event.properties.requestID
1049
+ });
1015
1050
  } finally {
1016
1051
  await ctx.pendingQuestions.deletePending(found.shortHash);
1017
1052
  }
@@ -1474,7 +1509,7 @@ function createPendingStartWork(sessionID, title, serverUrl, telegramMessageId)
1474
1509
  function startWorkShortHash(sessionID) {
1475
1510
  return createStartWorkShortHash(sessionID);
1476
1511
  }
1477
- async function expirePending3(ctx, shortHash, pending, messageId) {
1512
+ async function expirePending(ctx, shortHash, pending, messageId) {
1478
1513
  await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 /start-work request expired");
1479
1514
  await ctx.pendingStartWorks.deletePending(shortHash);
1480
1515
  ctx.logger.info("pending start-work expired", { sessionID: pending.sessionID });
@@ -1491,7 +1526,7 @@ function createStartWorkDispatcher(ctx) {
1491
1526
  return;
1492
1527
  }
1493
1528
  if (pending.expiresAt < Date.now()) {
1494
- await expirePending3(ctx, shortHash, pending, messageId);
1529
+ await expirePending(ctx, shortHash, pending, messageId);
1495
1530
  return;
1496
1531
  }
1497
1532
  try {
@@ -1522,9 +1557,21 @@ Session: ${label}`
1522
1557
 
1523
1558
  // src/events/session-idle.ts
1524
1559
  var ROOT_IDLE_RECHECK_DELAY_MS = 2500;
1560
+ var DEFERRED_PARENT_CONFIRM_DELAY_MS = 2500;
1561
+ var deferredConfirmTimers = /* @__PURE__ */ new Map();
1525
1562
  function sleep(ms) {
1526
1563
  return new Promise((resolve) => setTimeout(resolve, ms));
1527
1564
  }
1565
+ function agentFinishedMessage(title, agent) {
1566
+ const base = title ? `Agent has finished: ${title}` : "Agent has finished.";
1567
+ return agent ? `${base} (${agent})` : base;
1568
+ }
1569
+ function cancelDeferredParentConfirm(sessionId) {
1570
+ const timer = deferredConfirmTimers.get(sessionId);
1571
+ if (timer === void 0) return;
1572
+ clearTimeout(timer);
1573
+ deferredConfirmTimers.delete(sessionId);
1574
+ }
1528
1575
  async function resolveParentID(sessionId, ctx) {
1529
1576
  const cachedParentID = ctx.sessionTitleService.getParentID(sessionId);
1530
1577
  if (cachedParentID !== void 0) return cachedParentID;
@@ -1576,8 +1623,9 @@ async function sendIdleNotification(sessionId, ctx) {
1576
1623
  });
1577
1624
  if (!claimed) return;
1578
1625
  const title = ctx.sessionTitleService.getSessionTitle(sessionId);
1579
- const isPlanSession = ctx.sessionTitleService.getSessionAgent(sessionId) === "plan";
1580
- const text = isPlanSession ? planCompleteMessage(title) : title ? `Agent has finished: ${title}` : "Agent has finished.";
1626
+ const agent = ctx.sessionTitleService.getSessionAgent(sessionId);
1627
+ const isPlanSession = agent === "plan";
1628
+ const text = isPlanSession ? planCompleteMessage(title) : agentFinishedMessage(title, agent);
1581
1629
  try {
1582
1630
  if (isPlanSession) {
1583
1631
  const shortHash = startWorkShortHash(sessionId);
@@ -1601,18 +1649,49 @@ async function flushDeferredParentIfReady(parentID, ctx) {
1601
1649
  if (!ctx.sessionTitleService.hasDeferredIdleNotification(parentID)) return;
1602
1650
  if (ctx.sessionTitleService.hasUnfinishedDescendants(parentID)) return;
1603
1651
  const parentStatus = ctx.sessionTitleService.getSessionStatus(parentID);
1604
- if (parentStatus === "idle") {
1605
- ctx.logger.info("keeping deferred parent idle notification - waiting for parent to resume", {
1652
+ if (parentStatus !== "idle") {
1653
+ if (parentStatus !== void 0) {
1654
+ cancelDeferredParentConfirm(parentID);
1655
+ ctx.sessionTitleService.clearDeferredIdleNotification(parentID);
1656
+ ctx.logger.info("clearing deferred parent idle notification - parent resumed", {
1657
+ sessionId: parentID
1658
+ });
1659
+ }
1660
+ return;
1661
+ }
1662
+ scheduleDeferredParentConfirm(parentID, ctx);
1663
+ }
1664
+ function scheduleDeferredParentConfirm(parentID, ctx) {
1665
+ if (deferredConfirmTimers.has(parentID)) return;
1666
+ const delay = ctx.deferredConfirmDelayMs ?? DEFERRED_PARENT_CONFIRM_DELAY_MS;
1667
+ const timer = setTimeout(() => {
1668
+ deferredConfirmTimers.delete(parentID);
1669
+ void confirmDeferredParentIdle(parentID, ctx);
1670
+ }, delay);
1671
+ timer.unref?.();
1672
+ deferredConfirmTimers.set(parentID, timer);
1673
+ ctx.logger.info("parent idle and descendants finished - confirming deferred notification", {
1674
+ sessionId: parentID
1675
+ });
1676
+ }
1677
+ async function confirmDeferredParentIdle(parentID, ctx) {
1678
+ if (!ctx.sessionTitleService.hasDeferredIdleNotification(parentID)) return;
1679
+ if (ctx.sessionTitleService.getSessionStatus(parentID) !== "idle") {
1680
+ ctx.sessionTitleService.clearDeferredIdleNotification(parentID);
1681
+ ctx.logger.info("clearing deferred parent idle notification - parent resumed during confirm", {
1606
1682
  sessionId: parentID
1607
1683
  });
1608
1684
  return;
1609
1685
  }
1610
- if (parentStatus !== void 0) {
1611
- ctx.sessionTitleService.clearDeferredIdleNotification(parentID);
1612
- ctx.logger.info("clearing deferred parent idle notification - parent resumed", {
1686
+ await hydrateDescendants(parentID, ctx);
1687
+ if (ctx.sessionTitleService.hasUnfinishedDescendants(parentID)) {
1688
+ ctx.logger.info("keeping deferred parent idle notification - descendants active again", {
1613
1689
  sessionId: parentID
1614
1690
  });
1691
+ return;
1615
1692
  }
1693
+ ctx.logger.info("sending deferred parent idle notification", { sessionId: parentID });
1694
+ await sendIdleNotification(parentID, ctx);
1616
1695
  }
1617
1696
  async function deferParentIdleIfDescendantsRunning(sessionId, ctx) {
1618
1697
  await hydrateDescendants(sessionId, ctx);
@@ -1989,7 +2068,7 @@ function createStatusDispatcher(deps) {
1989
2068
  return async ({ chatId, bot, args }) => {
1990
2069
  const rawN = args[0];
1991
2070
  if (rawN === void 0 || rawN === "") {
1992
- await bot.sendMessage("\uC0AC\uC6A9\uBC95: /status <\uBC88\uD638>. \uBA3C\uC800 /sessions \uB85C \uBAA9\uB85D \uD655\uC778", {
2071
+ await bot.sendMessage("\uC0AC\uC6A9\uBC95: /status &lt;\uBC88\uD638&gt;. \uBA3C\uC800 /sessions \uB85C \uBAA9\uB85D \uD655\uC778", {
1993
2072
  parse_mode: "HTML"
1994
2073
  });
1995
2074
  return;
@@ -2031,7 +2110,11 @@ function createStatusDispatcher(deps) {
2031
2110
  let messages = [];
2032
2111
  if (sourceServerUrl && useRemoteServer) {
2033
2112
  try {
2034
- const getResult = await getRemoteSession(sourceServerUrl, entry.sessionId, deps.opencodeFetch);
2113
+ const getResult = await getRemoteSession(
2114
+ sourceServerUrl,
2115
+ entry.sessionId,
2116
+ deps.opencodeFetch
2117
+ );
2035
2118
  session = getResult.data;
2036
2119
  responseStatus = getResult.response.status;
2037
2120
  if (!session || responseStatus === 404) {
@@ -2050,7 +2133,11 @@ function createStatusDispatcher(deps) {
2050
2133
  await bot.sendMessage("\uC138\uC158 \uC0C1\uD0DC\uB97C \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694", {
2051
2134
  parse_mode: "HTML"
2052
2135
  });
2053
- deps.logger.error("status remote lookup failed", { chatId, sessionId: entry.sessionId, error: String(err) });
2136
+ deps.logger.error("status remote lookup failed", {
2137
+ chatId,
2138
+ sessionId: entry.sessionId,
2139
+ error: String(err)
2140
+ });
2054
2141
  return;
2055
2142
  }
2056
2143
  } else {
@@ -2285,7 +2372,7 @@ function loadPluginEnv(opts) {
2285
2372
  }
2286
2373
 
2287
2374
  // src/lib/lock.ts
2288
- import { open as open2, readFile as readFile6, stat as stat3, unlink as unlink6 } from "fs/promises";
2375
+ import { open as open2, readFile as readFile6, stat as stat3, unlink as unlink6, utimes } from "fs/promises";
2289
2376
  import { hostname } from "os";
2290
2377
  var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
2291
2378
  function hasCode6(err, code) {
@@ -2331,6 +2418,18 @@ async function createLock(lockPath, pid) {
2331
2418
  await unlink6(lockPath);
2332
2419
  } catch {
2333
2420
  }
2421
+ },
2422
+ async refresh() {
2423
+ if (released) return false;
2424
+ try {
2425
+ const data2 = parseLockData(await readFile6(lockPath, "utf8"));
2426
+ if (!data2 || data2.pid !== pid || data2.hostname !== hostname()) return false;
2427
+ const now = /* @__PURE__ */ new Date();
2428
+ await utimes(lockPath, now, now);
2429
+ return true;
2430
+ } catch {
2431
+ return false;
2432
+ }
2334
2433
  }
2335
2434
  };
2336
2435
  }
@@ -2374,7 +2473,11 @@ async function acquireLock(opts) {
2374
2473
  try {
2375
2474
  await unlink6(opts.lockPath);
2376
2475
  } catch {
2377
- return { acquired: false, reason: "failed to remove stale lock", ownerPid: existing.ownerPid };
2476
+ return {
2477
+ acquired: false,
2478
+ reason: "failed to remove stale lock",
2479
+ ownerPid: existing.ownerPid
2480
+ };
2378
2481
  }
2379
2482
  }
2380
2483
  }
@@ -2819,11 +2922,14 @@ var TelegramRemote = async (input) => {
2819
2922
  const pendingPermissions = createPendingPermissionStore({ tokenHash });
2820
2923
  const pendingStartWorks = createPendingStartWorkStore({ tokenHash });
2821
2924
  const lockResult = await acquireLock({ lockPath });
2822
- const isLeader = lockResult.acquired;
2823
- logger.info(
2824
- `lock ${isLeader ? "acquired - leader mode" : "held by other - pass-through mode"}`,
2825
- isLeader ? {} : { reason: lockResult.reason }
2826
- );
2925
+ const leadership = { isLeader: false };
2926
+ if (lockResult.acquired) {
2927
+ leadership.isLeader = true;
2928
+ leadership.handle = lockResult.handle;
2929
+ logger.info("lock acquired - leader mode");
2930
+ } else {
2931
+ logger.info("lock held by other - pass-through mode", { reason: lockResult.reason });
2932
+ }
2827
2933
  logger.info("server url", {
2828
2934
  url: input.serverUrl.toString(),
2829
2935
  href: input.serverUrl.href,
@@ -2888,21 +2994,57 @@ var TelegramRemote = async (input) => {
2888
2994
  config,
2889
2995
  stateStore,
2890
2996
  logger,
2891
- initialChatId: initialState.chatId ?? config.chatId,
2892
- polling: isLeader
2997
+ initialChatId: initialState.chatId ?? config.chatId
2893
2998
  });
2894
- if (isLeader) {
2895
- bot.start().catch((err) => {
2999
+ const startLeaderPolling = () => {
3000
+ bot.start().catch(async (err) => {
2896
3001
  logger.error("bot polling stopped", { error: String(err) });
3002
+ leadership.isLeader = false;
3003
+ if (leadership.handle) {
3004
+ await leadership.handle.release();
3005
+ leadership.handle = void 0;
3006
+ }
2897
3007
  });
2898
- }
3008
+ };
3009
+ let electionRunning = false;
3010
+ const runElection = async () => {
3011
+ if (electionRunning) return;
3012
+ electionRunning = true;
3013
+ try {
3014
+ if (leadership.isLeader && leadership.handle) {
3015
+ if (await leadership.handle.refresh()) return;
3016
+ leadership.isLeader = false;
3017
+ leadership.handle = void 0;
3018
+ await bot.stop();
3019
+ logger.info("leadership lost - demoted to pass-through");
3020
+ return;
3021
+ }
3022
+ if (bot.isPolling()) return;
3023
+ const result = await acquireLock({ lockPath });
3024
+ if (result.acquired) {
3025
+ leadership.isLeader = true;
3026
+ leadership.handle = result.handle;
3027
+ logger.info("leadership acquired - promoting to leader");
3028
+ startLeaderPolling();
3029
+ }
3030
+ } catch (err) {
3031
+ logger.warn("election cycle failed", { error: String(err) });
3032
+ } finally {
3033
+ electionRunning = false;
3034
+ }
3035
+ };
3036
+ const electionTimer = setInterval(() => {
3037
+ void runElection();
3038
+ }, 3e4);
3039
+ if (typeof electionTimer.unref === "function") electionTimer.unref();
2899
3040
  const cleanup = async () => {
3041
+ clearInterval(electionTimer);
2900
3042
  try {
2901
3043
  await bot.stop();
2902
3044
  } catch {
2903
3045
  }
2904
- if (lockResult.acquired) {
2905
- await lockResult.handle.release();
3046
+ if (leadership.handle) {
3047
+ await leadership.handle.release();
2906
3048
  }
2907
3049
  await logger.close();
2908
3050
  };
@@ -2934,34 +3076,41 @@ var TelegramRemote = async (input) => {
2934
3076
  replyToPermission,
2935
3077
  runSessionCommand
2936
3078
  };
2937
- if (isLeader) {
2938
- bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
2939
- bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
2940
- bot.setStartWorkDispatcher(createStartWorkDispatcher(ctx));
2941
- bot.setSessionsDispatcher(createSessionsDispatcher({
3079
+ bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
3080
+ bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
3081
+ bot.setStartWorkDispatcher(createStartWorkDispatcher(ctx));
3082
+ bot.setSessionsDispatcher(
3083
+ createSessionsDispatcher({
2942
3084
  client: input.client,
2943
3085
  sessionTitleService,
2944
3086
  sessionRegistry,
2945
3087
  snapshotStore,
2946
3088
  serverUrl: input.serverUrl.href,
2947
3089
  logger
2948
- }));
2949
- bot.setStatusDispatcher(createStatusDispatcher({
3090
+ })
3091
+ );
3092
+ bot.setStatusDispatcher(
3093
+ createStatusDispatcher({
2950
3094
  snapshotStore,
2951
3095
  sessionTitleService,
2952
3096
  client: input.client,
2953
3097
  logger,
2954
3098
  serverUrl: input.serverUrl.href
2955
- }));
2956
- bot.setStartWorkCommandDispatcher(createStartWorkCommandDispatcher({
3099
+ })
3100
+ );
3101
+ bot.setStartWorkCommandDispatcher(
3102
+ createStartWorkCommandDispatcher({
2957
3103
  snapshotStore,
2958
3104
  sessionTitleService,
2959
3105
  client: input.client,
2960
3106
  serverUrl: input.serverUrl.href,
2961
3107
  runSessionCommand,
2962
3108
  logger
2963
- }));
2964
- bot.setHelpDispatcher(createHelpDispatcher({ logger }));
3109
+ })
3110
+ );
3111
+ bot.setHelpDispatcher(createHelpDispatcher({ logger }));
3112
+ if (leadership.isLeader) {
3113
+ startLeaderPolling();
2965
3114
  }
2966
3115
  return {
2967
3116
  event: async ({ event }) => {
@@ -3012,14 +3161,12 @@ var TelegramRemote = async (input) => {
3012
3161
  return;
3013
3162
  }
3014
3163
  if (isEventPermissionAsked(extEvent)) {
3015
- if (!isLeader) return;
3016
3164
  return handlePermissionAsked(extEvent, ctx);
3017
3165
  }
3018
3166
  if (isEventSessionError(extEvent)) {
3019
3167
  return handleSessionError(extEvent, ctx);
3020
3168
  }
3021
3169
  if (isEventQuestionAsked(extEvent)) {
3022
- if (!isLeader) return;
3023
3170
  return handleQuestionAsked(extEvent, ctx);
3024
3171
  }
3025
3172
  if (isEventQuestionReplied(extEvent)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinseeker/opencode-telegram-plugin",
3
- "version": "1.0.14",
3
+ "version": "1.1.1",
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",